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
6 changes: 6 additions & 0 deletions packages/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
33 changes: 30 additions & 3 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand All @@ -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 };
Expand Down Expand Up @@ -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<Response> {
const slot: { requirements?: X402PaymentRequirementsView; error?: X402PolicyError } = {};
Expand All @@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/src/x402-spend-cap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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';
Expand Down
63 changes: 63 additions & 0 deletions packages/sdk/test/x402-spend-cap-config.test.ts
Original file line number Diff line number Diff line change
@@ -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));
}
});
Loading