From 8ee573fe9ee5930f0d48704bb8da06778da8527c Mon Sep 17 00:00:00 2001 From: dhyabi2 Date: Tue, 13 Oct 2026 03:47:49 +0000 Subject: [PATCH] feat(x402): support Nano (XNO) offers on nano:mainnet Add an experimental x402 network family for Nano beside EVM, Solana and NEAR. The buyer publishes a send block of native XNO to payTo and presents its hash; the gateway reads the block from the offer's Nano node with block_info and requires a confirmed state send to payTo of at least the offered raw amount. No facilitator is called, no key is held and nothing is broadcast. The block hash is the replay key, settle reads the block again under the replay reservation, and a node that does not answer is PAYMENT_PROVIDER_UNAVAILABLE. Addresses are checked with their BLAKE2b checksum (nano_ and legacy xrb_). XNO has no USD rate in the gateway, so a resource with a Nano offer must be priced in XNO and list only Nano offers. --- README.md | 20 +- docs/configuration.md | 35 +++ docs/contracts.md | 15 +- docs/protocols.md | 22 +- docs/protocols/x402.md | 28 +- src/config/schema/resources.ts | 19 ++ src/payments/x402/addresses.ts | 33 +- src/payments/x402/nano-address.ts | 130 ++++++++ src/payments/x402/nano.ts | 129 ++++++++ src/payments/x402/networks.ts | 20 +- src/payments/x402/options.ts | 19 +- src/payments/x402/provider/health.ts | 24 +- src/payments/x402/provider/index.ts | 15 +- src/payments/x402/provider/offers.ts | 24 +- src/payments/x402/provider/requirement.ts | 11 +- src/payments/x402/provider/settle.ts | 53 +++- src/payments/x402/provider/types.ts | 15 +- src/payments/x402/provider/verify.ts | 35 ++- src/payments/x402/replay-key.ts | 15 + src/payments/x402/transport.ts | 1 + src/x402.ts | 2 +- tests/integration/x402-nano.test.ts | 238 +++++++++++++++ tests/unit/payments-x402/nano.test.ts | 211 +++++++++++++ .../unit/payments-x402/provider-nano.test.ts | 284 ++++++++++++++++++ .../payments-x402/sdk-entry-points.test.ts | 5 +- 25 files changed, 1348 insertions(+), 55 deletions(-) create mode 100644 src/payments/x402/nano-address.ts create mode 100644 src/payments/x402/nano.ts create mode 100644 tests/integration/x402-nano.test.ts create mode 100644 tests/unit/payments-x402/nano.test.ts create mode 100644 tests/unit/payments-x402/provider-nano.test.ts diff --git a/README.md b/README.md index 0fbeba3..f06bc00 100644 --- a/README.md +++ b/README.md @@ -279,16 +279,16 @@ See [docs/configuration.md](docs/configuration.md). ## Protocol support -| Protocol | Status | Pinned revision | -| -------- | ------------ | ------------------------------------------------------------------------------------------------------------ | -| **HTTP** | Supported | native routes | -| **MCP** | Supported | `2026-07-28` and `2025-11-25`, `@modelcontextprotocol/server@2.3.0` | -| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`, `@x402/svm`, `@x402/near`), scheme `exact`, EVM and Solana; NEAR partial | -| **MPP** | Experimental | `mppx@0.13.1`, `charge` + `evm` + EIP-3009, USDC on Base Sepolia and Base | -| **A2A** | Experimental | A2A v1.0.1, binding `JSONRPC`, method `SendMessage`, x402 extension | -| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | -| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | -| UCP | Planned | no implementation | +| Protocol | Status | Pinned revision | +| -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------- | +| **HTTP** | Supported | native routes | +| **MCP** | Supported | `2026-07-28` and `2025-11-25`, `@modelcontextprotocol/server@2.3.0` | +| **x402** | Supported | x402 v2 (`@x402/core`, `@x402/evm`, `@x402/svm`, `@x402/near`), scheme `exact`, EVM and Solana; NEAR partial; Nano experimental | +| **MPP** | Experimental | `mppx@0.13.1`, `charge` + `evm` + EIP-3009, USDC on Base Sepolia and Base | +| **A2A** | Experimental | A2A v1.0.1, binding `JSONRPC`, method `SendMessage`, x402 extension | +| **ACP** | Experimental | ACP `2026-04-17`, REST checkout + discovery | +| **AP2** | Experimental | AP2 `v0.2.0`, Direct Checkout Mandate verification | +| UCP | Planned | no implementation | [AP2](docs/protocols/ap2.md) is an authorization method, not a transport. It verifies a mandate before a paid resource settles; the gateway does not issue the mandate diff --git a/docs/configuration.md b/docs/configuration.md index a00e903..eb625d7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -465,6 +465,40 @@ payments: slow backend call is refused at settlement, and the client signs again; use `upfront` for handlers that can outlast `maxTimeoutSeconds`. +### Nano + +Experimental. Nano options settle the native coin, XNO, which has no fees and +no tokens. The buyer publishes a send block to `payTo` first and presents its +hash; the gateway reads that block from a Nano node at `rpcUrl`. It holds no +key, signs nothing and broadcasts nothing, and no facilitator is called, though +the `facilitator` block and the mainnet guardrails still apply to the rail. + +```yaml +payments: + x402: + network: nano:mainnet + rpcUrl: ${NANO_RPC_URL} # a node's HTTP RPC + payTo: ${NANO_MERCHANT_ADDRESS} # nano_...; no asset fields: XNO +``` + +- `payTo` is a `nano_` (or legacy `xrb_`) address with a valid checksum. + `asset` may be omitted or set to `XNO`; `assetDecimals` is 30, so a price of + `0.001` is 10^27 raw. +- The gateway has no XNO exchange rate. A resource with a Nano offer must be + priced in `XNO`, and its offer list may hold only Nano offers; give it its + own [`x402.accepts`](#a-resources-own-offers) to keep USDC offers on the + other resources. +- Verification requires a confirmed (cemented) `state` block of subtype `send` + whose `link_as_account` is `payTo` and whose amount is at least the price. + A block not yet confirmed or not yet seen is refused, and the client can + present it again. A node that does not answer is + `PAYMENT_PROVIDER_UNAVAILABLE`. +- The block hash is the replay key, so one send pays for one request. A send + is public once published: use a `payTo` that receives nothing but payments + for this gateway, since any unclaimed send to it can be presented by whoever + presents it first. +- Health checks that the node answers `version` for the `live` network. + ### Several options Use `options` to offer x402 resources on several networks or in several @@ -584,6 +618,7 @@ development address, and either a facilitator credential or | `solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1` | Solana Devnet | testnet | [remote facilitator only](#solana) | | `near:mainnet` | NEAR | mainnet | [remote facilitator only](#near) | | `near:testnet` | NEAR Testnet | testnet | [remote facilitator only](#near) | +| `nano:mainnet` | Nano | mainnet | [experimental; no facilitator](#nano) | Every built-in EVM network is supported: the signing code is the same on each, and on 2026-10-10 each network's USDC signing domain matched the chain diff --git a/docs/contracts.md b/docs/contracts.md index 351d03a..c8dd706 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -225,6 +225,17 @@ persistence must not fail the commerce flow. - `createX402PaymentProvider` refuses a `maxTimeoutSeconds` that is not a positive integer with `CONFIG_INVALID`, as config load already did: the NEAR facilitator refuses every payment under such a value. +- The x402 provider serves Nano offers (`nano:mainnet`, experimental) without + a facilitator. + - `X402NetworkFamily` gains `nano`. A Nano offer's `asset` is `XNO`, its + `assetDecimals` 30 and its `payTo` a checksummed `nano_` address (`xrb_` + is read as the same account and stored as `nano_`). + - The proof's `payload` is `{ blockHash }`: a send block the buyer already + published. Verify and settle read it from the offer's `rpcUrl` + (`block_info`), the facilitator is never called, and settle sends + nothing. The replay key is derived from the network and block hash. + - The descriptor's `supportedSpec` can name `family=nano`; capabilities gain + `exact-nano-send-block`. A Nano offer must be priced in `XNO`. - x402 verification and settlement `currency` uses the network's default token symbol when paying that asset, otherwise the resource's currency. Settlement preserves a differently labeled price, such as `USD`, in @@ -392,13 +403,13 @@ Published as `@devlab.group/agent-commerce/x402`. export interface X402OfferOptions { readonly network: string; // CAIP-2, for example 'eip155:84532' readonly rpcUrl: string; - readonly asset: string; // ERC-20 contract, Solana mint or NEAR token account + readonly asset: string; // ERC-20 contract, Solana mint, NEAR token account or 'XNO' /** The token's EIP-712 domain; an EIP-3009 offer needs both */ readonly assetName?: string; readonly assetVersion?: string; readonly assetDecimals: number; /** Merchant-controlled settlement destination. */ - readonly payTo: string; // on Solana the wallet, on NEAR the account + readonly payTo: string; // on Solana the wallet, on NEAR the account, on Nano a nano_ address /** EVM only. Default 'eip3009'. A Permit2 buyer approves Permit2 for the token first */ readonly assetTransferMethod?: X402AssetTransferMethod; } diff --git a/docs/protocols.md b/docs/protocols.md index 9fdcb6e..6fa5b09 100644 --- a/docs/protocols.md +++ b/docs/protocols.md @@ -5,16 +5,16 @@ subset. Each protocol's page describes that subset in detail. ## Support matrix -| Protocol | Status | Revision | Implemented subset | -| -------- | ------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| **HTTP** | Supported | native | resource routes and rail-specific payment headers | -| **MCP** | Supported | MCP `2026-07-28` and `2025-11-25` through `@modelcontextprotocol/server@2.3.0` | tool discovery, invocation, payment-required and error mapping | -| **x402** | Supported | x402 v2, `@x402/core@2.25.0`, `@x402/evm@2.25.0`, `@x402/svm@2.25.0`, `@x402/near@2.25.0` | `exact` on EVM through EIP-3009 or Permit2, on Solana, and on NEAR (partial): challenge, verification, settlement and replay binding | -| **A2A** | Experimental | v1.0.1; negotiation `1.0`; `JSONRPC` binding | Agent Card, `SendMessage`, paid flow and the x402 extension | -| **ACP** | Experimental | stable snapshot `2026-04-17`; REST binding | discovery and the five checkout operations | -| **AP2** | Experimental | v0.2.0, tag 2026-04-28, commit `b4587ac`; Direct mode | closed Checkout Mandate verification before settlement | -| **MPP** | Experimental | drafts at `tempoxyz/mpp-specs@806fdb8`; `mppx@0.13.1` | `charge`/`evm`/EIP-3009 over HTTP, MCP and A2A | -| UCP | Planned | - | no implementation | +| Protocol | Status | Revision | Implemented subset | +| -------- | ------------ | ----------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **HTTP** | Supported | native | resource routes and rail-specific payment headers | +| **MCP** | Supported | MCP `2026-07-28` and `2025-11-25` through `@modelcontextprotocol/server@2.3.0` | tool discovery, invocation, payment-required and error mapping | +| **x402** | Supported | x402 v2, `@x402/core@2.25.0`, `@x402/evm@2.25.0`, `@x402/svm@2.25.0`, `@x402/near@2.25.0` | `exact` on EVM through EIP-3009 or Permit2, on Solana, and on NEAR (partial): challenge, verification, settlement and replay binding; Nano send blocks (experimental) | +| **A2A** | Experimental | v1.0.1; negotiation `1.0`; `JSONRPC` binding | Agent Card, `SendMessage`, paid flow and the x402 extension | +| **ACP** | Experimental | stable snapshot `2026-04-17`; REST binding | discovery and the five checkout operations | +| **AP2** | Experimental | v0.2.0, tag 2026-04-28, commit `b4587ac`; Direct mode | closed Checkout Mandate verification before settlement | +| **MPP** | Experimental | drafts at `tempoxyz/mpp-specs@806fdb8`; `mppx@0.13.1` | `charge`/`evm`/EIP-3009 over HTTP, MCP and A2A | +| UCP | Planned | - | no implementation | Experimental components are disabled unless configured and support only the subsets their pages describe. Planned means there is no adapter or endpoint. @@ -40,7 +40,7 @@ lists in `agent-commerce doctor`. and exclusions - [ACP](protocols/acp.md): headers, checkout operations, the payment boundary, idempotency, errors, discovery, and exclusions -- [x402](protocols/x402.md): the `exact` scheme on EVM, Solana and NEAR, +- [x402](protocols/x402.md): the `exact` scheme on EVM, Solana and NEAR, Nano (experimental), several options, payment flows, and mainnet guardrails - [MPP](protocols/mpp.md): the pinned drafts, the `charge` profile, payment flow, problem documents, and exclusions diff --git a/docs/protocols/x402.md b/docs/protocols/x402.md index 4dce0c1..f83ff7e 100644 --- a/docs/protocols/x402.md +++ b/docs/protocols/x402.md @@ -59,6 +59,17 @@ rejection only when its reason is one the SDK returns before submitting: `invalid_exact_near_*`, `insufficient_funds` or a version, scheme or network refusal. Any other reason leaves the outcome unknown. +On Nano (experimental), the buyer pays first: it publishes a send block of +native XNO to `payTo` and presents `{ "blockHash": "<64 hex>" }` as the +payload. No facilitator is involved and the gateway broadcasts nothing. It +reads the block with `block_info` from the offer's node and requires a +confirmed `state` send to `payTo` of at least the offered raw amount. Settlement +reads the block again under the replay reservation and records the block hash +as the transaction. The replay key uses the network and block hash. Refusals +are `invalid_exact_nano_*`; a node that does not answer gives no verdict, and +at settlement the attempt is released for a retry. Prices are in XNO raw units +(30 decimals); the gateway holds no USD rate. + The signature covers neither the flow nor the resource, so the proof's `accepted` must match the offer: amount, asset, `payTo` (case-insensitive on EVM, case-sensitive on Solana and NEAR), and every `extra` key the gateway @@ -124,14 +135,15 @@ Local settlement runs in the offline E2E suite against real contracts. Public-network runs come from the [smoke tests](../smoke-tests.md), and the README lists their transactions. -| Route | Support | Local settlement | Public testnet | Mainnet | Limits | -| -------------------------------------------------------------------------- | --------- | -------------------------------------------------- | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | -| EIP-3009 on Base Sepolia and Base | supported | Anvil | Base Sepolia, x402.org | Base, PayAI | | -| EIP-3009 on Ethereum, Polygon, Arbitrum One and Sepolia, Avalanche C-Chain | supported | two custom-id Anvil chains, remote facilitator | not run | not run | needs a facilitator that lists the network; PayAI did not list Ethereum on 2026-10-10 | -| Permit2 on the built-in EVM networks | supported | Anvil with the deployed Permit2 and proxy bytecode | Base Sepolia, x402.org | Base, PayAI | the buyer approves Permit2 once, paying gas; no gas-sponsoring extensions | -| EVM networks declared in config | partial | custom-id Anvil chains | | | the operator supplies the chain and all four asset fields | -| Solana | supported | `solana-test-validator`, SDK facilitator | devnet, x402.org | mainnet, PayAI | the merchant's token account must exist; a signed transfer expires with its blockhash | -| NEAR | partial | NEAR sandbox, SDK facilitator | testnet, SDK facilitator run by the suite | mainnet, SDK facilitator run by the suite | not run through a hosted facilitator; FastNEAR's requires an API key. The merchant needs a storage deposit, the buyer a full-access key | +| Route | Support | Local settlement | Public testnet | Mainnet | Limits | +| -------------------------------------------------------------------------- | ------------ | -------------------------------------------------- | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| EIP-3009 on Base Sepolia and Base | supported | Anvil | Base Sepolia, x402.org | Base, PayAI | | +| EIP-3009 on Ethereum, Polygon, Arbitrum One and Sepolia, Avalanche C-Chain | supported | two custom-id Anvil chains, remote facilitator | not run | not run | needs a facilitator that lists the network; PayAI did not list Ethereum on 2026-10-10 | +| Permit2 on the built-in EVM networks | supported | Anvil with the deployed Permit2 and proxy bytecode | Base Sepolia, x402.org | Base, PayAI | the buyer approves Permit2 once, paying gas; no gas-sponsoring extensions | +| EVM networks declared in config | partial | custom-id Anvil chains | | | the operator supplies the chain and all four asset fields | +| Solana | supported | `solana-test-validator`, SDK facilitator | devnet, x402.org | mainnet, PayAI | the merchant's token account must exist; a signed transfer expires with its blockhash | +| NEAR | partial | NEAR sandbox, SDK facilitator | testnet, SDK facilitator run by the suite | mainnet, SDK facilitator run by the suite | not run through a hosted facilitator; FastNEAR's requires an API key. The merchant needs a storage deposit, the buyer a full-access key | +| Nano send block | experimental | mocked node RPC only | no public testnet | not run | native XNO only, priced in XNO; the buyer pays before presenting the hash; no settlement on a live node demonstrated | Every public-network x402 payment was signed by the x402 SDK's own client for its family. Other wallets and clients have not been tested. diff --git a/src/config/schema/resources.ts b/src/config/schema/resources.ts index cb0a7b8..592184c 100644 --- a/src/config/schema/resources.ts +++ b/src/config/schema/resources.ts @@ -9,6 +9,7 @@ import { RESERVED_INPUT_FIELDS, } from '../../core'; import { MPP_PROFILE } from '../../payments/mpp/constants'; +import { findX402Network } from '../../payments/x402/networks'; import { MCP_TOOL_NAME_PATTERN } from '../../protocols/mcp/constants'; import { normalizeResourceAuthorization } from './ap2'; import { @@ -181,6 +182,24 @@ export function normalizeResource( { details: { path: `resources.${id}.payments`, resourceId: id, paymentMethods } }, ); } + // XNO has no USD peg and the gateway holds no exchange rate, so a Nano + // offer charges the price in XNO itself, and no other offer of the same + // list can share that price + const nanoOffers = (offers?.options ?? []).filter( + (option) => findX402Network(option.network)?.family === 'nano', + ); + if ( + x402?.config.enabled && + paymentMethods.includes('x402') && + nanoOffers.length > 0 && + (entry.pricing.currency !== 'XNO' || nanoOffers.length !== offers?.options.length) + ) { + throw new CommerceError( + 'CONFIG_INVALID', + `Resource "${id}" has an x402 offer in XNO on Nano, which has no USD rate. Price it in XNO and list only Nano offers for it, or give the resource its own offers without Nano.`, + { details: { path: `resources.${id}.pricing.currency`, resourceId: id } }, + ); + } // An offer in the network's default stablecoin, named or not, charges the // price in that token, so the price must name that token or USD const symbols = offers?.defaultAssetSymbols ?? []; diff --git a/src/payments/x402/addresses.ts b/src/payments/x402/addresses.ts index d64dc38..6e7b74b 100644 --- a/src/payments/x402/addresses.ts +++ b/src/payments/x402/addresses.ts @@ -1,7 +1,10 @@ /** * SDK-free address checks for config load and the CLI. EVM addresses compare - * without case; Solana keys and NEAR account IDs compare exactly. + * without case; Solana keys and NEAR account IDs compare exactly. Nano + * addresses carry a checksum and compare by public key, so the legacy `xrb_` + * prefix names the same account as `nano_`. */ +import { nanoPublicKey, normalizeNanoAddress } from './nano-address'; import type { X402Network } from './networks'; // `0x` + 40 hex characters. Config load cannot check the EIP-55 checksum, @@ -31,10 +34,15 @@ const SOLANA_SIGNATURE = /^[1-9A-HJ-NP-Za-km-z]{64,88}$/; const NEAR_ACCOUNT_ID = /^(([a-z\d]+[-_])*[a-z\d]+\.)*([a-z\d]+[-_])*[a-z\d]+$/; // A transaction hash: 32 bytes in base58 const NEAR_TRANSACTION_HASH = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/; +// A Nano block hash: 32 bytes in uppercase hex +const NANO_BLOCK_HASH = /^[0-9A-F]{64}$/; +// The all-zero public key, which no one holds +const NANO_BURN = 'nano_1111111111111111111111111111111111111111111111111111hifc8npp'; /** * Returns the family's wire address or `undefined`. NEAR uses named or * implicit account IDs; a `.near` suffix does not identify the network. + * A Nano address must carry a valid checksum and is returned as `nano_`. */ export function normalizeAddress(network: X402Network, value: string): string | undefined { switch (network.family) { @@ -46,12 +54,14 @@ export function normalizeAddress(network: X402Network, value: string): string | return value.length >= 2 && value.length <= 64 && NEAR_ACCOUNT_ID.test(value) ? value : undefined; + case 'nano': + return normalizeNanoAddress(value); } } /** * An address funds cannot be recovered from: the zero address of its family. - * NEAR has no zero account. + * NEAR has no zero account; Nano's is the all-zero public key. */ export function isBurnAddress(network: X402Network, wireAddress: string): boolean { switch (network.family) { @@ -61,17 +71,28 @@ export function isBurnAddress(network: X402Network, wireAddress: string): boolea return wireAddress === SOLANA_ZERO; case 'near': return false; + case 'nano': + return normalizeNanoAddress(wireAddress) === NANO_BURN; } } -/** Compare EVM addresses case-insensitively, Solana keys and NEAR account IDs exactly */ +/** + * Compare EVM addresses case-insensitively, Solana keys and NEAR account IDs + * exactly, and Nano addresses by public key + */ export function sameAddress(network: X402Network, a: string, b: string): boolean { - return network.family === 'evm' ? a.toLowerCase() === b.toLowerCase() : a === b; + if (network.family === 'evm') return a.toLowerCase() === b.toLowerCase(); + if (network.family === 'nano') { + // Nano's asset is the native coin's symbol, not an address + const key = (value: string) => nanoPublicKey(value) ?? value; + return key(a) === key(b); + } + return a === b; } /** * A settlement reference in its family's form: an EVM transaction hash, a - * Solana transaction signature or a NEAR transaction hash + * Solana transaction signature, a NEAR transaction hash or a Nano block hash */ export function isTransactionReference(network: X402Network, value: string): boolean { switch (network.family) { @@ -81,6 +102,8 @@ export function isTransactionReference(network: X402Network, value: string): boo return SOLANA_SIGNATURE.test(value) && base58ByteLength(value) === 64; case 'near': return NEAR_TRANSACTION_HASH.test(value) && base58ByteLength(value) === 32; + case 'nano': + return NANO_BLOCK_HASH.test(value); } } diff --git a/src/payments/x402/nano-address.ts b/src/payments/x402/nano-address.ts new file mode 100644 index 0000000..803264f --- /dev/null +++ b/src/payments/x402/nano-address.ts @@ -0,0 +1,130 @@ +/** + * SDK-free Nano account addresses, for config load, the CLI and the provider. + * + * An address is `nano_` (or the legacy `xrb_`) followed by 52 base32 + * characters of the 32-byte public key and 8 of its checksum: BLAKE2b with a + * 5-byte digest over the key, in reverse byte order. Node's `crypto` offers + * BLAKE2b only with a 64-byte digest, and the digest length is a parameter of + * the hash rather than a truncation, so the short BLAKE2b below (RFC 7693, + * unkeyed) computes it. + */ + +const ALPHABET = '13456789abcdefghijkmnopqrstuwxyz'; +const ADDRESS = + /^(?:nano|xrb)_([13][13456789abcdefghijkmnopqrstuwxyz]{51})([13456789abcdefghijkmnopqrstuwxyz]{8})$/; + +/** The account's public key as 64 uppercase hex characters, or `undefined` */ +export function nanoPublicKey(address: string): string | undefined { + const match = ADDRESS.exec(address); + if (!match) return undefined; + const key = decodeBase32(match[1] as string); + const checksum = decodeBase32(match[2] as string); + const hex = key.toString(16).padStart(64, '0'); + const digest = blake2b(Buffer.from(hex, 'hex'), 5).reverse(); + return BigInt(`0x${Buffer.from(digest).toString('hex')}`) === checksum + ? hex.toUpperCase() + : undefined; +} + +/** The `nano_` form of a valid address, or `undefined` */ +export function normalizeNanoAddress(address: string): string | undefined { + return nanoPublicKey(address) === undefined ? undefined : `nano_${address.split('_')[1]}`; +} + +/** The address of a 32-byte public key given as 64 hex characters */ +export function nanoAddressFromPublicKey(publicKeyHex: string): string { + const key = Buffer.from(publicKeyHex, 'hex'); + const checksum = Buffer.from(blake2b(key, 5).reverse()); + return `nano_${encodeBase32(BigInt(`0x${key.toString('hex')}`), 52)}${encodeBase32( + BigInt(`0x${checksum.toString('hex')}`), + 8, + )}`; +} + +function decodeBase32(chars: string): bigint { + let n = 0n; + for (const char of chars) n = (n << 5n) | BigInt(ALPHABET.indexOf(char)); + return n; +} + +function encodeBase32(n: bigint, length: number): string { + let out = ''; + for (let i = length - 1; i >= 0; i--) out += ALPHABET[Number((n >> BigInt(i * 5)) & 31n)]; + return out; +} + +const MASK = (1n << 64n) - 1n; +const IV = [ + 0x6a09e667f3bcc908n, + 0xbb67ae8584caa73bn, + 0x3c6ef372fe94f82bn, + 0xa54ff53a5f1d36f1n, + 0x510e527fade682d1n, + 0x9b05688c2b3e6c1fn, + 0x1f83d9abfb41bd6bn, + 0x5be0cd19137e2179n, +]; +const SIGMA = [ + [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], + [14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3], + [11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4], + [7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8], + [9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13], + [2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9], + [12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11], + [13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10], + [6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5], + [10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0], +] as const; + +const rotr = (x: bigint, n: bigint) => ((x >> n) | (x << (64n - n))) & MASK; + +/** Unkeyed BLAKE2b (RFC 7693) with a digest of `outLength` bytes, 1 to 64 */ +export function blake2b(input: Uint8Array, outLength: number): Uint8Array { + const h = [...IV]; + h[0] = (h[0] as bigint) ^ BigInt(0x01010000 ^ outLength); + const blocks = Math.max(1, Math.ceil(input.length / 128)); + for (let b = 0; b < blocks; b++) { + const block = new Uint8Array(128); + block.set(input.subarray(b * 128, (b + 1) * 128)); + const last = b === blocks - 1; + const view = new DataView(block.buffer); + const m = Array.from({ length: 16 }, (_, i) => view.getBigUint64(i * 8, true)); + const v = [...h, ...IV]; + const t = BigInt(last ? input.length : (b + 1) * 128); + v[12] = (v[12] as bigint) ^ (t & MASK); + v[13] = (v[13] as bigint) ^ (t >> 64n); + if (last) v[14] = (v[14] as bigint) ^ MASK; + const g = (a: number, bb: number, c: number, d: number, x: bigint, y: bigint) => { + v[a] = ((v[a] as bigint) + (v[bb] as bigint) + x) & MASK; + v[d] = rotr((v[d] as bigint) ^ (v[a] as bigint), 32n); + v[c] = ((v[c] as bigint) + (v[d] as bigint)) & MASK; + v[bb] = rotr((v[bb] as bigint) ^ (v[c] as bigint), 24n); + v[a] = ((v[a] as bigint) + (v[bb] as bigint) + y) & MASK; + v[d] = rotr((v[d] as bigint) ^ (v[a] as bigint), 16n); + v[c] = ((v[c] as bigint) + (v[d] as bigint)) & MASK; + v[bb] = rotr((v[bb] as bigint) ^ (v[c] as bigint), 63n); + }; + for (let r = 0; r < 12; r++) { + const s = SIGMA[r % 10] as readonly number[]; + const x = (i: number) => m[s[i] as number] as bigint; + g(0, 4, 8, 12, x(0), x(1)); + g(1, 5, 9, 13, x(2), x(3)); + g(2, 6, 10, 14, x(4), x(5)); + g(3, 7, 11, 15, x(6), x(7)); + g(0, 5, 10, 15, x(8), x(9)); + g(1, 6, 11, 12, x(10), x(11)); + g(2, 7, 8, 13, x(12), x(13)); + g(3, 4, 9, 14, x(14), x(15)); + } + for (let i = 0; i < 8; i++) { + h[i] = (h[i] as bigint) ^ (v[i] as bigint) ^ (v[i + 8] as bigint); + } + } + const out = new Uint8Array(64); + const view = new DataView(out.buffer); + h.forEach((word, i) => { + view.setBigUint64(i * 8, word, true); + }); + return out.slice(0, outLength); +} diff --git a/src/payments/x402/nano.ts b/src/payments/x402/nano.ts new file mode 100644 index 0000000..59fdd34 --- /dev/null +++ b/src/payments/x402/nano.ts @@ -0,0 +1,129 @@ +/** + * Nano payments: the buyer publishes a send block to `payTo` and presents its + * hash. There is nothing to sign for the gateway and nothing to broadcast, so + * no facilitator is involved: the gateway reads the block from a Nano node's + * RPC and checks that the network confirmed it, that it sends to `payTo`, + * and that it sends at least the offered amount. The block hash is the replay + * key, so one send pays for one request. + * + * The provider loads this module only for a Nano offer. + */ +import { CommerceError } from '../../core'; +import { isRecord } from '../../core/is-record'; +import { normalizeNanoAddress } from './nano-address'; +import { computeNanoReplayKey } from './replay-key'; + +/** One Nano node RPC call: the request body in, the parsed response body out */ +export type NanoRpc = (request: Readonly>) => Promise; + +const BLOCK_HASH = /^[0-9A-Fa-f]{64}$/; +const RAW_AMOUNT = /^\d{1,40}$/; + +/** A fetch-based `NanoRpc` against a node's HTTP RPC, bounded by `timeoutMs` */ +export function createNanoRpc(rpcUrl: string, timeoutMs: number): NanoRpc { + return async (request) => { + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(request), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!response.ok) { + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `Nano RPC ${String(request['action'])} returned HTTP ${response.status}`, + ); + } + return response.json(); + }; +} + +/** + * Returns the block hash and replay key of a Nano payload, `{ blockHash }` + * with 64 hex characters, or a refusal reason + */ +export function inspectNanoPayload( + payload: unknown, + network: string, +): { readonly blockHash: string; readonly replayKey: string } | string { + const blockHash = isRecord(payload) ? payload['blockHash'] : undefined; + if (typeof blockHash !== 'string' || !BLOCK_HASH.test(blockHash)) { + return 'invalid_exact_nano_payload_block_hash'; + } + const canonical = blockHash.toUpperCase(); + return { + blockHash: canonical, + replayKey: computeNanoReplayKey({ network, blockHash: canonical }), + }; +} + +export interface NanoSendTerms { + readonly blockHash: string; + /** The offer's payee, a `nano_` address */ + readonly payTo: string; + /** Raw units the send must carry at least */ + readonly amount: bigint; +} + +/** + * Reads the block and checks it against the offer. Returns the sender and the + * raw amount sent, or a refusal reason. Throws `PAYMENT_PROVIDER_UNAVAILABLE` + * when the node does not answer or answers something it cannot read, since + * that is no verdict on the payment. + */ +export async function checkNanoSend( + rpc: NanoRpc, + terms: NanoSendTerms, +): Promise<{ readonly payer: string; readonly amount: bigint } | string> { + let info: unknown; + try { + info = await rpc({ action: 'block_info', json_block: 'true', hash: terms.blockHash }); + } catch (cause) { + throw unavailable('block_info failed', cause); + } + if (!isRecord(info)) throw unavailable('block_info returned no object'); + if (info['error'] !== undefined) { + // A block the node has not seen may still arrive; anything else is the node's fault + if (info['error'] === 'Block not found') return 'invalid_exact_nano_block_not_found'; + throw unavailable('block_info returned an error'); + } + const contents = info['contents']; + const amount = info['amount']; + if (!isRecord(contents) || typeof amount !== 'string' || !RAW_AMOUNT.test(amount)) { + throw unavailable('block_info returned an unreadable block'); + } + // Only a cemented block is final; the node reports it as a string + if (info['confirmed'] !== 'true' && info['confirmed'] !== true) { + return 'invalid_exact_nano_block_unconfirmed'; + } + if (contents['type'] !== 'state' || info['subtype'] !== 'send') { + return 'invalid_exact_nano_block_not_send'; + } + const recipient = + typeof contents['link_as_account'] === 'string' + ? normalizeNanoAddress(contents['link_as_account']) + : undefined; + if (recipient === undefined || recipient !== normalizeNanoAddress(terms.payTo)) { + return 'invalid_exact_nano_recipient_mismatch'; + } + const sent = BigInt(amount); + if (sent < terms.amount) return 'invalid_exact_nano_amount_insufficient'; + const account = info['block_account'] ?? contents['account']; + const payer = typeof account === 'string' ? normalizeNanoAddress(account) : undefined; + if (payer === undefined) throw unavailable('block_info returned no valid sender'); + return { payer, amount: sent }; +} + +/** True when the node answers `version` for the live network */ +export async function isLiveNanoNode(rpc: NanoRpc): Promise { + const version = await rpc({ action: 'version' }); + return isRecord(version) && version['network'] === 'live'; +} + +function unavailable(reason: string, cause?: unknown): CommerceError { + return new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + `x402 provider: Nano RPC ${reason}`, + cause !== undefined ? { cause } : undefined, + ); +} diff --git a/src/payments/x402/networks.ts b/src/payments/x402/networks.ts index 05fa3ef..d25f4d9 100644 --- a/src/payments/x402/networks.ts +++ b/src/payments/x402/networks.ts @@ -17,7 +17,7 @@ import { CommerceError } from '../../core'; /** Chain id of the local Anvil dev chain, Anvil's default */ export const LOCAL_CHAIN_ID = 31337; -export type X402NetworkFamily = 'evm' | 'svm' | 'near'; +export type X402NetworkFamily = 'evm' | 'svm' | 'near' | 'nano'; export type NetworkKind = 'testnet' | 'mainnet'; @@ -52,7 +52,10 @@ interface NonEvmNetwork { readonly id: string; readonly displayName: string; readonly kind: NetworkKind; - /** Solana mint or NEAR NEP-141 contract account, with no EIP-712 domain */ + /** + * Solana mint or NEAR NEP-141 contract account, with no EIP-712 domain. + * Nano has no tokens: its asset is the native coin, named by its symbol. + */ readonly canonicalAsset: { readonly symbol: string; readonly address: string; @@ -63,7 +66,8 @@ interface NonEvmNetwork { export type X402Network = | (NetworkProfile & { readonly family: 'evm' }) | NonEvmNetwork<'svm'> - | NonEvmNetwork<'near'>; + | NonEvmNetwork<'near'> + | NonEvmNetwork<'nano'>; /** A merchant-declared EVM network, from `payments.x402.networks` */ export interface CustomNetworkDeclaration { @@ -186,6 +190,14 @@ const PROFILES: readonly X402Network[] = [ decimals: 6, }, }, + // Nano settles only its native coin, XNO, in raw units: 1 XNO is 10^30 raw + { + family: 'nano', + id: 'nano:mainnet', + displayName: 'Nano', + kind: 'mainnet', + canonicalAsset: { symbol: 'XNO', address: 'XNO', decimals: 30 }, + }, ]; const BY_ID = new Map(PROFILES.map((profile) => [profile.id, profile])); @@ -241,7 +253,7 @@ export function requireX402Network(id: string, path: string, custom?: CustomNetw /** * Allows only new `eip155` networks in `payments.x402.networks`. Redeclaring - * built-ins could relabel mainnet as testnet; Solana and NEAR IDs are SDK-fixed. + * built-ins could relabel mainnet as testnet; Solana, NEAR and Nano IDs are fixed. */ export function validateCustomNetworks(custom: CustomNetworks, path: string): void { for (const id of Object.keys(custom)) { diff --git a/src/payments/x402/options.ts b/src/payments/x402/options.ts index c2cb13d..46e5aa8 100644 --- a/src/payments/x402/options.ts +++ b/src/payments/x402/options.ts @@ -68,6 +68,7 @@ const ADDRESS_SHAPES = { evm: '"0x" followed by 40 hex characters', svm: 'a base58 public key of 32 bytes', near: 'a NEAR account ID: 2 to 64 lowercase characters, digits and single "-", "_" or "." separators', + nano: 'a nano_ address with a valid checksum', } as const; function invalid(message: string, path: string): CommerceError { @@ -134,7 +135,23 @@ export function resolveX402Option( const canonical = network.canonicalAsset; const domain = canonical && 'name' in canonical ? canonical : undefined; - const asset = input.asset !== undefined ? address('asset', input.asset) : undefined; + // Nano has no tokens: the only asset is the native coin, named by its symbol + if ( + network.family === 'nano' && + input.asset !== undefined && + input.asset !== canonical?.address + ) { + throw invalid( + `${path}.asset: ${network.displayName} settles only its native coin; omit asset or set it to "${canonical?.address}"`, + `${path}.asset`, + ); + } + const asset = + input.asset === undefined + ? undefined + : network.family === 'nano' + ? input.asset + : address('asset', input.asset); if (asset !== undefined && isBurnAddress(network, asset)) { throw invalid(`${path}.asset must not be the zero address`, `${path}.asset`); } diff --git a/src/payments/x402/provider/health.ts b/src/payments/x402/provider/health.ts index 156b675..b0f179f 100644 --- a/src/payments/x402/provider/health.ts +++ b/src/payments/x402/provider/health.ts @@ -11,11 +11,11 @@ import type { FacilitatorBinding } from '../facilitator'; import { describeDeploymentMode } from '../networks'; import { x402OfferName } from '../options'; import { HEALTH_TIMEOUT_MS, X402_VERSION } from './constants'; -import type { NearOffer, Offer, ProviderContext, SvmOffer } from './types'; +import type { NanoOffer, NearOffer, Offer, ProviderContext, SvmOffer } from './types'; /** Builds the provider's `health`, which probes every offer and caches the result */ export function createHealthCheck(ctx: ProviderContext): () => Promise { - const { offers, clock, deploymentMode, remoteBinding, svm, near } = ctx; + const { offers, clock, deploymentMode, remoteBinding, svm, near, nano } = ctx; // One offer's chain, asset and facilitator support. `kinds` fetches the // remote facilitator's `/supported` once per probe, shared by every offer. @@ -25,6 +25,7 @@ export function createHealthCheck(ctx: ProviderContext): () => Promise { if (offer.family === 'svm') return svmOfferHealth(offer, kinds); if (offer.family === 'near') return nearOfferHealth(offer, kinds); + if (offer.family === 'nano') return nanoOfferHealth(offer); const { profile, rpcOrigin, healthClient, binding } = offer; const chainId = await healthClient.getChainId(); if (chainId !== profile.chainId) { @@ -257,6 +258,25 @@ export function createHealthCheck(ctx: ProviderContext): () => Promise { + const nanoModule = await nano(); + const live = await nanoModule.isLiveNanoNode( + nanoModule.createNanoRpc(offer.rpcUrl, HEALTH_TIMEOUT_MS), + ); + return live + ? { + status: 'pass', + detail: `${describeDeploymentMode(deploymentMode)}: Nano node ${offer.rpcOrigin} serves the live network; payments to ${offer.payTo} are read from it, without a facilitator`, + } + : { + status: 'fail', + detail: `RPC at ${offer.rpcOrigin} does not report the live Nano network`, + }; + } + // Check all offers and report each status; any failure fails readiness. // Challenges retain the complete offer list. async function computeHealth(): Promise { diff --git a/src/payments/x402/provider/index.ts b/src/payments/x402/provider/index.ts index c0a54e3..afb4324 100644 --- a/src/payments/x402/provider/index.ts +++ b/src/payments/x402/provider/index.ts @@ -3,7 +3,8 @@ * EIP-3009 `transferWithAuthorization` or a Permit2 transfer through the x402 * exact proxy; on Solana, a token transfer whose fee the facilitator pays; on * NEAR, a delegated NEP-141 `ft_transfer` that the facilitator's relayer - * submits. + * submits; on Nano, a send block the buyer has already published, which the + * gateway reads from a Nano node instead of a facilitator (experimental). * * `verify()` never moves funds. `settle()` does, and throws unless it is handed * a successful verification. Both run through a facilitator (see @@ -66,6 +67,7 @@ export function createX402PaymentProvider( const evmOffers = offers.filter((offer): offer is EvmOffer => offer.family === 'evm'); const hasSvm = offers.some((offer) => offer.family === 'svm'); const hasNear = offers.some((offer) => offer.family === 'near'); + const hasNano = offers.some((offer) => offer.family === 'nano'); const descriptor: AdapterDescriptor = { name: 'x402', kind: 'payment', @@ -74,11 +76,13 @@ export function createX402PaymentProvider( ...(evmOffers.length > 0 ? ['eip155'] : []), ...(hasSvm ? ['solana'] : []), ...(hasNear ? ['near'] : []), + ...(hasNano ? ['nano'] : []), ].join(',')}`, capabilities: [ ...(evmOffers.length > 0 ? ['exact-evm'] : []), ...(hasSvm ? ['exact-svm'] : []), ...(hasNear ? ['exact-near'] : []), + ...(hasNano ? ['exact-nano-send-block'] : []), `${options.facilitator.mode}-facilitator`, ...(evmOffers.some((offer) => offer.method === 'eip3009') ? ['eip-3009'] : []), ...(evmOffers.some((offer) => offer.method === 'permit2') ? ['permit2'] : []), @@ -105,6 +109,9 @@ export function createX402PaymentProvider( const nearModule = hasNear ? loadOptionalModule(() => import('../near'), 'NEAR offers need @x402/near') : undefined; + const nanoModule = hasNano + ? loadOptionalModule(() => import('../nano'), 'Nano offers could not load') + : undefined; const svm = async () => { if (!svmModule) throw new CommerceError('INTERNAL_ERROR', 'x402 provider: no Solana offer is configured'); @@ -115,6 +122,11 @@ export function createX402PaymentProvider( throw new CommerceError('INTERNAL_ERROR', 'x402 provider: no NEAR offer is configured'); return nearModule; }; + const nano = async () => { + if (!nanoModule) + throw new CommerceError('INTERNAL_ERROR', 'x402 provider: no Nano offer is configured'); + return nanoModule; + }; const ctx: ProviderContext = { options, @@ -128,6 +140,7 @@ export function createX402PaymentProvider( maxTimeoutSeconds, svm, near, + nano, }; const { createRequirement, selectRequirement } = createRequirementBuilders( ctx, diff --git a/src/payments/x402/provider/offers.ts b/src/payments/x402/provider/offers.ts index 1dc62c7..b3f1181 100644 --- a/src/payments/x402/provider/offers.ts +++ b/src/payments/x402/provider/offers.ts @@ -55,8 +55,21 @@ export function resolveOffers( const known = findX402Network(input.network, options.customNetworks); if (known !== undefined && known.family !== 'evm') { const shape = - known.family === 'svm' ? 'a Solana address (base58, 32 bytes)' : 'a NEAR account ID'; - for (const name of ['asset', 'payTo'] as const) { + known.family === 'svm' + ? 'a Solana address (base58, 32 bytes)' + : known.family === 'near' + ? 'a NEAR account ID' + : 'a nano_ address with a valid checksum'; + // Nano's only asset is its native coin, named by its symbol + if (known.family === 'nano' && input.asset !== known.canonicalAsset.address) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: ${at('asset')} must be "${known.canonicalAsset.address}", ${known.displayName}'s native coin`, + ); + } + for (const name of known.family === 'nano' + ? (['payTo'] as const) + : (['asset', 'payTo'] as const)) { if (normalizeAddress(known, input[name]) === undefined) { throw new CommerceError('CONFIG_INVALID', `x402 provider: ${at(name)} is not ${shape}`); } @@ -187,6 +200,13 @@ export function resolveOffers( rpcOrigin: describeRpc(input.rpcUrl), binding, }; + if (profile.family === 'nano') { + // The `nano_` form, which the challenge names + const payTo = normalizeAddress(profile, input.payTo) as string; + const offer: Offer = { ...base, family: 'nano', profile, asset: input.asset, payTo }; + built.set(identity, offer); + return offer; + } const offer: Offer = profile.family === 'svm' ? { ...base, family: 'svm', profile, asset: input.asset, payTo: input.payTo } diff --git a/src/payments/x402/provider/requirement.ts b/src/payments/x402/provider/requirement.ts index 20c5bf1..c77812e 100644 --- a/src/payments/x402/provider/requirement.ts +++ b/src/payments/x402/provider/requirement.ts @@ -1,6 +1,7 @@ // The challenge a resource answers with, and the offer a proof selects from it import type { PaymentRequired, PaymentRequirements } from '@x402/core/types'; import type { PaymentContext, PaymentRequirement, PaymentVerificationContext } from '../../../core'; +import { CommerceError } from '../../../core'; import { REUSE_REJECTED_AUTHORIZATION_METADATA_KEY, SETTLE_AFTER_BACKEND_METADATA_KEY, @@ -64,6 +65,14 @@ export function createRequirementBuilders( async function createRequirement(context: PaymentContext): Promise { const offers = offersFor(context.resource.id); + // XNO has no USD peg and the gateway holds no rate: a price in any other + // currency would be charged as that many XNO + if (context.currency !== 'XNO' && offers.some((offer) => offer.family === 'nano')) { + throw new CommerceError( + 'CONFIG_INVALID', + `x402 provider: resource "${context.resource.id}" is priced in ${context.currency}, but a Nano offer charges XNO`, + ); + } const paymentFlow = options.resourcePaymentFlows?.[context.resource.id] ?? options.paymentFlow ?? @@ -88,7 +97,7 @@ export function createRequirementBuilders( extra: offer.family === 'svm' ? { feePayer: await svmFeePayer(offer), ...flow } - : offer.family === 'near' + : offer.family === 'near' || offer.family === 'nano' ? { ...flow } : { ...(offer.assetName !== undefined ? { name: offer.assetName } : {}), diff --git a/src/payments/x402/provider/settle.ts b/src/payments/x402/provider/settle.ts index ed474c0..2bbae8b 100644 --- a/src/payments/x402/provider/settle.ts +++ b/src/payments/x402/provider/settle.ts @@ -5,6 +5,7 @@ import { CommerceError } from '../../../core'; import { redactedErrorText } from '../../../core/errors'; import { isTransactionReference } from '../addresses'; import { decodePaymentSubmission, forFacilitator } from '../payload'; +import { HEALTH_TIMEOUT_MS } from './constants'; import { isNearVerdict, isOnChainRevertError, @@ -24,7 +25,7 @@ import type { ProviderContext } from './types'; export function createSettle( ctx: ProviderContext, ): (context: PaymentSettlementContext) => Promise { - const { offersFor, clock, logger } = ctx; + const { offersFor, clock, logger, nano } = ctx; async function settle(context: PaymentSettlementContext): Promise { const { requirement, submission, verification } = context; @@ -70,6 +71,56 @@ export function createSettle( rejectionReason, }); + // Nano: the buyer already sent the payment. Settling reads the block again + // under the replay reservation the pipeline now holds; nothing is sent. + if (offer.family === 'nano') { + const nanoModule = await nano(); + const inspected = nanoModule.inspectNanoPayload(payload.payload, offer.network); + if (typeof inspected === 'string') return rejectedSettlement(inspected); + let sent: Awaited>; + try { + sent = await nanoModule.checkNanoSend( + nanoModule.createNanoRpc(offer.rpcUrl, HEALTH_TIMEOUT_MS), + { + blockHash: inspected.blockHash, + payTo: offer.payTo, + amount: BigInt(requirements.amount), + }, + ); + } catch (err) { + // Nothing was submitted, so the attempt is released for a retry + throw new CommerceError( + 'PAYMENT_PROVIDER_UNAVAILABLE', + 'x402 provider: the Nano node could not be read during settle()', + { + cause: err, + details: { settlementNotSubmitted: true, reason: 'nano_rpc_unavailable' }, + }, + ); + } + if (typeof sent === 'string') return rejectedSettlement(sent); + return { + status: 'settled', + provider: 'x402', + externalReference: inspected.blockHash, + payer: sent.payer, + payee: offer.payTo, + amount: verification.amount, + currency: verification.currency, + network: offer.network, + ...(verification.asset !== undefined ? { asset: verification.asset } : {}), + replayKey: inspected.replayKey, + settledAt: clock.nowIso(), + metadata: { + // What the block sent, which may exceed the offer + amountBaseUnits: sent.amount.toString(), + ...(verification.currency !== requirement.currency + ? { quote: { amount: requirement.amount, currency: requirement.currency } } + : {}), + }, + }; + } + const scope = offer.binding.open(); let sdkResult: SettleResponse; try { diff --git a/src/payments/x402/provider/types.ts b/src/payments/x402/provider/types.ts index 7990f7f..a7dd05a 100644 --- a/src/payments/x402/provider/types.ts +++ b/src/payments/x402/provider/types.ts @@ -125,7 +125,18 @@ export interface NearOffer extends OfferBase { readonly payTo: string; } -export type Offer = EvmOffer | SvmOffer | NearOffer; +/** + * The buyer has already sent the payment, so no facilitator is called: the + * gateway reads the send block from the offer's RPC + */ +export interface NanoOffer extends OfferBase { + readonly family: 'nano'; + readonly profile: Extract; + readonly asset: string; + readonly payTo: string; +} + +export type Offer = EvmOffer | SvmOffer | NearOffer | NanoOffer; /** What the request-time modules share, resolved once when the provider is built */ export interface ProviderContext { @@ -145,4 +156,6 @@ export interface ProviderContext { readonly svm: () => Promise; /** The NEAR inspection module; rejects when no NEAR offer is configured */ readonly near: () => Promise; + /** The Nano verification module; rejects when no Nano offer is configured */ + readonly nano: () => Promise; } diff --git a/src/payments/x402/provider/verify.ts b/src/payments/x402/provider/verify.ts index e319896..addfaa3 100644 --- a/src/payments/x402/provider/verify.ts +++ b/src/payments/x402/provider/verify.ts @@ -6,7 +6,7 @@ import { CommerceError } from '../../../core'; import { redactedErrorText } from '../../../core/errors'; import { normalizeAddress, sameAddress } from '../addresses'; import { declaredX402Version, decodePaymentSubmission, forFacilitator } from '../payload'; -import { X402_VERSION } from './constants'; +import { HEALTH_TIMEOUT_MS, X402_VERSION } from './constants'; import { offerCurrency } from './offers'; import { isProviderUnavailableError, NO_VERDICT_REASONS, sanitizeReason } from './reasons'; import { acceptedMatchesOffer, offerIndexOf } from './requirement'; @@ -17,7 +17,7 @@ import type { ProviderContext } from './types'; export function createVerify( ctx: ProviderContext, ): (context: PaymentVerificationContext) => Promise { - const { offersFor, clock, logger, svm, near } = ctx; + const { offersFor, clock, logger, svm, near, nano } = ctx; /** * Checks a submission against the requirement this provider built, never @@ -88,10 +88,17 @@ export function createVerify( // EVM: the authorization names payer, payee and amount. Solana: the // facilitator reads the transaction, and the gateway takes its identity. // NEAR: the delegate names the transfer, checked here against the offer. + // Nano: the payload names a send block, read from the chain below. let transfer: Transfer | undefined; let replayKey: string; let derivedPayer: string | undefined; - if (offer.family === 'svm') { + let nanoBlockHash: string | undefined; + if (offer.family === 'nano') { + const inspected = (await nano()).inspectNanoPayload(payload.payload, offer.network); + if (typeof inspected === 'string') return rejected(inspected); + replayKey = inspected.replayKey; + nanoBlockHash = inspected.blockHash; + } else if (offer.family === 'svm') { const identity = (await svm()).svmReplayKey(payload.payload, offer.network); if (typeof identity === 'string') return rejected(identity); replayKey = identity.replayKey; @@ -148,6 +155,28 @@ export function createVerify( } } + // A Nano payment has already moved: the block on chain is the verdict, and + // no facilitator is involved. A node that does not answer throws. + if (offer.family === 'nano') { + const nanoModule = await nano(); + const sent = await nanoModule.checkNanoSend( + nanoModule.createNanoRpc(offer.rpcUrl, HEALTH_TIMEOUT_MS), + { blockHash: nanoBlockHash as string, payTo: offer.payTo, amount: required }, + ); + if (typeof sent === 'string') return rejected(sent); + return { + status: 'verified', + provider: 'x402', + payer: sent.payer, + payee: offer.payTo, + amount: formatUnits(required, offer.assetDecimals), + currency: offerCurrency(offer, requirement.currency), + network: payload.accepted.network, + asset: offer.asset, + replayKey, + }; + } + const scope = offer.binding.open(); let sdkResult: VerifyResponse; try { diff --git a/src/payments/x402/replay-key.ts b/src/payments/x402/replay-key.ts index 5ee3756..f00ffaf 100644 --- a/src/payments/x402/replay-key.ts +++ b/src/payments/x402/replay-key.ts @@ -90,3 +90,18 @@ export function computeNearReplayKey(input: { .digest('hex'); return `0x${digest}`; } + +/** + * Identifies a Nano payment by network and send block hash. A block is + * immutable once published, so its hash names the one transfer it carries. + */ +export function computeNanoReplayKey(input: { + readonly network: string; + readonly blockHash: string; +}): string { + const digest = createHash('sha256') + .update('x402/exact/nano/v1\0') + .update(`${input.network}\0${input.blockHash.toUpperCase()}`) + .digest('hex'); + return `0x${digest}`; +} diff --git a/src/payments/x402/transport.ts b/src/payments/x402/transport.ts index 2545109..1420ffd 100644 --- a/src/payments/x402/transport.ts +++ b/src/payments/x402/transport.ts @@ -32,6 +32,7 @@ export const X402_MCP_PAYMENT_RESPONSE_META_KEY = 'x402/payment-response'; const REPLAY_CODES: Readonly> = { near: 'invalid_exact_near_payload_delegate_action_nonce_already_used', solana: 'duplicate_settlement', + nano: 'invalid_exact_nano_block_already_used', }; /** diff --git a/src/x402.ts b/src/x402.ts index 0973921..acd7ed3 100644 --- a/src/x402.ts +++ b/src/x402.ts @@ -1,6 +1,6 @@ /** * `@devlab.group/agent-commerce/x402`: the x402 payment provider (`exact` - * scheme on EVM, Solana and NEAR). A separate entry point because the rail + * scheme on EVM, Solana and NEAR; Nano send blocks, experimental). A separate entry point because the rail * brings the EVM signing and RPC stack, which a consumer serving free resources * should not install. The EVM peers are needed whatever the offers' networks. * diff --git a/tests/integration/x402-nano.test.ts b/tests/integration/x402-nano.test.ts new file mode 100644 index 0000000..e3f620b --- /dev/null +++ b/tests/integration/x402-nano.test.ts @@ -0,0 +1,238 @@ +/** + * A Nano offer from config through the gateway: config load, the 402 + * challenge, a paid request and the same block hash presented again. The Nano + * node is a stubbed `fetch`; nothing leaves the process. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { parseConfig } from '../../src/config'; +import { isCommerceError } from '../../src/core'; +import { createGateway } from '../../src/gateway'; +import { nanoAddressFromPublicKey } from '../../src/payments/x402/nano-address'; +import { findX402Network } from '../../src/payments/x402/networks'; +import { createX402PaymentProvider } from '../../src/payments/x402/provider'; +import { createSqliteReceiptStore } from '../../src/storage/receipts'; + +const RESOURCE_ID = 'paid_report'; +const RPC_URL = 'https://nano-node.example'; +const PAY_TO = nanoAddressFromPublicKey('11'.repeat(32)); +const BUYER = nanoAddressFromPublicKey('22'.repeat(32)); +const HASH = 'CD'.repeat(32); +const FACILITATOR = { mode: 'remote', url: 'https://facilitator.example', auth: { type: 'none' } }; + +function rawConfig( + storePath: string, + currency = 'XNO', + x402: Record = {}, +): Record { + const { network, payTo, rpcUrl, ...rest } = { + network: 'nano:mainnet', + rpcUrl: RPC_URL, + payTo: PAY_TO, + ...x402, + }; + const option = 'options' in x402 ? {} : { network, payTo, rpcUrl }; + return { + version: 1, + merchant: { id: 'nano', name: 'Nano', publicBaseUrl: 'http://127.0.0.1:8080' }, + server: { port: 8080, host: '127.0.0.1', allowedOrigins: [] }, + storage: { receipts: { driver: 'sqlite', path: storePath } }, + protocols: { http: { enabled: true }, mcp: { enabled: false, mountPath: '/mcp' } }, + resources: { + [RESOURCE_ID]: { + name: 'Paid report', + backend: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.001', currency }, + expose: ['http'], + payments: ['x402'], + }, + }, + payments: { + x402: { + enabled: true, + facilitator: FACILITATOR, + allowMainnet: true, + allowUnauthenticatedFacilitator: true, + maxTimeoutSeconds: 120, + ...option, + ...rest, + }, + }, + }; +} + +function refusal(raw: Record): string { + try { + parseConfig(raw, {}); + } catch (error) { + if (isCommerceError(error) && error.code === 'CONFIG_INVALID') return error.message; + throw error; + } + throw new Error('expected parseConfig to refuse this configuration'); +} + +describe('nano:mainnet in config', () => { + it('is a known network', () => { + expect(findX402Network('nano:mainnet')?.family).toBe('nano'); + }); + + it('loads an offer in native XNO at 30 decimals', () => { + const x402 = parseConfig(rawConfig('/tmp/unused.sqlite'), {}).payments.x402; + expect(x402?.options?.[0]).toMatchObject({ + network: 'nano:mainnet', + payTo: PAY_TO, + asset: 'XNO', + assetDecimals: 30, + }); + }); + + it('stores a legacy xrb_ payee in its nano_ form', () => { + const legacy = PAY_TO.replace('nano_', 'xrb_'); + const x402 = parseConfig(rawConfig('/tmp/unused.sqlite', 'XNO', { payTo: legacy }), {}).payments + .x402; + expect(x402?.options?.[0]?.payTo).toBe(PAY_TO); + }); + + it.each<[string, string, Record, string]>([ + ['a USD price, since XNO has no USD rate', 'USD', {}, 'Price it in XNO'], + ['a payee with a bad checksum', 'XNO', { payTo: `${PAY_TO.slice(0, -1)}1` }, 'nano_ address'], + ['a token', 'XNO', { asset: 'USDC' }, 'native coin'], + ['other decimals', 'XNO', { assetDecimals: 6 }, 'decimals'], + ['the burn address', 'XNO', { payTo: nanoAddressFromPublicKey('00'.repeat(32)) }, 'zero'], + ['no mainnet opt-in', 'XNO', { allowMainnet: false }, 'allowMainnet'], + ])('refuses %s', (_label, currency, x402, message) => { + expect(refusal(rawConfig('/tmp/unused.sqlite', currency, x402))).toContain(message); + }); + + it('offers Nano beside a USDC rail, on a resource of its own', () => { + const raw = rawConfig('/tmp/unused.sqlite', 'USD', { + networks: { + 'nano:mainnet': { rpcUrl: RPC_URL }, + 'eip155:8453': { rpcUrl: 'https://base.example' }, + }, + options: [{ network: 'eip155:8453', payTo: '0x1111111111111111111111111111111111111111' }], + }); + const resources = raw['resources'] as Record>; + resources['nano_report'] = { + ...resources[RESOURCE_ID], + pricing: { type: 'fixed', amount: '0.001', currency: 'XNO' }, + x402: { accepts: [{ network: 'nano:mainnet', payTo: PAY_TO }] }, + }; + const x402 = parseConfig(raw, {}).payments.x402; + expect(x402?.options?.map((option) => option.network)).toEqual(['eip155:8453']); + expect(x402?.resourceOptions?.['nano_report']?.[0]).toMatchObject({ + network: 'nano:mainnet', + asset: 'XNO', + }); + }); + + it('refuses a list that mixes Nano with a USD-priced token', () => { + const message = refusal( + rawConfig('/tmp/unused.sqlite', 'XNO', { + networks: { + 'nano:mainnet': { rpcUrl: RPC_URL }, + 'eip155:8453': { rpcUrl: 'https://base.example' }, + }, + options: [ + { network: 'nano:mainnet', payTo: PAY_TO }, + { network: 'eip155:8453', payTo: '0x1111111111111111111111111111111111111111' }, + ], + }), + ); + expect(message).toContain('list only Nano offers'); + }); +}); + +describe('a Nano payment through the gateway', () => { + let dir: string; + const rpc = vi.fn<(body: Record) => unknown>(); + + beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'oac-nano-')); + }); + afterAll(async () => { + await rm(dir, { recursive: true, force: true }); + }); + beforeEach(() => { + rpc.mockReset(); + rpc.mockReturnValue({ + block_account: BUYER, + amount: (10n ** 27n).toString(), + confirmed: 'true', + subtype: 'send', + contents: { type: 'state', account: BUYER, link_as_account: PAY_TO }, + }); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: { body: string }) => { + if (url !== RPC_URL) throw new Error(`unexpected request to ${url}`); + return new Response(JSON.stringify(rpc(JSON.parse(init.body))), { status: 200 }); + }), + ); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('delivers once per send block and refuses the same hash again', async () => { + const storePath = join(dir, 'gateway.sqlite'); + const config = parseConfig(rawConfig(storePath), {}); + const store = createSqliteReceiptStore({ path: storePath }); + await store.init(); + let backendCalls = 0; + const gateway = await createGateway({ + config, + store, + paymentProviders: [ + createX402PaymentProvider({ + network: 'nano:mainnet', + rpcUrl: RPC_URL, + asset: 'XNO', + assetDecimals: 30, + payTo: PAY_TO, + facilitator: FACILITATOR as never, + allowMainnet: true, + allowUnauthenticatedFacilitator: true, + }), + ], + protocolAdapters: [], + backend: { + call: async () => { + backendCalls += 1; + return { status: 200, headers: {}, body: { ok: true }, durationMs: 1 }; + }, + }, + }); + const invoke = (headers: Record = {}) => + gateway.server.inject({ + method: 'POST', + url: `/api/resources/${RESOURCE_ID}/invoke`, + payload: {}, + headers, + }); + + const challenge = await invoke(); + expect(challenge.statusCode).toBe(402); + const required = JSON.parse( + Buffer.from(String(challenge.headers['payment-required']), 'base64').toString('utf8'), + ); + const accepted = required.accepts[0]; + expect(accepted).toMatchObject({ network: 'nano:mainnet', asset: 'XNO', payTo: PAY_TO }); + expect(accepted.amount).toBe((10n ** 27n).toString()); + + const proof = Buffer.from( + JSON.stringify({ x402Version: 2, accepted, payload: { blockHash: HASH } }), + ).toString('base64'); + const paid = await invoke({ 'payment-signature': proof }); + expect(paid.statusCode).toBe(200); + expect(backendCalls).toBe(1); + + const replayed = await invoke({ 'payment-signature': proof }); + expect(replayed.statusCode).toBe(402); + expect(replayed.json().code).toBe('PAYMENT_REPLAYED'); + expect(backendCalls).toBe(1); + await gateway.close(); + }); +}); diff --git a/tests/unit/payments-x402/nano.test.ts b/tests/unit/payments-x402/nano.test.ts new file mode 100644 index 0000000..65581da --- /dev/null +++ b/tests/unit/payments-x402/nano.test.ts @@ -0,0 +1,211 @@ +/** + * Nano address checksums, payload inspection and send-block verification + * against a mocked node RPC. Nothing here reaches a network. + */ +import { createHash } from 'node:crypto'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { isCommerceError } from '../../../src/core'; +import { + isBurnAddress, + isTransactionReference, + normalizeAddress, + sameAddress, +} from '../../../src/payments/x402/addresses'; +import { + checkNanoSend, + createNanoRpc, + inspectNanoPayload, + type NanoRpc, +} from '../../../src/payments/x402/nano'; +import { + blake2b, + nanoAddressFromPublicKey, + nanoPublicKey, +} from '../../../src/payments/x402/nano-address'; +import { findX402Network } from '../../../src/payments/x402/networks'; + +// The genesis account and its public key +const GENESIS = 'nano_3t6k35gi95xu6tergt6p69ck76ogmitsa8mnijtpxm9fkcm736xtoncuohr3'; +const GENESIS_KEY = 'E89208DD038FBB269987689621D52292AE9C35941A7484756ECCED92A65093BA'; +const PAY_TO = nanoAddressFromPublicKey('11'.repeat(32)); +const BUYER = nanoAddressFromPublicKey('22'.repeat(32)); +const HASH = 'AB'.repeat(32); +const NETWORK = 'nano:mainnet'; +const ONE_XNO = 10n ** 30n; + +const nano = findX402Network(NETWORK); + +function block(overrides: Record = {}, contents: Record = {}) { + return { + block_account: BUYER, + amount: ONE_XNO.toString(), + balance: '0', + height: '2', + confirmed: 'true', + subtype: 'send', + ...overrides, + contents: { + type: 'state', + account: BUYER, + link_as_account: PAY_TO, + ...contents, + }, + }; +} + +const rpcReturning = + (value: unknown): NanoRpc => + async () => + value; + +describe('the nano:mainnet network', () => { + it('is built in, with the native coin at 30 decimals', () => { + expect(nano).toMatchObject({ + family: 'nano', + kind: 'mainnet', + canonicalAsset: { symbol: 'XNO', address: 'XNO', decimals: 30 }, + }); + }); +}); + +describe('Nano addresses', () => { + it('computes BLAKE2b like node for the full digest, over one and several blocks', () => { + for (const input of [Buffer.alloc(0), Buffer.from('abc'), Buffer.alloc(300, 7)]) { + expect(Buffer.from(blake2b(input, 64)).toString('hex')).toBe( + createHash('blake2b512').update(input).digest('hex'), + ); + } + }); + + it('decodes and encodes the genesis account', () => { + expect(nanoPublicKey(GENESIS)).toBe(GENESIS_KEY); + expect(nanoAddressFromPublicKey(GENESIS_KEY)).toBe(GENESIS); + }); + + it('accepts the legacy xrb_ prefix as the same account', () => { + const legacy = GENESIS.replace('nano_', 'xrb_'); + if (!nano) throw new Error('no profile'); + expect(normalizeAddress(nano, legacy)).toBe(GENESIS); + expect(sameAddress(nano, legacy, GENESIS)).toBe(true); + expect(sameAddress(nano, PAY_TO, GENESIS)).toBe(false); + }); + + it.each([ + ['a bad checksum', `${GENESIS.slice(0, -1)}1`], + ['uppercase', GENESIS.toUpperCase()], + ['no prefix', GENESIS.slice('nano_'.length)], + ['a short body', GENESIS.slice(0, -2)], + ['a key above 256 bits', `nano_4${GENESIS.slice(6)}`], + ['a NEAR account', 'merchant.near'], + ])('refuses %s', (_label, value) => { + if (!nano) throw new Error('no profile'); + expect(normalizeAddress(nano, value)).toBeUndefined(); + }); + + it('knows the burn address and the block hash form', () => { + if (!nano) throw new Error('no profile'); + expect(isBurnAddress(nano, nanoAddressFromPublicKey('00'.repeat(32)))).toBe(true); + expect(isBurnAddress(nano, PAY_TO)).toBe(false); + expect(isTransactionReference(nano, HASH)).toBe(true); + expect(isTransactionReference(nano, HASH.toLowerCase())).toBe(false); + }); +}); + +describe('inspectNanoPayload', () => { + it('takes the block hash, in either case, as one replay key', () => { + const upper = inspectNanoPayload({ blockHash: HASH }, NETWORK); + const lower = inspectNanoPayload({ blockHash: HASH.toLowerCase() }, NETWORK); + expect(upper).toMatchObject({ blockHash: HASH }); + expect(typeof upper === 'object' && typeof lower === 'object' && upper.replayKey).toBe( + typeof lower === 'object' && lower.replayKey, + ); + }); + + it.each([ + ['no payload', undefined], + ['no hash', {}], + ['a short hash', { blockHash: 'AB'.repeat(31) }], + ['a non-hex hash', { blockHash: 'ZZ'.repeat(32) }], + ['a numeric hash', { blockHash: 123 }], + ])('refuses %s', (_label, payload) => { + expect(inspectNanoPayload(payload, NETWORK)).toBe('invalid_exact_nano_payload_block_hash'); + }); +}); + +describe('checkNanoSend', () => { + const terms = { blockHash: HASH, payTo: PAY_TO, amount: ONE_XNO }; + + it('asks the node for the block as JSON and returns the sender and amount', async () => { + const rpc = vi.fn().mockResolvedValue(block()); + await expect(checkNanoSend(rpc, terms)).resolves.toEqual({ payer: BUYER, amount: ONE_XNO }); + expect(rpc).toHaveBeenCalledWith({ action: 'block_info', json_block: 'true', hash: HASH }); + }); + + it('accepts more than the offered amount, and a legacy xrb_ recipient', async () => { + const more = block( + { amount: (ONE_XNO + 1n).toString() }, + { link_as_account: PAY_TO.replace('nano_', 'xrb_') }, + ); + await expect(checkNanoSend(rpcReturning(more), terms)).resolves.toMatchObject({ + amount: ONE_XNO + 1n, + }); + }); + + it.each<[string, unknown, string]>([ + ['less than the offer', block({ amount: (ONE_XNO - 1n).toString() }), 'amount_insufficient'], + ['another recipient', block({}, { link_as_account: BUYER }), 'recipient_mismatch'], + ['an unconfirmed block', block({ confirmed: 'false' }), 'block_unconfirmed'], + ['a receive block', block({ subtype: 'receive' }), 'block_not_send'], + ['a legacy block', block({}, { type: 'send' }), 'block_not_send'], + ['an unknown block', { error: 'Block not found' }, 'block_not_found'], + ])('refuses %s', async (_label, response, reason) => { + await expect(checkNanoSend(rpcReturning(response), terms)).resolves.toBe( + `invalid_exact_nano_${reason}`, + ); + }); + + it.each<[string, NanoRpc]>([ + [ + 'a node that is down', + async () => { + throw new TypeError('fetch failed'); + }, + ], + ['a node error', rpcReturning({ error: 'Internal server error' })], + ['a body without a block', rpcReturning({ amount: 'lots' })], + ['no object', rpcReturning('nope')], + ])('throws a retryable error for %s, never a verdict', async (_label, rpc) => { + const err = await checkNanoSend(rpc, terms).catch((e: unknown) => e); + expect(isCommerceError(err) && err.code).toBe('PAYMENT_PROVIDER_UNAVAILABLE'); + }); +}); + +describe('createNanoRpc', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('posts the request as JSON to the node and returns its body', async () => { + const fetchMock = vi.fn(async () => new Response('{"network":"live"}', { status: 200 })); + vi.stubGlobal('fetch', fetchMock); + await expect( + createNanoRpc('https://node.example', 1000)({ action: 'version' }), + ).resolves.toEqual({ network: 'live' }); + expect(fetchMock).toHaveBeenCalledWith( + 'https://node.example', + expect.objectContaining({ method: 'POST', body: '{"action":"version"}' }), + ); + }); + + it('treats an HTTP error as an unavailable node', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response('busy', { status: 503 })), + ); + const err = await createNanoRpc( + 'https://node.example', + 1000, + )({ action: 'version' }).catch((e: unknown) => e); + expect(isCommerceError(err) && err.code).toBe('PAYMENT_PROVIDER_UNAVAILABLE'); + }); +}); diff --git a/tests/unit/payments-x402/provider-nano.test.ts b/tests/unit/payments-x402/provider-nano.test.ts new file mode 100644 index 0000000..aa2d9f7 --- /dev/null +++ b/tests/unit/payments-x402/provider-nano.test.ts @@ -0,0 +1,284 @@ +/** + * Tests Nano offers through the x402 provider: the challenge, verification and + * settlement of a published send block, replay identity and readiness. The + * node RPC is a stubbed `fetch`, and the facilitator is mocked to prove it is + * never called. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { CommerceResource, PaymentContext, PaymentResult } from '../../../src/core'; +import { isCommerceError } from '../../../src/core'; +import { nanoAddressFromPublicKey } from '../../../src/payments/x402/nano-address'; +import { + createX402PaymentProvider, + type X402MultiOfferProviderOptions, +} from '../../../src/payments/x402/provider'; +import { computeNanoReplayKey } from '../../../src/payments/x402/replay-key'; + +const facilitatorCalls = vi.fn(); + +vi.mock('@x402/core/http', () => ({ + FacilitatorResponseError: class extends Error {}, + HTTPFacilitatorClient: class { + verify(...args: unknown[]) { + return facilitatorCalls('verify', ...args); + } + settle(...args: unknown[]) { + return facilitatorCalls('settle', ...args); + } + getSupported() { + return facilitatorCalls('supported'); + } + }, +})); + +const NETWORK = 'nano:mainnet'; +const RPC_URL = 'https://nano-node.example'; +const PAY_TO = nanoAddressFromPublicKey('11'.repeat(32)); +const BUYER = nanoAddressFromPublicKey('22'.repeat(32)); +const HASH = 'AB'.repeat(32); +// 0.001 XNO +const PRICE_RAW = 10n ** 27n; + +const RESOURCE: CommerceResource = { + id: 'demo.report', + name: 'Demo report', + handler: { type: 'http', method: 'GET', url: 'http://merchant.invalid/api/report' }, + pricing: { type: 'fixed', amount: '0.001', currency: 'XNO' }, + exposedVia: ['http'], + paymentMethods: ['x402'], +}; + +const paymentContext = (currency = 'XNO'): PaymentContext => ({ + requestId: 'req-1', + resource: RESOURCE, + amount: '0.001', + currency, + requestedAt: new Date().toISOString(), +}); + +const sendBlock = (overrides: Record = {}, link = PAY_TO) => ({ + block_account: BUYER, + amount: PRICE_RAW.toString(), + confirmed: 'true', + subtype: 'send', + ...overrides, + contents: { type: 'state', account: BUYER, link_as_account: link }, +}); + +const rpc = vi.fn<(body: Record) => unknown>(); + +function makeProvider(overrides: Partial = {}) { + return createX402PaymentProvider({ + offers: [{ network: NETWORK, rpcUrl: RPC_URL, asset: 'XNO', assetDecimals: 30, payTo: PAY_TO }], + facilitator: { mode: 'remote', url: 'https://facilitator.example', auth: { type: 'none' } }, + allowMainnet: true, + allowUnauthenticatedFacilitator: true, + ...overrides, + }); +} + +async function paidRequest( + provider: ReturnType, + payload: unknown = { blockHash: HASH }, + acceptedChange: Record = {}, +) { + const requirement = await provider.createRequirement(paymentContext()); + const accepted = { ...(requirement.challenge.accepts[0] as object), ...acceptedChange }; + const submission = { + method: 'x402' as const, + payload: Buffer.from(JSON.stringify({ x402Version: 2, accepted, payload })).toString('base64'), + }; + return { requestId: 'req-1', resource: RESOURCE, requirement, submission }; +} + +const rejection = (result: PaymentResult) => + result.status === 'rejected' ? result.rejectionReason : result.status; + +beforeEach(() => { + vi.resetAllMocks(); + rpc.mockReturnValue(sendBlock()); + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init: { body: string }) => { + expect(url).toBe(RPC_URL); + return new Response(JSON.stringify(await rpc(JSON.parse(init.body))), { status: 200 }); + }), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + expect(facilitatorCalls).not.toHaveBeenCalled(); +}); + +describe('a Nano offer', () => { + it('prices the challenge in raw XNO and names the family', async () => { + const provider = makeProvider(); + const requirement = await provider.createRequirement(paymentContext()); + expect(requirement.challenge.accepts[0]).toMatchObject({ + scheme: 'exact', + network: NETWORK, + asset: 'XNO', + payTo: PAY_TO, + amount: PRICE_RAW.toString(), + extra: {}, + }); + expect(provider.descriptor.supportedSpec).toContain('family=nano'); + expect(provider.descriptor.capabilities).toContain('exact-nano-send-block'); + }); + + it('refuses a price in another currency, since XNO has no USD rate', async () => { + const err = await makeProvider() + .createRequirement(paymentContext('USD')) + .catch((e: unknown) => e); + expect(isCommerceError(err) && err.code).toBe('CONFIG_INVALID'); + }); + + it.each<[string, Partial, RegExp]>([ + ['a payee with a bad checksum', { payTo: `${PAY_TO.slice(0, -1)}1` }, /payTo.*nano_ address/], + ['a token', { asset: 'USDC' }, /asset.*must be "XNO"/], + ['an EVM transfer method', { assetTransferMethod: 'permit2' }, /EVM networks only/], + ])('refuses %s at construction', (_label, change, message) => { + expect(() => + makeProvider({ + offers: [ + { + network: NETWORK, + rpcUrl: RPC_URL, + asset: 'XNO', + assetDecimals: 30, + payTo: PAY_TO, + ...change, + }, + ], + }), + ).toThrow(message); + }); + + it('verifies a confirmed send to payTo and settles it by reading it again', async () => { + const provider = makeProvider(); + const context = await paidRequest(provider); + const verification = await provider.verify(context); + const replayKey = computeNanoReplayKey({ network: NETWORK, blockHash: HASH }); + expect(verification).toMatchObject({ + status: 'verified', + payer: BUYER, + payee: PAY_TO, + amount: '0.001', + currency: 'XNO', + network: NETWORK, + asset: 'XNO', + replayKey, + }); + const settled = await provider.settle({ ...context, verification }); + expect(settled).toMatchObject({ + status: 'settled', + externalReference: HASH, + payer: BUYER, + replayKey, + metadata: { amountBaseUnits: PRICE_RAW.toString() }, + }); + expect(rpc).toHaveBeenCalledTimes(2); + expect(rpc).toHaveBeenCalledWith({ action: 'block_info', json_block: 'true', hash: HASH }); + }); + + it.each<[string, () => void, unknown, Record, string]>([ + ['no payment block', () => {}, {}, {}, 'invalid_exact_nano_payload_block_hash'], + [ + 'a malformed hash', + () => {}, + { blockHash: 'xyz' }, + {}, + 'invalid_exact_nano_payload_block_hash', + ], + [ + 'a short amount', + () => rpc.mockReturnValue(sendBlock({ amount: (PRICE_RAW - 1n).toString() })), + { blockHash: HASH }, + {}, + 'invalid_exact_nano_amount_insufficient', + ], + [ + 'another recipient', + () => rpc.mockReturnValue(sendBlock({}, BUYER)), + { blockHash: HASH }, + {}, + 'invalid_exact_nano_recipient_mismatch', + ], + [ + 'an unconfirmed block', + () => rpc.mockReturnValue(sendBlock({ confirmed: 'false' })), + { blockHash: HASH }, + {}, + 'invalid_exact_nano_block_unconfirmed', + ], + [ + 'a receive block', + () => rpc.mockReturnValue(sendBlock({ subtype: 'receive' })), + { blockHash: HASH }, + {}, + 'invalid_exact_nano_block_not_send', + ], + [ + 'another network', + () => {}, + { blockHash: HASH }, + { network: 'near:mainnet' }, + 'invalid_network', + ], + [ + 'another asset', + () => {}, + { blockHash: HASH }, + { asset: 'USDC' }, + 'invalid_payment_requirements', + ], + [ + 'a lower echoed amount', + () => {}, + { blockHash: HASH }, + { amount: '1' }, + 'invalid_payment_requirements', + ], + ])('rejects %s', async (_label, arrange, payload, acceptedChange, reason) => { + arrange(); + const provider = makeProvider(); + const result = await provider.verify(await paidRequest(provider, payload, acceptedChange)); + expect(rejection(result)).toBe(reason); + }); + + it('gives the same replay key to a hash presented in either case', async () => { + const provider = makeProvider(); + const upper = await provider.verify(await paidRequest(provider)); + const lower = await provider.verify( + await paidRequest(provider, { blockHash: HASH.toLowerCase() }), + ); + expect(upper.replayKey).toBeDefined(); + expect(lower.replayKey).toBe(upper.replayKey); + }); + + it('fails closed and retryable when the node is down, in verify and settle', async () => { + const provider = makeProvider(); + const context = await paidRequest(provider); + const verification = await provider.verify(context); + rpc.mockImplementation(() => { + throw new Error('connection refused'); + }); + const verifyErr = await provider.verify(context).catch((e: unknown) => e); + expect(isCommerceError(verifyErr) && verifyErr.code).toBe('PAYMENT_PROVIDER_UNAVAILABLE'); + const settleErr = await provider.settle({ ...context, verification }).catch((e: unknown) => e); + expect(isCommerceError(settleErr) && settleErr.code).toBe('PAYMENT_PROVIDER_UNAVAILABLE'); + // Nothing was submitted, so the pipeline releases the attempt for a retry + expect(isCommerceError(settleErr) && settleErr.details?.['settlementNotSubmitted']).toBe(true); + }); + + it('is ready only against a node of the live network', async () => { + rpc.mockReturnValue({ network: 'live', node_vendor: 'Nano V28.0' }); + const live = await makeProvider().health?.(); + expect(live?.status).toBe('pass'); + rpc.mockReturnValue({ network: 'beta' }); + const beta = await makeProvider().health?.(); + expect(beta?.status).toBe('fail'); + expect(beta?.detail).toContain('live Nano network'); + }); +}); diff --git a/tests/unit/payments-x402/sdk-entry-points.test.ts b/tests/unit/payments-x402/sdk-entry-points.test.ts index 869af87..91c098d 100644 --- a/tests/unit/payments-x402/sdk-entry-points.test.ts +++ b/tests/unit/payments-x402/sdk-entry-points.test.ts @@ -99,8 +99,9 @@ describe('pinned x402 SDK entry points', () => { }); // Config load cannot import the SDK, so the profiles repeat its default assets - // The local Anvil chain has no default asset and is skipped - it.each(BUILT_IN_NETWORKS.filter((n) => n.canonicalAsset).map((n) => [n.id, n] as const))( + // The local Anvil chain has no default asset and is skipped; Nano has no SDK + const sdkNetworks = BUILT_IN_NETWORKS.filter((n) => n.canonicalAsset && n.family !== 'nano'); + it.each(sdkNetworks.map((n) => [n.id, n] as const))( '%s defaults to the asset the SDK names', (_id, network) => { const id = network.id as `${string}:${string}`;