From 319c20b0c98d99c22e11a5848f603eeea46293b3 Mon Sep 17 00:00:00 2001 From: xeladev4 Date: Sun, 30 Aug 2026 07:29:31 +0100 Subject: [PATCH] fix(engine-bridge): release nonce on tx-aggregator execution failure Fixes nonce leak in TxAggregator.execute() when submission fails before reaching the ledger. Previously, reserved nonces were never released on sendTransaction ERROR or poll-loop timeout, causing permanent desync between the in-process cache and the real account sequence. Changes: - Add optional sourceAccountId and sequence parameters to execute() - Call nonceManager.release() on ERROR and timeout paths - Update all existing tests to pass the new parameters - Add regression tests for nonce release on both failure paths Closes #181 --- .../src/__tests__/tx-aggregator.test.ts | 111 +++++++++++++++++- engine-bridge/src/tx-aggregator.ts | 13 +- 2 files changed, 119 insertions(+), 5 deletions(-) diff --git a/engine-bridge/src/__tests__/tx-aggregator.test.ts b/engine-bridge/src/__tests__/tx-aggregator.test.ts index c6ea5dd..72223f6 100644 --- a/engine-bridge/src/__tests__/tx-aggregator.test.ts +++ b/engine-bridge/src/__tests__/tx-aggregator.test.ts @@ -105,7 +105,12 @@ describe("TxAggregator", () => { feeOptions: { multiplier: 2, safetyStroops: 25 }, }); - const res = await aggregator.execute(batch.transaction, { pollIntervalMs: 1, timeoutMs: 100 }); + const res = await aggregator.execute(batch.transaction, { + pollIntervalMs: 1, + timeoutMs: 100, + sourceAccountId: signer.publicKey(), + sequence: batch.sequence, + }); expect(res.status).toBe("SUCCESS"); expect(pollCount).toBe(2); }); @@ -137,7 +142,12 @@ describe("TxAggregator", () => { feeOptions: { multiplier: 2, safetyStroops: 25 }, }); - await expect(aggregator.execute(batch.transaction, { pollIntervalMs: 1, timeoutMs: 100 })) + await expect(aggregator.execute(batch.transaction, { + pollIntervalMs: 1, + timeoutMs: 100, + sourceAccountId: signer.publicKey(), + sequence: batch.sequence, + })) .rejects.toThrow("TxAggregator: sendTransaction failed with status ERROR: bad-tx"); }); @@ -169,7 +179,12 @@ describe("TxAggregator", () => { feeOptions: { multiplier: 2, safetyStroops: 25 }, }); - await expect(aggregator.execute(batch.transaction, { pollIntervalMs: 1, timeoutMs: 100 })) + await expect(aggregator.execute(batch.transaction, { + pollIntervalMs: 1, + timeoutMs: 100, + sourceAccountId: signer.publicKey(), + sequence: batch.sequence, + })) .rejects.toThrow("TxAggregator: transaction failed with result: fail-reason"); }); @@ -201,7 +216,95 @@ describe("TxAggregator", () => { feeOptions: { multiplier: 2, safetyStroops: 25 }, }); - await expect(aggregator.execute(batch.transaction, { pollIntervalMs: 5, timeoutMs: 20 })) + await expect(aggregator.execute(batch.transaction, { + pollIntervalMs: 5, + timeoutMs: 20, + sourceAccountId: signer.publicKey(), + sequence: batch.sequence, + })) .rejects.toThrow("TxAggregator: transaction execution timed out"); }); + + it("releases nonce on sendTransaction ERROR", async () => { + const rpc = new RpcClient(["http://test"]); + rpc.call = async (fn: any) => { + return fn({ + getFeeStats: async () => ({ base_fee: 100 }), + getAccount: async (_: string) => ({ sequenceNumber: () => "100" }), + sendTransaction: async () => ({ status: "ERROR", errorResultXdr: "bad-tx" }), + }); + }; + + const nonceManager = new NonceManager(rpc); + const aggregator = new TxAggregator( + rpc, + nonceManager, + new GasOracle(), + Networks.TESTNET, + ); + + const signer = Keypair.random(); + const batch = await aggregator.build({ + sourceAccountId: signer.publicKey(), + operations: [ + Operation.manageData({ name: "batched-1", value: "a" }) as any, + ], + signers: [signer], + feeOptions: { multiplier: 2, safetyStroops: 25 }, + }); + + await expect( + aggregator.execute(batch.transaction, { + pollIntervalMs: 1, + timeoutMs: 100, + sourceAccountId: signer.publicKey(), + sequence: batch.sequence, + }) + ).rejects.toThrow("TxAggregator: sendTransaction failed with status ERROR: bad-tx"); + + const nextSeq = await nonceManager.reserve(signer.publicKey()); + expect(nextSeq).toBe(batch.sequence); + }); + + it("releases nonce on poll-loop timeout", async () => { + const rpc = new RpcClient(["http://test"]); + rpc.call = async (fn: any) => { + return fn({ + getFeeStats: async () => ({ base_fee: 100 }), + getAccount: async (_: string) => ({ sequenceNumber: () => "100" }), + sendTransaction: async () => ({ status: "PENDING" }), + getTransaction: async () => ({ status: "NOT_FOUND" }), + }); + }; + + const nonceManager = new NonceManager(rpc); + const aggregator = new TxAggregator( + rpc, + nonceManager, + new GasOracle(), + Networks.TESTNET, + ); + + const signer = Keypair.random(); + const batch = await aggregator.build({ + sourceAccountId: signer.publicKey(), + operations: [ + Operation.manageData({ name: "batched-1", value: "a" }) as any, + ], + signers: [signer], + feeOptions: { multiplier: 2, safetyStroops: 25 }, + }); + + await expect( + aggregator.execute(batch.transaction, { + pollIntervalMs: 5, + timeoutMs: 20, + sourceAccountId: signer.publicKey(), + sequence: batch.sequence, + }) + ).rejects.toThrow("TxAggregator: transaction execution timed out"); + + const nextSeq = await nonceManager.reserve(signer.publicKey()); + expect(nextSeq).toBe(batch.sequence); + }); }); diff --git a/engine-bridge/src/tx-aggregator.ts b/engine-bridge/src/tx-aggregator.ts index d83c5c0..d23ba9d 100644 --- a/engine-bridge/src/tx-aggregator.ts +++ b/engine-bridge/src/tx-aggregator.ts @@ -122,7 +122,12 @@ export class TxAggregator { */ async execute( transaction: Transaction, - opts: { pollIntervalMs?: number; timeoutMs?: number } = {}, + opts: { + pollIntervalMs?: number; + timeoutMs?: number; + sourceAccountId?: string; + sequence?: bigint; + } = {}, ): Promise { const pollIntervalMs = opts.pollIntervalMs ?? 1000; const timeoutMs = opts.timeoutMs ?? 30000; @@ -134,11 +139,17 @@ export class TxAggregator { }); if (sendResponse.status === "ERROR") { + if (opts.sourceAccountId && opts.sequence) { + this.nonceManager.release(opts.sourceAccountId, opts.sequence); + } throw new Error(`TxAggregator: sendTransaction failed with status ERROR: ${(sendResponse as any).errorResultXdr || "No error result XDR"}`); } while (true) { if (Date.now() - startTime > timeoutMs) { + if (opts.sourceAccountId && opts.sequence) { + this.nonceManager.release(opts.sourceAccountId, opts.sequence); + } throw new Error(`TxAggregator: transaction execution timed out after ${timeoutMs}ms`); }