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
127 changes: 127 additions & 0 deletions lib/inbox/__tests__/relay-rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
});
}
});
Comment thread
whoabuddy marked this conversation as resolved.

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({
Expand Down
20 changes: 19 additions & 1 deletion lib/inbox/relay-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,22 @@ const RPC_ERROR_CODE_MAP: Record<string, InboxPaymentErrorCode> = {
// 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",
};
Expand All @@ -88,18 +94,30 @@ export function mapRPCErrorCode(
const PENDING_STATUSES = new Set(["queued", "broadcasting", "mempool"]);

const TERMINAL_REASON_ERROR_CODE_MAP: Partial<Record<TerminalReason, InboxPaymentErrorCode>> = {
// 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 {
Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading