UNPKG

@faast/ethereum-payments

Version:

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

455 lines (398 loc) 16.2 kB
import InputDataDecoder from 'ethereum-input-data-decoder' import { BigNumber } from 'bignumber.js' import type { TransactionReceipt, Transaction } from 'web3-core' import Contract from 'web3-eth-contract' import { deriveAddress } from './deriveAddress' import { BalanceResult, TransactionStatus, ResolveablePayport, Payport, PaymentsError, PaymentsErrorCode, } from '@faast/payments-common' import { BaseErc20PaymentsConfig, EthereumUnsignedTransaction, EthereumTransactionOptions, EthereumTransactionInfo, } from '../types' import { MIN_CONFIRMATIONS, TOKEN_WALLET_ABI, TOKEN_WALLET_ABI_LEGACY, TOKEN_METHODS_ABI, DEPOSIT_KEY_INDEX, } from '../constants' import { BaseEthereumPayments } from '../BaseEthereumPayments' import * as SIGNATURE from './constants' import { Numeric } from '@faast/ts-common' export abstract class BaseErc20Payments <Config extends BaseErc20PaymentsConfig> extends BaseEthereumPayments<Config> { public tokenAddress: string public depositKeyIndex: number public masterAddress: string constructor(config: Config) { super(config) this.tokenAddress = config.tokenAddress.toLowerCase() this.masterAddress = (config.masterAddress || '').toLowerCase() this.depositKeyIndex = (typeof config.depositKeyIndex === 'undefined') ? DEPOSIT_KEY_INDEX : config.depositKeyIndex } abstract getAddressSalt(index: number): string abstract getPayport(index: number): Promise<Payport> private newContract(...args: ConstructorParameters<typeof Contract>) { const contract = new Contract(...args) contract.setProvider(this.eth.currentProvider) return contract } async getBalance(resolveablePayport: ResolveablePayport): Promise<BalanceResult> { const payport = await this.resolvePayport(resolveablePayport) const contract = this.newContract(TOKEN_METHODS_ABI, this.tokenAddress) const balance = await contract.methods.balanceOf(payport.address).call({}) const sweepable = await this.isSweepableBalance(this.toMainDenomination(balance)) return { confirmedBalance: this.toMainDenomination(balance), unconfirmedBalance: '0', spendableBalance: this.toMainDenomination(balance), sweepable, requiresActivation: false, } } async isSweepableBalance(balance: Numeric): Promise<boolean> { // Any ERC20 balance greater than 0 is sweepable return new BigNumber(balance).isGreaterThan(0) } async createTransaction( from: number | string, to: ResolveablePayport, amountMain: string, options: EthereumTransactionOptions = {}, ): Promise<EthereumUnsignedTransaction> { this.logger.debug('createTransaction', from, to, amountMain) const fromTo = await this.resolveFromTo(from as number, to) const txFromAddress = fromTo.fromAddress.toLowerCase() const amountBase = this.toBaseDenominationBigNumber(amountMain) const contract = this.newContract(TOKEN_METHODS_ABI, this.tokenAddress) const txData = contract.methods.transfer(fromTo.toAddress, `0x${amountBase.toString(16)}`).encodeABI() const amountOfGas = await this.gasOptionOrEstimate(options, { from: fromTo.fromAddress, to: this.tokenAddress, data: txData, }, 'TOKEN_TRANSFER') const feeOption = await this.resolveFeeOption(options, amountOfGas) const feeBase = new BigNumber(feeOption.feeBase) const nonce = options.sequenceNumber || await this.getNextSequenceNumber(txFromAddress) let ethBalance = await this.getEthBaseBalance(fromTo.fromAddress) if (feeBase.isGreaterThan(ethBalance)) { throw new PaymentsError( PaymentsErrorCode.TxInsufficientBalance, `Insufficient ETH balance (${this.toMainDenominationEth(ethBalance)}) to pay transaction fee of ${feeOption.feeMain}`, ) } const transactionObject = { from: fromTo.fromAddress.toLowerCase(), to: this.tokenAddress, data: txData, value: '0x0', gas: `0x${amountOfGas.toString(16)}`, gasPrice: `0x${(new BigNumber(feeOption.gasPrice)).toString(16)}`, nonce: `0x${(new BigNumber(nonce)).toString(16)}`, } this.logger.debug('transactionObject', transactionObject) return { status: TransactionStatus.Unsigned, id: null, fromAddress: fromTo.fromAddress.toLowerCase(), toAddress: fromTo.toAddress.toLowerCase(), toExtraId: null, fromIndex: fromTo.fromIndex, toIndex: fromTo.toIndex, amount: amountMain, fee: feeOption.feeMain, targetFeeLevel: feeOption.targetFeeLevel, targetFeeRate: feeOption.targetFeeRate, targetFeeRateType: feeOption.targetFeeRateType, sequenceNumber: nonce.toString(), weight: amountOfGas, data: transactionObject, } } async createSweepTransaction( from: string | number, to: ResolveablePayport, options: EthereumTransactionOptions = {}, ): Promise<EthereumUnsignedTransaction> { this.logger.debug('createSweepTransaction', from, to) // NOTE sweep from hot wallet which is not guaranteed to support sweep contract execution if (from === 0) { const { confirmedBalance } = await this.getBalance(from as number) return this.createTransaction(from, to, confirmedBalance, options) } const { address: signerAddress } = await this.resolvePayport(this.depositKeyIndex) const { address: toAddress } = await this.resolvePayport(to) let txData: string let target: string let fromAddress: string if (typeof from === 'string') { // deployable wallet contract fromAddress = from.toLowerCase() target = from.toLowerCase() const contract = this.newContract(TOKEN_WALLET_ABI_LEGACY, from) txData = contract.methods.sweep(this.tokenAddress, toAddress).encodeABI() } else { // create2 selfdesctructuble proxy contract fromAddress = (await this.getPayport(from)).address target = this.masterAddress const { confirmedBalance } = await this.getBalance(fromAddress) const balance = this.toBaseDenomination(confirmedBalance) const contract = this.newContract(TOKEN_WALLET_ABI, this.masterAddress) const salt = this.getAddressSalt(from) txData = contract.methods.proxyTransfer(salt, this.tokenAddress, toAddress, balance).encodeABI() } const amountOfGas = await this.gasOptionOrEstimate(options, { from: signerAddress, to: target, data: txData }, 'TOKEN_SWEEP') const feeOption = await this.resolveFeeOption(options, amountOfGas) const feeBase = new BigNumber(feeOption.feeBase) let ethBalance = await this.getEthBaseBalance(signerAddress) if (feeBase.isGreaterThan(ethBalance)) { throw new PaymentsError( PaymentsErrorCode.TxInsufficientBalance, `Insufficient ETH balance (${this.toMainDenominationEth(ethBalance)}) at owner address ${signerAddress} ` + `to sweep contract ${from} with fee of ${feeOption.feeMain} ETH`) } const { confirmedBalance: tokenBalanceMain } = await this.getBalance({ address: fromAddress }) const tokenBalanceBase = this.toBaseDenominationBigNumber(tokenBalanceMain) if (tokenBalanceBase.isLessThan(0)) { throw new PaymentsError( PaymentsErrorCode.TxInsufficientBalance, `Insufficient token balance (${tokenBalanceMain}) to sweep`, ) } const nonce = options.sequenceNumber || await this.getNextSequenceNumber(signerAddress) const transactionObject = { from: signerAddress, to: target, data: txData, value: '0x0', nonce: `0x${(new BigNumber(nonce)).toString(16)}`, gasPrice: `0x${(new BigNumber(feeOption.gasPrice)).toString(16)}`, gas: `0x${amountOfGas.toString(16)}`, } return { status: TransactionStatus.Unsigned, id: null, fromAddress, toAddress, toExtraId: null, fromIndex: this.depositKeyIndex, toIndex: typeof to === 'number' ? to : null, amount: tokenBalanceMain, fee: feeOption.feeMain, targetFeeLevel: feeOption.targetFeeLevel, targetFeeRate: feeOption.targetFeeRate, targetFeeRateType: feeOption.targetFeeRateType, sequenceNumber: nonce.toString(), weight: amountOfGas, data: transactionObject, } } private getErc20TransferLogAmount(txReceipt: TransactionReceipt): string { const transferLog = txReceipt.logs.find((log) => log.topics[0] === SIGNATURE.LOG_TOPIC0_ERC20_SWEEP) if (!transferLog) { this.logger.warn(`Transaction ${txReceipt.transactionHash} was an ERC20 sweep but cannot find log for Transfer event`) return '0' } return this.toMainDenomination(transferLog.data) } async getTransactionInfo(txid: string): Promise<EthereumTransactionInfo> { const minConfirmations = MIN_CONFIRMATIONS const tx: Transaction | null = await this._retryDced(() => this.eth.getTransaction(txid)) if (!tx) { throw new Error(`Transaction ${txid} not found`) } if (!tx.input) { throw new Error(`Transaction ${txid} has no input data so it can't be an ERC20 tx`) } const currentBlockNumber = await this.getCurrentBlockNumber() let txReceipt: TransactionReceipt | null = await this._retryDced(() => this.eth.getTransactionReceipt(txid)) let txBlock: any = null let isConfirmed = false let confirmationTimestamp: Date | null = null let confirmations = 0 if (tx.blockNumber) { confirmations = currentBlockNumber - tx.blockNumber if (confirmations > minConfirmations) { isConfirmed = true txBlock = await this._retryDced(() => this.eth.getBlock(tx.blockNumber!)) confirmationTimestamp = new Date(txBlock.timestamp) } } let status: TransactionStatus = TransactionStatus.Pending let isExecuted = false if (isConfirmed) { status = TransactionStatus.Confirmed isExecuted = true // No trust to types description of web3 if (txReceipt && txReceipt.hasOwnProperty('status') && (txReceipt.status === false || txReceipt.status.toString() === 'false')) { status = TransactionStatus.Failed isExecuted = false } } let fromAddress = tx.from.toLowerCase() let toAddress = '' let amount = '' if (tx.input.startsWith(SIGNATURE.ERC20_TRANSFER)) { if((tx.to || '').toLowerCase() !== this.tokenAddress.toLowerCase()) { throw new Error(`Transaction ${txid} was sent to different contract: ${tx.to}, Expected: ${this.tokenAddress}`) } const tokenDecoder = new InputDataDecoder(TOKEN_METHODS_ABI) const txData = tokenDecoder.decodeData(tx.input) toAddress = this.web3.utils.toChecksumAddress(txData.inputs[0]).toLowerCase() amount = this.toMainDenomination(txData.inputs[1].toString()) if (txReceipt) { const actualAmount = this.getErc20TransferLogAmount(txReceipt) if (isExecuted && amount !== actualAmount) { this.logger.warn( `Transcation ${txid} tried to transfer ${amount} but only ${actualAmount} was actually transferred` ) } } } else if (tx.input.startsWith(SIGNATURE.ERC20_SWEEP_CONTRACT_DEPLOY) || tx.input.startsWith(SIGNATURE.ERC20_SWEEP_CONTRACT_DEPLOY_LEGACY)) { amount = '0' } else if (tx.input.startsWith(SIGNATURE.ERC20_PROXY)) { amount = '0' } else if (tx.input.startsWith(SIGNATURE.ERC20_SWEEP)) { const tokenDecoder = new InputDataDecoder(TOKEN_WALLET_ABI) const txData = tokenDecoder.decodeData(tx.input) if (txData.inputs.length !== 4) { throw new Error(`Transaction ${txid} has not recognized number of inputs ${txData.inputs.length}`) } // For ERC20 sweeps: // tx.from is the contract address // inputs[0] is salt // inputs[1] is the ERC20 contract address (this.tokenAddress) // inputs[2] is the recipient of the funds (toAddress) // inputs[3] is the amount const sweepContractAddress = tx.to if (!sweepContractAddress) { throw new Error(`Transaction ${txid} should have a to address destination`) } const addr = deriveAddress( this.masterAddress, `0x${txData.inputs[0].toString('hex')}`, true ) fromAddress = this.web3.utils.toChecksumAddress(addr).toLowerCase() toAddress = this.web3.utils.toChecksumAddress(txData.inputs[2]).toLowerCase() if (txReceipt) { amount = this.getErc20TransferLogAmount(txReceipt) } else { amount = this.toMainDenomination(txData.inputs[3].toString()) } } else if (tx.input.startsWith(SIGNATURE.ERC20_SWEEP_LEGACY)) { const tokenDecoder = new InputDataDecoder(TOKEN_WALLET_ABI_LEGACY) const txData = tokenDecoder.decodeData(tx.input) if (txData.inputs.length !== 2) { throw new Error(`Transaction ${txid} has not recognized number of inputs ${txData.inputs.length}`) } // For ERC20 legacy sweeps: // tx.to is the sweep contract address and source of funds (fromAddress) // tx.from is the contract owner address // inputs[0] is the ERC20 contract address (this.tokenAddress) // inputs[1] is the recipient of the funds (toAddress) const sweepContractAddress = tx.to if (!sweepContractAddress) { throw new Error(`Transaction ${txid} should have a to address destination`) } fromAddress = this.web3.utils.toChecksumAddress(sweepContractAddress).toLowerCase() toAddress = this.web3.utils.toChecksumAddress(txData.inputs[1]).toLowerCase() if (txReceipt) { amount = this.getErc20TransferLogAmount(txReceipt) } } else { throw new Error(`Transaction ${txid} is not ERC20 transaction neither swap`) } // NOTE: for the sake of consistent schema return if (!txReceipt) { txReceipt = { transactionHash: tx.hash, from: tx.from || '', to: toAddress, status: true, blockNumber: 0, cumulativeGasUsed: 0, gasUsed: 0, transactionIndex: 0, blockHash: '', logs: [], logsBloom: '' } return { id: txid, amount, toAddress, fromAddress: tx.from, toExtraId: null, fromIndex: null, toIndex: null, fee: this.toMainDenominationEth((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, ...txReceipt, currentBlock: currentBlockNumber }, } } return { id: txid, amount, toAddress, fromAddress, toExtraId: null, fromIndex: null, toIndex: null, fee: this.toMainDenominationEth((new BigNumber(tx.gasPrice)).multipliedBy(txReceipt.gasUsed)), sequenceNumber: tx.nonce, weight: txReceipt.gasUsed, // XXX if tx was confirmed but not accepted by network isExecuted must be false isExecuted, isConfirmed, confirmations, confirmationId: tx.blockHash, confirmationTimestamp, status, currentBlockNumber: currentBlockNumber, data: { ...tx, ...txReceipt, currentBlock: currentBlockNumber }, } } async getNextSequenceNumber(payport: ResolveablePayport): Promise<string> { const resolvedPayport = await this.resolvePayport(payport) const sequenceNumber = await this.gasStation.getNonce(resolvedPayport.address) return sequenceNumber } private async getEthBaseBalance(address: string): Promise<BigNumber> { const balanceBase = await this._retryDced(() => this.eth.getBalance(address)) return new BigNumber(balanceBase) } private logTopicToAddress(value: string): string { return `0x${value.slice(value.length - 40)}` } } export default BaseErc20Payments