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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ Every helper is extracted from a real consumer, not speculated.
|---|---|
| `@agent-score/commerce/identity/{hono,express,fastify,nextjs,web}` | Trust gate middleware (KYC, age, sanctions, jurisdiction) |
| `@agent-score/commerce/identity/policy` | Framework-agnostic per-product / per-tier compliance policy helpers — `PolicyBlock`, `policyToGateOptions`, `runGateWithEnforcement`, `shippingCountryAllowed`, `shippingStateAllowed` |
| `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, x402 server factory + scheme dual-register, MPP server factory, dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
| `@agent-score/commerce/payment` | Networks/USDC/rails registries, paymentauth.org directive builders, `createX402Server` (peer-dep `@x402/core` + `@coinbase/x402` for the Coinbase facilitator), `buildX402AcceptsFor402` (one-call helper for the 402-emit path: builds the requirements via the registered scheme so `extra.name` matches the on-chain USDC contract per network), `createMppxServer` (peer-dep `mppx`), `processX402Settle` (verify+settle in one call), dispatch-by-network, signer extraction, WWW-Authenticate header, Settlement-Overrides header |
| `@agent-score/commerce/discovery` | Discovery probe middleware, Bazaar wrapper, `/.well-known/mpp.json` builder, `llms.txt` builder, `skill.md` builder (Claude-Skill-compatible agent-discovery manifest), OpenAPI snippets, `noindexNonDiscoveryPaths` Hono middleware |
| `@agent-score/commerce/challenge` | 402-body builders: accepted_methods, identity metadata, how_to_pay, agent_instructions, build402Body, `buildValidationError` (4xx body builder) |
| `@agent-score/commerce/stripe-multichain` | Multichain PaymentIntent helper, deposit-address lookup, testnet simulator, mppx Stripe wrapper |
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,21 @@ await simulateDepositIfTestMode({
});
```

### Build the x402 accepts entry for the 402 challenge

```typescript
import { buildX402AcceptsFor402 } from '@agent-score/commerce/payment';

const x402Accepts = await buildX402AcceptsFor402(x402Server, {
network: X402_BASE,
price: `$${totalUsd}`,
payTo: process.env.TREASURY_BASE_RECIPIENT!,
maxTimeoutSeconds: 300,
});
```

Returns a list of plain objects ready for the 402 body's `accepts[]`. `extra.name` is derived from the registered scheme metadata so the EIP-712 domain matches the on-chain USDC contract.

### Drop-in 402 + settle (x402)

