From e80541a226e4a3ba30741fbccef6e3aead14a13e Mon Sep 17 00:00:00 2001 From: dhyabi2 Date: Tue, 13 Oct 2026 05:38:18 +0000 Subject: [PATCH] Pay x402 exact offers on nano:mainnet from a Nano keystore Add nano as an opt-in rail, reached only with --chain nano. It is never auto-selected, not part of the default wallet set, and never priced in USD. - src/chains/nano.ts: ed25519-blake2b keys, SLIP-10 m/44'/165'/0' for mnemonics, checksummed nano_/xrb_ addresses, exact raw <-> XNO, balance read, and a signed state send block published through an injectable RPC (work_generate + process). NANO_RPC_URL overrides the endpoint. - src/chains/nano-x402.ts: ExactNanoScheme, registered on nano:mainnet in payViaX402 next to Base; payload is { blockHash }. A retry within one invocation re-presents the published block instead of paying again. - pay --chain nano requires --max-spend-xno (limit_required otherwise), aborts over the cap before any block is built, and records price_xno instead of price_usd, so USD limits and history totals are unchanged. - wallet create/import/address/list/export/remove accept --chain nano. - quotes lists x402 exact on nano:mainnet as supported, without a USD price. --- README.md | 12 ++ bun.lock | 2 + package.json | 2 + src/chains/nano-x402.ts | 58 +++++++ src/chains/nano.ts | 300 ++++++++++++++++++++++++++++++++++ src/cli.ts | 22 ++- src/commands/fund-estimate.ts | 3 +- src/commands/pay.ts | 127 ++++++++++++-- src/commands/wallet.ts | 32 ++-- src/constants.ts | 20 +++ src/errors.ts | 3 + src/keystore.ts | 16 +- src/ledger.ts | 5 +- src/mnemonic.ts | 15 +- src/paths.ts | 6 +- src/quotes.ts | 11 +- src/wallets.ts | 16 +- tests/error-envelope.test.ts | 1 + tests/fund-estimate.test.ts | 33 ++++ tests/nano.test.ts | 296 +++++++++++++++++++++++++++++++++ tests/pay-nano.test.ts | 262 +++++++++++++++++++++++++++++ tests/wallet.test.ts | 16 ++ 22 files changed, 1194 insertions(+), 64 deletions(-) create mode 100644 src/chains/nano-x402.ts create mode 100644 src/chains/nano.ts create mode 100644 tests/nano.test.ts create mode 100644 tests/pay-nano.test.ts diff --git a/README.md b/README.md index 66218b6..8aa3ad8 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,17 @@ The CLI never touches stdout for human chrome when `--json` is passed: every byt Solana payments route through MPP `solana/charge` (Solana Foundation's `@solana/mpp` package) rather than x402. Tempo is always MPP via `mppx/client`. Base is the only x402 rail pay funds directly. +### Nano (opt-in) + +`nano` is an opt-in x402 `exact` rail on `nano:mainnet`, used only when named with `--chain nano`. It is never auto-selected and `wallet create` without `--chain` does not create it. XNO has no USD price inside pay, so `--max-spend` and `limits` cannot bound it: `pay --chain nano` requires `--max-spend-xno` and refuses with `limit_required` without it. Ledger entries carry `price_xno`, never `price_usd`. + +```bash +agentscore-pay wallet create --chain nano +agentscore-pay pay POST https://merchant.example/api --chain nano -d '{}' --max-spend-xno 0.05 +``` + +The client publishes a state send block to the 402's `payTo` (`nano_` or legacy `xrb_`) and sends `{ "blockHash": "..." }` as the x402 payload. Proof of work comes from the node's `work_generate`, so `NANO_RPC_URL` must point at a node or proxy that serves it. + ## Rail selection The CLI ships with **no default rail preference**. Algorithm: @@ -520,6 +531,7 @@ The npm package itself is published with [npm provenance](https://docs.npmjs.com | `SOLANA_DEVNET_RPC_URL` | override Solana Devnet RPC endpoint | | `TEMPO_RPC_URL` | override Tempo mainnet RPC endpoint | | `TEMPO_TESTNET_RPC_URL` | override Tempo testnet RPC endpoint | +| `NANO_RPC_URL` | Nano node RPC used by `--chain nano` (must serve `work_generate`) | | `AGENTSCORE_API_KEY` | API key for identity commands (`assess`, `sessions`, `credentials`, `associate-wallet`) | ## Relationship to other AgentScore packages diff --git a/bun.lock b/bun.lock index cc16aeb..fed1f54 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,8 @@ "dependencies": { "@agent-score/sdk": "^2.9.1", "@clack/prompts": "^1.8.1", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.4.0", "@scure/bip39": "^2.4.0", "@solana-program/system": "^0.15.0", "@solana-program/token": "^0.17.0", diff --git a/package.json b/package.json index d89bbeb..61befb8 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,8 @@ "dependencies": { "@agent-score/sdk": "^2.9.1", "@clack/prompts": "^1.8.1", + "@noble/ed25519": "^3.1.0", + "@noble/hashes": "^2.4.0", "@scure/bip39": "^2.4.0", "@solana-program/system": "^0.15.0", "@solana-program/token": "^0.17.0", diff --git a/src/chains/nano-x402.ts b/src/chains/nano-x402.ts new file mode 100644 index 0000000..4ff38d3 --- /dev/null +++ b/src/chains/nano-x402.ts @@ -0,0 +1,58 @@ +import { CliError } from '../errors'; +import { normalizeAddress, send, type NanoRpc } from './nano'; +import type { PaymentPayloadResult, PaymentRequirements, SchemeNetworkClient } from '@x402/core/types'; + +const NANO_DECIMALS = 30; + +/** + * x402 `exact` on Nano. Nano has no transfer authorization to hand a facilitator, + * so the client publishes the state send block itself and the payload carries + * the block hash for the merchant to confirm on its own node. + */ +export class ExactNanoScheme implements SchemeNetworkClient { + readonly scheme = 'exact'; + + constructor( + private readonly opts: { + key: Buffer; + rpc?: NanoRpc; + /** + * Blocks already published in this invocation, keyed by payTo and amount. A retried + * request re-presents the same block instead of paying a second time. + */ + published?: Map; + /** Receives errors before x402 wraps them, so the caller can keep the structured code. */ + onError?: (err: unknown) => void; + }, + ) {} + + async createPaymentPayload(x402Version: number, requirements: PaymentRequirements): Promise { + try { + const declared = requirements.extra?.decimals; + if (declared !== undefined && declared !== NANO_DECIMALS) { + throw new CliError( + 'merchant_spec_violation', + `Merchant 402 declared ${String(declared)} decimals for XNO; Nano raw has ${NANO_DECIMALS}. Refusing to pay.`, + { extra: { chain: 'nano', decimals: declared } }, + ); + } + if (!/^\d+$/.test(requirements.amount ?? '')) { + throw new CliError('merchant_spec_violation', `Merchant 402 amount is not integer raw: ${requirements.amount}`, { + extra: { chain: 'nano', amount: requirements.amount }, + }); + } + const payTo = normalizeAddress(requirements.payTo); + const key = `${payTo}:${requirements.amount}`; + let blockHash = this.opts.published?.get(key); + if (!blockHash) { + const sent = await send({ key: this.opts.key, to: payTo, amountRaw: BigInt(requirements.amount), rpc: this.opts.rpc }); + blockHash = sent.hash; + this.opts.published?.set(key, blockHash); + } + return { x402Version, payload: { blockHash } }; + } catch (err: unknown) { + this.opts.onError?.(err); + throw err; + } + } +} diff --git a/src/chains/nano.ts b/src/chains/nano.ts new file mode 100644 index 0000000..f7f664e --- /dev/null +++ b/src/chains/nano.ts @@ -0,0 +1,300 @@ +import { Point } from '@noble/ed25519'; +import { blake2b } from '@noble/hashes/blake2.js'; +import { derivePath } from 'ed25519-hd-key'; +import { nanoConfig } from '../constants'; +import { CliError, wrapRpcError } from '../errors'; + +// Nano signs with ed25519 but swaps SHA-512 for BLAKE2b-512 in key expansion and +// in the nonce/challenge hashes, so the stock ed25519 helpers cannot be reused; +// the curve arithmetic comes from @noble/ed25519's Point. + +const NANO_DERIVATION_PATH = "m/44'/165'/0'"; +const ADDRESS_ALPHABET = '13456789abcdefghijkmnopqrstuwxyz'; +const ADDRESS_RE = /^(nano|xrb)_([13][13456789abcdefghijkmnopqrstuwxyz]{59})$/; +const RAW_PER_XNO = 10n ** 30n; +const MAX_RAW = (1n << 128n) - 1n; +const ZERO_HASH = '0'.repeat(64); +const L = Point.CURVE().n; + +function bytesToBigIntLE(bytes: Uint8Array): bigint { + let n = 0n; + for (let i = bytes.length - 1; i >= 0; i--) n = (n << 8n) | BigInt(bytes[i]); + return n; +} + +function bigIntToBytesLE(n: bigint, length: number): Uint8Array { + const out = new Uint8Array(length); + for (let i = 0; i < length; i++) { + out[i] = Number(n & 0xffn); + n >>= 8n; + } + return out; +} + +function hexToBytes(hex: string, length: number): Uint8Array { + if (!new RegExp(`^[0-9a-fA-F]{${length * 2}}$`).test(hex)) { + throw new Error(`Expected ${length}-byte hex, got: ${hex}`); + } + return Uint8Array.from(Buffer.from(hex, 'hex')); +} + +function toHex(bytes: Uint8Array): string { + return Buffer.from(bytes).toString('hex').toUpperCase(); +} + +function expandKey(key: Uint8Array): { scalar: bigint; prefix: Uint8Array } { + const h = blake2b(key, { dkLen: 64 }); + const a = h.slice(0, 32); + a[0] &= 248; + a[31] &= 127; + a[31] |= 64; + return { scalar: bytesToBigIntLE(a) % L, prefix: h.slice(32) }; +} + +function hashToScalar(...parts: Uint8Array[]): bigint { + const total = parts.reduce((n, p) => n + p.length, 0); + const buf = new Uint8Array(total); + let off = 0; + for (const p of parts) { + buf.set(p, off); + off += p.length; + } + return bytesToBigIntLE(blake2b(buf, { dkLen: 64 })) % L; +} + +export function generateKey(): Buffer { + return Buffer.from(crypto.getRandomValues(new Uint8Array(32))); +} + +/** SLIP-10 ed25519 derivation at m/44'/165'/0' (the BIP-44 path Nano wallets use for a BIP-39 seed). */ +export function deriveKeyFromSeed(seed: Buffer): Buffer { + const { key } = derivePath(NANO_DERIVATION_PATH, seed.toString('hex')); + return Buffer.from(key); +} + +export function publicKeyFromKey(key: Uint8Array): Uint8Array { + if (key.length !== 32) throw new CliError('invalid_key', `Nano private key must be 32 bytes, got ${key.length}.`); + return Point.BASE.multiply(expandKey(key).scalar).toBytes(); +} + +/** ed25519-blake2b signature over a 32-byte block hash. */ +export function signHash(key: Uint8Array, hash: Uint8Array): Uint8Array { + const { scalar, prefix } = expandKey(key); + const pub = Point.BASE.multiply(scalar).toBytes(); + const r = hashToScalar(prefix, hash); + const R = Point.BASE.multiply(r).toBytes(); + const k = hashToScalar(R, pub, hash); + const s = (r + k * scalar) % L; + const sig = new Uint8Array(64); + sig.set(R, 0); + sig.set(bigIntToBytesLE(s, 32), 32); + return sig; +} + +function encodeBase32(bytes: Uint8Array, bits: number): string { + let n = 0n; + for (const b of bytes) n = (n << 8n) | BigInt(b); + let out = ''; + for (let i = bits / 5 - 1; i >= 0; i--) out += ADDRESS_ALPHABET[Number((n >> BigInt(i * 5)) & 31n)]; + return out; +} + +function decodeBase32(chars: string): bigint { + let n = 0n; + for (const c of chars) n = (n << 5n) | BigInt(ADDRESS_ALPHABET.indexOf(c)); + return n; +} + +function checksum(pub: Uint8Array): Uint8Array { + return blake2b(pub, { dkLen: 5 }).reverse(); +} + +export function publicKeyToAddress(pub: Uint8Array): string { + return `nano_${encodeBase32(pub, 260)}${encodeBase32(checksum(pub), 40)}`; +} + +export function keyToAddress(key: Buffer): string { + return publicKeyToAddress(publicKeyFromKey(key)); +} + +/** Decode a `nano_` or legacy `xrb_` address, rejecting a bad prefix, alphabet or checksum. */ +export function addressToPublicKey(address: string): Uint8Array { + const m = ADDRESS_RE.exec(address); + if (!m) { + throw new CliError('invalid_wallet_address', `Not a Nano address: ${address}`); + } + const body = m[2]; + const pubN = decodeBase32(body.slice(0, 52)); + const pub = new Uint8Array(32); + for (let i = 0; i < 32; i++) pub[i] = Number((pubN >> BigInt((31 - i) * 8)) & 0xffn); + if (encodeBase32(checksum(pub), 40) !== body.slice(52)) { + throw new CliError('invalid_wallet_address', `Nano address checksum mismatch: ${address}`); + } + return pub; +} + +/** Canonical `nano_` form of a `nano_` or `xrb_` address. */ +export function normalizeAddress(address: string): string { + return publicKeyToAddress(addressToPublicKey(address)); +} + +/** Exact XNO decimal for a raw amount, trailing zeros trimmed (prices can sit far below 10^-6 XNO). */ +export function formatBalance(raw: bigint): string { + const whole = raw / RAW_PER_XNO; + const frac = (raw % RAW_PER_XNO).toString().padStart(30, '0').replace(/0+$/, ''); + return `${whole.toString()}.${frac || '0'}`; +} + +/** Exact decimal XNO -> raw. No floating point: 1 raw is 10^-30 XNO. */ +export function parseXno(amount: string): bigint { + const m = /^(\d+)(?:\.(\d{1,30}))?$/.exec(amount.trim()); + if (!m) { + throw new CliError('invalid_amount', `Not an XNO amount: ${amount}`, { + nextSteps: { action: 'fix_amount', suggestion: 'Pass a decimal XNO amount, e.g. 0.01.' }, + }); + } + return BigInt(m[1]) * RAW_PER_XNO + BigInt((m[2] ?? '').padEnd(30, '0')); +} + +interface StateBlock { + account: string; + previous: string; + representative: string; + balance: bigint; + link: string; +} + +export function hashStateBlock(block: StateBlock): Uint8Array { + const preamble = new Uint8Array(32); + preamble[31] = 6; + const balance = new Uint8Array(16); + let b = block.balance; + for (let i = 15; i >= 0; i--) { + balance[i] = Number(b & 0xffn); + b >>= 8n; + } + const parts = [ + preamble, + addressToPublicKey(block.account), + hexToBytes(block.previous, 32), + addressToPublicKey(block.representative), + balance, + hexToBytes(block.link, 32), + ]; + const buf = new Uint8Array(32 * 5 + 16); + let off = 0; + for (const p of parts) { + buf.set(p, off); + off += p.length; + } + return blake2b(buf, { dkLen: 32 }); +} + +export type NanoRpc = (body: Record) => Promise>; + +/** JSON-RPC over HTTP to a Nano node (or a proxy that also serves work_generate). */ +export function createRpc(url: string = nanoConfig().rpcUrl, fetchImpl: typeof fetch = fetch): NanoRpc { + return async (body) => { + const res = await fetchImpl(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(`Nano RPC ${String(body.action)} returned HTTP ${res.status}`); + const json = (await res.json()) as Record; + if (typeof json.error === 'string') throw new Error(`Nano RPC ${String(body.action)}: ${json.error}`); + return json; + }; +} + +function rawField(value: unknown, field: string): bigint { + if (typeof value !== 'string' || !/^\d+$/.test(value)) throw new Error(`Nano RPC returned a non-integer ${field}`); + return BigInt(value); +} + +export async function balance(address: string, rpc: NanoRpc = createRpc()): Promise { + try { + const res = await rpc({ action: 'account_balance', account: address }); + return rawField(res.balance, 'balance'); + } catch (err: unknown) { + throw wrapRpcError('nano', 'mainnet', err); + } +} + +export interface SendResult { + hash: string; + from: string; + to: string; + amount_raw: string; +} + +/** + * Build, sign and publish a state send block. Work comes from the node's + * `work_generate`, so the caller needs an RPC that serves it. Insufficient funds + * abort before anything is signed; transport failures surface as plain errors. + */ +export async function send(input: { key: Buffer; to: string; amountRaw: bigint; rpc?: NanoRpc }): Promise { + const rpc = input.rpc ?? createRpc(); + const to = normalizeAddress(input.to); + const linkPub = addressToPublicKey(to); + if (input.amountRaw <= 0n || input.amountRaw > MAX_RAW) { + throw new CliError('invalid_amount', `Nano send amount out of range: ${input.amountRaw.toString()} raw`); + } + const from = keyToAddress(input.key); + + const info = await rpc({ action: 'account_info', account: from, representative: 'true' }).catch((err: unknown) => { + if (err instanceof Error && /account not found/i.test(err.message)) { + throw new CliError('insufficient_balance', `Nano account ${from} is unopened (no receive yet).`, { + nextSteps: { action: 'fund_wallet', suggestion: `Send XNO to ${from} and receive it before paying.` }, + extra: { chain: 'nano', address: from }, + }); + } + throw err; + }); + const current = rawField(info.balance, 'balance'); + if (current < input.amountRaw) { + throw new CliError('insufficient_balance', `Nano wallet holds ${formatBalance(current)} XNO, needs ${formatBalance(input.amountRaw)}.`, { + nextSteps: { action: 'fund_wallet', suggestion: `Send XNO to ${from}.` }, + extra: { chain: 'nano', address: from, balance_raw: current.toString(), amount_raw: input.amountRaw.toString() }, + }); + } + const previous = typeof info.frontier === 'string' ? info.frontier.toUpperCase() : ZERO_HASH; + const representative = typeof info.representative === 'string' ? info.representative : from; + + const block: StateBlock = { + account: from, + previous, + representative, + balance: current - input.amountRaw, + link: toHex(linkPub), + }; + const hash = hashStateBlock(block); + const signature = signHash(input.key, hash); + const work = await rpc({ action: 'work_generate', hash: previous }); + if (typeof work.work !== 'string') throw new Error('Nano RPC work_generate returned no work'); + + const processed = await rpc({ + action: 'process', + json_block: 'true', + subtype: 'send', + block: { + type: 'state', + account: block.account, + previous: block.previous, + representative: block.representative, + balance: block.balance.toString(), + link: block.link, + signature: toHex(signature), + work: work.work, + }, + }); + const blockHash = toHex(hash); + if (typeof processed.hash === 'string' && processed.hash.toUpperCase() !== blockHash) { + throw new Error(`Nano node processed ${processed.hash}, expected ${blockHash}`); + } + return { hash: blockHash, from, to, amount_raw: input.amountRaw.toString() }; +} + +export function qrUri(addr: string): string { + return `nano:${addr}`; +} diff --git a/src/cli.ts b/src/cli.ts index 3d33aa0..d6fd505 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,7 +39,7 @@ import { walletShowMnemonic, } from './commands/wallet'; import { whoami } from './commands/whoami'; -import { SUPPORTED_CHAINS, SUPPORTED_NETWORKS } from './constants'; +import { OPT_IN_CHAINS, SUPPORTED_CHAINS, SUPPORTED_NETWORKS } from './constants'; import { CliError, exitCodeForError } from './errors'; declare const __VERSION__: string; @@ -47,6 +47,10 @@ declare const __VERSION__: string; const VERSION = typeof __VERSION__ === 'string' ? __VERSION__ : '0.0.0-dev'; const chainSchema = z.enum(SUPPORTED_CHAINS).describe('Blockchain rail (base, solana, tempo)'); +// Keystore and pay commands also take opt-in rails, which are only ever used when named. +const walletChainSchema = z + .enum([...SUPPORTED_CHAINS, ...OPT_IN_CHAINS]) + .describe('Blockchain rail (base, solana, tempo; nano only when named)'); const networkSchema = z.enum(SUPPORTED_NETWORKS).default('mainnet').describe('Mainnet (default) or testnet'); const walletNameSchema = z.string().default('default').describe('Named keystore, pass a name to pick a non-default wallet'); @@ -209,7 +213,7 @@ export function buildCli() { mcp: false, description: 'Generate a new encrypted keystore. Omit --chain to create all three chains.', options: z.object({ - chain: chainSchema.optional().describe('Blockchain rail (base, solana, tempo)'), + chain: walletChainSchema.optional().describe('Blockchain rail (base, solana, tempo; nano only when named)'), name: walletNameSchema, mnemonic: z.boolean().optional().describe('Generate a BIP-39 mnemonic and derive keys from it'), }), @@ -232,7 +236,7 @@ export function buildCli() { key: z.string().optional().describe('Hex private key (EVM) or base64 private-key seed (Solana)'), }), options: z.object({ - chain: chainSchema.optional().describe('Blockchain rail (base, solana, tempo)'), + chain: walletChainSchema.optional().describe('Blockchain rail (base, solana, tempo; nano only when named)'), name: walletNameSchema, mnemonic: z.string().optional().describe('BIP-39 mnemonic phrase (12 or 24 words, quoted)'), }), @@ -260,7 +264,7 @@ export function buildCli() { wallet.command('list', { description: 'List all named keystores per chain', - options: z.object({ chain: chainSchema.optional().describe('Filter to one chain') }), + options: z.object({ chain: walletChainSchema.optional().describe('Filter to one chain') }), examples: [ { description: 'List every named wallet across all chains' }, { options: { chain: 'base' }, description: 'List only base wallets' }, @@ -294,7 +298,7 @@ export function buildCli() { wallet.command('address', { description: 'Print a wallet address', options: z.object({ - chain: chainSchema, + chain: walletChainSchema, name: walletNameSchema, }), examples: [ @@ -317,7 +321,7 @@ export function buildCli() { hint: 'The exported key gives full control of the wallet to anyone who reads it. Pipe to an encrypted store; never to a shared shell history.', outputPolicy: 'agent-only', options: z.object({ - chain: chainSchema, + chain: walletChainSchema, name: walletNameSchema, danger: z.boolean().optional().describe('Acknowledge the risk of this destructive operation'), skipConfirm: z.boolean().optional().describe('Skip the type-EXPORT prompt (for scripting)'), @@ -344,7 +348,7 @@ export function buildCli() { description: 'Delete a keystore. DANGER, irrecoverable unless you have the BIP-39 mnemonic backup.', hint: 'No undo. Run `wallet show-mnemonic --danger` first if you want to be able to restore.', options: z.object({ - chain: chainSchema, + chain: walletChainSchema, name: walletNameSchema, danger: z.boolean().optional().describe('Acknowledge the risk of this destructive operation'), skipConfirm: z.boolean().optional().describe('Skip the type-EXPORT prompt (for scripting)'), @@ -758,12 +762,13 @@ export function buildCli() { url: z.string().describe('URL to pay'), }), options: z.object({ - chain: chainSchema.optional().describe('Blockchain rail (base, solana, tempo)'), + chain: walletChainSchema.optional().describe('Blockchain rail (base, solana, tempo; nano only when named)'), network: networkSchema, name: walletNameSchema, data: z.string().optional().describe('Request body'), header: z.array(z.string()).optional().describe("Additional header (repeatable, 'Name: value')"), maxSpend: z.coerce.number().optional().describe('Reject payments above this USD amount'), + maxSpendXno: z.string().optional().describe('Reject payments above this XNO amount (required with --chain nano)'), timeout: z.coerce.number().optional().describe('Abort the merchant request if it takes longer than N seconds'), retries: z.coerce.number().optional().describe('Retry transient network errors up to N times'), dryRun: z.boolean().optional().describe('Print the payment plan without signing or sending'), @@ -805,6 +810,7 @@ export function buildCli() { body: c.options.data, headers: parseHeaders(c.options.header), maxSpendUsd: c.options.maxSpend, + maxSpendXno: c.options.maxSpendXno, timeoutSeconds: c.options.timeout, retries: c.options.retries, dryRun: c.options.dryRun, diff --git a/src/commands/fund-estimate.ts b/src/commands/fund-estimate.ts index 3d04e4b..16f177e 100644 --- a/src/commands/fund-estimate.ts +++ b/src/commands/fund-estimate.ts @@ -87,7 +87,8 @@ export async function fundEstimate(opts: FundEstimateOptions): Promise { const funded = await keystoreExists(q.chain); let balance: number | null = null; - if (funded) { + // A nano quote is in XNO, so there is no USD balance to weigh it against. + if (funded && q.chain !== 'nano') { const ks = await loadKeystore(q.chain); balance = await balanceUsd(q.chain, network, ks.address); } diff --git a/src/commands/pay.ts b/src/commands/pay.ts index e62dac2..3e189c8 100644 --- a/src/commands/pay.ts +++ b/src/commands/pay.ts @@ -4,9 +4,12 @@ import { x402Client } from '@x402/core/client'; import { ExactEvmScheme } from '@x402/evm/exact/client'; import { wrapFetchWithPayment } from '@x402/fetch'; import { Mppx, tempo } from 'mppx/client'; -import { evmConfig, isKnownUSDC, svmConfig, type Chain, type Network } from '../constants'; +import * as nanoChain from '../chains/nano'; +import { ExactNanoScheme } from '../chains/nano-x402'; +import { evmConfig, isKnownUSDC, nanoConfig, svmConfig, type Network, type WalletChain } from '../constants'; import { CliError } from '../errors'; import { mergeHeaders } from '../headers'; +import { loadKeystore } from '../keystore'; import { appendEntry } from '../ledger'; import { enforce, loadLimits } from '../limits'; import { attachPassport } from '../passport/attach'; @@ -25,13 +28,15 @@ import type { ClientEvmSigner } from '@x402/evm'; type IdentityMode = 'auto' | 'operator' | 'wallet'; export interface PayInput { - chain?: Chain; + chain?: WalletChain; network?: Network; method: string; url: string; body?: string; headers?: Record; maxSpendUsd?: number; + /** Per-call XNO ceiling (decimal string). Required with chain 'nano', which has no USD price. */ + maxSpendXno?: string; verbose?: boolean; dryRun?: boolean; timeoutSeconds?: number; @@ -53,9 +58,10 @@ interface PayIdentityPlan { interface PayDryRunResult { dry_run: true; - selected_chain: Chain; + selected_chain: WalletChain; signer: string; - balance_usdc: string; + balance_usdc?: string; + balance_xno?: string; method: string; url: string; protocol: Protocol; @@ -63,6 +69,7 @@ interface PayDryRunResult { headers: Record; body: string | null; max_spend_usd: number | null; + max_spend_xno?: string; } interface PaySettledResult { @@ -70,10 +77,11 @@ interface PaySettledResult { ok: boolean; status: number; status_text: string; - chain: Chain; + chain: WalletChain; signer: string; protocol: Protocol; price_usd?: string; + price_xno?: string; tx_hash?: string; next_steps_action?: string; body: unknown; @@ -86,6 +94,7 @@ const DEFAULT_RETRIES = 0; interface PaymentSettled { price_usd?: string; + price_xno?: string; tx_hash?: string; /** * Structured abort surfaced by the x402 onBeforePaymentCreation hook. x402-fetch wraps @@ -94,22 +103,29 @@ interface PaymentSettled { * with the right code + nextSteps. */ cliError?: CliError; + /** Nano blocks published during this invocation, so a retry re-presents rather than re-pays. */ + nanoPublished?: Map; } export async function pay(input: PayInput): Promise { const network: Network = input.network ?? 'mainnet'; const walletName = input.name ?? DEFAULT_WALLET_NAME; - const candidate = await selectRail({ chainOverride: input.chain, walletName, network }); + const candidate: PayCandidate = + input.chain === 'nano' + ? await selectNanoRail(input, walletName, network) + : await selectRail({ chainOverride: input.chain, walletName, network }); if (input.verbose) { emitProgress('rail_selected', { chain: candidate.chain, address: candidate.address, - balance_usdc: candidate.balance_usdc, + ...(candidate.balance_xno !== undefined + ? { balance_xno: candidate.balance_xno } + : { balance_usdc: candidate.balance_usdc }), reason: input.chain ? 'user_override' : 'auto', }); } - const protocol: Protocol = candidate.chain === 'base' ? 'x402' : 'mpp'; + const protocol: Protocol = usesX402(candidate.chain) ? 'x402' : 'mpp'; // Resolve AgentScore Passport once (also reused on the live path below). When the // caller provided their own X-Operator-Token or set --skip-passport, this is a no-op. @@ -223,7 +239,9 @@ export async function pay(input: PayInput): Promise { dry_run: true, selected_chain: candidate.chain, signer: candidate.address, - balance_usdc: candidate.balance_usdc, + ...(candidate.balance_xno !== undefined + ? { balance_xno: candidate.balance_xno } + : { balance_usdc: candidate.balance_usdc }), method: input.method, url: input.url, protocol, @@ -231,6 +249,7 @@ export async function pay(input: PayInput): Promise { headers, body: input.body ?? null, max_spend_usd: input.maxSpendUsd ?? null, + ...(input.maxSpendXno !== undefined ? { max_spend_xno: input.maxSpendXno } : {}), }; } @@ -298,7 +317,7 @@ export async function pay(input: PayInput): Promise { try { res = await withRetries( () => - wallet.chain === 'base' + usesX402(wallet.chain) ? payViaX402(wallet, input, init, network, settled, willAttachOperatorToken) : payViaMpp(wallet, input, init, network, settled, willAttachOperatorToken), { @@ -428,7 +447,7 @@ export async function pay(input: PayInput): Promise { const retrySettled: PaymentSettled = {}; try { res = await ( - wallet.chain === 'base' + usesX402(wallet.chain) ? payViaX402(wallet, { ...input, body: retryBody }, retryInit, network, retrySettled, true) : payViaMpp(wallet, { ...input, body: retryBody }, retryInit, network, retrySettled, true) ); @@ -442,6 +461,7 @@ export async function pay(input: PayInput): Promise { text = await res.text(); parsed = tryParseJson(text); settled.price_usd = retrySettled.price_usd ?? settled.price_usd; + settled.price_xno = retrySettled.price_xno ?? settled.price_xno; settled.tx_hash = retrySettled.tx_hash ?? settled.tx_hash; } @@ -459,6 +479,7 @@ export async function pay(input: PayInput): Promise { status: res.status, protocol, ...(settled.price_usd ? { price_usd: settled.price_usd } : {}), + ...(settled.price_xno ? { price_xno: settled.price_xno } : {}), ...(settled.tx_hash ? { tx_hash: settled.tx_hash } : {}), ...(nextStepsAction ? { next_steps_action: nextStepsAction } : {}), ok: res.ok, @@ -473,6 +494,7 @@ export async function pay(input: PayInput): Promise { signer: wallet.address, protocol, ...(settled.price_usd ? { price_usd: settled.price_usd } : {}), + ...(settled.price_xno ? { price_xno: settled.price_xno } : {}), ...(settled.tx_hash ? { tx_hash: settled.tx_hash } : {}), ...(nextStepsAction ? { next_steps_action: nextStepsAction } : {}), body: parsed ?? text, @@ -522,9 +544,9 @@ function safeHost(url: string): string { } } -function resolveDecimals(declared: number | undefined, asset: string | undefined, chain: Chain): number { +function resolveDecimals(declared: number | undefined, asset: string | undefined, chain: WalletChain): number { if (declared !== undefined) return Number(declared); - if (isKnownUSDC(asset, chain)) return 6; + if (chain !== 'nano' && isKnownUSDC(asset, chain)) return 6; throw new CliError( 'merchant_spec_violation', `Merchant 402 omitted 'decimals' for unrecognized asset ${asset ?? '(none)'} on ${chain}. Refusing to pay, guessing decimals risks orders-of-magnitude mis-billing.`, @@ -546,7 +568,6 @@ async function payViaX402( settled: PaymentSettled, guardRedirects = false, ): Promise { - const signer = await createX402Signer(wallet, network); const client = new x402Client(); // @x402/core 2.23.0 turns spend controls on by default: an omitted // maxAmountPerPayment falls back to $1, and non-default assets are rejected @@ -559,12 +580,24 @@ async function payViaX402( // authority. client.setSpendControls(false); if (wallet.chain === 'base') { + const signer = await createX402Signer(wallet, network); const cfg = evmConfig('base', network); const evmClient = new ExactEvmScheme(signer as ClientEvmSigner); client.register(cfg.network as `${string}:${string}`, evmClient); client.registerV1(cfg.network as `${string}:${string}`, evmClient); + } else if (wallet.chain === 'nano') { + const cfg = nanoConfig(); + const nanoClient = new ExactNanoScheme({ + key: wallet.secret, + rpc: nanoChain.createRpc(cfg.rpcUrl), + published: (settled.nanoPublished ??= new Map()), + onError: (err) => { + if (err instanceof CliError) settled.cliError = err; + }, + }); + client.register(cfg.network as `${string}:${string}`, nanoClient); } else { - throw new CliError('unsupported_rail', `x402 path called on chain ${wallet.chain}, only Base is supported under x402; Tempo and Solana go through MPP.`); + throw new CliError('unsupported_rail', `x402 path called on chain ${wallet.chain}, only Base and Nano are supported under x402; Tempo and Solana go through MPP.`); } const host = safeHost(input.url); @@ -577,6 +610,7 @@ async function payViaX402( extra?: { decimals?: number }; }; try { + if (wallet.chain === 'nano') return checkNanoSpend(req, input, settled); const declaredDecimals = typeof req.extra?.decimals === 'number' ? req.extra.decimals : undefined; const decimals = resolveDecimals(declaredDecimals, req.asset, wallet.chain); const priceRaw = BigInt(req.amount ?? req.maxAmountRequired ?? '0'); @@ -616,6 +650,69 @@ async function payViaX402( return fetchWithPay(input.url, init); } +function usesX402(chain: WalletChain): boolean { + return chain === 'base' || chain === 'nano'; +} + +interface PayCandidate { + chain: WalletChain; + address: string; + balance_usdc?: string; + balance_xno?: string; +} + +/** + * Nano is reached only by an explicit `--chain nano`. XNO has no USD price inside + * pay, so neither --max-spend nor the USD `limits` can bound it: the caller must + * name an XNO ceiling, and the payment is never written as price_usd. + */ +async function selectNanoRail(input: PayInput, walletName: string, network: Network): Promise { + if (network !== 'mainnet') { + throw new CliError('invalid_network', 'Nano is mainnet-only in pay; drop --network testnet.', { + extra: { chain: 'nano', network }, + }); + } + if (input.maxSpendXno === undefined) { + throw new CliError( + 'limit_required', + '--chain nano needs --max-spend-xno. XNO is not priced in USD, so --max-spend and the USD limits cannot cap it.', + { + nextSteps: { action: 'pass_max_spend_xno', suggestion: 'Re-run with --max-spend-xno , e.g. --max-spend-xno 0.05.' }, + extra: { chain: 'nano' }, + }, + ); + } + nanoChain.parseXno(input.maxSpendXno); + const ks = await loadKeystore('nano', walletName); + const raw = await nanoChain.balance(ks.address); + return { chain: 'nano', address: ks.address, balance_xno: nanoChain.formatBalance(raw) }; +} + +function checkNanoSpend( + req: { amount?: string; maxAmountRequired?: string }, + input: PayInput, + settled: PaymentSettled, +): { abort: true; reason: string } | undefined { + const amount = req.amount ?? req.maxAmountRequired ?? ''; + if (!/^\d+$/.test(amount)) { + throw new CliError('merchant_spec_violation', `Merchant 402 amount for nano is not integer raw: ${amount}`, { + extra: { chain: 'nano', amount }, + }); + } + const priceRaw = BigInt(amount); + const capRaw = nanoChain.parseXno(input.maxSpendXno ?? '0'); + settled.price_xno = nanoChain.formatBalance(priceRaw); + if (priceRaw > capRaw) { + settled.cliError = new CliError( + 'max_spend_exceeded', + `Payment ${settled.price_xno} XNO exceeds --max-spend-xno ${input.maxSpendXno}`, + { extra: { price_raw: priceRaw.toString(), max_spend_xno: input.maxSpendXno, chain: 'nano' } }, + ); + return { abort: true, reason: settled.cliError.message }; + } + return undefined; +} + async function payViaMpp( wallet: Wallet, input: PayInput, diff --git a/src/commands/wallet.ts b/src/commands/wallet.ts index 6ed8e32..b67573a 100644 --- a/src/commands/wallet.ts +++ b/src/commands/wallet.ts @@ -1,4 +1,4 @@ -import { SUPPORTED_CHAINS, type Chain } from '../constants'; +import { SUPPORTED_CHAINS, type WalletChain } from '../constants'; import { CliError } from '../errors'; import { decryptSecret, deleteKeystore, keystoreExists, listWallets, loadKeystore } from '../keystore'; import { deriveKey, generatePhrase, validatePhrase } from '../mnemonic'; @@ -18,7 +18,7 @@ function validateWalletName(name: string): void { } interface CreateResult { - chain: Chain; + chain: WalletChain; name: string; address: string; keystore: string; @@ -28,7 +28,7 @@ interface CreateResult { } export interface WalletCreateInput { - chain?: Chain; + chain?: WalletChain; mnemonic?: boolean; name?: string; } @@ -55,7 +55,7 @@ export async function walletCreate(input: WalletCreateInput = {}): Promise { +async function walletCreateMnemonic(chain?: WalletChain): Promise { if (await mnemonicExists()) { throw new CliError('wallet_exists', 'A mnemonic is already stored.', { nextSteps: { @@ -145,13 +145,13 @@ async function walletCreateMnemonic(chain?: Chain): Promise } export interface WalletImportInput { - chain: Chain; + chain: WalletChain; key: string; name?: string; } export interface WalletImportResult { - chain: Chain; + chain: WalletChain; name: string; address: string; keystore: string; @@ -187,7 +187,7 @@ export async function walletImport(input: WalletImportInput): Promise { +export async function walletAddress(input: WalletAddressInput): Promise<{ chain: WalletChain; name: string; address: string }> { const name = input.name ?? DEFAULT_WALLET_NAME; validateWalletName(name); const file = await loadKeystore(input.chain, name); @@ -254,10 +254,10 @@ export async function walletAddress(input: WalletAddressInput): Promise<{ chain: } export interface WalletListInput { - chain?: Chain; + chain?: WalletChain; } -export async function walletList(input: WalletListInput = {}): Promise<{ wallets: Array<{ chain: Chain; names: string[] }> }> { +export async function walletList(input: WalletListInput = {}): Promise<{ wallets: Array<{ chain: WalletChain; names: string[] }> }> { const chains = input.chain ? [input.chain] : [...SUPPORTED_CHAINS]; const rows = await Promise.all( chains.map(async (c) => ({ chain: c, names: await listWallets(c) })), @@ -266,7 +266,7 @@ export async function walletList(input: WalletListInput = {}): Promise<{ wallets } export interface WalletRemoveInput { - chain: Chain; + chain: WalletChain; name?: string; danger?: boolean; skipConfirm?: boolean; @@ -274,7 +274,7 @@ export interface WalletRemoveInput { export interface WalletRemoveResult { ok: true; - chain: Chain; + chain: WalletChain; name: string; address: string; removed_files: string[]; @@ -314,14 +314,14 @@ export async function walletRemove(input: WalletRemoveInput): Promise = { 'solana-testnet': 'SOLANA_DEVNET_RPC_URL', tempo: 'TEMPO_RPC_URL', 'tempo-testnet': 'TEMPO_TESTNET_RPC_URL', + nano: 'NANO_RPC_URL', }; export function wrapRpcError(chain: string, network: string, err: unknown): CliError { diff --git a/src/keystore.ts b/src/keystore.ts index 6e2199b..dcc76c2 100644 --- a/src/keystore.ts +++ b/src/keystore.ts @@ -9,7 +9,7 @@ import { legacyKeystorePath, walletsDir, } from './paths'; -import type { Chain } from './constants'; +import type { WalletChain } from './constants'; export { keystorePath } from './paths'; @@ -30,7 +30,7 @@ function scryptAsync(passphrase: string, salt: Buffer, keylen: number, opts: Scr export interface KeystoreFile { version: 1; - chain: Chain; + chain: WalletChain; address: string; name?: string; encryption: { @@ -91,7 +91,7 @@ export async function saveKeystore(file: KeystoreFile): Promise { return path; } -async function readKeystoreFromPath(path: string, chain: Chain): Promise { +async function readKeystoreFromPath(path: string, chain: WalletChain): Promise { const raw = await readFile(path, 'utf-8'); const parsed = JSON.parse(raw) as KeystoreFile; if (parsed.version !== 1) throw new Error(`Unsupported keystore version: ${parsed.version}`); @@ -99,7 +99,7 @@ async function readKeystoreFromPath(path: string, chain: Chain): Promise { +export async function loadKeystore(chain: WalletChain, name: string = DEFAULT_WALLET_NAME): Promise { if (!isValidWalletName(name)) throw new Error(`Invalid wallet name: ${name}`); try { return await readKeystoreFromPath(_keystorePath(chain, name), chain); @@ -117,7 +117,7 @@ export async function loadKeystore(chain: Chain, name: string = DEFAULT_WALLET_N } } -function missingKeystore(chain: Chain, name: string): CliError { +function missingKeystore(chain: WalletChain, name: string): CliError { return new CliError('no_wallet', `No ${chain} keystore for wallet "${name}".`, { nextSteps: { action: 'create_or_import_wallet', @@ -127,7 +127,7 @@ function missingKeystore(chain: Chain, name: string): CliError { }); } -export async function keystoreExists(chain: Chain, name: string = DEFAULT_WALLET_NAME): Promise { +export async function keystoreExists(chain: WalletChain, name: string = DEFAULT_WALLET_NAME): Promise { if (!isValidWalletName(name)) return false; try { await readFile(_keystorePath(chain, name), 'utf-8'); @@ -145,7 +145,7 @@ export async function keystoreExists(chain: Chain, name: string = DEFAULT_WALLET } } -export async function listWallets(chain: Chain): Promise { +export async function listWallets(chain: WalletChain): Promise { let entries: string[]; try { entries = await readdir(walletsDir()); @@ -167,7 +167,7 @@ export async function listWallets(chain: Chain): Promise { return [...names].sort(); } -export async function deleteKeystore(chain: Chain, name: string = DEFAULT_WALLET_NAME): Promise { +export async function deleteKeystore(chain: WalletChain, name: string = DEFAULT_WALLET_NAME): Promise { if (!isValidWalletName(name)) throw new Error(`Invalid wallet name: ${name}`); const removed: string[] = []; const newPath = _keystorePath(chain, name); diff --git a/src/ledger.ts b/src/ledger.ts index e166c2a..f77e673 100644 --- a/src/ledger.ts +++ b/src/ledger.ts @@ -1,13 +1,13 @@ import { appendFile, mkdir, readFile } from 'fs/promises'; import { dirname } from 'path'; import { ledgerPath } from './paths'; -import type { Chain } from './constants'; +import type { WalletChain } from './constants'; export { ledgerPath } from './paths'; export interface LedgerEntry { timestamp: string; - chain: Chain; + chain: WalletChain; signer: string; method: string; url: string; @@ -15,6 +15,7 @@ export interface LedgerEntry { status: number; protocol: 'x402' | 'mpp'; price_usd?: string; + price_xno?: string; tx_hash?: string; next_steps_action?: string; ok: boolean; diff --git a/src/mnemonic.ts b/src/mnemonic.ts index 8868a54..30bc494 100644 --- a/src/mnemonic.ts +++ b/src/mnemonic.ts @@ -2,8 +2,9 @@ import { generateMnemonic, mnemonicToSeedSync, validateMnemonic } from '@scure/b import { wordlist } from '@scure/bip39/wordlists/english.js'; import { derivePath } from 'ed25519-hd-key'; import { mnemonicToAccount } from 'viem/accounts'; +import { deriveKeyFromSeed as deriveNanoKeyFromSeed } from './chains/nano'; import { CliError } from './errors'; -import type { Chain } from './constants'; +import type { WalletChain } from './constants'; const SOLANA_DERIVATION_PATH = "m/44'/501'/0'/0'"; const EVM_DERIVATION_PATH = "m/44'/60'/0'/0/0"; @@ -40,7 +41,17 @@ export function deriveSolanaKey(phrase: string): Buffer { return Buffer.from(key); } -export function deriveKey(chain: Chain, phrase: string): Buffer { +function deriveNanoKey(phrase: string): Buffer { + if (!validatePhrase(phrase)) { + throw new CliError('invalid_key', 'Invalid BIP-39 mnemonic phrase.', { + nextSteps: { action: 'check_phrase', suggestion: 'Verify word count (12 or 24) and spelling against the BIP-39 English wordlist.' }, + }); + } + return deriveNanoKeyFromSeed(Buffer.from(mnemonicToSeedSync(phrase.trim()))); +} + +export function deriveKey(chain: WalletChain, phrase: string): Buffer { if (chain === 'solana') return deriveSolanaKey(phrase); + if (chain === 'nano') return deriveNanoKey(phrase); return deriveEvmKey(phrase); } diff --git a/src/paths.ts b/src/paths.ts index 0adc7a7..2f04af1 100644 --- a/src/paths.ts +++ b/src/paths.ts @@ -1,6 +1,6 @@ import { homedir } from 'os'; import { join } from 'path'; -import type { Chain } from './constants'; +import type { WalletChain } from './constants'; export const DEFAULT_WALLET_NAME = 'default'; @@ -18,11 +18,11 @@ export function walletsDir(): string { return join(baseDir(), 'wallets'); } -export function keystorePath(chain: Chain, name: string = DEFAULT_WALLET_NAME): string { +export function keystorePath(chain: WalletChain, name: string = DEFAULT_WALLET_NAME): string { return join(walletsDir(), `${chain}-${name}.json`); } -export function legacyKeystorePath(chain: Chain): string { +export function legacyKeystorePath(chain: WalletChain): string { return join(walletsDir(), `${chain}.json`); } diff --git a/src/quotes.ts b/src/quotes.ts index 7558b84..cd736a3 100644 --- a/src/quotes.ts +++ b/src/quotes.ts @@ -1,4 +1,4 @@ -import { isKnownUSDC, type Chain } from './constants'; +import { isKnownUSDC, type Chain, type WalletChain } from './constants'; import { lookupRailHint, type RailHint } from './rail-hints'; import { challengeToRail, parsePaymentChallenges } from './www-authenticate'; @@ -11,7 +11,7 @@ function safeBigInt(raw: string): bigint | null { } export interface RailQuote { - chain: Chain; + chain: WalletChain; price_usd?: number; decimals?: number; price_raw?: string; @@ -70,11 +70,16 @@ export function parseBody(body: unknown, headers?: Headers): ParsedRails { if (Array.isArray(record.accepts)) { for (const a of record.accepts as X402Accept[]) { - const chain = chainFromNetworkId(a.network); // x402 v1 → maxAmountRequired; v2 → amount. Decimals not at top level; use // extra.decimals if present, else canonical USDC = 6, else leave undefined // rather than display a wrong value. const priceRaw = a.amount ?? a.maxAmountRequired ?? '0'; + if (a.network === 'nano:mainnet' && (a.scheme ?? 'exact') === 'exact') { + // Opt-in rail (`pay --chain nano`). XNO is quoted in raw and is never reported as USD. + supported.push({ chain: 'nano', decimals: 30, price_raw: priceRaw, asset: a.asset, pay_to: a.payTo, protocol: 'x402' }); + continue; + } + const chain = chainFromNetworkId(a.network); if (!chain) { unsupported.push({ protocol: 'x402', diff --git a/src/wallets.ts b/src/wallets.ts index 78489ed..a257632 100644 --- a/src/wallets.ts +++ b/src/wallets.ts @@ -1,32 +1,35 @@ import * as base from './chains/base'; +import * as nano from './chains/nano'; import * as solana from './chains/solana'; import * as tempo from './chains/tempo'; import { CliError } from './errors'; import { decryptSecret, encryptSecret, keystoreExists, loadKeystore, saveKeystore } from './keystore'; import { DEFAULT_WALLET_NAME } from './paths'; -import type { Chain, Network } from './constants'; +import type { Network, WalletChain } from './constants'; export interface Wallet { - chain: Chain; + chain: WalletChain; address: string; secret: Buffer; name?: string; } -function generateKey(chain: Chain): Buffer { +function generateKey(chain: WalletChain): Buffer { if (chain === 'base') return base.generateKey(); + if (chain === 'nano') return nano.generateKey(); if (chain === 'solana') return solana.generateKey(); return tempo.generateKey(); } -async function deriveAddress(chain: Chain, secret: Buffer): Promise { +async function deriveAddress(chain: WalletChain, secret: Buffer): Promise { if (chain === 'base') return base.keyToAddress(secret); + if (chain === 'nano') return nano.keyToAddress(secret); if (chain === 'solana') return solana.keyToAddress(secret); return tempo.keyToAddress(secret); } export async function createWallet( - chain: Chain, + chain: WalletChain, passphrase: string, existing?: Buffer, name: string = DEFAULT_WALLET_NAME, @@ -45,7 +48,7 @@ export async function createWallet( } export async function loadWallet( - chain: Chain, + chain: WalletChain, passphrase: string, name: string = DEFAULT_WALLET_NAME, ): Promise { @@ -65,6 +68,7 @@ export async function loadWallet( export function getQrUri(wallet: Wallet, amountUsd?: number, network: Network = 'mainnet'): string { if (wallet.chain === 'base') return base.qrUri(wallet.address, amountUsd, network); if (wallet.chain === 'solana') return solana.qrUri(wallet.address, amountUsd, network); + if (wallet.chain === 'nano') return nano.qrUri(wallet.address); return tempo.qrUri(wallet.address, amountUsd, network); } diff --git a/tests/error-envelope.test.ts b/tests/error-envelope.test.ts index ceb23a8..7634703 100644 --- a/tests/error-envelope.test.ts +++ b/tests/error-envelope.test.ts @@ -402,6 +402,7 @@ describe('exitCodeForError mapping', () => { ['merchant_spec_violation', EXIT_CODES.PAYMENT_REJECTED], ['max_spend_exceeded', EXIT_CODES.PAYMENT_REJECTED], ['limit_exceeded', EXIT_CODES.PAYMENT_REJECTED], + ['limit_required', EXIT_CODES.PAYMENT_REJECTED], ['insufficient_balance', EXIT_CODES.INSUFFICIENT_FUNDS], ['no_funded_rail', EXIT_CODES.INSUFFICIENT_FUNDS], ['multi_rail_candidates', EXIT_CODES.MULTI_RAIL_AMBIGUITY], diff --git a/tests/fund-estimate.test.ts b/tests/fund-estimate.test.ts index 9499e2b..a6d9493 100644 --- a/tests/fund-estimate.test.ts +++ b/tests/fund-estimate.test.ts @@ -129,6 +129,39 @@ describe('parseBody', () => { expect(supported.map((q) => q.protocol).sort()).toEqual(['mpp', 'x402']); }); + it('lists x402 exact on nano:mainnet as supported, in raw and never as USD', () => { + const body = { + accepts: [ + { + scheme: 'exact', + network: 'nano:mainnet', + amount: '10000000000000000000000000000', + asset: 'XNO', + payTo: 'nano_3i1aq1cchnmbn9x5rsbap8b15akfh7wj7pwskuzi7ahz8oq6cobd99d4r3b7', + extra: { decimals: 30 }, + }, + ], + }; + const { supported, unsupported } = parseBody(body); + expect(unsupported).toHaveLength(0); + expect(supported).toEqual([ + { + chain: 'nano', + decimals: 30, + price_raw: '10000000000000000000000000000', + asset: 'XNO', + pay_to: 'nano_3i1aq1cchnmbn9x5rsbap8b15akfh7wj7pwskuzi7ahz8oq6cobd99d4r3b7', + protocol: 'x402', + }, + ]); + }); + + it('keeps non-exact schemes on nano:mainnet unsupported', () => { + const { supported, unsupported } = parseBody({ accepts: [{ scheme: 'upto', network: 'nano:mainnet', amount: '1' }] }); + expect(supported).toHaveLength(0); + expect(unsupported).toEqual([expect.objectContaining({ scheme: 'upto', network: 'nano:mainnet' })]); + }); + it('surfaces unsupported x402 rails with hints when known', () => { const body = { accepts: [{ scheme: 'exact', network: 'eip155:137', maxAmountRequired: '100' }], diff --git a/tests/nano.test.ts b/tests/nano.test.ts new file mode 100644 index 0000000..8f45029 --- /dev/null +++ b/tests/nano.test.ts @@ -0,0 +1,296 @@ +import { Point } from '@noble/ed25519'; +import { blake2b } from '@noble/hashes/blake2.js'; +import { mnemonicToSeedSync } from '@scure/bip39'; +import { describe, expect, it, vi } from 'vitest'; +import { + addressToPublicKey, + balance, + createRpc, + deriveKeyFromSeed, + formatBalance, + hashStateBlock, + keyToAddress, + normalizeAddress, + parseXno, + publicKeyFromKey, + publicKeyToAddress, + send, + signHash, + type NanoRpc, +} from '../src/chains/nano'; +import { ExactNanoScheme } from '../src/chains/nano-x402'; +import { deriveKey } from '../src/mnemonic'; + +// Published Nano test vector (docs.nano.org integration guide): the all-zero seed at +// index 0 under the legacy derivation, private key = blake2b-256(seed || index_be32). +// The derivation is done here in the test; the module only maps key -> address. +const ZERO_SEED_SK = blake2b(new Uint8Array(36), { dkLen: 32 }); +const ZERO_SEED_SK_HEX = '9f0e444c69f77a49bd0be89db92c38fe713e0963165cca12faf5712d7657120f'; +const ZERO_SEED_PUB_HEX = 'c008b814a7d269a1fa3c6528b19201a24d797912db9996ff02a1ff356e45552b'; +const ZERO_SEED_ADDR = 'nano_3i1aq1cchnmbn9x5rsbap8b15akfh7wj7pwskuzi7ahz8oq6cobd99d4r3b7'; + +// Published BIP-39 / SLIP-10 vector for m/44'/165'/0' (mnemonic + "some password"). +const BIP39_PHRASE = + 'edge defense waste choose enrich upon flee junk siren film clown finish luggage leader kid quick brick print evidence swap drill paddle truly occur'; +const BIP39_SK_HEX = '3be4fc2ef3f3b7374e6fc4fb6e7bb153f8a2998b3b3dab50853eabe128024143'; +const BIP39_ADDR = 'nano_1pu7p5n3ghq1i1p4rhmek41f5add1uh34xpb94nkbxe8g4a6x1p69emk8y1d'; + +const L = Point.CURVE().n; +const le = (b: Uint8Array) => b.reduceRight((n, x) => (n << 8n) | BigInt(x), 0n); + +/** Independent ed25519-blake2b verification: S*G == R + H(R||A||M)*A. */ +function verify(sig: Uint8Array, msg: Uint8Array, pub: Uint8Array): boolean { + const R = Point.fromBytes(sig.slice(0, 32)); + const S = le(sig.slice(32)); + const k = le(blake2b(new Uint8Array([...sig.slice(0, 32), ...pub, ...msg]), { dkLen: 64 })) % L; + return Point.BASE.multiply(S).equals(R.add(Point.fromBytes(pub).multiply(k))); +} + +const hex = (b: Uint8Array) => Buffer.from(b).toString('hex'); + +describe('nano keys and addresses', () => { + it('maps the published zero-seed key to its published public key and address', () => { + expect(hex(ZERO_SEED_SK)).toBe(ZERO_SEED_SK_HEX); + expect(hex(publicKeyFromKey(ZERO_SEED_SK))).toBe(ZERO_SEED_PUB_HEX); + expect(keyToAddress(Buffer.from(ZERO_SEED_SK))).toBe(ZERO_SEED_ADDR); + }); + + it("derives m/44'/165'/0' from a BIP-39 seed per the published vector", () => { + const seed = Buffer.from(mnemonicToSeedSync(BIP39_PHRASE, 'some password')); + expect(deriveKeyFromSeed(seed).toString('hex')).toBe(BIP39_SK_HEX); + expect(keyToAddress(deriveKeyFromSeed(seed))).toBe(BIP39_ADDR); + }); + + it('deriveKey(nano) uses the same path on the mnemonic seed', () => { + const viaMnemonic = deriveKey('nano', BIP39_PHRASE); + expect(viaMnemonic.equals(deriveKeyFromSeed(Buffer.from(mnemonicToSeedSync(BIP39_PHRASE))))).toBe(true); + }); + + it('round-trips address <-> public key', () => { + const pub = addressToPublicKey(ZERO_SEED_ADDR); + expect(hex(pub)).toBe(ZERO_SEED_PUB_HEX); + expect(publicKeyToAddress(pub)).toBe(ZERO_SEED_ADDR); + }); + + it('accepts the legacy xrb_ alias and normalizes it to nano_', () => { + const xrb = ZERO_SEED_ADDR.replace(/^nano_/, 'xrb_'); + expect(hex(addressToPublicKey(xrb))).toBe(ZERO_SEED_PUB_HEX); + expect(normalizeAddress(xrb)).toBe(ZERO_SEED_ADDR); + }); + + it('rejects a bad checksum', () => { + const last = ZERO_SEED_ADDR.at(-1) === '1' ? '3' : '1'; + const bad = ZERO_SEED_ADDR.slice(0, -1) + last; + expect(() => addressToPublicKey(bad)).toThrow(expect.objectContaining({ code: 'invalid_wallet_address' })); + }); + + it('rejects wrong prefix, length and alphabet', () => { + for (const bad of [ + ZERO_SEED_ADDR.replace('nano_', 'ban_'), + ZERO_SEED_ADDR.slice(0, -1), + ZERO_SEED_ADDR.replace(/b7$/, 'l7'), + '0x1234', + ]) { + expect(() => addressToPublicKey(bad)).toThrow(expect.objectContaining({ code: 'invalid_wallet_address' })); + } + }); + + it('rejects a key that is not 32 bytes', () => { + expect(() => publicKeyFromKey(new Uint8Array(31))).toThrow(expect.objectContaining({ code: 'invalid_key' })); + }); +}); + +describe('nano amounts', () => { + it('parses decimal XNO to raw exactly', () => { + expect(parseXno('1')).toBe(10n ** 30n); + expect(parseXno('0.000001')).toBe(10n ** 24n); + expect(parseXno('0.000000000000000000000000000001')).toBe(1n); + }); + + it('refuses non-decimal input and more than 30 fractional digits', () => { + for (const bad of ['', '-1', '1e3', 'abc', '0.0000000000000000000000000000001']) { + expect(() => parseXno(bad)).toThrow(expect.objectContaining({ code: 'invalid_amount' })); + } + }); + + it('formats raw exactly with trailing zeros trimmed', () => { + expect(formatBalance(0n)).toBe('0.0'); + expect(formatBalance(15n * 10n ** 29n)).toBe('1.5'); + expect(formatBalance(1n)).toBe('0.000000000000000000000000000001'); + }); +}); + +describe('nano state blocks', () => { + const block = { + account: ZERO_SEED_ADDR, + previous: 'AB'.repeat(32), + representative: ZERO_SEED_ADDR, + balance: 123456789n, + link: 'CD'.repeat(32), + }; + + it('hashes preamble || account || previous || rep || balance(16 BE) || link with blake2b-256', () => { + const pub = Buffer.from(ZERO_SEED_PUB_HEX, 'hex'); + const bal = Buffer.alloc(16); + bal.writeBigUInt64BE(123456789n, 8); + const expected = blake2b( + Buffer.concat([Buffer.alloc(31), Buffer.from([6]), pub, Buffer.from(block.previous, 'hex'), pub, bal, Buffer.from(block.link, 'hex')]), + { dkLen: 32 }, + ); + expect(hex(hashStateBlock(block))).toBe(hex(expected)); + }); + + it('signs block hashes with ed25519-blake2b', () => { + const h = hashStateBlock(block); + const sig = signHash(ZERO_SEED_SK, h); + expect(verify(sig, h, publicKeyFromKey(ZERO_SEED_SK))).toBe(true); + const other = new Uint8Array(h); + other[0] ^= 1; + expect(verify(sig, other, publicKeyFromKey(ZERO_SEED_SK))).toBe(false); + }); +}); + +const PAYEE_SK = blake2b(new Uint8Array([...new Uint8Array(32), 0, 0, 0, 1]), { dkLen: 32 }); +const PAYEE = keyToAddress(Buffer.from(PAYEE_SK)); +const FRONTIER = '1F'.repeat(32); +const XNO = 10n ** 30n; + +function fakeRpc(opts: { balance?: bigint; missing?: boolean; processError?: string } = {}) { + const calls: Array> = []; + const rpc: NanoRpc = async (body) => { + calls.push(body); + if (body.action === 'account_info') { + if (opts.missing) throw new Error('Nano RPC account_info: Account not found'); + return { frontier: FRONTIER, balance: (opts.balance ?? 2n * XNO).toString(), representative: ZERO_SEED_ADDR }; + } + if (body.action === 'work_generate') return { work: '0123456789abcdef' }; + if (body.action === 'process') { + if (opts.processError) throw new Error(opts.processError); + return {}; + } + throw new Error(`unexpected ${String(body.action)}`); + }; + return { rpc, calls }; +} + +describe('nano send', () => { + it('builds, signs and publishes a state send block to the payee', async () => { + const { rpc, calls } = fakeRpc(); + const res = await send({ key: Buffer.from(ZERO_SEED_SK), to: PAYEE, amountRaw: XNO / 2n, rpc }); + expect(calls.map((c) => c.action)).toEqual(['account_info', 'work_generate', 'process']); + expect(calls[1]).toMatchObject({ hash: FRONTIER }); + const blk = (calls[2] as { block: Record }).block; + expect(blk).toMatchObject({ + type: 'state', + account: ZERO_SEED_ADDR, + previous: FRONTIER, + representative: ZERO_SEED_ADDR, + balance: (2n * XNO - XNO / 2n).toString(), + link: hex(addressToPublicKey(PAYEE)).toUpperCase(), + work: '0123456789abcdef', + }); + const h = hashStateBlock({ ...blk, balance: BigInt(blk.balance) }); + expect(res.hash).toBe(hex(h).toUpperCase()); + expect(verify(Buffer.from(blk.signature, 'hex'), h, publicKeyFromKey(ZERO_SEED_SK))).toBe(true); + }); + + it('refuses before signing when the balance is short', async () => { + const { rpc, calls } = fakeRpc({ balance: 1n }); + await expect(send({ key: Buffer.from(ZERO_SEED_SK), to: PAYEE, amountRaw: 2n, rpc })).rejects.toMatchObject({ + code: 'insufficient_balance', + }); + expect(calls.map((c) => c.action)).toEqual(['account_info']); + }); + + it('treats an unopened account as insufficient balance', async () => { + const { rpc } = fakeRpc({ missing: true }); + await expect(send({ key: Buffer.from(ZERO_SEED_SK), to: PAYEE, amountRaw: 1n, rpc })).rejects.toMatchObject({ + code: 'insufficient_balance', + }); + }); + + it('refuses zero and over-128-bit amounts', async () => { + const { rpc, calls } = fakeRpc(); + for (const amountRaw of [0n, 1n << 128n]) { + await expect(send({ key: Buffer.from(ZERO_SEED_SK), to: PAYEE, amountRaw, rpc })).rejects.toMatchObject({ + code: 'invalid_amount', + }); + } + expect(calls).toHaveLength(0); + }); + + it('rejects a node that reports a different block hash', async () => { + const rpc: NanoRpc = async (body) => + body.action === 'account_info' + ? { frontier: FRONTIER, balance: XNO.toString(), representative: ZERO_SEED_ADDR } + : body.action === 'work_generate' + ? { work: '0123456789abcdef' } + : { hash: '00'.repeat(32) }; + await expect(send({ key: Buffer.from(ZERO_SEED_SK), to: PAYEE, amountRaw: 1n, rpc })).rejects.toThrow(/expected/); + }); +}); + +describe('nano rpc transport', () => { + it('posts JSON and surfaces node errors', async () => { + const fetchImpl = vi.fn(async () => new Response(JSON.stringify({ error: 'Bad account number' }), { status: 200 })); + const rpc = createRpc('http://node.invalid', fetchImpl as unknown as typeof fetch); + await expect(rpc({ action: 'account_balance' })).rejects.toThrow(/Bad account number/); + const [, init] = fetchImpl.mock.calls[0] as unknown as [string, RequestInit]; + expect(JSON.parse(init.body as string)).toEqual({ action: 'account_balance' }); + }); + + it('surfaces HTTP failures', async () => { + const rpc = createRpc('http://node.invalid', (async () => new Response('', { status: 503 })) as unknown as typeof fetch); + await expect(rpc({ action: 'process' })).rejects.toThrow(/HTTP 503/); + }); + + it('balance reads raw and wraps failures as rpc_error naming NANO_RPC_URL', async () => { + await expect(balance(ZERO_SEED_ADDR, async () => ({ balance: '42' }))).resolves.toBe(42n); + await expect( + balance(ZERO_SEED_ADDR, async () => { + throw new Error('down'); + }), + ).rejects.toMatchObject({ code: 'rpc_error', nextSteps: { suggestion: expect.stringContaining('NANO_RPC_URL') } }); + await expect(balance(ZERO_SEED_ADDR, async () => ({ balance: '1.5' }))).rejects.toMatchObject({ code: 'rpc_error' }); + }); +}); + +describe('ExactNanoScheme', () => { + const requirements = { + scheme: 'exact', + network: 'nano:mainnet' as const, + asset: 'XNO', + amount: (XNO / 100n).toString(), + payTo: PAYEE, + maxTimeoutSeconds: 60, + extra: {}, + }; + + it('returns the published block hash as the payload', async () => { + const { rpc } = fakeRpc(); + const scheme = new ExactNanoScheme({ key: Buffer.from(ZERO_SEED_SK), rpc }); + const out = await scheme.createPaymentPayload(2, requirements); + expect(out.x402Version).toBe(2); + expect(out.payload).toEqual({ blockHash: expect.stringMatching(/^[0-9A-F]{64}$/) }); + }); + + it('refuses a decimals declaration other than 30 and reports it through onError', async () => { + const { rpc, calls } = fakeRpc(); + const onError = vi.fn(); + const scheme = new ExactNanoScheme({ key: Buffer.from(ZERO_SEED_SK), rpc, onError }); + await expect(scheme.createPaymentPayload(2, { ...requirements, extra: { decimals: 6 } })).rejects.toMatchObject({ + code: 'merchant_spec_violation', + }); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ code: 'merchant_spec_violation' })); + expect(calls).toHaveLength(0); + }); + + it('refuses a non-integer amount', async () => { + const { rpc, calls } = fakeRpc(); + const scheme = new ExactNanoScheme({ key: Buffer.from(ZERO_SEED_SK), rpc }); + await expect(scheme.createPaymentPayload(2, { ...requirements, amount: '0.01' })).rejects.toMatchObject({ + code: 'merchant_spec_violation', + }); + expect(calls).toHaveLength(0); + }); +}); diff --git a/tests/pay-nano.test.ts b/tests/pay-nano.test.ts new file mode 100644 index 0000000..85fc873 --- /dev/null +++ b/tests/pay-nano.test.ts @@ -0,0 +1,262 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { blake2b } from '@noble/hashes/blake2.js'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { addressToPublicKey, keyToAddress } from '../src/chains/nano'; +import { pay } from '../src/commands/pay'; +import { createWallet } from '../src/wallets'; + +// The nano rail never goes through selectRail; this stub only serves the Base +// control case, which would otherwise read a live USDC balance. +vi.mock('../src/selection', () => ({ + selectRail: vi.fn().mockResolvedValue({ + chain: 'base', + name: 'default', + address: '0x7E5F4552091A69125d5DfCb7b8C2659029395Bdf', + balance_raw: 5_000_000n, + balance_usdc: '5.000000', + }), +})); + +const PASS = 'nano-test-passphrase'; +const XNO = 10n ** 30n; +const PAYER_KEY = Buffer.from(blake2b(new Uint8Array(36), { dkLen: 32 })); +const PAYER = keyToAddress(PAYER_KEY); +const PAYEE = keyToAddress(Buffer.from(blake2b(new Uint8Array([...new Uint8Array(32), 0, 0, 0, 1]), { dkLen: 32 }))); +const FRONTIER = '2A'.repeat(32); + +interface State { + accepts: Array>; + rpcCalls: Array>; + paymentHeaders: Array>; + merchantHits: number; + processError?: string; + dropPaidRequests?: number; +} + +let server: Server; +let origin = ''; +let state: State; + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + let data = ''; + req.on('data', (c: Buffer) => (data += c.toString())); + req.on('end', () => resolve(data)); + }); +} + +function json(res: ServerResponse, status: number, body: unknown, headers: Record = {}) { + res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); + res.end(JSON.stringify(body)); +} + +async function handle(req: IncomingMessage, res: ServerResponse) { + const body = await readBody(req); + if (req.url === '/rpc') { + const call = JSON.parse(body) as Record; + state.rpcCalls.push(call); + if (call.action === 'account_balance') return json(res, 200, { balance: (3n * XNO).toString(), receivable: '0' }); + if (call.action === 'account_info') { + return json(res, 200, { frontier: FRONTIER, balance: (3n * XNO).toString(), representative: PAYER }); + } + if (call.action === 'work_generate') return json(res, 200, { work: 'fedcba9876543210' }); + if (call.action === 'process') { + if (state.processError) return json(res, 200, { error: state.processError }); + return json(res, 200, {}); + } + return json(res, 200, { error: 'unknown action' }); + } + state.merchantHits += 1; + const sig = req.headers['payment-signature']; + if (typeof sig === 'string') { + state.paymentHeaders.push(JSON.parse(Buffer.from(sig, 'base64').toString('utf8')) as Record); + if (state.dropPaidRequests) { + state.dropPaidRequests -= 1; + req.socket.destroy(); + return; + } + return json(res, 200, { ok: true, item: 'report' }); + } + const required = { + x402Version: 2, + resource: { url: `${origin}${req.url}`, description: 'report', mimeType: 'application/json' }, + accepts: state.accepts, + }; + return json(res, 402, {}, { 'PAYMENT-REQUIRED': Buffer.from(JSON.stringify(required)).toString('base64') }); +} + +function nanoAccept(overrides: Record = {}) { + return { + scheme: 'exact', + network: 'nano:mainnet', + asset: 'XNO', + amount: (XNO / 100n).toString(), + payTo: PAYEE, + maxTimeoutSeconds: 60, + extra: {}, + ...overrides, + }; +} + +const baseAccept = { + scheme: 'exact', + network: 'eip155:8453', + asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + amount: '10000000', + payTo: '0x000000000000000000000000000000000000dEaD', + maxTimeoutSeconds: 60, + extra: { name: 'USD Coin', version: '2', decimals: 6 }, +}; + +let home = ''; + +beforeAll(async () => { + server = createServer((req, res) => void handle(req, res)); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const addr = server.address() as { port: number }; + origin = `http://127.0.0.1:${addr.port}`; + home = await mkdtemp(join(tmpdir(), 'pay-nano-')); + process.env.AGENTSCORE_PAY_HOME = home; + process.env.AGENTSCORE_PAY_PASSPHRASE = PASS; + await createWallet('nano', PASS, PAYER_KEY); + await createWallet('base', PASS, Buffer.alloc(32, 1)); +}); + +afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + delete process.env.AGENTSCORE_PAY_HOME; + delete process.env.AGENTSCORE_PAY_PASSPHRASE; + await rm(home, { recursive: true, force: true }); +}); + +beforeEach(() => { + state = { accepts: [nanoAccept()], rpcCalls: [], paymentHeaders: [], merchantHits: 0 }; + process.env.NANO_RPC_URL = `${origin}/rpc`; +}); + +afterEach(() => { + delete process.env.NANO_RPC_URL; +}); + +const actions = () => state.rpcCalls.map((c) => c.action); + +describe('pay --chain nano (x402 exact on nano:mainnet)', () => { + it('settles a 402 that offers only nano:mainnet exact', async () => { + const result = await pay({ + chain: 'nano', + method: 'POST', + url: `${origin}/report`, + body: '{}', + maxSpendXno: '0.05', + skipPassport: true, + }); + expect(result).toMatchObject({ ok: true, status: 200, chain: 'nano', protocol: 'x402', signer: PAYER, price_xno: '0.01' }); + expect(result).not.toHaveProperty('price_usd'); + expect(actions()).toEqual(['account_balance', 'account_info', 'work_generate', 'process']); + + const block = (state.rpcCalls[3] as { block: Record }).block; + expect(block.link).toBe(Buffer.from(addressToPublicKey(PAYEE)).toString('hex').toUpperCase()); + expect(BigInt(block.balance)).toBe(3n * XNO - XNO / 100n); + + expect(state.paymentHeaders).toHaveLength(1); + expect(state.paymentHeaders[0]).toMatchObject({ + x402Version: 2, + accepted: { network: 'nano:mainnet', payTo: PAYEE }, + payload: { blockHash: expect.stringMatching(/^[0-9A-F]{64}$/) }, + }); + + const ledger = (await readFile(join(home, 'history.jsonl'), 'utf8')).trim().split('\n').map((l) => JSON.parse(l)); + expect(ledger.at(-1)).toMatchObject({ chain: 'nano', price_xno: '0.01', ok: true }); + expect(ledger.at(-1)).not.toHaveProperty('price_usd'); + }); + + it('dry-run reports the XNO balance and cap without touching the merchant', async () => { + const result = await pay({ + chain: 'nano', + method: 'POST', + url: `${origin}/report`, + maxSpendXno: '0.05', + skipPassport: true, + dryRun: true, + }); + expect(result).toMatchObject({ dry_run: true, selected_chain: 'nano', protocol: 'x402', balance_xno: '3.0', max_spend_xno: '0.05' }); + expect(result).not.toHaveProperty('balance_usdc'); + expect(state.merchantHits).toBe(0); + }); + + it('refuses with limit_required when --max-spend-xno is absent, even with a USD --max-spend', async () => { + await expect( + pay({ chain: 'nano', method: 'POST', url: `${origin}/report`, maxSpendUsd: 100, skipPassport: true }), + ).rejects.toMatchObject({ code: 'limit_required' }); + expect(state.merchantHits).toBe(0); + expect(state.rpcCalls).toHaveLength(0); + }); + + it('aborts over the XNO cap before any block is built or published', async () => { + await expect( + pay({ chain: 'nano', method: 'POST', url: `${origin}/report`, maxSpendXno: '0.001', skipPassport: true }), + ).rejects.toMatchObject({ code: 'max_spend_exceeded', extra: { chain: 'nano', price_raw: (XNO / 100n).toString() } }); + expect(actions()).toEqual(['account_balance']); + expect(state.paymentHeaders).toHaveLength(0); + }); + + it('maps a node error on publish to network_error and sends no payment header', async () => { + state.processError = 'Fork'; + await expect( + pay({ chain: 'nano', method: 'POST', url: `${origin}/report`, maxSpendXno: '0.05', skipPassport: true }), + ).rejects.toMatchObject({ code: 'network_error', message: expect.stringContaining('Fork') }); + expect(state.paymentHeaders).toHaveLength(0); + }); + + it('refuses a payTo whose checksum does not match, before publishing', async () => { + const last = PAYEE.at(-1) === '1' ? '3' : '1'; + state.accepts = [nanoAccept({ payTo: PAYEE.slice(0, -1) + last })]; + await expect( + pay({ chain: 'nano', method: 'POST', url: `${origin}/report`, maxSpendXno: '0.05', skipPassport: true }), + ).rejects.toMatchObject({ code: 'invalid_wallet_address' }); + expect(actions()).not.toContain('process'); + expect(state.paymentHeaders).toHaveLength(0); + }); + + it('pays an xrb_ payTo to the same account', async () => { + state.accepts = [nanoAccept({ payTo: PAYEE.replace(/^nano_/, 'xrb_') })]; + await pay({ chain: 'nano', method: 'POST', url: `${origin}/report`, maxSpendXno: '0.05', skipPassport: true }); + const block = (state.rpcCalls.find((c) => c.action === 'process') as { block: Record }).block; + expect(block.link).toBe(Buffer.from(addressToPublicKey(PAYEE)).toString('hex').toUpperCase()); + }); + + it('re-presents the same block when a retry follows a dropped paid request', async () => { + state.dropPaidRequests = 1; + const result = await pay({ + chain: 'nano', + method: 'POST', + url: `${origin}/report`, + maxSpendXno: '0.05', + skipPassport: true, + retries: 1, + }); + expect(result).toMatchObject({ ok: true, chain: 'nano' }); + expect(actions().filter((a) => a === 'process')).toHaveLength(1); + expect(state.paymentHeaders).toHaveLength(2); + const hashes = state.paymentHeaders.map((p) => (p.payload as { blockHash: string }).blockHash); + expect(hashes[1]).toBe(hashes[0]); + }); + + it('is mainnet-only', async () => { + await expect( + pay({ chain: 'nano', network: 'testnet', method: 'POST', url: `${origin}/report`, maxSpendXno: '0.05', skipPassport: true }), + ).rejects.toMatchObject({ code: 'invalid_network' }); + }); + + it('still picks the Base requirement with --chain base when both rails are offered', async () => { + state.accepts = [nanoAccept(), baseAccept]; + await expect( + pay({ chain: 'base', method: 'POST', url: `${origin}/report`, maxSpendUsd: 1, skipPassport: true }), + ).rejects.toMatchObject({ code: 'max_spend_exceeded', extra: { price_usd: 10, max_spend_usd: 1 } }); + expect(state.rpcCalls).toHaveLength(0); + expect(state.paymentHeaders).toHaveLength(0); + }); +}); diff --git a/tests/wallet.test.ts b/tests/wallet.test.ts index e0f5f40..9530017 100644 --- a/tests/wallet.test.ts +++ b/tests/wallet.test.ts @@ -5,6 +5,7 @@ import { walletAddress, walletCreate, walletExport, + walletImport, walletList, walletRemove, walletShowMnemonic, @@ -76,4 +77,19 @@ describe('wallet commands', () => { expect(result.created.map((c) => c.chain).sort()).toEqual(['solana', 'tempo']); expect(result.skipped.map((c) => c.chain)).toEqual(['base']); }); + + it('creates nano only when named, never as part of the default set', async () => { + const all = await walletCreate({}); + expect(all.created.map((c) => c.chain).sort()).toEqual(['base', 'solana', 'tempo']); + const nano = await walletCreate({ chain: 'nano' }); + expect(nano.created[0]).toMatchObject({ chain: 'nano', qr_uri: expect.stringMatching(/^nano:nano_/) }); + expect(nano.created[0]!.address).toMatch(/^nano_[13][13456789abcdefghijkmnopqrstuwxyz]{59}$/); + }); + + it('imports a raw nano key into a keystore under its nano_ address', async () => { + const key = '9f0e444c69f77a49bd0be89db92c38fe713e0963165cca12faf5712d7657120f'; + const result = await walletImport({ chain: 'nano', key }); + expect(result.address).toBe('nano_3i1aq1cchnmbn9x5rsbap8b15akfh7wj7pwskuzi7ahz8oq6cobd99d4r3b7'); + await expect(walletAddress({ chain: 'nano' })).resolves.toMatchObject({ address: result.address }); + }); });