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`); }