diff --git a/README.md b/README.md index 05235b0..01395e8 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,33 @@ Before signing, the SDK checks the requirements against the network it was given - **The modes do not mix.** Per-call mode never sends `depositAndAuthorize`, and the default mode never signs a per-call payment. - **A refused payment comes back as the API answered it** (for example `402` `insufficient_funds`, or `invalid_prepaid_debit_payload_expired`), with the reason as `error.code`. The SDK does not pay it again. The Developer API's Payment page lists the codes and what to do about each. +### From a browser wallet + +In a web page, the visitor's wallet signs each payment instead of a key in your code. Give `account` the wallet's address as a viem JSON-RPC account, and `transport` the wallet. Every paid call then asks the wallet for one signature (`eth_signTypedData_v4`). It sends no transaction, costs no gas, and the payment is the same one a local key signs. + +```ts +import OpenAI from "openai"; +import { custom } from "viem"; +import { Lightchain } from "@lightchainai/sdk"; + +const [address] = await window.ethereum.request({ method: "eth_requestAccounts" }); +const lc = new Lightchain({ + network: "testnet", + account: { address, type: "json-rpc" }, + transport: custom(window.ethereum), // the wallet: it signs each payment + payment: "per-call", + maxPaymentWei: 5n * 10n ** 16n, +}); + +const openai = new OpenAI({ baseURL: lc.baseURL, apiKey: lc.apiKey, fetch: lc.fetch, dangerouslyAllowBrowser: true }); +``` + +- **Put the wallet on the network's chain first** (testnet `8200`, mainnet `9200`), with `wallet_switchEthereumChain`, or `wallet_addEthereumChain` for a wallet that doesn't know it. Wallets such as MetaMask refuse to sign typed data for a chain other than the one they are on. +- **A rejected signature** comes back as the 402, its `error.message` starting `Not paid by the SDK: User rejected the request.` +- **The authorization's clock starts when the 402 arrives,** not when the visitor approves: a signature approved more than `maxTimeoutSeconds` later is refused `invalid_prepaid_debit_payload_expired`. +- **One address per client.** The address is fixed when the client is made: when the visitor switches accounts, make a new `Lightchain`. Its chain reads, such as `getBalance`, go through the wallet too. +- **Not a smart account.** The JobRegistry takes only an ECDSA signature by the payer's own key, which a smart account can't make. + ## Members | Mode | `apiKey` | `account` | @@ -140,6 +167,6 @@ FUNDER_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:per-call WALLET_PRIVATE_KEY=0x... npm run acceptance:pool # streaming, and two interleaved conversations in one pooled session ``` -The fixtures in `test/fixtures` were recorded from a local devnet with Foundry's publicly known test account 7. The per-call tests sign the scheme's test vectors (`test/fixtures/x402-vectors.json`) byte for byte, with Foundry's test account 3. Never use either key on a real network. +The fixtures in `test/fixtures` were recorded from a local devnet with Foundry's publicly known test account 7. The per-call tests sign the scheme's test vectors (`test/fixtures/x402-vectors.json`) byte for byte, with Foundry's test account 3, as a local key and through a stand-in browser wallet. Never use either key on a real network. CI (`.github/workflows/ci.yml`) typechecks, builds and tests every pull request and every push to `main`. `acceptance.yml` runs `acceptance:pool`, then `acceptance:per-call:fresh`, against testnet every night at 03:17 UTC, and on demand from the Actions tab. It pays from the wallet in the `TESTNET_WALLET_PRIVATE_KEY` secret, which needs testnet LCAI. `acceptance:per-call:fresh` (`scripts/fresh-wallet.ts`) runs `acceptance:per-call` with a wallet made for that run, which the secret's wallet funds and which sends what it has left back: `acceptance:per-call` needs a wallet that never authorized a delegate. Each run's log is kept as an artifact. diff --git a/src/client.ts b/src/client.ts index c5c1346..61b953f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -9,6 +9,7 @@ import { http, isAddress, isAddressEqual, + type JsonRpcAccount, type LocalAccount, type PublicClient, type Transport, @@ -65,8 +66,12 @@ type PerCallPayment = { * per-payer limits. */ apiKey?: string; - /** A viem local account: it signs every payment. */ - account: LocalAccount; + /** + * The viem account that signs every payment: a local account, or a browser + * wallet's JSON-RPC account, `{ address, type: 'json-rpc' }`, whose wallet + * is `transport` (`custom(window.ethereum)`). + */ + account: Signer; /** The most one request may pay, in wei. A 402 asking for more is not paid. */ maxPaymentWei: bigint; /** Told of every payment the server settled. */ @@ -75,6 +80,13 @@ type PerCallPayment = { onDeposit?: never; }; +/** + * The account behind the client: a local one signs by itself, a JSON-RPC one + * through `transport`, its wallet. Not a smart account: the JobRegistry takes + * only an ECDSA signature by the payer's own key. + */ +type Signer = LocalAccount | JsonRpcAccount; + /** A depositAndAuthorize the SDK sent on a 402. */ export type Deposit = { hash: Hex; value: bigint; delegate: Address }; @@ -163,10 +175,9 @@ export class Lightchain { /** The OpenAI-compatible base URL, for the OpenAI SDK's `baseURL`. */ readonly baseURL: string; readonly #apiKey: string; - readonly #account: LocalAccount | undefined; readonly #fetch: typeof fetch; readonly #publicClient: PublicClient; - readonly #walletClient: WalletClient | undefined; + readonly #walletClient: WalletClient | undefined; readonly #registry: { address: Address; abi: typeof jobRegistryAbi }; /** How a 402 is paid: by a transaction (delegate) or by a signature (per-call), never both. */ readonly #payment: 'delegate' | 'per-call'; @@ -180,7 +191,6 @@ export class Lightchain { constructor(options: LightchainOptions) { this.network = typeof options.network === 'string' ? networks[options.network] : options.network; - this.#account = options.account; this.address = options.account?.address; this.baseURL = `${this.network.apiUrl}/v1`; // Called unbound: a browser's fetch refuses any other `this`. @@ -278,9 +288,9 @@ export class Lightchain { init = { ...init, headers }; const again = input instanceof Request ? input.clone() : input; const response = await this.#fetch(input, init); - const account = this.#account; + const wallet = this.#walletClient; // No account, nothing to pay with: the 402 goes to the caller, and a human tops up in the chat. - if (response.status !== 402 || !account) return response; + if (response.status !== 402 || !wallet) return response; const body = (await response.clone().json().catch(() => null)) as PaymentRequired | null; const perCall = this.#payment === 'per-call'; const accepts = body?.error?.accepts ?? []; @@ -291,9 +301,9 @@ export class Lightchain { let payment = ''; try { if (!accept) throw new Error('payment is "per-call", and the 402 offers no prepaid-debit payment.'); - if (perCall) payment = await this.#signPayment(account, accept as X402Accept); + if (perCall) payment = await this.#signPayment(wallet, accept as X402Accept); else { - const deposit = this.#checkDelegateOffer(account, accept as DelegateAccept); + const deposit = this.#checkDelegateOffer(wallet.account, accept as DelegateAccept); const hash = await this.depositAndAuthorize(deposit.delegate, deposit.value); this.#onDeposit?.({ hash, ...deposit }); } @@ -337,7 +347,7 @@ export class Lightchain { } /** Sends a transaction once the one before it is done, and waits for it to succeed. */ - #send(name: string, write: (wallet: WalletClient) => Promise): Promise { + #send(name: string, write: (wallet: WalletClient) => Promise): Promise { const wallet = this.#walletClient; if (!wallet) return Promise.reject(new Error(`${name} needs account: the wallet that sends it.`)); // One at a time: each transaction reads the account's nonce, so two sent @@ -368,7 +378,7 @@ export class Lightchain { } /** The deposit a 402's delegate offer asks for, once it names this network's JobRegistry and the account as payer. */ - #checkDelegateOffer(account: LocalAccount, accept: DelegateAccept): { delegate: Address; value: bigint } { + #checkDelegateOffer(account: Signer, accept: DelegateAccept): { delegate: Address; value: bigint } { const { chain_id, payer, delegate, instruction } = accept; if (chain_id !== this.network.chainId) throw new Error(`the 402 is for chain ${chain_id}, not ${this.network.chainId}.`); if (!sameAddress(payer, account.address)) throw new Error(`the key belongs to another wallet (${payer}), not ${account.address}.`); @@ -392,9 +402,10 @@ export class Lightchain { * The PAYMENT-SIGNATURE for a 402's prepaid-debit requirements, once they * name this network, its JobRegistry and the scheme's domain, and ask for * at most maxPaymentWei: a debit authorization of that amount, valid for - * maxTimeoutSeconds, with a fresh random nonce. + * maxTimeoutSeconds, with a fresh random nonce. The wallet client signs it: + * a local account by itself, a JSON-RPC one by eth_signTypedData_v4 to its wallet. */ - async #signPayment(account: LocalAccount, accept: X402Accept): Promise { + async #signPayment(wallet: WalletClient, accept: X402Accept): Promise { const { network, asset, amount, payTo, maxTimeoutSeconds: timeout, extra } = accept; const chain = `eip155:${this.network.chainId}`; if (network !== chain) throw new Error(`the 402 is for network ${network}, not ${chain}.`); @@ -417,8 +428,8 @@ export class Lightchain { // The cap is the amount asked, not maxPaymentWei: the server debits the fee, at most the cap. const deadline = BigInt(Math.floor(Date.now() / 1000) + timeout); const nonce = bytesToHex(crypto.getRandomValues(new Uint8Array(32))); - const message = { payer: account.address, payTo, facilitator, maxAmount, deadline, nonce }; - const signature = await account.signTypedData({ + const message = { payer: wallet.account.address, payTo, facilitator, maxAmount, deadline, nonce }; + const signature = await wallet.signTypedData({ domain: { ...DEBIT_DOMAIN, chainId: this.network.chainId, verifyingContract: this.network.jobRegistry }, types: DEBIT_AUTHORIZATION, primaryType: 'DebitAuthorization', diff --git a/test/per-call.test.ts b/test/per-call.test.ts index c635c59..dc659ce 100644 --- a/test/per-call.test.ts +++ b/test/per-call.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { type TestContext, test } from 'node:test'; -import { type Hex, hexToBytes } from 'viem'; +import { custom, type Hex, hexToBytes } from 'viem'; import { privateKeyToAccount } from 'viem/accounts'; import { Lightchain, type LightchainOptions, networks, type Payment } from '../src/index.ts'; import vectors from './fixtures/x402-vectors.json' with { type: 'json' }; @@ -52,6 +52,26 @@ const perCall = (fetch: typeof globalThis.fetch, transport = replayRpc([]).trans }); const networkOf = (v: Vector) => (v.chain.chainId === networks.mainnet.chainId ? 'mainnet' : 'testnet'); +/** + * A browser wallet (an EIP-1193 provider, as window.ethereum is), as a viem + * transport, with its JSON-RPC account: it signs the typed data it is sent + * with the payer's key, or, when its user rejects, refuses as MetaMask does. + * `sent` keeps every call made. + */ +function browserWallet({ rejects = false } = {}) { + const payer = privateKeyToAccount(payerKey); + const sent: { method: string; params: unknown[] }[] = []; + const transport = custom({ + async request({ method, params }: { method: string; params: unknown[] }) { + sent.push({ method, params }); + assert.equal(method, 'eth_signTypedData_v4', `unexpected RPC call ${method}`); + if (rejects) throw Object.assign(new Error('MetaMask Tx Signature: User denied message signature.'), { code: 4001 }); + return payer.signTypedData(JSON.parse(params[1] as string)); + }, + }); + return { account: { address: payer.address, type: 'json-rpc' } as const, transport, sent }; +} + // The vectors whose payload is the one the SDK builds from their requirements: // the payer's key, maxAmount = amount, deadline = now + maxTimeoutSeconds. The // others carry a payload another signer got wrong (another payer, cap, @@ -86,6 +106,41 @@ for (const name of signedBySdk) { }); } +for (const name of signedBySdk) { + test(`with a browser wallet, signs the ${name} vector byte for byte, in one eth_signTypedData_v4 for the payer`, async (t) => { + const v = vector(name); + signAs(t, v); + const http = replayHttp([paymentRequired(v.paymentRequirements), completed]); + const wallet = browserWallet(); + const lc = new Lightchain({ network: networkOf(v), ...perCall(http.fetch, wallet.transport), account: wallet.account }); + + const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); + + assert.equal(response.status, 200); + assert.deepEqual(paymentOf(http.sent[1]), v.paymentPayload); + assert.deepEqual( + wallet.sent.map(({ method, params }) => [method, params[0]]), + [['eth_signTypedData_v4', wallet.account.address]], + 'one signature request, no transaction', + ); + http.done(); + }); +} + +test('when the browser wallet user rejects the signature, hands back the 402 with the reason, sending nothing again', async () => { + const http = replayHttp([paymentRequired(vector('valid-testnet').paymentRequirements)]); + const wallet = browserWallet({ rejects: true }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch, wallet.transport), account: wallet.account }); + + const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); + + assert.equal(response.status, 402); + const { error } = (await response.json()) as { error: { message: string } }; + assert.match(error.message, /^Not paid by the SDK: User rejected the request\./); + assert.match(error.message, /User denied message signature\./, "the wallet's own reason too"); + http.done(); +}); + const refusedVectors: [string, RegExp][] = [ ['unsupported-network', /network eip155:1, not eip155:8200/], ['wrong-asset', /not this network's JobRegistry/],