diff --git a/packages/sdk/CHANGELOG.md b/packages/sdk/CHANGELOG.md index baccc0d..42e9cfd 100644 --- a/packages/sdk/CHANGELOG.md +++ b/packages/sdk/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to the `nirium` package are documented here. +## Unreleased + +### Added + +- `initX402({ maxAmountPerPayment })` ([#106](https://github.com/nirium-protocol/nirium/issues/106)): sets the `@x402/core` 2.28 per-payment cap (`spendControls.maxAmountPerPayment`) so APIs priced above $1 can be paid. Accepts `'$5'`, `'5'` or `5`; validated at `initX402()` time (positive amounts only, `false` is rejected). Without the option, behavior is unchanged: the default stays `$1`. Works with and without `policy`. The `X402SpendCapError` message now points to the option. + ## 0.16.1 - 2026-10-11 Documentation and package metadata only. `dist/` is identical to 0.16.0. diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 896b665..3a3b4fe 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -109,7 +109,15 @@ try { } ``` -The error carries `url`, `amount` (atomic units, the cheapest offer when the server lists several), `formattedAmount`, `asset`, `network` and `cap`, and `signerCalled` is always `false`: nothing was signed or sent. The cap is enforced by `@x402/core` and cannot be changed from `initX402()` yet. Other `spendControls` rejections (for example a non-default asset) still surface as the original error. +The error carries `url`, `amount` (atomic units, the cheapest offer when the server lists several), `formattedAmount`, `asset`, `network` and `cap`, and `signerCalled` is always `false`: nothing was signed or sent. The cap is enforced by `@x402/core`. Other `spendControls` rejections (for example a non-default asset) still surface as the original error. + +To pay an API priced above $1, raise the cap explicitly with `maxAmountPerPayment` (a USD amount: `'$5'`, `'5'` or `5`; it applies to default assets such as USDC): + +```typescript +agent.initX402({ secretKey: 'S...', network: 'stellar:pubnet', maxAmountPerPayment: '$5' }); +``` + +Why the default stays at $1: an agent pays whatever a server's 402 asks for, without a human in the loop, so a low cap limits the damage from a mispriced or malicious endpoint. Raising it is a choice the integrator makes on purpose; omitting the option keeps `@x402/core`'s $1. It must be a positive amount, checked when `initX402()` runs; `false` (no cap) is not accepted. The option also applies when a `policy` is set, and a payment above the cap is refused before the policy or the signer is reached. #### Pre-sign policy hook (experimental) diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 67b30c7..257b436 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -73,6 +73,29 @@ export interface X402Config { * x402Fetch behaves exactly as before. See X402PolicyHook. */ policy?: X402PolicyHook; + /** + * Per-payment cap for x402Fetch (#106), passed to @x402/core as + * `spendControls.maxAmountPerPayment`. USD amount as `'$5'`, `'5'` or `5`; + * it applies to default assets such as USDC. Defaults to @x402/core's `'$1'`. + * Raising it is an explicit choice: a payment above the cap is refused + * before anything is signed (`X402SpendCapError`). Must be a positive + * amount; `false` (no cap) is deliberately not accepted here. + */ + maxAmountPerPayment?: string | number; +} + +/** Validates and normalizes `maxAmountPerPayment` at initX402 time ("$5" form). */ +function normalizeMaxAmountPerPayment(v: unknown): string | undefined { + if (v === undefined) return undefined; + const text = typeof v === 'number' ? (Number.isFinite(v) ? String(v) : '') : typeof v === 'string' ? v.trim() : ''; + const m = /^\$?(\d+(?:\.\d+)?)$/.exec(text); + if (!m || !(Number(m[1]) > 0)) { + throw new Error( + 'initX402: `maxAmountPerPayment` must be a positive USD amount such as \'$5\', \'5\' or 5 ' + + `(got ${JSON.stringify(v)}). Omit it to keep the $1 default.`, + ); + } + return `$${m[1]}`; } export interface MppConfig { @@ -588,7 +611,7 @@ export class Agent { private token: string | null = null; private x402Client: { client: any; scheme: any; baseFetch: typeof fetch } | null = null; - private x402Policy: { base: X402BaseSigner; rpcUrl: string; policy: X402PolicyHook } | null = null; + private x402Policy: { base: X402BaseSigner; rpcUrl: string; policy: X402PolicyHook; maxAmountPerPayment?: string } | null = null; private mppClient: { fetch: typeof fetch } | null = null; constructor(config: AgentConfig) { @@ -1178,6 +1201,7 @@ export class Agent { ); } if (config.policy !== undefined) assertValidPolicyHook(config.policy); + const maxAmountPerPayment = normalizeMaxAmountPerPayment(config.maxAmountPerPayment); const signer = config.signer ?? (createEd25519Signer as any)(config.secretKey, network); // Pubnet: el SDF NO corre RPC público de mainnet — soroban.stellar.org no // existe. Default al RPC público de gateway.fm (mismo default que @@ -1189,13 +1213,15 @@ export class Agent { // Con policy el cliente se arma en cada x402Fetch: el signer de // stellar-sdk no recibe la URL, y un closure por llamada ata URL, // método y requisitos a ese pago sin estado compartido. - this.x402Policy = { base: signer, rpcUrl, policy: config.policy }; + this.x402Policy = { base: signer, rpcUrl, policy: config.policy, maxAmountPerPayment }; this.x402Client = null; return; } this.x402Policy = null; const scheme = new (ExactStellarScheme as any)(signer, { url: rpcUrl }); const client = new (X402ClientClass as any)().register('stellar:*', scheme); + // Sin la opción no se toca spendControls: @x402/core aplica su $1. + if (maxAmountPerPayment) client.setSpendControls({ maxAmountPerPayment }); // El cliente es uno solo; el fetch envuelto se arma en cada llamada // (un closure barato) para que lo capturado de cada 402 sea de esa llamada. this.x402Client = { client, scheme, baseFetch: fetch }; @@ -1225,7 +1251,7 @@ export class Agent { } private async x402FetchWithPolicy( - cfg: { base: X402BaseSigner; rpcUrl: string; policy: X402PolicyHook }, + cfg: { base: X402BaseSigner; rpcUrl: string; policy: X402PolicyHook; maxAmountPerPayment?: string }, url: string, init?: RequestInit, ): Promise { const slot: { requirements?: X402PaymentRequirementsView; error?: X402PolicyError } = {}; @@ -1244,6 +1270,7 @@ export class Agent { payTo: r.payTo, amount: r.amount, maxTimeoutSeconds: r.maxTimeoutSeconds, }; }); + if (cfg.maxAmountPerPayment) client.setSpendControls({ maxAmountPerPayment: cfg.maxAmountPerPayment }); // NUNCA registrar onPaymentCreationFailure aquí: puede devolver un // payload sin pasar por el signer, y el gate quedaría puenteado. try { diff --git a/packages/sdk/src/x402-spend-cap.ts b/packages/sdk/src/x402-spend-cap.ts index 57a3a84..57cfe93 100644 --- a/packages/sdk/src/x402-spend-cap.ts +++ b/packages/sdk/src/x402-spend-cap.ts @@ -7,7 +7,7 @@ // ("Failed to create payment payload: All payment requirements were // rejected by spendControls.maxAmountPerPayment (...)"), sin decir cuánto se // pedía ni que nadie firmó nada. Este módulo reconoce ese caso y lo convierte -// en un error que lo dice. No cambia el tope ni lo expone: solo lo explica. +// en un error que lo dice. El tope se configura con initX402({ maxAmountPerPayment }) (#106). // ═══════════════════════════════════════════════════════════════ /** One payment option from the server's 402 response, as x402 v2 sends it. */ @@ -57,7 +57,7 @@ export class X402SpendCapError extends Error { super( `x402Fetch refused to pay ${d.url}: the server asked for ${asked} and ${cap}. ` + 'The signer was not called; nothing was signed or sent. ' - + 'The cap is enforced by @x402/core and cannot be changed from initX402() yet.', + + 'The cap is enforced by @x402/core; raise it explicitly with initX402({ maxAmountPerPayment }).', d.cause !== undefined ? { cause: d.cause } : undefined, ); this.name = 'X402SpendCapError'; diff --git a/packages/sdk/test/x402-spend-cap-config.test.ts b/packages/sdk/test/x402-spend-cap-config.test.ts new file mode 100644 index 0000000..08772b3 --- /dev/null +++ b/packages/sdk/test/x402-spend-cap-config.test.ts @@ -0,0 +1,63 @@ +/** + * initX402({ maxAmountPerPayment }) (#106): the @x402/core per-payment cap, + * configurable and still $1 by default. End to end on the built package with + * the in-memory edges of test/helpers/x402-edges.ts. + */ +import { test, beforeEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { X402SpendCapError, counters, keySigner, agent, init, paid } from './helpers/x402-edges.ts'; + +beforeEach(() => { counters.paidRetries = 0; counters.challenges = 0; }); + +const ONE_O_ONE = '10100000'; // $1.01 in USDC (7 decimals) +const allowAll = { evaluate: async (ctx: any) => ({ decision: 'ALLOW', contextHash: ctx.contextHash }) }; + +test('default: a $1.01 payment is refused and the signer is never called', async () => { + const { calls, signer } = keySigner(); + const a = agent(); init(a, { signer }); + const err = await paid(a, ONE_O_ONE).catch((e: any) => e); + assert.ok(err instanceof X402SpendCapError, String(err)); + assert.equal(err.cap, '$1'); + assert.match(err.message, /initX402\(\{ maxAmountPerPayment \}\)/); + assert.equal(calls.length, 0); + assert.equal(counters.paidRetries, 0); +}); + +for (const cap of ['$2', '2', 2, '1.01']) { + test(`raised cap ${JSON.stringify(cap)}: $1.01 pays and is signed once`, async () => { + const { calls, signer } = keySigner(); + const a = agent(); init(a, { signer, maxAmountPerPayment: cap }); + assert.equal((await paid(a, ONE_O_ONE)).status, 200); + assert.equal(calls.length, 1); + assert.equal(counters.paidRetries, 1); + }); +} + +test('raised cap still blocks above it, before the signer', async () => { + const { calls, signer } = keySigner(); + const a = agent(); init(a, { signer, maxAmountPerPayment: '$5' }); + const err = await paid(a, '50000001').catch((e: any) => e); + assert.ok(err instanceof X402SpendCapError, String(err)); + assert.equal(err.cap, '$5'); + assert.equal(calls.length, 0); +}); + +test('with a policy: the raised cap applies, and the policy is not asked when the cap blocks', async () => { + const { calls, signer } = keySigner(); + let asked = 0; + const a = agent(); + init(a, { signer, maxAmountPerPayment: '$2', policy: { evaluate: async (ctx: any) => { asked++; return allowAll.evaluate(ctx); } } }); + assert.equal((await paid(a, ONE_O_ONE)).status, 200); + assert.equal(asked, 1); + const err = await paid(a, '20000001').catch((e: any) => e); + assert.ok(err instanceof X402SpendCapError, String(err)); + assert.equal(asked, 1); + assert.equal(calls.length, 1); +}); + +test('invalid values fail at initX402, not at payment time', () => { + const { signer } = keySigner(); + for (const bad of [0, -1, '0', '$0', 'abc', '', '1e3', NaN, Infinity, false, null, '$-1', '1,5']) { + assert.throws(() => init(agent(), { signer, maxAmountPerPayment: bad }), /maxAmountPerPayment` must be a positive USD amount/, JSON.stringify(bad)); + } +});