TypeScript + Angular Ethereum transaction progress bar component
import { Component, OnInit, Input, ViewChild, Output, EventEmitter } from '@angular/core'; | |
import { Web3Service, WalletState, WalletType } from '../web3.service'; | |
import Web3 from 'web3'; | |
import { waitTransaction, isSuccessfulTransaction } from '../transactionwait'; | |
import { isRequired, checkRequired } from '../requiredInput'; | |
import { EthereumProgressBarComponent } from '../ethereum-progress-bar/ethereum-progress-bar.component'; | |
import { Subscription } from 'rxjs'; | |
import { NGXLogger } from 'ngx-logger'; | |
// Called before creating a tranaction. | |
// Need to do all await() tasks before a transaction is triggered | |
type PrepareTransationCallback = (web3: Web3) => Promise<any>; | |
// Called when the user pressed Make transaction button | |
// Is filled with with the awaited inputs from PrepareTransationCallback | |
// Must return a promise from web3.eth.methods.myContractMethod().send(). | |
type CreateTransactionCallback = (web3: Web3, inputs: any) => Promise<any>; | |
enum TransactionState { | |
// User has not reached Fetch tokens page yet | |
Pristine, | |
// The user has clicked Make tranasction button, | |
// but we are still resolving inputs for the | |
// actual tx asynchronously | |
ButtonPressed, | |
// MetaMask pop-up triggered | |
WaitingForWallet, | |
// User pressed cancel in the wallet | |
WalletRejected, | |
// Transaction being mined | |
TransactionInProgress, | |
// Transaction failed after a confirmation | |
TransactionFailed, | |
// Transaction succeed | |
TransactionSucceed, | |
}; | |
/** | |
* Helps user to perform a transaction with a connected wallet. | |
* | |
* 1. Call smart contract functions | |
* 2. Handle different error modes (wallet cancel, transaction rejected after mining) | |
*/ | |
@Component({ | |
selector: 'transaction-helper', | |
templateUrl: './transaction-helper.component.html', | |
styleUrls: ['./transaction-helper.component.scss'] | |
}) | |
export class TransactionHelperComponent implements OnInit { | |
// In the instant mode, we report back to the parent as | |
// soon as txHash (txid) is available and we do not wait for | |
// the transaction to be mined | |
@Input() | |
instant = false; | |
// Wallets (MetaMask) return the receipt as soon as they think the | |
// first confirmation hits the blockchain. However it looks like | |
// this change is ofter temporary reverted due to minor forking | |
// and miner competition, and the tx might be later. | |
// If you are doing two subsequent transactions in a wizard, | |
// please set this value at least to 1, | |
// or blockchain reads after the first transaction confirmation | |
// might give incorrec results. | |
@Input() | |
extraConfirmationBlocks = 0; | |
// Called immediately when use pressed Make transaction button | |
// Is awaited and needs to return inputs for createTransactionCallback | |
@Input() | |
prepareTransactionCallback: PrepareTransationCallback; | |
// Called when user press Make transaction button | |
// Must return a Promise from web3.eth.send() | |
// The function MUST NOT be async due to | |
// problems with Angular ZoneAwarePromise | |
// Needs to defined as an arrow function | |
// https://stackoverflow.com/a/59067002/315168 | |
@isRequired | |
@Input() | |
createTransactionCallback: CreateTransactionCallback; | |
// Id of the Ethereum blockchain we are in | |
@isRequired | |
@Input() | |
chainId: number; | |
// Called when the transaction finished or | |
// when txHash is avaiable in the instant mode. | |
@Output() | |
onTransactionHash: EventEmitter<string> = new EventEmitter<string>(); | |
// Called when the transaction finished or | |
// when txHash is avaiable in the instant mode. | |
// You can check if the tx was succesful | |
// with isSuccesfulTransaction(). | |
@Output() | |
onTransactionReceipt: EventEmitter<any> = new EventEmitter<any>(); | |
// Our transaction ticker | |
@ViewChild("ethprogressbar") | |
progressBar: EthereumProgressBarComponent; | |
// Injected Web3 heavy lifter | |
protected web3service: Web3Service; | |
// Wallet and tx broadcasting state machinery | |
txState: TransactionState; | |
// Expose enum to templates | |
// https://coryrylan.com/blog/angular-tips-template-binding-with-static-types | |
txStateTypes = TransactionState; | |
// txhash that has been against the token fauced smart contract | |
transactionHash: string = null; | |
// Tx hash that fits in the layout | |
transactionHashShortened: string = null; | |
// URL to the active transaction on Etherscan | |
etherscanTxUrl: string; | |
// Our subscription to the user wallet updates | |
private walletStateSubscription: Subscription; | |
walletState: WalletState; | |
// Expose enum to templates | |
walletTypes = WalletType; | |
constructor(web3service: Web3Service, private logger: NGXLogger) { | |
this.web3service = web3service; | |
} | |
ngOnInit(): void { | |
checkRequired(this, this.logger.warn.bind(this.logger)); | |
this.walletStateSubscription = this.web3service.walletState.subscribe((data) => { this.updateWalletState(data) }); | |
this.txState = TransactionState.Pristine; | |
} | |
ngOnDestroy() { | |
this.walletStateSubscription.unsubscribe(); | |
} | |
/** | |
* User has updated their wallet (connected, changed account, changed network). | |
* | |
* This will determine if we can move forward in the wizard. | |
*/ | |
async updateWalletState(data: WalletState) { | |
this.walletState = data; | |
} | |
/** | |
* Wait until the transaaction is mined. | |
* | |
* Then change the UI state based on it | |
*/ | |
async waitForTransaction() { | |
const txHash = this.transactionHash; | |
const web3 = this.walletState.web3; | |
const receipt = await waitTransaction(web3, txHash, { interval: 3000, blocksToWait: this.extraConfirmationBlocks }); | |
this.processTxReceipt(receipt) | |
} | |
/** | |
* We got a transaction receipt from the network. | |
* | |
* In this point the tx has been mined, though its state | |
* may change in the subsequent blocks due to uncle blocks. | |
* | |
* @param receipt | |
*/ | |
processTxReceipt(receipt: any) { | |
const success = isSuccessfulTransaction(receipt); | |
this.logger.debug("Checking transaction success", success, receipt); | |
if(success) { | |
this.txState = TransactionState.TransactionSucceed; | |
this.onTransactionReceipt.emit(receipt); | |
} else { | |
// TODO: Work around the issue that WalletConnect web3 connection | |
// gives receipt twice, first it success: true, then with success: fail | |
if(this.txState == TransactionState.TransactionSucceed) { | |
// We cannot go from Success -> failed | |
console.log("Cannot go from success to failed transaction"); | |
return; | |
} | |
this.txState = TransactionState.TransactionFailed; | |
} | |
} | |
/** | |
* Set transaction state to the transaction mining in progress. | |
*/ | |
setInitialTxState(transactionHash: string, txPromiEvent: any) { | |
this.transactionHash = transactionHash; | |
this.transactionHashShortened = transactionHash.slice(0, 16) + "…"; | |
this.txState = TransactionState.TransactionInProgress; | |
// this.logger.debug("setInitialTxState: Got tx", this.transactionHash); | |
this.etherscanTxUrl = this.web3service.getEtherscanTxUrl(this.walletState.chainId, this.transactionHash); | |
this.onTransactionHash.emit(transactionHash); | |
if(this.instant) { | |
// No waiting | |
this.txState = TransactionState.TransactionSucceed; | |
} else { | |
// Progress bar will try to estimate how long this transaction takes | |
this.progressBar.startTransaction(this.transactionHash, txPromiEvent); | |
} | |
} | |
/** | |
* Got a transaction receipt from the wallet. | |
* @param receipt | |
*/ | |
updateTxReceipt(receipt: any) { | |
this.progressBar.updateReceipt(receipt); | |
} | |
/** | |
* Set transaction state to the transaction mining in progress. | |
*/ | |
setMinedTxState(transactionHash: string, receipt: any) { | |
this.logger.debug("Setting mined tx state"); | |
this.transactionHash = transactionHash; | |
this.etherscanTxUrl = this.web3service.getEtherscanTxUrl(this.walletState.chainId, this.transactionHash); | |
this.transactionHashShortened = transactionHash.slice(0, 16) + "…"; | |
if(this.extraConfirmationBlocks > 0) { | |
// MetaMask WalletConnect may report success state earlier before the tx | |
// has any confirmations, so do our own wait here | |
// even though it is likely the tx has been already mined in this point | |
this.txState = TransactionState.TransactionInProgress; | |
this.waitForTransaction(); | |
} else { | |
// Assume transaction is good as soon as the wallet returns a receipt | |
this.processTxReceipt(receipt); | |
} | |
} | |
/** | |
* User clicks the final button to create transaction to get tokens. | |
* | |
* Trigger MetaMask pop-up / WalletConnect notification. | |
*/ | |
async onMakeTransactionButtonClick() { | |
const web3 = this.walletState.web3; | |
this.txState = TransactionState.ButtonPressed; | |
this.logger.debug("onMakeTransactionButtonClick", web3); | |
if(web3) { | |
// {blockHash: "0xae27e80c529fa829f852d4b9941a20320808c3c6fb65766e2d8446b64214d0fe", blockNumber: 2369362, contractAddress: null, cumulativeGasUsed: 82635, from: "0x168767eeb7b63a49f1d1e213ff354a6a934a93b0", …} | |
try { | |
this.logger.debug("Preparing tx"); | |
let inputs; | |
if(this.prepareTransactionCallback) { | |
inputs = await this.prepareTransactionCallback(web3); | |
} else { | |
inputs = {}; | |
} | |
this.txState = TransactionState.WaitingForWallet; | |
// this.logger.debug("Calling", this.createTransactionCallback, "prepared inputs", this.prepareTransactionCallback, inputs); | |
// web3.js uses something called PromiEvents, | |
// which are not 1:1 promises, but have their "on" methods | |
// https://web3js.readthedocs.io/en/v1.2.6/callbacks-promises-events.html | |
// This creates problems with Angular's monkey patches ZoneAwarePromise, | |
// so we split preparing a transactino to two different functions | |
const promise = this.createTransactionCallback(web3, inputs) as any; | |
this.logger.debug("Wrapped promise", promise); | |
promise.once("transactionHash", (txHash) => { | |
this.logger.debug("Got transactionHash callback"); | |
this.setInitialTxState(txHash, promise); | |
}); | |
promise.once("receipt", (receipt) => { | |
this.logger.debug("Got receipt callback"); | |
this.updateTxReceipt(receipt); | |
}); | |
// If you await web3.eth.send() promise | |
// it will not return until it has at least one confirmations | |
// which is a long time | |
const txNote = await promise; | |
if(!txNote) { | |
this.logger.debug("Bad callback", this.createTransactionCallback); | |
throw new Error("Bad tx creation callback"); | |
} | |
// After the web3.eth.send() promise has been resolved the | |
// tx should have at least one confirmation, | |
// but looks like our mileage may vary | |
this.logger.debug("Got txNote", txNote); | |
const txHash = txNote.transactionHash; | |
this.setMinedTxState(txHash, txNote); | |
} catch(e) { | |
// e will have properties of | |
// e.receipt | |
// e.name == Error | |
// e.message | |
if(e.toString().includes("newBlockHeaders")) { | |
console.error("The web3.js bug hit https://github.com/ethereum/web3.js/issues/3498"); | |
console.error(e); | |
if(this.transactionHash) { | |
this.waitForTransaction(); | |
// Don't display this error the the user, try to wait it out | |
return; | |
} | |
} | |
this.logger.debug("Transaction error", e); | |
console.error("A bad / cancelled response from the wallet"); | |
console.error(e); | |
// MetaMask will reject the send() promise if the user presses cance; | |
// OR | |
// MetMask will send transctionRecept if the mining fails | |
if(e.receipt && e.receipt.status === false) { | |
this.logger.debug(e.receipt); | |
this.txState = TransactionState.TransactionFailed; | |
this.transactionHash = e.receipt.transactionHash | |
this.etherscanTxUrl = this.web3service.getEtherscanTxUrl(this.walletState.chainId, e.receipt.transactionHash); | |
this.logger.debug("Transaction failed in mining", this.etherscanTxUrl); | |
} else { | |
this.logger.debug("Transaction was cancelled by the user?"); | |
this.txState = TransactionState.WalletRejected; | |
this.transactionHash = null; | |
} | |
} | |
} else { | |
this.transactionHash = null; | |
this.etherscanTxUrl = null; | |
this.txState = TransactionState.Pristine; | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment