UNPKG

@faast/tron-payments

Version:

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

366 lines (338 loc) 12.3 kB
import TronWeb, { Transaction as TronTransaction } from 'tronweb' import { pick, get, cloneDeep } from 'lodash' import { BalanceResult, BasePayments, TransactionStatus, FeeLevel, FeeOption, FeeRateType, FeeOptionCustom, ResolvedFeeOption, Payport, FromTo, ResolveablePayport, } from '@faast/payments-common' import { isType, DelegateLogger, Logger } from '@faast/ts-common' import { TronTransactionInfo, TronUnsignedTransaction, TronSignedTransaction, TronBroadcastResult, CreateTransactionOptions, GetPayportOptions, BaseTronPaymentsConfig, TronWebTransaction, } from './types' import { toBaseDenominationNumber, isValidAddress, isValidPayport } from './helpers' import { toError } from './utils' import { DEFAULT_FULL_NODE, DEFAULT_EVENT_SERVER, DEFAULT_SOLIDITY_NODE, MIN_BALANCE_SUN, MIN_BALANCE_TRX, PACKAGE_NAME, DEFAULT_FEE_LEVEL, } from './constants' import { TronPaymentsUtils } from './TronPaymentsUtils' export abstract class BaseTronPayments<Config extends BaseTronPaymentsConfig> extends TronPaymentsUtils implements BasePayments<Config, TronUnsignedTransaction, TronSignedTransaction, TronBroadcastResult, TronTransactionInfo> { // You may notice that many function blocks are enclosed in a try/catch. // I had to do this because tronweb thinks it's a good idea to throw // strings instead of Errors and now we need to convert them all ourselves // to be consistent. fullNode: string solidityNode: string eventServer: string tronweb: TronWeb constructor(config: Config) { super(config) this.fullNode = config.fullNode || DEFAULT_FULL_NODE this.solidityNode = config.solidityNode || DEFAULT_SOLIDITY_NODE this.eventServer = config.eventServer || DEFAULT_EVENT_SERVER this.logger = new DelegateLogger(config.logger, PACKAGE_NAME) this.tronweb = new TronWeb(this.fullNode, this.solidityNode, this.eventServer) } abstract getFullConfig(): Config abstract getPublicConfig(): Config abstract getAccountId(index: number): string abstract getAccountIds(): string[] abstract async getPayport(index: number, options?: GetPayportOptions): Promise<Payport> abstract async getPrivateKey(index: number): Promise<string> requiresBalanceMonitor() { return false } async getBalance(resolveablePayport: ResolveablePayport): Promise<BalanceResult> { try { const payport = await this.resolvePayport(resolveablePayport) const balanceSun = await this.tronweb.trx.getBalance(payport.address) this.logger.debug(`trx.getBalance(${payport.address}) -> ${balanceSun}`) const sweepable = this.canSweepBalance(balanceSun) return { confirmedBalance: this.toMainDenomination(balanceSun).toString(), unconfirmedBalance: '0', sweepable, } } catch (e) { throw toError(e) } } async resolveFeeOption(feeOption: FeeOption): Promise<ResolvedFeeOption> { let targetFeeLevel: FeeLevel if (isType(FeeOptionCustom, feeOption)) { if (feeOption.feeRate !== '0') { throw new Error('tron-payments custom fees are unsupported') } targetFeeLevel = FeeLevel.Custom } else { targetFeeLevel = feeOption.feeLevel || DEFAULT_FEE_LEVEL } return { targetFeeLevel, targetFeeRate: '0', targetFeeRateType: FeeRateType.Base, feeBase: '0', feeMain: '0', } } async createSweepTransaction( from: number, to: ResolveablePayport, options: CreateTransactionOptions = {}, ): Promise<TronUnsignedTransaction> { this.logger.debug('createSweepTransaction', from, to) try { const { fromAddress, fromIndex, fromPayport, toAddress, toIndex } = await this.resolveFromTo(from, to) const { targetFeeLevel, targetFeeRate, targetFeeRateType, feeBase, feeMain } = await this.resolveFeeOption( options, ) const feeSun = Number.parseInt(feeBase) const { confirmedBalance: balanceTrx } = await this.getBalance(fromPayport) const balanceSun = toBaseDenominationNumber(balanceTrx) if (!this.canSweepBalance(balanceSun)) { throw new Error( `Insufficient balance (${balanceTrx}) to sweep with fee of ${feeMain} ` + `while maintaining a minimum required balance of ${MIN_BALANCE_TRX}`, ) } const amountSun = balanceSun - feeSun - MIN_BALANCE_SUN const amountTrx = this.toMainDenomination(amountSun) const tx = await this.tronweb.transactionBuilder.sendTrx(toAddress, amountSun, fromAddress) return { id: tx.txID, fromAddress, toAddress, toExtraId: null, fromIndex, toIndex, amount: amountTrx, fee: feeMain, targetFeeLevel, targetFeeRate, targetFeeRateType, status: TransactionStatus.Unsigned, data: tx, } } catch (e) { throw toError(e) } } async createTransaction( from: number, to: ResolveablePayport, amountTrx: string, options: CreateTransactionOptions = {}, ): Promise<TronUnsignedTransaction> { this.logger.debug('createTransaction', from, to, amountTrx) try { const { fromAddress, fromIndex, fromPayport, toAddress, toIndex } = await this.resolveFromTo(from, to) const { targetFeeLevel, targetFeeRate, targetFeeRateType, feeBase, feeMain } = await this.resolveFeeOption( options, ) const feeSun = Number.parseInt(feeBase) const { confirmedBalance: balanceTrx } = await this.getBalance(fromPayport) const balanceSun = toBaseDenominationNumber(balanceTrx) const amountSun = toBaseDenominationNumber(amountTrx) if (balanceSun - feeSun - MIN_BALANCE_SUN < amountSun) { throw new Error( `Insufficient balance (${balanceTrx}) to send ${amountTrx} including fee of ${feeMain} ` + `while maintaining a minimum required balance of ${MIN_BALANCE_TRX}`, ) } const tx = await this.tronweb.transactionBuilder.sendTrx(toAddress, amountSun, fromAddress) return { id: tx.txID, fromAddress, toAddress, toExtraId: null, fromIndex, toIndex, amount: amountTrx, fee: feeMain, targetFeeLevel, targetFeeRate, targetFeeRateType, status: TransactionStatus.Unsigned, data: tx, } } catch (e) { throw toError(e) } } async signTransaction(unsignedTx: TronUnsignedTransaction): Promise<TronSignedTransaction> { try { const fromPrivateKey = await this.getPrivateKey(unsignedTx.fromIndex) const unsignedRaw = cloneDeep(unsignedTx.data) as TronWebTransaction // tron modifies unsigned object const signedTx = await this.tronweb.trx.sign(unsignedRaw, fromPrivateKey) return { ...unsignedTx, status: TransactionStatus.Signed, data: signedTx, } } catch (e) { throw toError(e) } } async broadcastTransaction(tx: TronSignedTransaction): Promise<TronBroadcastResult> { /* * I’ve discovered that tron nodes like to “remember” every transaction you give it. * If you try broadcasting an invalid TX the first time you’ll get a `SIGERROR` but * every subsequent broadcast gives a `DUP_TRANSACTION_ERROR`. Which is the exact same * error you get after rebroadcasting a valid transaction. And to make things worse, * if you try to look up the invalid transaction by ID it tells you `Transaction not found`. * So in order to actually determine the status of a broadcast the logic becomes: * `success status` -> broadcast succeeded * `error status` -> broadcast failed * `(DUP_TRANSACTION_ERROR && Transaction found)` -> tx already broadcast * `(DUP_TRANASCTION_ERROR && Transaction not found)` -> tx was probably invalid? Maybe? Who knows… */ try { const status = await this.tronweb.trx.sendRawTransaction(tx.data as TronWebTransaction) let success = false let rebroadcast = false if (status.result || status.code === 'SUCCESS') { success = true } else { try { await this.tronweb.trx.getTransaction(tx.id) success = true rebroadcast = true } catch (e) {} } if (success) { return { id: tx.id, rebroadcast, } } else { let statusCode: string | undefined = status.code if (statusCode === 'DUP_TRANSACTION_ERROR') { statusCode = 'DUP_TX_BUT_TX_NOT_FOUND_SO_PROBABLY_INVALID_TX_ERROR' } this.logger.warn(`Tron broadcast tx unsuccessful ${tx.id}`, status) throw new Error(`Failed to broadcast transaction: ${statusCode} ${status.message}`) } } catch (e) { throw toError(e) } } async getTransactionInfo(txid: string): Promise<TronTransactionInfo> { try { const [tx, txInfo, currentBlock] = await Promise.all([ this.tronweb.trx.getTransaction(txid), this.tronweb.trx.getTransactionInfo(txid), this.tronweb.trx.getCurrentBlock(), ]) const { amountTrx, fromAddress, toAddress } = this.extractTxFields(tx) const contractRet = get(tx, 'ret[0].contractRet') const isExecuted = contractRet === 'SUCCESS' const block = txInfo.blockNumber || null const feeTrx = this.toMainDenomination(txInfo.fee || 0) const currentBlockNumber = get(currentBlock, 'block_header.raw_data.number', 0) const confirmations = currentBlockNumber && block ? currentBlockNumber - block : 0 const isConfirmed = confirmations > 0 const confirmationTimestamp = txInfo.blockTimeStamp ? new Date(txInfo.blockTimeStamp) : null let status: TransactionStatus = TransactionStatus.Pending if (isConfirmed) { if (!isExecuted) { status = TransactionStatus.Failed } status = TransactionStatus.Confirmed } return { id: tx.txID, amount: amountTrx, toAddress, fromAddress, toExtraId: null, fromIndex: null, toIndex: null, fee: feeTrx, isExecuted, isConfirmed, confirmations, confirmationId: block ? String(block) : null, confirmationTimestamp, status, data: { ...tx, ...txInfo, currentBlock: pick(currentBlock, 'block_header', 'blockID'), }, } } catch (e) { throw toError(e) } } // HELPERS private canSweepBalance(balanceSun: number): boolean { return balanceSun > MIN_BALANCE_SUN } private extractTxFields(tx: TronTransaction) { const contractParam = get(tx, 'raw_data.contract[0].parameter.value') if (!(contractParam && typeof contractParam.amount === 'number')) { throw new Error('Unable to get transaction') } const amountSun = contractParam.amount || 0 const amountTrx = this.toMainDenomination(amountSun) const toAddress = this.tronweb.address.fromHex(contractParam.to_address) const fromAddress = this.tronweb.address.fromHex(contractParam.owner_address) return { amountTrx, amountSun, toAddress, fromAddress, } } async resolvePayport(payport: ResolveablePayport): Promise<Payport> { if (typeof payport === 'number') { return this.getPayport(payport) } else if (typeof payport === 'string') { if (!isValidAddress(payport)) { throw new Error(`Invalid TRON address: ${payport}`) } return { address: payport } } if (!isValidPayport(payport)) { throw new Error(`Invalid TRON payport: ${JSON.stringify(payport)}`) } return payport } 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, } } } export default BaseTronPayments