diff --git a/src/integration/blockchain/shared/evm/evm-client.ts b/src/integration/blockchain/shared/evm/evm-client.ts index 974742d8a2..c2a3caa668 100644 --- a/src/integration/blockchain/shared/evm/evm-client.ts +++ b/src/integration/blockchain/shared/evm/evm-client.ts @@ -867,14 +867,14 @@ export abstract class EvmClient extends BlockchainClient { amount: number, nonce?: number, ): Promise { - const gasLimit = +(await this.getTokenGasLimitForContact(contract, toAddress)); + const token = await this.getTokenByContract(contract); + const targetAmount = EvmUtil.toWeiAmount(amount, token.decimals); + + const gasLimit = +(await this.getTokenGasLimitForContact(contract, toAddress, targetAmount)); const gasPrice = +(await this.getRecommendedGasPrice()); const currentNonce = await this.getNonce(fromAddress); const txNonce = nonce ?? currentNonce; - const token = await this.getTokenByContract(contract); - const targetAmount = EvmUtil.toWeiAmount(amount, token.decimals); - const tx = await contract.transfer(toAddress, targetAmount, { gasPrice, gasLimit, nonce: txNonce }); if (txNonce >= currentNonce) this.setNonce(fromAddress, txNonce + 1); diff --git a/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts b/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts index 7592fd8b57..7d76a8ad9f 100644 --- a/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts +++ b/src/subdomains/supporting/payout/entities/__tests__/payout-order.entity.spec.ts @@ -110,6 +110,23 @@ describe('PayoutOrder', () => { }); }); + describe('#rollbackPayout(...)', () => { + it('clears payoutTxId and resets status to PayoutOrderStatus.PREPARATION_CONFIRMED for retry', () => { + const entity = createCustomPayoutOrder({ + payoutTxId: 'PID_01', + status: PayoutOrderStatus.PAYOUT_PENDING, + }); + + expect(entity.payoutTxId).toBe('PID_01'); + expect(entity.status).toBe(PayoutOrderStatus.PAYOUT_PENDING); + + entity.rollbackPayout(); + + expect(entity.payoutTxId).toBeNull(); + expect(entity.status).toBe(PayoutOrderStatus.PREPARATION_CONFIRMED); + }); + }); + describe('#complete(...)', () => { it('sets status to PayoutOrderStatus.COMPLETE', () => { const entity = createCustomPayoutOrder({ diff --git a/src/subdomains/supporting/payout/entities/payout-order.entity.ts b/src/subdomains/supporting/payout/entities/payout-order.entity.ts index 5108e74076..7a4b9c0d8a 100644 --- a/src/subdomains/supporting/payout/entities/payout-order.entity.ts +++ b/src/subdomains/supporting/payout/entities/payout-order.entity.ts @@ -122,6 +122,13 @@ export class PayoutOrder extends IEntity { return this; } + rollbackPayout(): this { + this.payoutTxId = null; + this.status = PayoutOrderStatus.PREPARATION_CONFIRMED; + + return this; + } + recordPayoutFee(payoutFeeAsset: Asset, payoutFeeAmount: number, payoutFeeAmountChf: number): this { this.payoutFeeAsset = payoutFeeAsset; this.payoutFeeAmount = payoutFeeAmount; diff --git a/src/subdomains/supporting/payout/interfaces/index.ts b/src/subdomains/supporting/payout/interfaces/index.ts index c44a554c69..3a4d795bc7 100644 --- a/src/subdomains/supporting/payout/interfaces/index.ts +++ b/src/subdomains/supporting/payout/interfaces/index.ts @@ -13,3 +13,8 @@ export interface FeeResult { asset: Asset; amount: number; } + +export type PayoutTxStatus = + | { state: 'pending' } + | { state: 'complete'; fee: number } + | { state: 'failed'; isOutOfGas: boolean }; diff --git a/src/subdomains/supporting/payout/services/payout-evm.service.ts b/src/subdomains/supporting/payout/services/payout-evm.service.ts index d0c80227c6..d3a28ea29d 100644 --- a/src/subdomains/supporting/payout/services/payout-evm.service.ts +++ b/src/subdomains/supporting/payout/services/payout-evm.service.ts @@ -1,6 +1,7 @@ import { EvmClient } from 'src/integration/blockchain/shared/evm/evm-client'; import { EvmService } from 'src/integration/blockchain/shared/evm/evm.service'; import { Asset } from 'src/shared/models/asset/asset.entity'; +import { PayoutTxStatus } from '../interfaces'; export abstract class PayoutEvmService { private readonly client: EvmClient; @@ -17,11 +18,18 @@ export abstract class PayoutEvmService { return this.client.sendTokenFromDex(address, tokenName, amount, nonce); } - async getPayoutCompletionData(txHash: string): Promise<[boolean, number]> { - const isComplete = await this.client.isTxComplete(txHash); - const payoutFee = isComplete ? await this.client.getTxActualFee(txHash) : 0; + async getPayoutCompletionData(txHash: string): Promise { + const receipt = await this.client.getTxReceipt(txHash); + if (!receipt || receipt.confirmations <= 0) return { state: 'pending' }; - return [isComplete, payoutFee]; + if (receipt.status === 1) { + const fee = await this.client.getTxActualFee(txHash); + return { state: 'complete', fee }; + } + + const tx = await this.client.getTx(txHash); + const isOutOfGas = tx ? receipt.gasUsed.eq(tx.gasLimit) : false; + return { state: 'failed', isOutOfGas }; } async getCurrentGasForCoinTransaction(): Promise { diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base-coin.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base-coin.strategy.ts index e3dc144e8c..4a1d127423 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base-coin.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base-coin.strategy.ts @@ -38,8 +38,4 @@ export class BaseCoinStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getBaseCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.baseService.getPayoutCompletionData(payoutTxId); - } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base-token.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base-token.strategy.ts index f077615dee..31d752fd57 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base-token.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base-token.strategy.ts @@ -38,8 +38,4 @@ export class BaseTokenStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getBaseCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.baseService.getPayoutCompletionData(payoutTxId); - } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts index 4a6e9f4fe0..d9d60acc45 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/base/evm.strategy.ts @@ -4,7 +4,7 @@ import { DfxLogger } from 'src/shared/services/dfx-logger'; import { DisabledProcess, Process } from 'src/shared/services/process.service'; import { AsyncCache, CacheItemResetPeriod } from 'src/shared/utils/async-cache'; import { Util } from 'src/shared/utils/util'; -import { FeeResult } from 'src/subdomains/supporting/payout/interfaces'; +import { FeeResult, PayoutTxStatus } from 'src/subdomains/supporting/payout/interfaces'; import { PriceCurrency, PriceValidity } from 'src/subdomains/supporting/pricing/services/pricing.service'; import { PayoutOrder } from '../../../../entities/payout-order.entity'; import { PayoutOrderRepository } from '../../../../repositories/payout-order.repository'; @@ -52,19 +52,34 @@ export abstract class EvmStrategy extends PayoutStrategy { async checkPayoutCompletionData(orders: PayoutOrder[]): Promise { for (const order of orders) { try { - const [isComplete, payoutFee] = await this.getPayoutCompletionData(order.payoutTxId); + const status = await this.getPayoutCompletionData(order.payoutTxId); - if (isComplete) { + if (status.state === 'complete') { order.complete(); const feeAsset = await this.feeAsset(); const price = await this.pricingService.getPrice(feeAsset, PriceCurrency.CHF, PriceValidity.ANY); - order.recordPayoutFee(feeAsset, payoutFee, price.convert(payoutFee, Config.defaultVolumeDecimal)); + order.recordPayoutFee(feeAsset, status.fee, price.convert(status.fee, Config.defaultVolumeDecimal)); await this.payoutOrderRepo.save(order); - } else if (await this.canRetryFailedPayout(order)) { - // TX expired (not on-chain, not in mempool) - retry immediately, no gas costs incurred - this.logger.info(`Payout order ${order.id} has expired TX (${order.payoutTxId}), retrying immediately`); + } else if (status.state === 'failed' && !status.isOutOfGas) { + // Non-recoverable on-chain revert (paused contract, balance mismatch, etc.). + // Designate for investigation - processFailedOrders will mail and move to PayoutUncertain. + this.logger.error(`Payout order ${order.id} reverted on-chain (tx ${order.payoutTxId}, not OOG)`); + order.designatePayout(); + await this.payoutOrderRepo.save(order); + } else if (await this.canRetryFailedPayout(order, status)) { + if (status.state === 'failed' && status.isOutOfGas) { + // OOG: free the spent nonce so the retry gets a fresh one + this.logger.warn( + `Payout order ${order.id} failed with out-of-gas (tx ${order.payoutTxId}), retrying with fresh nonce`, + ); + order.rollbackPayout(); + await this.payoutOrderRepo.save(order); + } else { + // TX expired (not on-chain, not in mempool) - retry immediately, no gas costs incurred + this.logger.info(`Payout order ${order.id} has expired TX (${order.payoutTxId}), retrying immediately`); + } await this.doPayout([order]); } } catch (e) { @@ -73,7 +88,7 @@ export abstract class EvmStrategy extends PayoutStrategy { } } - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { + protected async getPayoutCompletionData(payoutTxId: string): Promise { return this.payoutEvmService.getPayoutCompletionData(payoutTxId); } @@ -83,11 +98,14 @@ export abstract class EvmStrategy extends PayoutStrategy { } } - override async canRetryFailedPayout(order: PayoutOrder): Promise { + override async canRetryFailedPayout(order: PayoutOrder, status?: PayoutTxStatus): Promise { if (!order.payoutTxId) return false; - if (Util.hoursDiff(order.updated) < 1) return false; + // OOG-mined: retry immediately, re-estimation should resolve the state divergence + if (status?.state === 'failed' && status.isOutOfGas) return true; + // Expired in mempool: retry after 1h cooldown + if (Util.hoursDiff(order.updated) < 1) return false; return this.payoutEvmService.isTxExpired(order.payoutTxId); } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-coin.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-coin.strategy.ts index ef65303e90..c11fde54d7 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-coin.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-coin.strategy.ts @@ -38,8 +38,4 @@ export class GnosisCoinStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getGnosisCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.gnosisService.getPayoutCompletionData(payoutTxId); - } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-token.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-token.strategy.ts index f614e636d8..8230636862 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-token.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/gnosis-token.strategy.ts @@ -38,8 +38,4 @@ export class GnosisTokenStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getGnosisCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.gnosisService.getPayoutCompletionData(payoutTxId); - } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/optimism-coin.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/optimism-coin.strategy.ts index 6e45107216..a2621bb579 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/optimism-coin.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/optimism-coin.strategy.ts @@ -38,8 +38,4 @@ export class OptimismCoinStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getOptimismCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.optimismService.getPayoutCompletionData(payoutTxId); - } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/optimism-token.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/optimism-token.strategy.ts index dbd68ed3a8..04ab3efd68 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/optimism-token.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/optimism-token.strategy.ts @@ -38,8 +38,4 @@ export class OptimismTokenStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getOptimismCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.optimismService.getPayoutCompletionData(payoutTxId); - } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/polygon-coin.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/polygon-coin.strategy.ts index d978238bb5..132fcee1a8 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/polygon-coin.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/polygon-coin.strategy.ts @@ -38,8 +38,4 @@ export class PolygonCoinStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getPolygonCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.polygonService.getPayoutCompletionData(payoutTxId); - } } diff --git a/src/subdomains/supporting/payout/strategies/payout/impl/polygon-token.strategy.ts b/src/subdomains/supporting/payout/strategies/payout/impl/polygon-token.strategy.ts index beb8c8ca28..5a029b7c4b 100644 --- a/src/subdomains/supporting/payout/strategies/payout/impl/polygon-token.strategy.ts +++ b/src/subdomains/supporting/payout/strategies/payout/impl/polygon-token.strategy.ts @@ -38,8 +38,4 @@ export class PolygonTokenStrategy extends EvmStrategy { protected getFeeAsset(): Promise { return this.assetService.getPolygonCoin(); } - - protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> { - return this.polygonService.getPayoutCompletionData(payoutTxId); - } }