Created
May 26, 2020 16:39
-
-
Save miohtama/a91a5edabac6bf66de860a444fc13206 to your computer and use it in GitHub Desktop.
TypeScript + Angular Ethereum transaction progress bar component
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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