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
17 changes: 16 additions & 1 deletion app/api/inbox/[address]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -899,7 +899,8 @@ export async function POST(
const paymentRequirements = buildInboxPaymentRequirements(
agent.stxAddress,
network,
networkCAIP2
networkCAIP2,
env.X402_SPONSOR_FEE_PAYER?.trim() || undefined
);
const paymentRequiredBody = {
x402Version: 2 as const,
Expand Down Expand Up @@ -1505,6 +1506,20 @@ export async function POST(
);
}

// RATE_LIMITED — the relay caps sponsored payments per sender.
if (errorCode === "RATE_LIMITED") {
return NextResponse.json(
{
error: paymentResult.error ?? "Too many sponsored payments from this sender",
code: errorCode,
retryable: true,
retryAfter: 60,
nextSteps: "Wait a minute, then re-sign and resend the payment",
},
{ status: 429, headers: { "Retry-After": "60" } }
);
}

// INSUFFICIENT_FUNDS — not enough sBTC.
if (errorCode === "INSUFFICIENT_FUNDS") {
return NextResponse.json(
Expand Down
1 change: 1 addition & 0 deletions cloudflare-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ interface CloudflareEnv {
CF_PAGES_COMMIT_SHA?: string; // Pages-provided commit SHA when available
X402_NETWORK?: "mainnet" | "testnet"; // Stacks network for x402 verification
X402_RELAY_URL?: string; // x402 relay URL for all payment settlement (default: https://x402-relay.aibtc.com)
X402_SPONSOR_FEE_PAYER?: string; // relay sponsor address advertised as 402 extra.feePayer (gasless inbox sends); unset = senders pay gas
X402_RELAY?: import("./lib/inbox/relay-rpc").RelayRPC; // x402 sponsor relay RPC service binding (undefined in local dev)
INBOX_RECONCILIATION_QUEUE?: Queue<import("./lib/inbox/reconciliation-queue").InboxReconciliationQueueMessage>;
// Scheduled background work runs from a Cloudflare Cron Trigger (see
Expand Down
4 changes: 2 additions & 2 deletions lib/inbox/__tests__/relay-rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,8 +897,8 @@ describe("tx-schemas 1.0.0 schema compatibility", () => {
});

describe("new RpcErrorCode variants map to correct InboxPaymentErrorCode", () => {
it("maps SPONSOR_EXHAUSTED to INSUFFICIENT_FUNDS", () => {
expect(mapRPCErrorCode("SPONSOR_EXHAUSTED")).toBe("INSUFFICIENT_FUNDS");
it("maps SPONSOR_EXHAUSTED to RELAY_ERROR (relay-side, not the sender's funds)", () => {
expect(mapRPCErrorCode("SPONSOR_EXHAUSTED")).toBe("RELAY_ERROR");
});

it("maps ORIGIN_CHAINING_LIMIT to NONCE_CONFLICT", () => {
Expand Down
19 changes: 19 additions & 0 deletions lib/inbox/__tests__/x402-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, it, expect } from "vitest";
import { buildInboxPaymentRequirements } from "../x402-config";

const RECIPIENT = "SPKH9AWG0ENZ87J1X0PBD4HETP22G8W22AFNVF8K";
const SPONSOR = "SP1PMPPVCMVW96FSWFV30KJQ4MNBMZ8MRWR3JWQ7";

describe("buildInboxPaymentRequirements", () => {
it("omits extra.feePayer when no sponsor is configured", () => {
const req = buildInboxPaymentRequirements(RECIPIENT, "mainnet", "stacks:1");
expect(req.payTo).toBe(RECIPIENT);
expect(req.extra).not.toHaveProperty("feePayer");
});

it("advertises extra.feePayer when a sponsor is configured", () => {
const req = buildInboxPaymentRequirements(RECIPIENT, "mainnet", "stacks:1", SPONSOR);
expect(req.extra.feePayer).toBe(SPONSOR);
expect(req.extra.pricing).toEqual({ type: "fixed", tier: "inbox-message" });
});
});
101 changes: 42 additions & 59 deletions lib/inbox/__tests__/x402-verify-rpc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,6 @@ import { getSBTCAsset } from "../x402-config";
import { networkToCAIP2 } from "x402-stacks";
import { createMockKVWithOptions } from "./kv-mock";

const mocks = vi.hoisted(() => ({
submitViaRPC: vi.fn(),
}));

vi.mock("../relay-rpc", async () => {
const actual = await vi.importActual<typeof import("../relay-rpc")>("../relay-rpc");
return {
...actual,
submitViaRPC: mocks.submitViaRPC,
};
});

vi.mock("@stacks/transactions", () => ({
AuthType: { Sponsored: 1 },
StacksWireType: { Address: "address" },
Expand All @@ -35,7 +23,7 @@ vi.mock("@stacks/transactions", () => ({
addressToString: vi.fn(() => "SP2SENDERTESTADDRESS"),
}));

describe("verifyInboxPayment RPC contract", () => {
describe("verifyInboxPayment sponsored (RPC sponsorPayment)", () => {
const recipientStxAddress = "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7";
const network = "mainnet";
const payload = {
Expand All @@ -51,66 +39,61 @@ describe("verifyInboxPayment RPC contract", () => {
vi.clearAllMocks();
});

it("preserves relay-owned paymentId and canonical checkStatusUrl from the RPC path", async () => {
mocks.submitViaRPC.mockResolvedValue({
success: true,
paymentStatus: "pending",
paymentId: "pay_rpc_hint_case",
checkStatusUrl: "https://relay.example/check/pay_rpc_hint_case",
it("sponsors through the binding and returns the txid and payer", async () => {
const sponsorPayment = vi.fn().mockResolvedValue({
success: true, txid: "ab".repeat(32), payer: "SP2SENDERTESTADDRESS", fee: "3000",
});
const relayRPC = { sponsorPayment } as unknown as RelayRPC;

const relayRPC = {} as RelayRPC;
const result = await verifyInboxPayment(
payload,
recipientStxAddress,
network,
"https://relay.example",
undefined,
undefined,
relayRPC
payload, recipientStxAddress, network, "https://relay.example", undefined, undefined, relayRPC
);

expect(result).toMatchObject({
success: true,
paymentStatus: "pending",
paymentId: "pay_rpc_hint_case",
checkStatusUrl: "https://relay.example/check/pay_rpc_hint_case",
expect(sponsorPayment).toHaveBeenCalledWith("00", {
expectedRecipient: recipientStxAddress,
minAmount: expect.any(String),
tokenType: "sBTC",
});
expect(result).toMatchObject({ success: true, payerStxAddress: "SP2SENDERTESTADDRESS", paymentTxid: "ab".repeat(32) });
});

it("counts missing canonical identity as a relay failure for breaker accounting (P4: ratelimits binding)", async () => {
const { kv } = createMockKVWithOptions();
mocks.submitViaRPC.mockResolvedValue({
success: false,
errorCode: "MISSING_CANONICAL_IDENTITY",
error: "Relay accepted payment but did not return a canonical payment identity",
});
it("maps a relay refusal to a typed error without counting it as a relay failure", async () => {
const limit = vi.fn().mockResolvedValue({ success: true });
const cfEnv = { RATE_LIMIT_RELAY_FAILURES: { limit } } as unknown as CloudflareEnv;
const relayRPC = {
sponsorPayment: vi.fn().mockResolvedValue({
success: false, code: "RATE_LIMITED", error: "Too many sponsored payments", retryable: true,
}),
} as unknown as RelayRPC;

// P4: circuit breaker now uses env.RATE_LIMIT_RELAY_FAILURES.limit()
// instead of a KV-RMW counter. Mock the binding and assert it was
// called when the relay failure code is breaker-eligible.
const result = await verifyInboxPayment(
payload, recipientStxAddress, network, "https://relay.example", undefined, undefined, relayRPC, undefined, cfEnv
);

expect(result).toMatchObject({ success: false, errorCode: "RATE_LIMITED", relayCode: "RATE_LIMITED" });
expect(limit).not.toHaveBeenCalled();
});

it("counts a sponsored broadcast failure toward the relay circuit breaker", async () => {
const { kv } = createMockKVWithOptions();
const limit = vi.fn().mockResolvedValue({ success: true });
const cfEnv = {
RATE_LIMIT_RELAY_FAILURES: { limit },
} as unknown as CloudflareEnv;
const cfEnv = { RATE_LIMIT_RELAY_FAILURES: { limit } } as unknown as CloudflareEnv;
const relayRPC = {
sponsorPayment: vi.fn().mockResolvedValue({
success: false, code: "BROADCAST_FAILED", error: "node unreachable", retryable: true,
}),
} as unknown as RelayRPC;

const relayRPC = {} as RelayRPC;
const result = await verifyInboxPayment(
payload,
recipientStxAddress,
network,
"https://relay.example",
undefined,
kv,
relayRPC,
undefined,
cfEnv
payload, recipientStxAddress, network, "https://relay.example", undefined, kv, relayRPC, undefined, cfEnv
);

expect(result.success).toBe(false);
expect(result.errorCode).toBe("MISSING_CANONICAL_IDENTITY");
// Failure was counted via the ratelimits binding (atomic per-key
// counter, replaces the prior `kv.put("inbox:relay:circuit-breaker:count", ...)` shape).
expect(result).toMatchObject({ success: false, errorCode: "BROADCAST_FAILED" });
expect(limit).toHaveBeenCalledWith({ key: "relay-failures" });
});

it("refuses sponsored payments when the relay binding is missing", async () => {
const result = await verifyInboxPayment(payload, recipientStxAddress, network, "https://relay.example");
expect(result).toMatchObject({ success: false, errorCode: "RELAY_ERROR" });
});
});
13 changes: 12 additions & 1 deletion lib/inbox/relay-rpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,13 @@ function extractRelayWireExtras(raw: unknown): RelayWireExtras {
* Typed interface for the X402_RELAY service binding RPC methods.
* Must match the actual relay WorkerEntrypoint method signatures.
*/
/** Result of RelayRPC.sponsorPayment() — sponsored + broadcast now, or refused with a reason. */
export type RelaySponsorPaymentResult =
| { success: true; txid: string; payer: string; fee: string }
| { success: false; code: string; error: string; retryable: boolean };

export interface RelayRPC {
sponsorPayment(txHex: string, settle: RelaySettleOptions): Promise<RelaySponsorPaymentResult>;
submitPayment(txHex: string, settle?: RelaySettleOptions, paymentIdentifier?: string): Promise<RelaySubmitResult>;
checkPayment(paymentId: string): Promise<RelayCheckResult>;
getSponsorStatus?(): Promise<SponsorStatusResult>;
Expand All @@ -120,10 +126,15 @@ const RPC_ERROR_CODE_MAP: Record<string, InboxPaymentErrorCode> = {
BROADCAST_RATE_LIMITED: "BROADCAST_FAILED",
// Settlement
SETTLEMENT_FAILED: "SETTLEMENT_FAILED",
// sponsorPayment refusals
PAYMENT_MISMATCH: "PAYMENT_REJECTED",
RATE_LIMITED: "RATE_LIMITED",
SENDER_NONCE_CONFLICT: "NONCE_CONFLICT",
// Insufficient funds
INSUFFICIENT_FUNDS: "INSUFFICIENT_FUNDS",
BALANCE_ERROR: "INSUFFICIENT_FUNDS",
SPONSOR_EXHAUSTED: "INSUFFICIENT_FUNDS",
// Relay's sponsor is out of STX or at its daily budget — relay-side, not the sender's funds
SPONSOR_EXHAUSTED: "RELAY_ERROR",
// Nonce conflicts (retryable)
NONCE_CONFLICT: "NONCE_CONFLICT",
CLIENT_NONCE_CONFLICT: "NONCE_CONFLICT",
Expand Down
10 changes: 9 additions & 1 deletion lib/inbox/x402-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,22 @@ export function getSBTCAsset(network: "mainnet" | "testnet"): string {
* Key design: dynamic payTo — the recipient agent's STX address is used
* as the payment recipient, not a single platform address.
*
* When `feePayer` is set, it is advertised as `extra.feePayer`: x402-stacks
* clients (>=2.1.0) then sign a sponsored transfer with fee 0, and the relay
* co-signs and pays the STX gas. Without it, clients sign a standard transfer
* and pay their own gas.
*
* @param recipientStxAddress - Recipient agent's Stacks address from AgentRecord
* @param network - Stacks network (mainnet or testnet)
* @param networkCAIP2 - Network in CAIP-2 format (from networkToCAIP2)
* @param feePayer - Relay sponsor address to advertise, or undefined for self-paid gas
* @returns Payment requirements for x402 verification
*/
export function buildInboxPaymentRequirements(
recipientStxAddress: string,
network: "mainnet" | "testnet",
networkCAIP2: `stacks:${string}`
networkCAIP2: `stacks:${string}`,
feePayer?: string
) {
const asset = getSBTCAsset(network);

Expand All @@ -46,6 +53,7 @@ export function buildInboxPaymentRequirements(
type: "fixed" as const,
tier: "inbox-message",
},
...(feePayer && { feePayer }),
},
};
}
Expand Down
Loading
Loading