Skip to content

Instantly share code, notes, and snippets.

@miohtama
Created May 26, 2020 16:39
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save miohtama/a91a5edabac6bf66de860a444fc13206 to your computer and use it in GitHub Desktop.
Save miohtama/a91a5edabac6bf66de860a444fc13206 to your computer and use it in GitHub Desktop.
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