UNPKG

@faast/ethereum-payments

Version:

Library to assist in processing ethereum payments, such as deriving addresses and sweeping funds

351 lines (307 loc) 11.6 kB
import Web3 from 'web3' import { PaymentsUtils, Payport, createUnitConverters, AutoFeeLevels, FeeRate, FeeRateType, NetworkType, BalanceResult, TransactionStatus, } from '@faast/payments-common' import { Logger, DelegateLogger, assertType, isNull, Numeric } from '@faast/ts-common' import BigNumber from 'bignumber.js' import { Transaction, TransactionReceipt } from 'web3-core' import { PACKAGE_NAME, ETH_DECIMAL_PLACES, ETH_NAME, ETH_SYMBOL, DEFAULT_ADDRESS_FORMAT, MIN_SWEEPABLE_WEI, MIN_CONFIRMATIONS, } from './constants' import { EthereumAddressFormat, EthereumAddressFormatT, EthereumPaymentsUtilsConfig, EthereumTransactionInfo } from './types' import { isValidXkey } from './bip44' import { NetworkData } from './NetworkData' import { retryIfDisconnected } from './utils' type UnitConverters = ReturnType<typeof createUnitConverters> export class EthereumPaymentsUtils implements PaymentsUtils { readonly networkType: NetworkType readonly coinSymbol: string readonly coinName: string readonly coinDecimals: number logger: Logger server: string | null web3: Web3 eth: Web3['eth'] gasStation: NetworkData constructor(config: EthereumPaymentsUtilsConfig) { this.logger = new DelegateLogger(config.logger, PACKAGE_NAME) this.networkType = config.network || NetworkType.Mainnet this.coinName = config.name ?? ETH_NAME this.coinSymbol = config.symbol ?? ETH_SYMBOL this.coinDecimals = config.decimals ?? ETH_DECIMAL_PLACES this.server = config.fullNode || null let provider: any if (config.web3) { this.web3 = config.web3 } else if (isNull(this.server)) { this.web3 = new Web3() } else if (this.server.startsWith('http')) { provider = new Web3.providers.HttpProvider(this.server, config.providerOptions) this.web3 = new Web3(provider) } else if (this.server.startsWith('ws')) { provider = new Web3.providers.WebsocketProvider(this.server, config.providerOptions) this.web3 = new Web3(provider) } else { throw new Error(`Invalid ethereum payments fullNode, must start with http or ws: ${this.server}`) } // Debug mode to print out all outgoing req/res if (provider && process.env.NODE_DEBUG && process.env.NODE_DEBUG.includes('ethereum-payments')) { const send = provider.send provider.send = (payload: any, cb: Function) => { this.logger.debug(`web3 provider request ${this.server}`, payload) send.call(provider, payload, (error: Error, result: any) => { if (error) { this.logger.debug(`web3 provider response error ${this.server}`, error) } else { this.logger.debug(`web3 provider response result ${this.server}`, result) } cb(error, result) }) } } this.eth = this.web3.eth this.gasStation = new NetworkData(this.eth, config.gasStation, config.parityNode, this.logger) const unitConverters = createUnitConverters(this.coinDecimals) this.toMainDenominationBigNumber = unitConverters.toMainDenominationBigNumber this.toBaseDenominationBigNumber = unitConverters.toBaseDenominationBigNumber this.toMainDenomination = unitConverters.toMainDenominationString this.toBaseDenomination = unitConverters.toBaseDenominationString const ethUnitConverters = createUnitConverters(ETH_DECIMAL_PLACES) this.toMainDenominationBigNumberEth = ethUnitConverters.toMainDenominationBigNumber this.toBaseDenominationBigNumberEth = ethUnitConverters.toBaseDenominationBigNumber this.toMainDenominationEth = ethUnitConverters.toMainDenominationString this.toBaseDenominationEth = ethUnitConverters.toBaseDenominationString } async init() {} async destroy() {} toMainDenominationBigNumber: UnitConverters['toMainDenominationBigNumber'] toBaseDenominationBigNumber: UnitConverters['toMainDenominationBigNumber'] toMainDenomination: UnitConverters['toMainDenominationString'] toBaseDenomination: UnitConverters['toBaseDenominationString'] toMainDenominationBigNumberEth: UnitConverters['toMainDenominationBigNumber'] toBaseDenominationBigNumberEth: UnitConverters['toMainDenominationBigNumber'] toMainDenominationEth: UnitConverters['toMainDenominationString'] toBaseDenominationEth: UnitConverters['toBaseDenominationString'] isValidAddress(address: string, options: { format?: string } = {}): boolean { const { format } = options if (format === EthereumAddressFormat.Lowercase) { return this.web3.utils.isAddress(address) && address === address.toLowerCase() } else if (format === EthereumAddressFormat.Checksum) { return this.web3.utils.checkAddressChecksum(address) } return this.web3.utils.isAddress(address) } standardizeAddress(address: string, options?: { format?: string }): string | null { if (!this.web3.utils.isAddress(address)) { return null } const format = assertType(EthereumAddressFormatT, options?.format ?? DEFAULT_ADDRESS_FORMAT, 'format') if (format === EthereumAddressFormat.Lowercase) { return address.toLowerCase() } else { return this.web3.utils.toChecksumAddress(address) } } isValidExtraId(extraId: unknown): boolean { return false } // XXX Payport methods can be moved to payments-common isValidPayport(payport: Payport): boolean { return Payport.is(payport) && !this._getPayportValidationMessage(payport) } validatePayport(payport: Payport): void { const message = this._getPayportValidationMessage(payport) if (message) { throw new Error(message) } } getPayportValidationMessage(payport: Payport): string | undefined { try { payport = assertType(Payport, payport, 'payport') } catch (e) { return e.message } return this._getPayportValidationMessage(payport) } isValidXprv(xprv: string): boolean { return isValidXkey(xprv) && xprv.substring(0, 4) === 'xprv' } isValidXpub(xpub: string): boolean { return isValidXkey(xpub) && xpub.substring(0, 4) === 'xpub' } isValidPrivateKey(prv: string): boolean { try { return Boolean(this.web3.eth.accounts.privateKeyToAccount(prv)) } catch (e) { return false } } privateKeyToAddress(prv: string): string { let key: string if (prv.substring(0, 2) === '0x') { key = prv } else { key = `0x${prv}` } return this.web3.eth.accounts.privateKeyToAccount(key).address.toLowerCase() } private _getPayportValidationMessage(payport: Payport): string | undefined { try { const { address } = payport if (!(this.isValidAddress(address))) { return 'Invalid payport address' } } catch (e) { return 'Invalid payport address' } return undefined } async getFeeRateRecommendation(level: AutoFeeLevels): Promise<FeeRate> { const gasPrice = await this.gasStation.getGasPrice(level) return { feeRate: gasPrice, feeRateType: FeeRateType.BasePerWeight, } } async _retryDced<T>(fn: () => Promise<T>): Promise<T> { return retryIfDisconnected(fn, this.logger) } async getCurrentBlockNumber() { return this._retryDced(() => this.eth.getBlockNumber()) } isAddressBalanceSweepable(balanceEth: Numeric): boolean { return this.toBaseDenominationBigNumberEth(balanceEth).gt(MIN_SWEEPABLE_WEI) } async getAddressBalance(address: string): Promise<BalanceResult> { const balance = await this._retryDced(() => this.eth.getBalance(address)) const confirmedBalance = this.toMainDenomination(balance).toString() const sweepable = this.isAddressBalanceSweepable(confirmedBalance) return { confirmedBalance, unconfirmedBalance: '0', spendableBalance: confirmedBalance, sweepable, requiresActivation: false, } } async getAddressNextSequenceNumber(address: string) { return this.gasStation.getNonce(address) } async getAddressUtxos() { return [] } async getTransactionInfo(txid: string): Promise<EthereumTransactionInfo> { // XXX it is suggested to keep 12 confirmations // https://ethereum.stackexchange.com/questions/319/what-number-of-confirmations-is-considered-secure-in-ethereum const minConfirmations = MIN_CONFIRMATIONS const tx: Transaction | null = await this._retryDced(() => this.eth.getTransaction(txid)) if (!tx) { throw new Error(`Transaction ${txid} not found`) } const currentBlockNumber = await this.getCurrentBlockNumber() let txInfo: TransactionReceipt | null = await this._retryDced(() => this.eth.getTransactionReceipt(txid)) tx.from = tx.from ? tx.from.toLowerCase() : ''; tx.to = tx.to ? tx.to.toLowerCase() : ''; // NOTE: for the sake of consistent schema return if (!txInfo) { txInfo = { transactionHash: tx.hash, from: tx.from, to: tx.to, status: true, blockNumber: 0, cumulativeGasUsed: 0, gasUsed: 0, transactionIndex: 0, blockHash: '', logs: [], logsBloom: '' } return { id: txid, amount: this.toMainDenomination(tx.value), toAddress: tx.to ? tx.to.toLowerCase() : null, fromAddress: tx.from ? tx.from.toLowerCase() : null, toExtraId: null, fromIndex: null, toIndex: null, fee: this.toMainDenomination((new BigNumber(tx.gasPrice)).multipliedBy(tx.gas)), sequenceNumber: tx.nonce, weight: tx.gas, isExecuted: false, isConfirmed: false, confirmations: 0, confirmationId: null, confirmationTimestamp: null, currentBlockNumber: currentBlockNumber, status: TransactionStatus.Pending, data: { ...tx, ...txInfo, currentBlock: currentBlockNumber }, } } let isConfirmed = false let confirmationTimestamp: Date | null = null let confirmations = 0 if (tx.blockNumber) { confirmations = currentBlockNumber - tx.blockNumber if (confirmations > minConfirmations) { isConfirmed = true const txBlock = await this._retryDced(() => this.eth.getBlock(tx.blockNumber!)) confirmationTimestamp = new Date(Number(txBlock.timestamp) * 1000) } } let status: TransactionStatus = TransactionStatus.Pending if (isConfirmed) { status = TransactionStatus.Confirmed // No trust to types description of web3 if (txInfo.hasOwnProperty('status') && (txInfo.status === false || txInfo.status.toString() === 'false')) { status = TransactionStatus.Failed } } txInfo.from = tx.from txInfo.to = tx.to return { id: txid, amount: this.toMainDenomination(tx.value), toAddress: tx.to ? tx.to.toLowerCase() : null, fromAddress: tx.from ? tx.from.toLowerCase() : null, toExtraId: null, fromIndex: null, toIndex: null, fee: this.toMainDenomination((new BigNumber(tx.gasPrice)).multipliedBy(txInfo.gasUsed)), sequenceNumber: tx.nonce, weight: txInfo.gasUsed, // XXX if tx was confirmed but not accepted by network isExecuted must be false isExecuted: status !== TransactionStatus.Failed, isConfirmed, confirmations, confirmationId: tx.blockHash, confirmationTimestamp, status, currentBlockNumber: currentBlockNumber, data: { ...tx, ...txInfo, currentBlock: currentBlockNumber }, } } }