diff --git a/src/services/x402.service.ts b/src/services/x402.service.ts index 659b3f49..d3030e44 100644 --- a/src/services/x402.service.ts +++ b/src/services/x402.service.ts @@ -483,6 +483,8 @@ export interface PaymentRequirements { recipient: string; network: string; account: Account; + /** True when the endpoint advertised `extra.sponsored` — the relay pays the STX fee. */ + sponsored: boolean; } /** @@ -892,6 +894,10 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien ); } + // The endpoint offers gas sponsorship: sign with `sponsored: true, fee: 0` + // and its relay co-signs and pays the STX fee at settlement. + const sponsored = selectedOption.extra?.sponsored === true; + // Invoke pre-payment callback (e.g. balance check) before signing/broadcasting. // If the callback throws, the payment is aborted and the error propagates to the caller. if (options?.onBeforePayment) { @@ -901,34 +907,32 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien recipient: selectedOption.payTo, network: paymentNetwork ?? acct.network, account: acct, + sponsored, }); } // Clamped fee — do NOT let @stacks auto-estimate, since Hiro's // contract_call tiers are polluted by outliers (observed >2000 STX). // The sbtc_transfer clamp caps it at 0.003 STX. - const fee = await resolveDefaultFee( - acct.network, - isSbtc ? "sbtc_transfer" : "token_transfer" - ); + const fee = sponsored + ? 0n + : await resolveDefaultFee(acct.network, isSbtc ? "sbtc_transfer" : "token_transfer"); // Cumulative spending limit: the per-payment cap above bounds a single // 402, but a malicious endpoint can loop sub-cap payments. This blocks - // once the session/day total would be exceeded. The sender pays the - // fee, so it is booked in ustx alongside the amount. + // once the session/day total would be exceeded. A self-paying sender + // pays the fee, so it is booked in ustx alongside the amount. reservation = await getSpendLimiter().reserve( [ { unit: isSbtc ? "sats" : "ustx", amount }, - { unit: "ustx", amount: fee }, + ...(sponsored ? [] : [{ unit: "ustx" as const, amount: fee }]), ], acct.address ); - // Build a non-sponsored signed transaction: the sender pays its own - // STX gas (fee + nonce auto-estimated by @stacks), and the x402 - // facilitator simply verifies and broadcasts it. No relay sponsorship - // — this is the same tx shape the x402-stacks lib produces, and the - // balance check in onBeforePayment already accounts for the gas. + // Build the signed transaction. Self-paid: the sender pays its own STX + // gas and the facilitator verifies and broadcasts it. Sponsored: the + // sponsor slot is left empty for the endpoint's relay to fill. const networkName = getStacksNetwork(acct.network); const memo = paymentIdMemo(selectedOption.extra); @@ -961,6 +965,7 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien .ft(contracts.SBTC_TOKEN as `${string}.${string}`, "sbtc-token"), ], fee, + sponsored, }); } else { transaction = await makeSTXTokenTransfer({ @@ -970,6 +975,7 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien network: networkName, memo: memo ?? "", fee, + sponsored, }); } diff --git a/src/tools/endpoint.tools.ts b/src/tools/endpoint.tools.ts index b85d83fc..0dc89998 100644 --- a/src/tools/endpoint.tools.ts +++ b/src/tools/endpoint.tools.ts @@ -434,9 +434,8 @@ For aibtc.com inbox messages, use send_inbox_message_direct instead — it signs toolName: "execute_x402_endpoint", asset, onBeforePayment: async (requirements) => { - // Non-sponsored: sender pays its own gas, so validate STX for the - // fee too (sponsored=false is the default, passed explicitly here). - await checkSufficientBalance(requirements.account, requirements.amount, requirements.asset, false); + // Self-paid payments also need STX for the fee; sponsored ones don't. + await checkSufficientBalance(requirements.account, requirements.amount, requirements.asset, requirements.sponsored); }, }); const response = await api.request({ method, url: parsed.requestPath, params, data }); diff --git a/tests/services/x402-fee-metering.test.ts b/tests/services/x402-fee-metering.test.ts index b63f832f..3058353f 100644 --- a/tests/services/x402-fee-metering.test.ts +++ b/tests/services/x402-fee-metering.test.ts @@ -28,7 +28,7 @@ vi.mock("../../src/services/wallet-manager.js", () => ({ const { createApiClient } = await import("../../src/services/x402.service.js"); -function respond402(asset: string, amount: string) { +function respond402(asset: string, amount: string, extra?: Record) { const paymentRequired = { x402Version: 2, resource: { url: "https://x402.test.com/paid" }, @@ -40,6 +40,7 @@ function respond402(asset: string, amount: string) { asset, payTo: "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7", maxTimeoutSeconds: 300, + ...(extra ? { extra } : {}), }, ], }; @@ -94,4 +95,19 @@ describe("x402 auto-payment metering (#713)", () => { "SP000000000000000000002Q6VF78" ); }); + + it("books no STX fee when the endpoint sponsors gas", async () => { + const client = await createApiClient("https://x402.test.com"); + client.defaults.adapter = respond402( + "SM3VDXK3WZZSA84XXFKAFAF15NNZX32CTSG82JFQ4.sbtc-token", + "100", + { sponsored: true } + ) as never; + + await expect(client.get("/paid")).rejects.toThrow("stop at the rail"); + expect(reserve).toHaveBeenCalledWith( + [{ unit: "sats", amount: 100n }], + "SP000000000000000000002Q6VF78" + ); + }); });