From c258a875ed38822d006e128eb978aeb4cfd223a3 Mon Sep 17 00:00:00 2001 From: Anambraboi-1 <262577401+Anambraboi-1@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:04:49 +0100 Subject: [PATCH] fix(api): only expire a pending intent when the network answers NOT_FOUND (#37) The sweep expired any stale intent whose settleIntent threw, so an RPC outage or a failed book update after a successful transaction wrote the transaction off and flagged its line or pledge failed. Expiry now needs a NOT_FOUND answer on the same pass; throws leave the intent pending for the next sweep. --- services/api/src/__tests__/intents.test.ts | 35 ++++++++++++++++++---- services/api/src/intents.ts | 34 ++++++++++++++++----- 2 files changed, 56 insertions(+), 13 deletions(-) diff --git a/services/api/src/__tests__/intents.test.ts b/services/api/src/__tests__/intents.test.ts index b6d468e..9e920a1 100644 --- a/services/api/src/__tests__/intents.test.ts +++ b/services/api/src/__tests__/intents.test.ts @@ -195,20 +195,45 @@ describe("sweepPendingIntents", () => { expect(updated.some((u) => u.table === schema.txIntent && u.set.status === "expired")).toBe(true); }); - it("keeps going past an intent the network errors on, and still ages it out", async () => { - const broken = intent({ txHash: "bad", createdAt: new Date(Date.now() - INTENT_EXPIRY_MS - 1000) }); + it("keeps going past an intent the network errors on, and leaves it pending however old", async () => { + const broken = intent({ txHash: "bad", createdAt: new Date(Date.now() - 100 * INTENT_EXPIRY_MS) }); const fine = intent({ txHash: "good" }); - const { db } = makeDb(new Map([[schema.txIntent, [broken, fine]]])); + const { db, updated } = makeDb(new Map([[schema.txIntent, [broken, fine]]])); const chain = { outcome: vi.fn(async (hash: string) => { - if (hash === "bad") throw new Error("unexpected hash length"); + if (hash === "bad") throw new Error("RPC unavailable"); return { status: "SUCCESS", ledger: 5, returnValue: 1n }; }), } as unknown as ChainGateway; const tally = await sweepPendingIntents(db, chain, noContracts); - expect(tally).toEqual({ settled: 1, failed: 0, expired: 1, errors: 1 }); + expect(tally).toEqual({ settled: 1, failed: 0, expired: 0, errors: 1 }); + expect(updated.some((u) => u.table === schema.txIntent && u.set.status === "expired")).toBe(false); + expect(updated).not.toContainEqual({ table: schema.creditLine, set: { status: "failed" } }); + }); + + it("retries a stale intent whose book update failed instead of expiring it", async () => { + const stale = intent({ createdAt: new Date(Date.now() - INTENT_EXPIRY_MS - 1000) }); + const { db, updated } = makeDb(new Map([[schema.txIntent, [stale]]])); + const chain = chainWith({ status: "SUCCESS", ledger: 12, returnValue: 7n }); + const update = db.update as any; + let linesDown = true; + db.update = ((table: unknown) => { + if (table === schema.creditLine && linesDown) throw new Error("DB connection lost during line update"); + return update(table); + }) as any; + + const first = await sweepPendingIntents(db, chain, noContracts); + + expect(first).toEqual({ settled: 0, failed: 0, expired: 0, errors: 1 }); + expect(updated.some((u) => u.table === schema.txIntent && u.set.status === "expired")).toBe(false); + expect(updated).not.toContainEqual({ table: schema.creditLine, set: { status: "failed" } }); + + linesDown = false; + const second = await sweepPendingIntents(db, chain, noContracts); + + expect(second).toEqual({ settled: 1, failed: 0, expired: 0, errors: 0 }); }); it("leaves a recent unsubmitted transaction pending", async () => { diff --git a/services/api/src/intents.ts b/services/api/src/intents.ts index 5ec77af..f4ac525 100644 --- a/services/api/src/intents.ts +++ b/services/api/src/intents.ts @@ -245,13 +245,27 @@ export async function settleIntent( contracts: ContractClients, intent: IntentRow, ): Promise { + return (await settle(db, chain, contracts, intent)).result; +} + +/** + * `settleIntent`, also saying whether the network answered NOT_FOUND for the transaction. A + * `pending` result alone doesn't say that: a lost claim race re-reads the row, which can still be + * pending if the winner's book update failed. + */ +async function settle( + db: Database, + chain: ChainGateway, + contracts: ContractClients, + intent: IntentRow, +): Promise<{ result: SettleResult; notFound: boolean }> { if (intent.status !== "pending") { - return result(intent, intent.status as SettleResult["status"], intent.ledger); + return { result: result(intent, intent.status as SettleResult["status"], intent.ledger), notFound: false }; } const outcome = await chain.outcome(intent.txHash); if (outcome.status === "NOT_FOUND") { - return result(intent, "pending", null); + return { result: result(intent, "pending", null), notFound: true }; } const next = outcome.status === "SUCCESS" ? "settled" : "failed"; @@ -319,10 +333,10 @@ export async function settleIntent( if (!settleRes) { const current = (await findIntent(db, intent.txHash)) ?? intent; - return result(current, current.status as SettleResult["status"], current.ledger); + return { result: result(current, current.status as SettleResult["status"], current.ledger), notFound: false }; } - return settleRes; + return { result: settleRes, notFound: false }; } catch (err) { // If a transaction rollback was not used (or for safety recording the error message), record error try { @@ -358,18 +372,22 @@ export async function sweepPendingIntents( const pending = await db.select().from(schema.txIntent).where(eq(schema.txIntent.status, "pending")); for (const intent of pending) { - // One intent the RPC chokes on must not stop the sweep reaching the others; it stays - // pending (and is retried next sweep) until it settles or ages out below. + // One intent the RPC chokes on, or whose book update fails, must not stop the sweep reaching + // the others. It stays pending and is retried next sweep: a throw says nothing about whether + // the transaction landed, so only a NOT_FOUND answer on this pass can write it off. NOT_FOUND + // also covers a transaction older than the RPC's retention window, so a success that went + // unswept for that long is still expired here. let status: SettleResult["status"] = "pending"; + let notFound = false; try { - status = (await settleIntent(db, chain, contracts, intent)).status; + ({ result: { status }, notFound } = await settle(db, chain, contracts, intent)); } catch { tally.errors++; } if (status === "settled") tally.settled++; else if (status === "failed") tally.failed++; - else if (status === "pending" && now - intent.createdAt.getTime() > INTENT_EXPIRY_MS) { + else if (notFound && now - intent.createdAt.getTime() > INTENT_EXPIRY_MS) { const claimed = await db .update(schema.txIntent) .set({ status: "expired", settledAt: new Date(now) })