Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.
41 changes: 26 additions & 15 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
http,
isAddress,
isAddressEqual,
type JsonRpcAccount,
type LocalAccount,
type PublicClient,
type Transport,
Expand Down Expand Up @@ -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. */
Expand All @@ -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 };

Expand Down Expand Up @@ -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<Transport, Chain, LocalAccount> | undefined;
readonly #walletClient: WalletClient<Transport, Chain, Signer> | 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';
Expand All @@ -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`.
Expand Down Expand Up @@ -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 ?? [];
Expand All @@ -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 });
}
Expand Down Expand Up @@ -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<Transport, Chain, LocalAccount>) => Promise<Hex>): Promise<Hex> {
#send(name: string, write: (wallet: WalletClient<Transport, Chain, Signer>) => Promise<Hex>): Promise<Hex> {
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
Expand Down Expand Up @@ -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}.`);
Expand All @@ -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<string> {
async #signPayment(wallet: WalletClient<Transport, Chain, Signer>, accept: X402Accept): Promise<string> {
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}.`);
Expand All @@ -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',
Expand Down
57 changes: 56 additions & 1 deletion test/per-call.test.ts
Original file line number Diff line number Diff line change
@@ -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' };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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/],
Expand Down
Loading