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
35 changes: 30 additions & 5 deletions services/api/src/__tests__/intents.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
34 changes: 26 additions & 8 deletions services/api/src/intents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,13 +257,27 @@ export async function settleIntent(
contracts: ContractClients,
intent: IntentRow,
): Promise<SettleResult> {
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";
Expand Down Expand Up @@ -336,10 +350,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) {
await recordSettleError(db, intent, err);
throw err;
Expand Down Expand Up @@ -367,18 +381,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) })
Expand Down
Loading