From 1d705bea4191a1292dd6313fc18ac87cee82728c Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 21 Apr 2026 17:14:52 -0700 Subject: [PATCH 1/3] chore(deps): bump @aibtc/tx-schemas to 1.0.0 Upgrade from ^0.3.0 to ^1.0.0. The 1.0.0 breaking change (two-phase broadcast status + reconcile grace window, aibtcdev/tx-schemas#26) adds five new TerminalReason variants and five new RpcErrorCode variants; the core enums used by this repo (TrackedPaymentStateSchema, TerminalFailureStateSchema, paymentStateDefaultDeliveryByState) are unchanged and required no code edits. Co-Authored-By: Claude --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index db9134e2..521f147e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.39.0", "hasInstallScript": true, "dependencies": { - "@aibtc/tx-schemas": "^0.3.0", + "@aibtc/tx-schemas": "^1.0.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@opennextjs/cloudflare": "^1.17.1", @@ -41,9 +41,9 @@ } }, "node_modules/@aibtc/tx-schemas": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@aibtc/tx-schemas/-/tx-schemas-0.3.0.tgz", - "integrity": "sha512-pXzW9TnmFR3uJMfajbUdAYiPKRkNlzWWL2WGZ554NAeVIPq9vWyL6x8nrYtaDohuHlZRmMj1tSVeFap9AA+adw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@aibtc/tx-schemas/-/tx-schemas-1.0.0.tgz", + "integrity": "sha512-KFVfzP+1gLU67mHL94ck4ue1QDJIMjlHwaOya58q2cGPwuSjGjixx/Rt/AsWJr+ppRWECZgUm9Pv+N8kfL3r5w==", "license": "MIT", "dependencies": { "zod": "^4.3.6" diff --git a/package.json b/package.json index 784e655e..221ba447 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "postinstall": "patch-package" }, "dependencies": { - "@aibtc/tx-schemas": "^0.3.0", + "@aibtc/tx-schemas": "^1.0.0", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1", "@opennextjs/cloudflare": "^1.17.1", From 1a96782b21f53e7eaaaebc915b90816b12f616f6 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 21 Apr 2026 17:15:00 -0700 Subject: [PATCH 2/3] refactor(inbox): adopt new broadcast states from tx-schemas 1.0.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expand RPC_ERROR_CODE_MAP and TERMINAL_REASON_ERROR_CODE_MAP in relay-rpc.ts to cover the five new variants introduced in tx-schemas 1.0.0: TerminalReason: sponsor_exhausted → INSUFFICIENT_FUNDS, sponsor_nonce_conflict → RELAY_ERROR, origin_chaining_limit → NONCE_CONFLICT, broadcast_rate_limited → BROADCAST_FAILED, sender_hand_expired → PAYMENT_NOT_FOUND RpcErrorCode: SPONSOR_EXHAUSTED → INSUFFICIENT_FUNDS, ORIGIN_CHAINING_LIMIT → NONCE_CONFLICT, BROADCAST_RATE_LIMITED → BROADCAST_FAILED, SENDER_HAND_EXPIRED → PAYMENT_NOT_FOUND, NONCE_OCCUPIED → NONCE_CONFLICT Add 18 schema-compat tests verifying enum parse round-trips and map resolution for each new variant (506 total, up from 488). Co-Authored-By: Claude --- lib/inbox/__tests__/relay-rpc.test.ts | 144 ++++++++++++++++++++++++++ lib/inbox/relay-rpc.ts | 20 +++- 2 files changed, 163 insertions(+), 1 deletion(-) diff --git a/lib/inbox/__tests__/relay-rpc.test.ts b/lib/inbox/__tests__/relay-rpc.test.ts index 91822f47..e146e570 100644 --- a/lib/inbox/__tests__/relay-rpc.test.ts +++ b/lib/inbox/__tests__/relay-rpc.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { __testUtils, mapRPCErrorCode, submitViaRPC } from "../relay-rpc"; import type { RelayRPC, RelaySettleOptions } from "../relay-rpc"; import type { Logger } from "@/lib/logging"; +import { TerminalReasonSchema } from "@aibtc/tx-schemas/terminal-reasons"; +import { RpcErrorCodeSchema } from "@aibtc/tx-schemas/rpc"; const mockLogger: Logger = { debug: vi.fn(), @@ -769,6 +771,148 @@ describe("submitViaRPC", () => { }); }); +describe("tx-schemas 1.0.0 schema compatibility", () => { + describe("new TerminalReason variants parse correctly", () => { + const newFailedReasons = [ + "sponsor_exhausted", + "sponsor_nonce_conflict", + "origin_chaining_limit", + "broadcast_rate_limited", + "sender_hand_expired", + ] as const; + + for (const reason of newFailedReasons) { + it(`parses new failed terminal reason: ${reason}`, () => { + expect(TerminalReasonSchema.parse(reason)).toBe(reason); + }); + } + }); + + describe("new RpcErrorCode variants parse correctly", () => { + const newRpcCodes = [ + "SPONSOR_EXHAUSTED", + "ORIGIN_CHAINING_LIMIT", + "BROADCAST_RATE_LIMITED", + "SENDER_HAND_EXPIRED", + "NONCE_OCCUPIED", + ] as const; + + for (const code of newRpcCodes) { + it(`parses new RPC error code: ${code}`, () => { + expect(RpcErrorCodeSchema.parse(code)).toBe(code); + }); + } + }); + + describe("new TerminalReason variants map to correct InboxPaymentErrorCode", () => { + it("maps sponsor_exhausted checkPayment to INSUFFICIENT_FUNDS", async () => { + vi.useFakeTimers(); + + const rpc: RelayRPC = { + submitPayment: vi.fn().mockResolvedValue({ + accepted: true, + paymentId: "pay_sponsor_exhausted", + status: "queued", + }), + checkPayment: vi.fn().mockResolvedValue({ + paymentId: "pay_sponsor_exhausted", + status: "failed", + terminalReason: "sponsor_exhausted", + error: "sponsor wallet has no available capacity", + }), + }; + + const resultPromise = submitViaRPC(rpc, baseTxHex, baseSettle, mockLogger); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(result.errorCode).toBe("INSUFFICIENT_FUNDS"); + expect(result.terminalReason).toBe("sponsor_exhausted"); + + vi.useRealTimers(); + }); + + it("maps origin_chaining_limit checkPayment to NONCE_CONFLICT", async () => { + vi.useFakeTimers(); + + const rpc: RelayRPC = { + submitPayment: vi.fn().mockResolvedValue({ + accepted: true, + paymentId: "pay_chaining_limit", + status: "queued", + }), + checkPayment: vi.fn().mockResolvedValue({ + paymentId: "pay_chaining_limit", + status: "failed", + terminalReason: "origin_chaining_limit", + error: "sender exceeded chaining limit", + }), + }; + + const resultPromise = submitViaRPC(rpc, baseTxHex, baseSettle, mockLogger); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(result.errorCode).toBe("NONCE_CONFLICT"); + expect(result.terminalReason).toBe("origin_chaining_limit"); + + vi.useRealTimers(); + }); + + it("maps sender_hand_expired checkPayment to PAYMENT_NOT_FOUND", async () => { + vi.useFakeTimers(); + + const rpc: RelayRPC = { + submitPayment: vi.fn().mockResolvedValue({ + accepted: true, + paymentId: "pay_hand_expired", + status: "queued", + }), + checkPayment: vi.fn().mockResolvedValue({ + paymentId: "pay_hand_expired", + status: "failed", + terminalReason: "sender_hand_expired", + error: "sender hand TTL expired before dispatch", + }), + }; + + const resultPromise = submitViaRPC(rpc, baseTxHex, baseSettle, mockLogger); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(result.errorCode).toBe("PAYMENT_NOT_FOUND"); + expect(result.terminalReason).toBe("sender_hand_expired"); + + vi.useRealTimers(); + }); + }); + + describe("new RpcErrorCode variants map to correct InboxPaymentErrorCode", () => { + it("maps SPONSOR_EXHAUSTED to INSUFFICIENT_FUNDS", () => { + expect(mapRPCErrorCode("SPONSOR_EXHAUSTED")).toBe("INSUFFICIENT_FUNDS"); + }); + + it("maps ORIGIN_CHAINING_LIMIT to NONCE_CONFLICT", () => { + expect(mapRPCErrorCode("ORIGIN_CHAINING_LIMIT")).toBe("NONCE_CONFLICT"); + }); + + it("maps BROADCAST_RATE_LIMITED to BROADCAST_FAILED", () => { + expect(mapRPCErrorCode("BROADCAST_RATE_LIMITED")).toBe("BROADCAST_FAILED"); + }); + + it("maps SENDER_HAND_EXPIRED to PAYMENT_NOT_FOUND", () => { + expect(mapRPCErrorCode("SENDER_HAND_EXPIRED")).toBe("PAYMENT_NOT_FOUND"); + }); + + it("maps NONCE_OCCUPIED to NONCE_CONFLICT", () => { + expect(mapRPCErrorCode("NONCE_OCCUPIED")).toBe("NONCE_CONFLICT"); + }); + }); +}); + describe("relay-rpc parser compatibility", () => { it("drops unknown relay errorCode values while preserving canonical not_found fields", () => { const parsed = __testUtils.parseCheckPaymentResult({ diff --git a/lib/inbox/relay-rpc.ts b/lib/inbox/relay-rpc.ts index 50c9254c..bc751527 100644 --- a/lib/inbox/relay-rpc.ts +++ b/lib/inbox/relay-rpc.ts @@ -58,16 +58,22 @@ const RPC_ERROR_CODE_MAP: Record = { // Broadcast failures BROADCAST_FAILED: "BROADCAST_FAILED", TX_BROADCAST_ERROR: "BROADCAST_FAILED", + BROADCAST_RATE_LIMITED: "BROADCAST_FAILED", // Settlement SETTLEMENT_FAILED: "SETTLEMENT_FAILED", // Insufficient funds INSUFFICIENT_FUNDS: "INSUFFICIENT_FUNDS", BALANCE_ERROR: "INSUFFICIENT_FUNDS", + SPONSOR_EXHAUSTED: "INSUFFICIENT_FUNDS", // Nonce conflicts (retryable) NONCE_CONFLICT: "NONCE_CONFLICT", CLIENT_NONCE_CONFLICT: "NONCE_CONFLICT", CLIENT_BAD_NONCE: "NONCE_CONFLICT", TOO_MUCH_CHAINING: "NONCE_CONFLICT", + ORIGIN_CHAINING_LIMIT: "NONCE_CONFLICT", + NONCE_OCCUPIED: "NONCE_CONFLICT", + // Payment identity expired/gone + SENDER_HAND_EXPIRED: "PAYMENT_NOT_FOUND", // Internal INTERNAL_ERROR: "RELAY_ERROR", }; @@ -88,18 +94,30 @@ export function mapRPCErrorCode( const PENDING_STATUSES = new Set(["queued", "broadcasting", "mempool"]); const TERMINAL_REASON_ERROR_CODE_MAP: Partial> = { + // Validation failures (sender must fix and resubmit) invalid_transaction: "PAYMENT_REJECTED", not_sponsored: "PAYMENT_REJECTED", + // Sender nonce rejections sender_nonce_stale: "SENDER_NONCE_STALE", sender_nonce_gap: "SENDER_NONCE_GAP", sender_nonce_duplicate: "SENDER_NONCE_DUPLICATE", + // Sender chaining limit (retryable after drain — same InboxPaymentErrorCode as nonce conflict) + origin_chaining_limit: "NONCE_CONFLICT", + // Relay-internal failures queue_unavailable: "RELAY_ERROR", sponsor_failure: "RELAY_ERROR", + sponsor_nonce_conflict: "RELAY_ERROR", + internal_error: "RELAY_ERROR", + // Sponsor wallet exhausted — no relay funds; treat as insufficient funds from client perspective + sponsor_exhausted: "INSUFFICIENT_FUNDS", + // Broadcast / settlement failures broadcast_failure: "BROADCAST_FAILED", + broadcast_rate_limited: "BROADCAST_FAILED", chain_abort: "SETTLEMENT_FAILED", - internal_error: "RELAY_ERROR", + // Identity / expiry expired: "PAYMENT_NOT_FOUND", unknown_payment_identity: "PAYMENT_NOT_FOUND", + sender_hand_expired: "PAYMENT_NOT_FOUND", }; function parseSubmitPaymentResult(raw: unknown): RelaySubmitResult { From dd1d9ceb796693bbc98e99a16d0a02acda818a97 Mon Sep 17 00:00:00 2001 From: Jason Schrader Date: Tue, 21 Apr 2026 17:36:52 -0700 Subject: [PATCH 3/3] fix: address PR review feedback - Refactor new-TerminalReason mapping tests to be table-driven and cover all 5 new variants, not just 3. Previously sponsor_nonce_conflict and broadcast_rate_limited were added to TERMINAL_REASON_ERROR_CODE_MAP without matching regression tests. - Add `afterEach(() => vi.useRealTimers())` to the mapping describe so an assertion throwing mid-test can't leak fake timers into later tests. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/inbox/__tests__/relay-rpc.test.ts | 141 +++++++++++--------------- 1 file changed, 62 insertions(+), 79 deletions(-) diff --git a/lib/inbox/__tests__/relay-rpc.test.ts b/lib/inbox/__tests__/relay-rpc.test.ts index e146e570..812cfce5 100644 --- a/lib/inbox/__tests__/relay-rpc.test.ts +++ b/lib/inbox/__tests__/relay-rpc.test.ts @@ -805,89 +805,72 @@ describe("tx-schemas 1.0.0 schema compatibility", () => { }); describe("new TerminalReason variants map to correct InboxPaymentErrorCode", () => { - it("maps sponsor_exhausted checkPayment to INSUFFICIENT_FUNDS", async () => { - vi.useFakeTimers(); - - const rpc: RelayRPC = { - submitPayment: vi.fn().mockResolvedValue({ - accepted: true, - paymentId: "pay_sponsor_exhausted", - status: "queued", - }), - checkPayment: vi.fn().mockResolvedValue({ - paymentId: "pay_sponsor_exhausted", - status: "failed", - terminalReason: "sponsor_exhausted", - error: "sponsor wallet has no available capacity", - }), - }; - - const resultPromise = submitViaRPC(rpc, baseTxHex, baseSettle, mockLogger); - await vi.runAllTimersAsync(); - const result = await resultPromise; - - expect(result.success).toBe(false); - expect(result.errorCode).toBe("INSUFFICIENT_FUNDS"); - expect(result.terminalReason).toBe("sponsor_exhausted"); - + // Safety net: if an assertion throws mid-test, restore real timers so fake + // timers don't leak into unrelated tests further down the file. + afterEach(() => { vi.useRealTimers(); }); - it("maps origin_chaining_limit checkPayment to NONCE_CONFLICT", async () => { - vi.useFakeTimers(); - - const rpc: RelayRPC = { - submitPayment: vi.fn().mockResolvedValue({ - accepted: true, - paymentId: "pay_chaining_limit", - status: "queued", - }), - checkPayment: vi.fn().mockResolvedValue({ - paymentId: "pay_chaining_limit", - status: "failed", - terminalReason: "origin_chaining_limit", - error: "sender exceeded chaining limit", - }), - }; - - const resultPromise = submitViaRPC(rpc, baseTxHex, baseSettle, mockLogger); - await vi.runAllTimersAsync(); - const result = await resultPromise; - - expect(result.success).toBe(false); - expect(result.errorCode).toBe("NONCE_CONFLICT"); - expect(result.terminalReason).toBe("origin_chaining_limit"); - - vi.useRealTimers(); - }); - - it("maps sender_hand_expired checkPayment to PAYMENT_NOT_FOUND", async () => { - vi.useFakeTimers(); - - const rpc: RelayRPC = { - submitPayment: vi.fn().mockResolvedValue({ - accepted: true, - paymentId: "pay_hand_expired", - status: "queued", - }), - checkPayment: vi.fn().mockResolvedValue({ - paymentId: "pay_hand_expired", - status: "failed", - terminalReason: "sender_hand_expired", - error: "sender hand TTL expired before dispatch", - }), - }; - - const resultPromise = submitViaRPC(rpc, baseTxHex, baseSettle, mockLogger); - await vi.runAllTimersAsync(); - const result = await resultPromise; - - expect(result.success).toBe(false); - expect(result.errorCode).toBe("PAYMENT_NOT_FOUND"); - expect(result.terminalReason).toBe("sender_hand_expired"); + const newReasonMappings = [ + { + reason: "sponsor_exhausted", + expectedErrorCode: "INSUFFICIENT_FUNDS", + paymentId: "pay_sponsor_exhausted", + error: "sponsor wallet has no available capacity", + }, + { + reason: "sponsor_nonce_conflict", + expectedErrorCode: "RELAY_ERROR", + paymentId: "pay_sponsor_nonce_conflict", + error: "sponsor nonce conflicted with an in-flight tx", + }, + { + reason: "origin_chaining_limit", + expectedErrorCode: "NONCE_CONFLICT", + paymentId: "pay_chaining_limit", + error: "sender exceeded chaining limit", + }, + { + reason: "broadcast_rate_limited", + expectedErrorCode: "BROADCAST_FAILED", + paymentId: "pay_broadcast_rate_limited", + error: "broadcast rate limit exceeded", + }, + { + reason: "sender_hand_expired", + expectedErrorCode: "PAYMENT_NOT_FOUND", + paymentId: "pay_hand_expired", + error: "sender hand TTL expired before dispatch", + }, + ] as const; - vi.useRealTimers(); - }); + for (const { reason, expectedErrorCode, paymentId, error } of newReasonMappings) { + it(`maps ${reason} checkPayment to ${expectedErrorCode}`, async () => { + vi.useFakeTimers(); + + const rpc: RelayRPC = { + submitPayment: vi.fn().mockResolvedValue({ + accepted: true, + paymentId, + status: "queued", + }), + checkPayment: vi.fn().mockResolvedValue({ + paymentId, + status: "failed", + terminalReason: reason, + error, + }), + }; + + const resultPromise = submitViaRPC(rpc, baseTxHex, baseSettle, mockLogger); + await vi.runAllTimersAsync(); + const result = await resultPromise; + + expect(result.success).toBe(false); + expect(result.errorCode).toBe(expectedErrorCode); + expect(result.terminalReason).toBe(reason); + }); + } }); describe("new RpcErrorCode variants map to correct InboxPaymentErrorCode", () => {