Skip to content

Commit 90eee5e

Browse files
committed
fix: prevent race condition overwriting EXECUTED status and add reconciler cron (#222)
1 parent c6d5862 commit 90eee5e

3 files changed

Lines changed: 134 additions & 10 deletions

File tree

packages/backend/src/transaction/transaction-executor.service.ts

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ export class TransactionExecutorService {
6262
throw new NotFoundException(`Transaction ${txId} not found`);
6363
}
6464

65+
if (transaction.status !== TxStatus.PENDING) {
66+
throw new BadRequestException(
67+
`Transaction cannot be executed (current status: ${transaction.status})`,
68+
);
69+
}
70+
6571
// Mark as EXECUTING
6672
await this.updateStatusAndEmit(
6773
txId,
@@ -120,14 +126,14 @@ export class TransactionExecutorService {
120126
error.message?.includes('Transaction reverted');
121127

122128
if (isOnChainRevert) {
123-
// Tx confirmed as REVERTED on-chain — safe to revert to PENDING
129+
// Tx confirmed as REVERTED on-chain — only revert if still EXECUTING
130+
// (another concurrent call may have already set EXECUTED)
124131
this.logger.warn(
125-
`txId ${txId} reverted on-chain (txHash: ${submittedTxHash}). Reverting to PENDING.`,
132+
`txId ${txId} reverted on-chain (txHash: ${submittedTxHash}). Reverting to PENDING if still EXECUTING.`,
126133
);
127-
await this.updateStatusAndEmit(
134+
await this.conditionalRevertToPending(
128135
txId,
129136
executionData.accountAddress,
130-
TxStatus.PENDING,
131137
);
132138
throw new BadRequestException(
133139
'Transaction reverted on-chain. Please check contract conditions.',
@@ -144,12 +150,8 @@ export class TransactionExecutorService {
144150
);
145151
}
146152

147-
// Tx was NOT submitted on-chain — safe to revert to PENDING
148-
await this.updateStatusAndEmit(
149-
txId,
150-
executionData.accountAddress,
151-
TxStatus.PENDING,
152-
);
153+
// Tx was NOT submitted on-chain — only revert if still EXECUTING
154+
await this.conditionalRevertToPending(txId, executionData.accountAddress);
153155

154156
if (error.message?.includes('Insufficient wallet balance')) {
155157
const match = error.message.match(
@@ -624,6 +626,37 @@ export class TransactionExecutorService {
624626
}
625627
}
626628

629+
/**
630+
* Revert status to PENDING only if current status is EXECUTING.
631+
* Prevents race condition where a concurrent call already set EXECUTED.
632+
*/
633+
private async conditionalRevertToPending(
634+
txId: number,
635+
accountAddress: string,
636+
) {
637+
const result = await this.prisma.transaction.updateMany({
638+
where: { txId, status: TxStatus.EXECUTING },
639+
data: { status: TxStatus.PENDING, txHash: null },
640+
});
641+
642+
if (result.count > 0) {
643+
const eventData: TxStatusEventData = {
644+
txId,
645+
status: TxStatus.PENDING,
646+
};
647+
this.eventsService.emitToAccount(
648+
accountAddress,
649+
TX_STATUS_EVENT,
650+
eventData,
651+
);
652+
this.logger.log(`txId ${txId} reverted to PENDING`);
653+
} else {
654+
this.logger.log(
655+
`txId ${txId} not reverted — status is no longer EXECUTING`,
656+
);
657+
}
658+
}
659+
627660
private async updateStatusAndEmit(
628661
txId: number,
629662
accountAddress: string,
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { Injectable, Logger } from '@nestjs/common';
2+
import { Cron } from '@nestjs/schedule';
3+
import { createPublicClient, http, type Hex } from 'viem';
4+
import { PrismaService } from '@/database/prisma.service';
5+
import { TransactionExecutorService } from './transaction-executor.service';
6+
import { TxStatus, getChainById } from '@polypay/shared';
7+
8+
@Injectable()
9+
export class TransactionReconcilerScheduler {
10+
private readonly logger = new Logger(TransactionReconcilerScheduler.name);
11+
private readonly publicClients = new Map<number, any>();
12+
13+
constructor(
14+
private readonly prisma: PrismaService,
15+
private readonly transactionExecutor: TransactionExecutorService,
16+
) {}
17+
18+
private getPublicClient(chainId: number) {
19+
let client = this.publicClients.get(chainId);
20+
if (!client) {
21+
const chain = getChainById(chainId);
22+
client = createPublicClient({ chain, transport: http() });
23+
this.publicClients.set(chainId, client);
24+
}
25+
return client;
26+
}
27+
28+
// 13:00 Vietnam time = 06:00 UTC
29+
@Cron('0 6 * * *', { timeZone: 'UTC' })
30+
async reconcileStuckTransactions() {
31+
this.logger.log('Running daily transaction reconciliation');
32+
33+
const stuckTxs = await this.prisma.transaction.findMany({
34+
where: {
35+
txHash: { not: null },
36+
status: { in: [TxStatus.EXECUTING, TxStatus.PENDING] },
37+
},
38+
include: { account: true },
39+
});
40+
41+
if (stuckTxs.length === 0) {
42+
this.logger.log('No stuck transactions found');
43+
return;
44+
}
45+
46+
this.logger.log(`Found ${stuckTxs.length} stuck transactions with txHash`);
47+
48+
let reconciled = 0;
49+
let reverted = 0;
50+
let skipped = 0;
51+
52+
for (const tx of stuckTxs) {
53+
try {
54+
const publicClient = this.getPublicClient(tx.account.chainId);
55+
const receipt = await publicClient.getTransactionReceipt({
56+
hash: tx.txHash as Hex,
57+
});
58+
59+
if (receipt.status === 'success') {
60+
await this.transactionExecutor.markExecuted(tx.txId, tx.txHash);
61+
this.logger.log(
62+
`txId ${tx.txId} reconciled to EXECUTED (txHash: ${tx.txHash})`,
63+
);
64+
reconciled++;
65+
} else {
66+
// receipt.status === 'reverted'
67+
await this.prisma.transaction.update({
68+
where: { txId: tx.txId },
69+
data: { status: TxStatus.PENDING, txHash: null },
70+
});
71+
this.logger.warn(
72+
`txId ${tx.txId} reverted on-chain, reset to PENDING`,
73+
);
74+
reverted++;
75+
}
76+
} catch (error) {
77+
// No receipt found (tx not mined or invalid hash) — skip
78+
this.logger.warn(
79+
`txId ${tx.txId} receipt not found, skipping: ${error.message}`,
80+
);
81+
skipped++;
82+
}
83+
}
84+
85+
this.logger.log(
86+
`Reconciliation complete: ${reconciled} executed, ${reverted} reverted, ${skipped} skipped`,
87+
);
88+
}
89+
}

packages/backend/src/transaction/transaction.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
22
import { TransactionController } from './transaction.controller';
33
import { TransactionService } from './transaction.service';
44
import { TransactionExecutorService } from './transaction-executor.service';
5+
import { TransactionReconcilerScheduler } from './transaction-reconciler.scheduler';
56
import { ZkVerifyModule } from '@/zkverify/zkverify.module';
67
import { DatabaseModule } from '@/database/database.module';
78
import { RelayerModule } from '@/relayer-wallet/relayer-wallet.module';
@@ -23,6 +24,7 @@ import { QuestModule } from '@/quest/quest.module';
2324
providers: [
2425
TransactionService,
2526
TransactionExecutorService,
27+
TransactionReconcilerScheduler,
2628
AnalyticsLoggerService,
2729
],
2830
exports: [TransactionService],

0 commit comments

Comments
 (0)