UNPKG

@faast/ethereum-payments

Version:

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

441 lines (386 loc) 14.7 kB
import { BigNumber } from 'bignumber.js' import { Transaction as Tx } from 'ethereumjs-tx' import type { TransactionReceipt, Transaction, TransactionConfig } from 'web3-core' import { cloneDeep } from 'lodash' import { BalanceResult, BasePayments, TransactionStatus, FeeLevel, FeeOption, FeeRateType, FeeOptionCustom, Payport, FromTo, ResolveablePayport, PaymentsError, PaymentsErrorCode, CreateTransactionOptions as TransactionOptions, NetworkType, PayportOutput, AutoFeeLevels, DEFAULT_MAX_FEE_PERCENT, } from '@faast/payments-common' import { isType, isString, isMatchingError, Numeric } from '@faast/ts-common' import request from 'request-promise-native' import { EthereumTransactionInfo, EthereumUnsignedTransaction, EthereumSignedTransaction, EthereumBroadcastResult, BaseEthereumPaymentsConfig, EthereumResolvedFeeOption, EthereumTransactionOptions, EthTxType, } from './types' import { NetworkData } from './NetworkData' import { // TODO use them // DEFAULT_FULL_NODE, // DEFAULT_SOLIDITY_NODE, DEFAULT_FEE_LEVEL, MIN_CONFIRMATIONS, ETHEREUM_TRANSFER_COST, TOKEN_WALLET_DATA, DEPOSIT_KEY_INDEX, TOKEN_PROXY_DATA, MIN_SWEEPABLE_WEI, } from './constants' import { EthereumPaymentsUtils } from './EthereumPaymentsUtils' import { retryIfDisconnected } from './utils' export abstract class BaseEthereumPayments<Config extends BaseEthereumPaymentsConfig> extends EthereumPaymentsUtils implements BasePayments< Config, EthereumUnsignedTransaction, EthereumSignedTransaction, EthereumBroadcastResult, EthereumTransactionInfo > { private config: Config public depositKeyIndex: number constructor(config: Config) { super(config) this.config = config this.depositKeyIndex = (typeof config.depositKeyIndex === 'undefined') ? DEPOSIT_KEY_INDEX : config.depositKeyIndex } getFullConfig(): Config { return this.config } abstract getPublicConfig(): Config async resolvePayport(payport: ResolveablePayport): Promise<Payport> { // NOTE: this type of nesting suggests to revise payport as an abstraction if (typeof payport === 'number') { return this.getPayport(payport) } else if (typeof payport === 'string') { if (!this.isValidAddress(payport)) { throw new Error(`Invalid Ethereum address: ${payport}`) } return { address: payport.toLowerCase() } } if (!this.isValidPayport(payport)) { throw new Error(`Invalid Ethereum payport: ${JSON.stringify(payport)}`) } else { if(!this.isValidAddress(payport.address)) { throw new Error(`Invalid Ethereum payport: ${JSON.stringify(payport)}`) } } return { ...payport, address: payport.address.toLowerCase() } } async resolveFromTo(from: number, to: ResolveablePayport): Promise<FromTo> { const fromPayport = await this.getPayport(from) const toPayport = await this.resolvePayport(to) return { fromAddress: fromPayport.address, fromIndex: from, fromExtraId: fromPayport.extraId, fromPayport, toAddress: toPayport.address, toIndex: typeof to === 'number' ? to : null, toExtraId: toPayport.extraId, toPayport, } } async resolveFeeOption( feeOption: FeeOption, amountOfGas: number = ETHEREUM_TRANSFER_COST, ): Promise<EthereumResolvedFeeOption> { if (new BigNumber(amountOfGas).dp() > 0) { throw new Error(`Amount of gas must be a whole number ${amountOfGas}`) } return isType(FeeOptionCustom, feeOption) ? this.resolveCustomFeeOption(feeOption, amountOfGas) : this.resolveLeveledFeeOption(feeOption.feeLevel, amountOfGas) } resolveCustomFeeOption( feeOption: FeeOptionCustom, amountOfGas: number, ): EthereumResolvedFeeOption { const { feeRate, feeRateType } = feeOption // Determine the gas price first let gasPrice: BigNumber if (feeRateType === FeeRateType.BasePerWeight) { gasPrice = new BigNumber(feeRate) } else { const feeRateBase = feeRateType === FeeRateType.Main ? this.toBaseDenominationBigNumberEth(feeRate) : new BigNumber(feeRate) gasPrice = feeRateBase.dividedBy(amountOfGas) } gasPrice = gasPrice.dp(0, BigNumber.ROUND_DOWN) // Round down to avoid exceeding target // Calculate the actual total fees after gas price is rounded const feeBase = gasPrice.multipliedBy(amountOfGas) const feeMain = this.toMainDenominationBigNumberEth(feeBase) return { targetFeeRate: feeOption.feeRate, targetFeeLevel: FeeLevel.Custom, targetFeeRateType: feeOption.feeRateType, feeBase: feeBase.toFixed(), feeMain: feeMain.toFixed(), gasPrice: gasPrice.toFixed(), } } async resolveLeveledFeeOption( feeLevel: AutoFeeLevels = DEFAULT_FEE_LEVEL, amountOfGas: number, ): Promise<EthereumResolvedFeeOption> { const gasPrice = new BigNumber(await this.gasStation.getGasPrice(feeLevel)) const feeBase = gasPrice.multipliedBy(amountOfGas).toFixed() return { targetFeeRate: gasPrice.toFixed(), targetFeeLevel: feeLevel, targetFeeRateType: FeeRateType.BasePerWeight, feeBase, feeMain: this.toMainDenominationEth(feeBase), gasPrice: gasPrice.toFixed(), } } abstract getAccountIds(): string[] abstract getAccountId(index: number): string requiresBalanceMonitor() { return false } async getAvailableUtxos() { return [] } async getUtxos() { return [] } usesSequenceNumber() { return true } usesUtxos() { return false } abstract getPayport(index: number): Promise<Payport> abstract getPrivateKey(index: number): Promise<string> async getBalance(resolveablePayport: ResolveablePayport): Promise<BalanceResult> { const payport = await this.resolvePayport(resolveablePayport) return this.getAddressBalance(payport.address) } async isSweepableBalance(balance: Numeric) { return this.isAddressBalanceSweepable(balance) } async getNextSequenceNumber(payport: ResolveablePayport) { const resolvedPayport = await this.resolvePayport(payport) return this.getAddressNextSequenceNumber(resolvedPayport.address) } async createTransaction( from: number, to: ResolveablePayport, amountEth: string, options: EthereumTransactionOptions = {}, ): Promise<EthereumUnsignedTransaction> { this.logger.debug('createTransaction', from, to, amountEth) return this.createTransactionObject(from, to, amountEth, options) } async createServiceTransaction( from: number = this.depositKeyIndex, options: EthereumTransactionOptions = {}, ): Promise<EthereumUnsignedTransaction> { this.logger.debug('createDepositTransaction', from) return this.createTransactionObject(from, undefined, '', options) } async createSweepTransaction( from: number | string, to: ResolveablePayport, options: EthereumTransactionOptions = {}, ): Promise<EthereumUnsignedTransaction> { this.logger.debug('createSweepTransaction', from, to) return this.createTransactionObject(from as number, to, 'max', options) } async createMultiOutputTransaction( from: number, to: PayportOutput[], options: TransactionOptions = {}, ): Promise<null> { return null } async signTransaction(unsignedTx: EthereumUnsignedTransaction): Promise<EthereumSignedTransaction> { const fromPrivateKey = await this.getPrivateKey(unsignedTx.fromIndex) const payport = await this.getPayport(unsignedTx.fromIndex) const unsignedRaw: any = cloneDeep(unsignedTx.data) const extraParam = this.networkType === NetworkType.Testnet ? {chain :'ropsten'} : undefined const tx = new Tx(unsignedRaw, extraParam) const key = Buffer.from(fromPrivateKey.slice(2), 'hex') tx.sign(key) const result: EthereumSignedTransaction = { ...unsignedTx, id: `0x${tx.hash().toString('hex')}`, status: TransactionStatus.Signed, data: { hex: `0x${tx.serialize().toString('hex')}` } } this.logger.debug('signTransaction result', result) return result } private sendTransactionWithoutConfirmation(txHex: string): Promise<string> { return this._retryDced(() => new Promise((resolve, reject) => this.eth.sendSignedTransaction(txHex) .on('transactionHash', resolve) .on('error', reject))) } async broadcastTransaction(tx: EthereumSignedTransaction): Promise<EthereumBroadcastResult> { if (tx.status !== TransactionStatus.Signed) { throw new Error(`Tx ${tx.id} has not status ${TransactionStatus.Signed}`) } try { if (this.config.blockbookNode) { const url = `${this.config.blockbookNode}/api/sendtx/${tx.data.hex}` request .get(url, { json: true }) .then((res) => this.logger.log(`Successful secondary broadcast to blockbook ethereum ${res.result}`)) .catch((e) => this.logger.log(`Failed secondary broadcast to blockbook ethereum ${tx.id}: ${url} - ${e}`), ) } const txId = await this.sendTransactionWithoutConfirmation(tx.data.hex) return { id: txId, } } catch (e) { if (isMatchingError(e, ['already known'])) { this.logger.log(`Ethereum broadcast tx already known ${tx.id}`) return { id: tx.id } } this.logger.warn(`Ethereum broadcast tx unsuccessful ${tx.id}: ${e.message}`) if (isMatchingError(e, ['nonce too low'])) { throw new PaymentsError(PaymentsErrorCode.TxSequenceCollision, e.message) } throw new Error(`Ethereum broadcast tx unsuccessful: ${tx.id} ${e.message}`) } } /** Helper for determining what gas limit should be used when creating tx. Prefer provided option over estimate. */ protected async gasOptionOrEstimate( options: EthereumTransactionOptions, txObject: TransactionConfig, txType: EthTxType, ): Promise<number> { if (options.gas) { return new BigNumber(options.gas).dp(0, BigNumber.ROUND_UP).toNumber() } return this.gasStation.estimateGas(txObject, txType) } private async createTransactionObject( from: number, to: ResolveablePayport | undefined, amountEth: string, options: EthereumTransactionOptions = {} ): Promise<EthereumUnsignedTransaction> { const serviceFlag = (amountEth === '' && typeof to === 'undefined') const sweepFlag = amountEth === 'max' const txType = serviceFlag ? 'CONTRACT_DEPLOY' : 'ETHEREUM_TRANSFER' const fromPayport = await this.getPayport(from) const toPayport = serviceFlag ? { address: '' } : await this.resolvePayport(to as ResolveablePayport) const toIndex = typeof to === 'number' ? to : null const txConfig: TransactionConfig = { from: fromPayport.address } if (serviceFlag) { if (options.data) { txConfig.data = options.data } else if (options.proxyAddress) { txConfig.data = TOKEN_PROXY_DATA .replace(/<address to proxy>/g, options.proxyAddress.replace('0x', '').toLowerCase()) } else { txConfig.data = TOKEN_WALLET_DATA } } if (toPayport.address) { txConfig.to = toPayport.address } const amountOfGas = await this.gasOptionOrEstimate(options, txConfig, txType) const feeOption = await this.resolveFeeOption(options, amountOfGas) const { confirmedBalance: balanceEth } = await this.getBalance(fromPayport) const nonce = options.sequenceNumber || await this.getNextSequenceNumber(fromPayport.address) const { feeMain, feeBase } = feeOption const feeWei = new BigNumber(feeBase) const maxFeePercent = new BigNumber(options.maxFeePercent ?? DEFAULT_MAX_FEE_PERCENT) const balanceWei = this.toBaseDenominationBigNumberEth(balanceEth) let amountWei: BigNumber = new BigNumber(0) if (balanceWei.eq(0)) { throw new PaymentsError( PaymentsErrorCode.TxInsufficientBalance, `${fromPayport.address} No balance available (${balanceEth})`, ) } if (sweepFlag) { amountWei = balanceWei.minus(feeWei) if (balanceWei.isLessThan(feeWei)) { throw new PaymentsError( PaymentsErrorCode.TxFeeTooHigh, `${fromPayport.address} Insufficient balance (${balanceEth}) to pay sweep fee of ${feeMain}`, ) } if (feeWei.gt(maxFeePercent.times(balanceWei))) { throw new PaymentsError( PaymentsErrorCode.TxFeeTooHigh, `${fromPayport.address} Sweep fee (${feeMain}) exceeds max fee percent (${maxFeePercent}%) of address balance (${balanceEth})`, ) } } else if (!sweepFlag && !serviceFlag){ amountWei = this.toBaseDenominationBigNumberEth(amountEth) if (amountWei.plus(feeWei).isGreaterThan(balanceWei)) { throw new PaymentsError( PaymentsErrorCode.TxInsufficientBalance, `${fromPayport.address} Insufficient balance (${balanceEth}) to send ${amountEth} including fee of ${feeOption.feeMain}`, ) } if (feeWei.gt(maxFeePercent.times(amountWei))) { throw new PaymentsError( PaymentsErrorCode.TxFeeTooHigh, `${fromPayport.address} Sweep fee (${feeMain}) exceeds max fee percent (${maxFeePercent}%) of send amount (${amountEth})`, ) } } else { if (balanceWei.isLessThan(feeWei)) { throw new PaymentsError( PaymentsErrorCode.TxFeeTooHigh, `${fromPayport.address} Insufficient balance (${balanceEth}) to pay contract deploy fee of ${feeOption.feeMain}`, ) } } const result: EthereumUnsignedTransaction = { id: null, status: TransactionStatus.Unsigned, fromAddress: fromPayport.address, fromIndex: from, toAddress: serviceFlag ? '' : toPayport.address, toIndex, toExtraId: null, amount: serviceFlag ? '' : this.toMainDenomination(amountWei), fee: feeOption.feeMain, targetFeeLevel: feeOption.targetFeeLevel, targetFeeRate: feeOption.targetFeeRate, targetFeeRateType: feeOption.targetFeeRateType, weight: amountOfGas, sequenceNumber: nonce.toString(), data: { ...txConfig, value: `0x${amountWei.toString(16)}`, gas: `0x${amountOfGas.toString(16)}`, gasPrice: `0x${(new BigNumber(feeOption.gasPrice)).toString(16)}`, nonce: `0x${(new BigNumber(nonce)).toString(16)}`, }, } this.logger.debug('createTransactionObject result', result) return result } } export default BaseEthereumPayments