Skip to content

Commit 2064c6c

Browse files
badjerclaude
andauthored
feat(x402): advertise upto scheme + send settlementOverrides (#182)
* feat(x402): advertise upto + send settlementOverrides for metered settle - omniChallenge buildX402Requirements advertises scheme=upto for EVM (Base) x402 options (SVM stays exact; Solana upto not implemented). The deployed SDK version thus selects exact vs upto. - ProtocolSettlement.buildRequestBody adds settlementOverrides.amount (atomic micro-USDC from the metered actualAmount) for x402, so settle charges the actual (<= the Permit2 cap). settlePaymentSession passes session.spent as actualAmount. - x402Wrapper / baseAccount select UptoEvmScheme vs ExactEvmScheme from the chosen accept's scheme (self-custody client paths). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(x402): advertise both schemes with facilitatorAddress; scheme-gate overrides Review fixes for x402 up-to: - buildX402Requirements advertises BOTH 'exact' and 'upto' per EVM chain (upto only when a facilitatorAddress is available), so non-accounts clients keep an exact fallback and accounts picks the scheme. upto accepts carry extra.facilitatorAddress, fetched once from auth's GET /x402/supported (cached). Dedupe accepts by (scheme, network) so a chain surfaced twice isn't advertised twice. - buildRequestBody adds settlementOverrides ONLY for the upto scheme (exact/EIP-3009 commits the signature to a fixed value; overriding it mismatches the signature and the facilitator rejects it). Clamp the override to the cap. - Revert the self-custody paths (x402Wrapper, baseAccount) to exact-only — they can't complete upto (no facilitator approval); the accounts-mediated path is the only upto path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(x402): TTL on facilitator-address cache; match accept by scheme in settle Re-review: - Bound the facilitator-address cache with a 10-minute TTL (was process-lifetime). The CDP settle address rotates; a long-lived process would advertise a stale witness and silently revert every upto settle until restart. - buildRequestBody's full-accepts branch matched by network only; the challenge now carries both exact and upto per network, so match by scheme too (else a network match returns exact-first and drops the override). Added a same-network test. - Dropped the stale "EVM payloads don't embed accepted" comment (createPaymentPayload does embed accepted for v2). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 868f331 commit 2064c6c

10 files changed

Lines changed: 580 additions & 47 deletions

File tree

packages/atxp-base/src/baseAccount.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,9 @@ export class BaseAccount implements Account {
9696
// encodePaymentSignatureHeader) is duplicated in x402Wrapper.ts. Extract a shared helper
9797
// once both packages can import from a common location that depends on @x402/core + @x402/evm.
9898
const signer = toClientEvmSigner(this.getLocalAccount());
99+
// This self-custody EOA path can only complete `exact` (EIP-3009 transfer
100+
// authorization). It has no Permit2 approval and no facilitatorAddress to
101+
// sign an `upto` permit; the accounts-mediated path is the only upto path.
99102
const scheme = new ExactEvmScheme(signer);
100103
const client = new x402Client();
101104
// v2 uses CAIP-2 network IDs ("eip155:8453")

packages/atxp-server/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ export {
124124
sourcesToOptions,
125125
buildPaymentOptions,
126126
buildAuthorizeParamsFromSources,
127+
fetchUptoFacilitatorAddresses,
127128
} from './omniChallenge.js';
128129

129130
// Opaque identity for MPP Authorization: Payment ↔ OAuth Bearer coexistence

packages/atxp-server/src/omniChallenge.test.ts

Lines changed: 140 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, it, expect } from 'vitest';
1+
import { describe, it, expect, vi } from 'vitest';
22
import { BigNumber } from 'bignumber.js';
33
import {
44
buildX402Requirements,
@@ -11,6 +11,7 @@ import {
1111
buildOmniChallenge,
1212
buildPaymentOptions,
1313
buildAuthorizeParamsFromSources,
14+
fetchUptoFacilitatorAddresses,
1415
} from './omniChallenge.js';
1516
import { PAYMENT_REQUIRED_PREAMBLE, PAYMENT_REQUIRED_ERROR_CODE } from '@atxp/common';
1617
import { parseMPPHeader } from '@atxp/mpp';
@@ -20,15 +21,22 @@ describe('omniChallenge', () => {
2021
{ network: 'base', currency: 'USDC', address: '0xDestination', amount: new BigNumber('0.01') },
2122
];
2223

24+
// CAIP-2 → upto facilitator address map (from GET /x402/supported).
25+
const FACILITATORS = {
26+
'eip155:8453': '0x7720030000000000000000000000000000000000',
27+
'eip155:84532': '0x14fDa00000000000000000000000000000000000',
28+
};
29+
2330
describe('buildX402Requirements', () => {
24-
it('should build valid X402 payment requirements', () => {
31+
it('should build valid X402 payment requirements (exact accept)', () => {
2532
const result = buildX402Requirements({
2633
options: defaultOptions,
2734
resource: 'https://example.com/api',
2835
payeeName: 'Test Server',
2936
});
3037

3138
expect(result.x402Version).toBe(2);
39+
// Without a facilitator address, only the exact accept is advertised.
3240
expect(result.accepts).toHaveLength(1);
3341
expect(result.accepts[0]).toMatchObject({
3442
scheme: 'exact',
@@ -42,6 +50,62 @@ describe('omniChallenge', () => {
4250
});
4351
});
4452

53+
it('advertises BOTH exact and upto for EVM when a facilitatorAddress is known', () => {
54+
const result = buildX402Requirements({
55+
options: defaultOptions,
56+
resource: 'https://example.com/api',
57+
payeeName: 'Test Server',
58+
facilitatorAddresses: FACILITATORS,
59+
});
60+
61+
// exact first, then upto — both for the same Base network.
62+
expect(result.accepts).toHaveLength(2);
63+
expect(result.accepts[0].scheme).toBe('exact');
64+
expect(result.accepts[0].extra).toEqual({ name: 'USD Coin', version: '2' });
65+
expect(result.accepts[1].scheme).toBe('upto');
66+
expect(result.accepts[1].network).toBe('eip155:8453');
67+
// upto carries the facilitator address (the only address allowed to settle).
68+
expect(result.accepts[1].extra).toEqual({
69+
name: 'USD Coin',
70+
version: '2',
71+
facilitatorAddress: FACILITATORS['eip155:8453'],
72+
});
73+
});
74+
75+
it('omits upto (exact only) for a network with no facilitatorAddress', () => {
76+
const result = buildX402Requirements({
77+
options: defaultOptions,
78+
resource: 'https://example.com/api',
79+
payeeName: 'Test Server',
80+
facilitatorAddresses: { 'eip155:84532': FACILITATORS['eip155:84532'] }, // wrong network
81+
});
82+
83+
expect(result.accepts).toHaveLength(1);
84+
expect(result.accepts[0].scheme).toBe('exact');
85+
});
86+
87+
it('emits exact (+upto when facilitator known) for EVM and exact-only for SVM (Solana)', () => {
88+
const options = [
89+
{ network: 'base', currency: 'USDC', address: '0xAddr1', amount: new BigNumber('0.01') },
90+
{ network: 'solana', currency: 'USDC', address: '7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV', amount: new BigNumber('0.02') },
91+
];
92+
93+
const result = buildX402Requirements({
94+
options,
95+
resource: 'https://example.com',
96+
payeeName: 'Multi-chain Server',
97+
facilitatorAddresses: FACILITATORS,
98+
});
99+
100+
// EVM: exact + upto; SVM: exact only (Solana upto not implemented).
101+
expect(result.accepts[0].network).toBe('eip155:8453');
102+
expect(result.accepts[0].scheme).toBe('exact');
103+
expect(result.accepts[1].network).toBe('eip155:8453');
104+
expect(result.accepts[1].scheme).toBe('upto');
105+
expect(result.accepts[2].network).toBe('solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp');
106+
expect(result.accepts[2].scheme).toBe('exact');
107+
});
108+
45109
it('should include both EVM and Solana X402 options', () => {
46110
const options = [
47111
{ network: 'base', currency: 'USDC', address: '0xAddr1', amount: new BigNumber('0.01') },
@@ -55,11 +119,14 @@ describe('omniChallenge', () => {
55119
payeeName: 'Multi-chain Server',
56120
});
57121

58-
// EVM options first, then Solana
59-
expect(result.accepts).toHaveLength(3);
122+
// The same chain appears twice (two Base addresses). Dedupe keeps the first
123+
// per (scheme, network) — a destination has one receiving address per chain —
124+
// so the second Base option (0xAddr2) is dropped. No facilitatorAddresses were
125+
// passed, so only exact accepts: one Base, one Solana.
126+
expect(result.accepts).toHaveLength(2);
60127
expect(result.accepts[0].payTo).toBe('0xAddr1');
61-
expect(result.accepts[1].payTo).toBe('0xAddr2');
62-
expect(result.accepts[2].payTo).toBe('7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV');
128+
expect(result.accepts[0].network).toBe('eip155:8453');
129+
expect(result.accepts[1].payTo).toBe('7EcDhSYGxXyscszYEp35KHN8vvw3svAuLKTzXwCFLtV');
63130
});
64131

65132
it('should include feePayer in extra for Solana X402 options', () => {
@@ -609,5 +676,72 @@ describe('omniChallenge', () => {
609676
expect(result.paymentRequirements!.accepts[0].payTo).toBe('0xBaseOnly');
610677
expect(result.challenges).toEqual([]);
611678
});
679+
680+
it('threads facilitatorAddresses through to the upto accept', () => {
681+
const result = buildAuthorizeParamsFromSources({
682+
amount: new BigNumber('0.10'),
683+
sources: [{ chain: 'base', address: '0xBaseAddr' }],
684+
facilitatorAddresses: { 'eip155:8453': '0x7720030000000000000000000000000000000000' },
685+
});
686+
687+
const accepts = result.paymentRequirements!.accepts;
688+
expect(accepts.map(a => a.scheme)).toEqual(['exact', 'upto']);
689+
expect(accepts[1].extra).toMatchObject({ facilitatorAddress: '0x7720030000000000000000000000000000000000' });
690+
});
691+
});
692+
693+
describe('fetchUptoFacilitatorAddresses', () => {
694+
function okResponse(body: unknown) {
695+
return { ok: true, json: async () => body } as unknown as Response;
696+
}
697+
698+
it('fetches GET /x402/supported and returns the flat network → address map', async () => {
699+
const map = { 'eip155:8453': '0x7720030000000000000000000000000000000000' };
700+
const fetchFn = vi.fn().mockResolvedValue(okResponse(map));
701+
702+
const result = await fetchUptoFacilitatorAddresses('https://auth1.test', fetchFn as any);
703+
704+
expect(result).toEqual(map);
705+
expect(fetchFn).toHaveBeenCalledWith('https://auth1.test/x402/supported');
706+
});
707+
708+
it('caches the result per URL (fetches at most once)', async () => {
709+
const map = { 'eip155:8453': '0xabc0000000000000000000000000000000000000' };
710+
const fetchFn = vi.fn().mockResolvedValue(okResponse(map));
711+
712+
await fetchUptoFacilitatorAddresses('https://auth2.test', fetchFn as any);
713+
await fetchUptoFacilitatorAddresses('https://auth2.test', fetchFn as any);
714+
715+
expect(fetchFn).toHaveBeenCalledTimes(1);
716+
});
717+
718+
it('returns {} and does not throw when the fetch fails', async () => {
719+
const fetchFn = vi.fn().mockRejectedValue(new Error('network down'));
720+
const warn = vi.fn();
721+
722+
const result = await fetchUptoFacilitatorAddresses('https://auth3.test', fetchFn as any, { warn });
723+
724+
expect(result).toEqual({});
725+
expect(warn).toHaveBeenCalled();
726+
});
727+
728+
it('returns {} on a non-OK response', async () => {
729+
const fetchFn = vi.fn().mockResolvedValue({ ok: false, status: 503 } as unknown as Response);
730+
731+
const result = await fetchUptoFacilitatorAddresses('https://auth4.test', fetchFn as any);
732+
733+
expect(result).toEqual({});
734+
});
735+
736+
it('does not cache an empty result (allows a later retry)', async () => {
737+
const map = { 'eip155:8453': '0xdef0000000000000000000000000000000000000' };
738+
const fetchFn = vi.fn()
739+
.mockRejectedValueOnce(new Error('down'))
740+
.mockResolvedValue(okResponse(map));
741+
742+
expect(await fetchUptoFacilitatorAddresses('https://auth5.test', fetchFn as any)).toEqual({});
743+
expect(await fetchUptoFacilitatorAddresses('https://auth5.test', fetchFn as any)).toEqual(map);
744+
expect(fetchFn).toHaveBeenCalledTimes(2);
745+
});
612746
});
613747
});

0 commit comments

Comments
 (0)