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
30 changes: 18 additions & 12 deletions src/services/x402.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -970,6 +975,7 @@ export async function createApiClient(baseUrl?: string, options?: CreateApiClien
network: networkName,
memo: memo ?? "",
fee,
sponsored,
});
}

Expand Down
5 changes: 2 additions & 3 deletions src/tools/endpoint.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
18 changes: 17 additions & 1 deletion tests/services/x402-fee-metering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
const paymentRequired = {
x402Version: 2,
resource: { url: "https://x402.test.com/paid" },
Expand All @@ -40,6 +40,7 @@ function respond402(asset: string, amount: string) {
asset,
payTo: "SP2J6ZY48GV1EZ5V2V5RB9MP66SW86PYKKNRV9EJ7",
maxTimeoutSeconds: 300,
...(extra ? { extra } : {}),
},
],
};
Expand Down Expand Up @@ -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"
);
});
});
Loading