diff --git a/lib/inbox/__tests__/relay-rpc.test.ts b/lib/inbox/__tests__/relay-rpc.test.ts index 91822f47..812cfce5 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,131 @@ 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", () => { + // 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(); + }); + + 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; + + 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", () => { + 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 { 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",