```typescript
Expand Down
5 changes: 5 additions & 0 deletions examples/api-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ app.post('/search', async (c) => {
request: '',
}),
];
// The minimal-example shape: a hand-rolled accept entry. Production code should
// use `buildX402AcceptsFor402(x402Server, {...})` from `@agent-score/commerce/payment`
// — the helper builds the requirement via the registered x402 scheme so `extra`
// (incl. the EIP-712 `name`/`version` for EVM USDC) is derived from the on-chain
// contract metadata rather than guessed. See `examples/multi-rail-merchant.ts`.
const accepts = [
{
scheme: 'exact',
Expand Down
22 changes: 11 additions & 11 deletions examples/multi-rail-merchant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ import {
import { agentscoreGate, getAgentScoreData } from '@agent-score/commerce/identity/hono';
import {
createMppxServer,
buildX402AcceptsFor402,
createX402Server,
networks,
processX402Settle,
USDC,
validateX402NetworkConfig,
verifyX402Request,
} from '@agent-score/commerce/payment';
Expand Down Expand Up @@ -251,16 +251,16 @@ app.post('/purchase', async (c) => {
},
x402: {
x402Version: 2,
accepts: [
{
scheme: 'exact',
network: X402_BASE_NETWORK,
amount: String(Math.round(Number(totalUsd) * 1_000_000)),
asset: USDC.base.mainnet.address,
payTo: depositAddresses.base,
maxTimeoutSeconds: 300,
},
],
// Build the accept via the registered x402 scheme — fills in `extra` (incl.
// the network-correct USDC `name`) so agents can sign EIP-712 against the
// right domain. Hardcoding `extra` is the trap that breaks every signature
// verify on base mainnet (USDC contract returns "USD Coin", not "USDC").
accepts: await buildX402AcceptsFor402(x402Server, {
network: X402_BASE_NETWORK,
price: `$${totalUsd}`,
payTo: depositAddresses.base,
maxTimeoutSeconds: 300,
}),
resource: { url: c.req.url, mimeType: 'application/json' },
},
});
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agent-score/commerce",
"version": "1.3.1",
"version": "1.3.2",
"description": "Agent commerce SDK — identity middleware (Hono, Express, Fastify, Next.js, Web Fetch) + payment helpers + 402 builders + discovery + Stripe multichain. The full merchant-side toolkit for AgentScore-powered agent commerce.",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down
6 changes: 5 additions & 1 deletion src/discovery/probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ export function sampleX402AcceptForNetwork(
asset: USDC.base.mainnet.address,
payTo: ZERO_EVM_PAYTO,
maxTimeoutSeconds: 300,
extra: { name: 'USDC', version: '2' },
// ``extra.name`` mirrors the on-chain USDC contract's ``name()`` because
// EIP-712 domain hashes include this string. Wrong name → every signed
// payload fails facilitator verify with ``invalid_exact_evm_payload_signature``.
// Base mainnet USDC returns "USD Coin"; base sepolia USDC returns "USDC".
extra: { name: 'USD Coin', version: '2' },
};
}
if (caip2 === networks.base.sepolia.caip2) {
Expand Down
42 changes: 42 additions & 0 deletions src/payment/x402_server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,48 @@ export async function createX402Server(opts: CreateX402ServerOptions = {}): Prom
return server;
}

export interface BuildX402AcceptsForOptions {
network: string;
price: string;
payTo: string;
scheme?: string;
maxTimeoutSeconds?: number;
extensions?: string[];
}

/**
* Build x402 `accepts[]` entries for a 402 challenge body.
*
* Wraps `server.buildPaymentRequirements(...)` so merchants don't have to:
*
* 1. Construct the resource-config object themselves
* 2. Remember to serialize each Pydantic-equivalent requirement back to a
* plain object before stitching it into the 402 body
* 3. Hardcode `extra` (which differs by the actual on-chain contract — base
* mainnet USDC has `name: "USD Coin"`, base sepolia USDC has `name: "USDC"`;
* EIP-712 domain hashes differ, so getting this wrong silently breaks every
* signature verify at the facilitator)
*
* Returns a list of plain objects in the shape that x402 expects on the wire —
* drop them straight into the `accepts` field of the 402 challenge body.
*/
export async function buildX402AcceptsFor402(
server: X402Server,
opts: BuildX402AcceptsForOptions,
): Promise<unknown[]> {
const requirements = await server.buildPaymentRequirements(
{
scheme: opts.scheme ?? 'exact',
network: opts.network,
price: opts.price,
payTo: opts.payTo,
maxTimeoutSeconds: opts.maxTimeoutSeconds ?? 300,
},
opts.extensions,
);
return Array.isArray(requirements) ? requirements : [];
}

async function dynamicImport<T>(moduleName: string): Promise<T | null> {
try {
return (await import(moduleName)) as T;
Expand Down
5 changes: 4 additions & 1 deletion tests/discovery/probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ describe('sampleX402AcceptForNetwork', () => {
asset: USDC.base.mainnet.address,
payTo: '0x0000000000000000000000000000000000000000',
maxTimeoutSeconds: 300,
extra: { name: 'USDC', version: '2' },
// Base mainnet USDC contract returns ``name() == "USD Coin"``;
// ``extra.name`` mirrors that for EIP-712 domain-hash parity.
extra: { name: 'USD Coin', version: '2' },
});
});

Expand All @@ -112,6 +114,7 @@ describe('sampleX402AcceptForNetwork', () => {
expect(a?.network).toBe(networks.base.sepolia.caip2);
expect(a?.asset).toBe(USDC.base.sepolia.address);
expect(a?.amount).toBe('500000');
// Base sepolia USDC returns ``name() == "USDC"`` (different from mainnet).
expect(a?.extra).toEqual({ name: 'USDC', version: '2' });
});

Expand Down
88 changes: 87 additions & 1 deletion tests/payment/x402_server.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { createX402Server } from '../../src/payment/x402_server';
import { buildX402AcceptsFor402, createX402Server } from '../../src/payment/x402_server';

describe('createX402Server', () => {
it('returns an x402ResourceServer with HTTP facilitator by default', async () => {
Expand Down Expand Up @@ -51,3 +51,89 @@ describe('createX402Server', () => {
expect(server).toBeDefined();
});
});

describe('buildX402AcceptsFor402', () => {
it('passes the resource-config kwargs to server.buildPaymentRequirements and returns its array', async () => {
let captured: { config?: Record<string, unknown>; extensions?: unknown } = {};
const fakeServer = {
register: () => {},
registerExtension: () => {},
initialize: async () => {},
buildPaymentRequirements: async (config: Record<string, unknown>, extensions?: unknown) => {
captured = { config, extensions };
return [
{
scheme: 'exact',
network: config.network,
amount: '100000',
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
payTo: config.payTo,
maxTimeoutSeconds: config.maxTimeoutSeconds ?? 300,
extra: { name: 'USD Coin', version: '2' },
},
];
},
} as never;

const accepts = await buildX402AcceptsFor402(fakeServer, {
network: 'eip155:8453',
price: '$0.10',
payTo: '0x000000000000000000000000000000000000dEaD',
});
// Defaults applied: scheme=exact, maxTimeoutSeconds=300
expect(captured.config).toEqual({
scheme: 'exact',
network: 'eip155:8453',
price: '$0.10',
payTo: '0x000000000000000000000000000000000000dEaD',
maxTimeoutSeconds: 300,
});
expect(captured.extensions).toBeUndefined();
expect(Array.isArray(accepts)).toBe(true);
expect(accepts).toHaveLength(1);
const accept = accepts[0] as Record<string, unknown>;
expect(accept.network).toBe('eip155:8453');
expect(accept.payTo).toBe('0x000000000000000000000000000000000000dEaD');
expect(accept.extra).toEqual({ name: 'USD Coin', version: '2' });
});

it('honours scheme + maxTimeoutSeconds + extensions when supplied', async () => {
let captured: { config?: Record<string, unknown>; extensions?: unknown } = {};
const fakeServer = {
register: () => {},
registerExtension: () => {},
initialize: async () => {},
buildPaymentRequirements: async (config: Record<string, unknown>, extensions?: unknown) => {
captured = { config, extensions };
return [];
},
} as never;

await buildX402AcceptsFor402(fakeServer, {
network: 'eip155:8453',
price: '$1.00',
payTo: '0xabc',
scheme: 'upto',
maxTimeoutSeconds: 600,
extensions: ['bazaar'],
});
expect(captured.config?.scheme).toBe('upto');
expect(captured.config?.maxTimeoutSeconds).toBe(600);
expect(captured.extensions).toEqual(['bazaar']);
});

it('returns an empty array when buildPaymentRequirements returns a non-array', async () => {
const fakeServer = {
register: () => {},
registerExtension: () => {},
initialize: async () => {},
buildPaymentRequirements: async () => null,
} as never;
const accepts = await buildX402AcceptsFor402(fakeServer, {
network: 'eip155:8453',
price: '$0.10',
payTo: '0xabc',
});
expect(accepts).toEqual([]);
});
});
Loading