Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions src/integration/blockchain/shared/evm/evm-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -867,14 +867,14 @@ export abstract class EvmClient extends BlockchainClient {
amount: number,
nonce?: number,
): Promise<string> {
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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions src/subdomains/supporting/payout/interfaces/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ export interface FeeResult {
asset: Asset;
amount: number;
}

export type PayoutTxStatus =
| { state: 'pending' }
| { state: 'complete'; fee: number }
| { state: 'failed'; isOutOfGas: boolean };
16 changes: 12 additions & 4 deletions src/subdomains/supporting/payout/services/payout-evm.service.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<PayoutTxStatus> {
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<number> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class BaseCoinStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getBaseCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.baseService.getPayoutCompletionData(payoutTxId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class BaseTokenStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getBaseCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.baseService.getPayoutCompletionData(payoutTxId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -52,19 +52,34 @@ export abstract class EvmStrategy extends PayoutStrategy {
async checkPayoutCompletionData(orders: PayoutOrder[]): Promise<void> {
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) {
Expand All @@ -73,7 +88,7 @@ export abstract class EvmStrategy extends PayoutStrategy {
}
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
protected async getPayoutCompletionData(payoutTxId: string): Promise<PayoutTxStatus> {
return this.payoutEvmService.getPayoutCompletionData(payoutTxId);
}

Expand All @@ -83,11 +98,14 @@ export abstract class EvmStrategy extends PayoutStrategy {
}
}

override async canRetryFailedPayout(order: PayoutOrder): Promise<boolean> {
override async canRetryFailedPayout(order: PayoutOrder, status?: PayoutTxStatus): Promise<boolean> {
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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class GnosisCoinStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getGnosisCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.gnosisService.getPayoutCompletionData(payoutTxId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class GnosisTokenStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getGnosisCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.gnosisService.getPayoutCompletionData(payoutTxId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class OptimismCoinStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getOptimismCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.optimismService.getPayoutCompletionData(payoutTxId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class OptimismTokenStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getOptimismCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.optimismService.getPayoutCompletionData(payoutTxId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class PolygonCoinStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getPolygonCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.polygonService.getPayoutCompletionData(payoutTxId);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,4 @@ export class PolygonTokenStrategy extends EvmStrategy {
protected getFeeAsset(): Promise<Asset> {
return this.assetService.getPolygonCoin();
}

protected async getPayoutCompletionData(payoutTxId: string): Promise<[boolean, number]> {
return this.polygonService.getPayoutCompletionData(payoutTxId);
}
}
Loading