From a52f7b20837744ba4e80efe8f152a8d5e97d910a Mon Sep 17 00:00:00 2001 From: lightchainaidev Date: Sat, 10 Oct 2026 07:54:26 +0200 Subject: [PATCH] Rename the x402 mode to per-call, experimental, with apiKey optional payment: "x402" read as standard x402, which stock x402 clients can't pay: the mode is now payment: "per-call", marked experimental, and the README says it uses x402 v2 headers with LightChain's own scheme. The keyless option is gone: in per-call mode a missing or empty apiKey calls with no key. The acceptance script, its npm scripts and the nightly job follow the new name. --- .github/workflows/acceptance.yml | 10 +-- README.md | 36 ++++---- package.json | 6 +- ...eptance-x402.ts => acceptance-per-call.ts} | 27 +++--- scripts/fresh-wallet.ts | 4 +- src/client.ts | 85 +++++++++---------- test/keys.test.ts | 6 +- test/payment.test.ts | 2 +- test/{x402.test.ts => per-call.test.ts} | 78 +++++++++-------- 9 files changed, 124 insertions(+), 130 deletions(-) rename scripts/{acceptance-x402.ts => acceptance-per-call.ts} (83%) rename test/{x402.test.ts => per-call.test.ts} (78%) diff --git a/.github/workflows/acceptance.yml b/.github/workflows/acceptance.yml index 35fefa7..85e2686 100644 --- a/.github/workflows/acceptance.yml +++ b/.github/workflows/acceptance.yml @@ -1,6 +1,6 @@ # Testnet acceptance, from the wallet in TESTNET_WALLET_PRIVATE_KEY (it needs testnet LCAI). # pool: mints a key, tops up on the first 402, then a streaming completion and two interleaved conversations through -# the OpenAI SDK. x402: keyless pay-per-call, from a fresh wallet that wallet funds and that sends the rest back. +# the OpenAI SDK. per-call: keyless pay-per-call, from a fresh wallet that wallet funds and that sends the rest back. name: Testnet acceptance on: @@ -35,7 +35,7 @@ jobs: path: acceptance-pool.log # After pool, not beside it: both send from the same wallet, and two jobs at once would race for its nonce. - x402: + per-call: needs: pool if: ${{ !cancelled() }} runs-on: ubuntu-latest @@ -47,7 +47,7 @@ jobs: node-version: '24' cache: npm - run: npm ci - - run: npm run acceptance:x402:fresh 2>&1 | tee acceptance-x402.log + - run: npm run acceptance:per-call:fresh 2>&1 | tee acceptance-per-call.log env: FUNDER_PRIVATE_KEY: ${{ secrets.TESTNET_WALLET_PRIVATE_KEY }} LIGHTCHAIN_NETWORK: testnet @@ -55,5 +55,5 @@ jobs: - if: always() uses: actions/upload-artifact@v7 with: - name: acceptance-x402-${{ github.run_id }} - path: acceptance-x402.log + name: acceptance-per-call-${{ github.run_id }} + path: acceptance-per-call.log diff --git a/README.md b/README.md index db70fca..05235b0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @lightchainai/sdk -The LightChain AI Developer API for TypeScript: it runs an API key against the right network, and, given a wallet, pays for calls by itself: it tops up the prepaid balance on a `402` (the default mode), or signs each call's x402 payment (x402 mode). Completions go through the OpenAI SDK: the Developer API is OpenAI-compatible. +The LightChain AI Developer API for TypeScript: it runs an API key against the right network, and, given a wallet, pays for calls by itself: it tops up the prepaid balance on a `402` (the default mode), or signs each call's own payment (per-call mode, experimental). Completions go through the OpenAI SDK: the Developer API is OpenAI-compatible. ```sh npm install @lightchainai/sdk openai @@ -34,7 +34,7 @@ Until the wallet behind the key has paid, and whenever its balance or allowance ## Automatic top-ups -To run a bot unattended, give it the wallet behind the key as `account` (a viem local account). On a `402` for an empty balance or allowance, `lc.fetch` then sends the `depositAndAuthorize` the 402 names from that wallet, and sends the request again. This is the default mode (`payment: "delegate"`): x402 mode never deposits by itself (see below). +To run a bot unattended, give it the wallet behind the key as `account` (a viem local account). On a `402` for an empty balance or allowance, `lc.fetch` then sends the `depositAndAuthorize` the 402 names from that wallet, and sends the request again. This is the default mode (`payment: "delegate"`): per-call mode never deposits by itself (see below). ```ts import { privateKeyToAccount } from "viem/accounts"; @@ -54,9 +54,11 @@ The delegate and the fee come from the API: the SDK trusts the API it talks to f Each 402 pays its own deposit, one transaction after the other. Calls made together before the first deposit lands each deposit `depositWei`; what one of them did not need stays in the prepaid balance, and `withdrawBalance` on the JobRegistry takes it back. -## x402: pay per request +## Pay per call (experimental) -With `payment: "x402"`, the SDK pays each call from the account's own prepaid balance with a signed debit authorization ([x402](https://x402.org), LightChain's `prepaid-debit` scheme). The account authorizes no delegate and sends no transaction per call. The SDK never deposits in this mode: fund the balance yourself with `lc.deposit(value)`. When it can't cover a call, the call fails with `402` `insufficient_funds`: deposit again and retry. It needs `account`, and either an API key or `keyless: true`. +> **Experimental.** This mode, its options and LightChain's `prepaid-debit` scheme may change in any release. It uses the [x402](https://x402.org) v2 HTTP headers, but LightChain's own payment scheme: standard x402 clients can't pay it. Use this SDK. + +With `payment: "per-call"`, the SDK pays each call from the account's own prepaid balance with a signed debit authorization. The account authorizes no delegate and sends no transaction per call. The SDK never deposits in this mode: fund the balance yourself with `lc.deposit(value)`. When it can't cover a call, the call fails with `402` `insufficient_funds`: deposit again and retry. It needs `account`. `apiKey` is optional: without one, the call goes out with no key at all. ```ts import OpenAI from "openai"; @@ -66,9 +68,9 @@ import { Lightchain, type LightchainJob } from "@lightchainai/sdk"; const lc = new Lightchain({ network: "testnet", account: privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`), - payment: "x402", + payment: "per-call", maxPaymentWei: 5n * 10n ** 16n, // required: the most one call may pay (0.05 LCAI; testnet charges 0.02 a job) - keyless: true, // or apiKey: process.env.LIGHTCHAIN_API_KEY + // apiKey: process.env.LIGHTCHAIN_API_KEY, // optional: without it, no key at all onPayment: (p) => console.log(`settled in ${p.hash}, ${p.amount} wei debited`), }); @@ -84,19 +86,19 @@ console.log((completion as unknown as { lightchain: LightchainJob }).lightchain. On a `402` whose `accepts` lists the `prepaid-debit` requirements, `lc.fetch` signs an EIP-712 debit authorization with the account: capped at the requirements' `amount` (one job's fee), valid until now + `maxTimeoutSeconds`, with a random 32-byte nonce. It sends the request again with the authorization in the `PAYMENT-SIGNATURE` header. The API settles it before it answers: one JobRegistry transaction submits the job and debits the fee from the account's balance. The completion's `lightchain.tx_hash` is that settlement transaction, and `onPayment` gets it with the fee debited, from the `PAYMENT-RESPONSE` header. -Before signing, the SDK checks the requirements against the network it was given: `network` is `eip155:`, `asset` is the network's JobRegistry, the signing domain is `LightChain JobRegistry` version `1`, `payTo` and `facilitatorAddress` are addresses, `maxTimeoutSeconds` is between 1 and 600, and `amount` is at most `maxPaymentWei`. Requirements that fail a check are not signed: the 402 comes back with the reason at the start of `error.message`. So does a 402 that offers only the delegate way (an API that takes no x402 payments). +Before signing, the SDK checks the requirements against the network it was given: `network` is `eip155:`, `asset` is the network's JobRegistry, the signing domain is `LightChain JobRegistry` version `1`, `payTo` and `facilitatorAddress` are addresses, `maxTimeoutSeconds` is between 1 and 600, and `amount` is at most `maxPaymentWei`. Requirements that fail a check are not signed: the 402 comes back with the reason at the start of `error.message`. So does a 402 that offers only the delegate way (an API that takes no per-call payments). -- **Keyless, no key at all.** `lc.fetch` drops the `Authorization` header, and `lc.apiKey` is a placeholder for the OpenAI SDK, which insists on one. The API answers every call `402` with the x402 requirements alone, the SDK pays it, and the account is held, as payer, to the API's per-payer limits instead of a key's: by default 30 requests a minute and one call in flight. -- **With a key,** `lc.fetch` sends it on every call: it authenticates the call and holds it to the API's limits for that key. A key whose wallet has a delegate gets no 402, so x402 pays only the calls the API asks it to pay. -- **The modes do not mix.** x402 mode never sends `depositAndAuthorize`, and the default mode never signs an x402 payment. -- **A refused payment comes back as the API answered it** (for example `402` `insufficient_funds`, or `invalid_prepaid_debit_payload_expired`), with the x402 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. +- **No `apiKey`, no key at all.** `lc.fetch` drops the `Authorization` header, and `lc.apiKey` is a placeholder for the OpenAI SDK, which insists on one. The API answers every call `402` with the per-call requirements alone, the SDK pays it, and the account is held, as payer, to the API's per-payer limits instead of a key's: by default 30 requests a minute and one call in flight. +- **With a key,** `lc.fetch` sends it on every call: it authenticates the call and holds it to the API's limits for that key. A key whose wallet has a delegate gets no 402, so the SDK pays only the calls the API asks it to pay. +- **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. ## Members | Mode | `apiKey` | `account` | | --- | --- | --- | | default (`payment: "delegate"`) | required | optional: with it, `fetch` tops up the balance on a 402 (`depositWei`, `onDeposit`) | -| `payment: "x402"` | required, unless `keyless: true` | required: it signs each payment and never deposits (`maxPaymentWei`, required, `onPayment`) | +| `payment: "per-call"` (experimental) | optional: without it, no key is sent | required: it signs each payment and never deposits (`maxPaymentWei`, required, `onPayment`) | The constructor throws on any other combination, and on an option the mode would ignore. @@ -106,7 +108,7 @@ The constructor throws on any other combination, and on an option the mode would | `fetch` | `fetch` that sends `Authorization: Bearer ` to the network's API (and to no other host) and, given an `account`, pays a `402` the way `payment` says and sends the request again, once. Give it to the OpenAI SDK. | | `baseURL`, `apiKey` | The OpenAI SDK's `baseURL` (the network's `/v1`) and `apiKey`. | | `getBalance(delegate?)` | The account's prepaid balance; with `delegate`, also whether it is authorized and its remaining allowance. Needs `account`. | -| `deposit(value)` | Adds `value` to the account's prepaid balance and authorizes nobody: what x402 pays from. Needs `account`. | +| `deposit(value)` | Adds `value` to the account's prepaid balance and authorizes nobody: what per-call payments pay from. Needs `account`. | | `depositAndAuthorize(delegate, value)` | Adds `value` to the balance, authorizes `delegate` (the API's signer) and raises its allowance by `value`. Needs `account`. | | `address`, `network` | The account's address, if any, and the endpoints in use. | @@ -133,11 +135,11 @@ npm run build # dist/ npm run abi -- IJobRegistry.json # regenerate src/abi.ts from the JobRegistry's ABI, after the contracts change npm run openapi # refresh test/fixtures/openapi.json from the API testnet serves WALLET_PRIVATE_KEY=0x... npm run acceptance # a fresh wallet: the key alone gets its 402, then account pays it; see scripts/acceptance.ts -WALLET_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:x402 # keyless x402 mode; see scripts/acceptance-x402.ts -FUNDER_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:x402:fresh # the same, from a fresh wallet FUNDER_PRIVATE_KEY funds +WALLET_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:per-call # per-call mode with no key; see scripts/acceptance-per-call.ts +FUNDER_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:per-call:fresh # the same, from a fresh wallet FUNDER_PRIVATE_KEY funds 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 x402 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. 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:x402: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:x402:fresh` (`scripts/fresh-wallet.ts`) runs `acceptance:x402` with a wallet made for that run, which the secret's wallet funds and which sends what it has left back: `acceptance:x402` needs a wallet that never authorized a delegate. Each run's log is kept as an artifact. +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/package.json b/package.json index 2ed74a2..8fc5fbf 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@lightchainai/sdk", "version": "0.1.0-alpha.0", - "description": "The LightChain AI Developer API with an API key, through the OpenAI SDK; a wallet can top up on a 402 by itself, or pay per request with x402.", + "description": "The LightChain AI Developer API with an API key, through the OpenAI SDK; a wallet can top up on a 402 by itself, or pay per call (experimental).", "license": "MIT", "repository": { "type": "git", @@ -26,9 +26,9 @@ "abi": "node scripts/abi.ts", "openapi": "node scripts/openapi.ts", "acceptance": "node scripts/acceptance.ts", - "acceptance:x402": "node scripts/acceptance-x402.ts", + "acceptance:per-call": "node scripts/acceptance-per-call.ts", "acceptance:pool": "node scripts/acceptance-pool.ts", - "acceptance:x402:fresh": "node scripts/fresh-wallet.ts acceptance:x402" + "acceptance:per-call:fresh": "node scripts/fresh-wallet.ts acceptance:per-call" }, "dependencies": { "viem": "^2.47.6" diff --git a/scripts/acceptance-x402.ts b/scripts/acceptance-per-call.ts similarity index 83% rename from scripts/acceptance-x402.ts rename to scripts/acceptance-per-call.ts index 12d607b..7427e97 100644 --- a/scripts/acceptance-x402.ts +++ b/scripts/acceptance-per-call.ts @@ -1,10 +1,10 @@ -// End to end in x402 mode with no API key: a wallet that only deposits into -// its prepaid balance, and never authorizes a delegate or mints a key, gets a -// 402 listing x402 alone, then a completion through the OpenAI SDK paid by -// x402, then one refusal of two calls sent at once past the payer's -// concurrency limit. +// End to end in per-call mode with no API key: a wallet that only deposits +// into its prepaid balance, and never authorizes a delegate or mints a key, +// gets a 402 listing the per-call payment alone, then a completion through the +// OpenAI SDK paid per call, then one refusal of two calls sent at once past +// the payer's concurrency limit. // -// WALLET_PRIVATE_KEY=0x... LIGHTCHAIN_NETWORK=testnet LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:x402 +// WALLET_PRIVATE_KEY=0x... LIGHTCHAIN_NETWORK=testnet LIGHTCHAIN_MODEL=gemma4:e2b npm run acceptance:per-call // // Another network (a devnet): set LIGHTCHAIN_API_URL, LIGHTCHAIN_RPC_URL, // LIGHTCHAIN_CHAIN_ID and LIGHTCHAIN_JOB_REGISTRY instead of LIGHTCHAIN_NETWORK. @@ -34,16 +34,15 @@ const payments: Payment[] = []; const lc = new Lightchain({ network, account: privateKeyToAccount(env.WALLET_PRIVATE_KEY as Hex), - payment: 'x402', + payment: 'per-call', maxPaymentWei: BigInt(env.LIGHTCHAIN_MAX_PAYMENT_WEI ?? 5n * 10n ** 16n), - keyless: true, onPayment: (p) => { payments.push(p); - console.log(`402 paid by x402: settlement tx ${p.hash}, ${p.amount} wei debited`); + console.log(`402 paid per call: settlement tx ${p.hash}, ${p.amount} wei debited`); }, }); const chain = createPublicClient({ transport: http(lc.network.rpcUrl) }); -// Keyless, lc.apiKey is a placeholder lc.fetch never sends. +// With no apiKey, lc.apiKey is a placeholder lc.fetch never sends. const openai = new OpenAI({ baseURL: lc.baseURL, apiKey: lc.apiKey, fetch: lc.fetch, maxRetries: 0 }); const ask = (content: string) => openai.chat.completions.create({ model, messages: [{ role: 'user', content }] }); console.log(`wallet ${lc.address}, chain ${lc.network.chainId}, API ${lc.baseURL}, model ${model}`); @@ -61,13 +60,13 @@ type Accept = { scheme: string; payTo: Address; extra: { facilitatorAddress: Add const { error } = (await unpaid.json()) as { error: { code: string; accepts: Accept[] } }; console.log(`${unpaid.status} ${error.code}, accepts ${error.accepts?.map((a) => a.scheme).join(', ')}, PAYMENT-REQUIRED ${unpaid.headers.has('payment-required') ? 'set' : 'missing'}`); assert.equal(unpaid.status, 402); -assert.deepEqual(error.accepts.map((a) => a.scheme), ['prepaid-debit'], 'x402 alone: no wallet to name a delegate for'); +assert.deepEqual(error.accepts.map((a) => a.scheme), ['prepaid-debit'], 'the per-call payment alone: no wallet to name a delegate for'); assert.ok(unpaid.headers.has('payment-required')); const [{ payTo, extra }] = error.accepts; const before = await lc.getBalance(payTo); assert.equal(before.authorized, false, 'the wallet must have no delegate: run with a fresh one'); -console.log('\n== keyless x402 through the OpenAI SDK =='); +console.log('\n== keyless, paid per call through the OpenAI SDK =='); const completion = await ask('Say hello in five words.'); const job = (completion as unknown as { lightchain: LightchainJob }).lightchain; console.log(`answer: ${completion.choices[0]?.message.content}`); @@ -79,7 +78,7 @@ assert.equal(settlement.status, 'success'); assert.ok(isAddressEqual(settlement.from, extra.facilitatorAddress) && isAddressEqual(settlement.to!, lc.network.jobRegistry)); const after = await lc.getBalance(payTo); console.log('prepaid balance after:', after); -assert.equal(after.authorized, false, 'x402 authorized no delegate'); +assert.equal(after.authorized, false, 'paying per call authorized no delegate'); assert.equal(before.balance - after.balance, payments[0].amount); const limit = Number(env.LIGHTCHAIN_PAYER_CONCURRENCY ?? 1); @@ -94,4 +93,4 @@ assert.equal(refused.length, 1, 'one call refused'); assert.ok(refused[0] instanceof OpenAI.APIError && refused[0].status === 429 && refused[0].code === 'concurrency_limit_exceeded'); assert.equal(payments.length, 1 + limit, 'the refused call paid nothing'); -console.log(`\nPASS: keyless x402 job ${job.job_id} (settlement ${job.tx_hash}); ${limit} of ${limit + 1} calls at once served, one refused`); +console.log(`\nPASS: keyless per-call job ${job.job_id} (settlement ${job.tx_hash}); ${limit} of ${limit + 1} calls at once served, one refused`); diff --git a/scripts/fresh-wallet.ts b/scripts/fresh-wallet.ts index 2cf698d..ba81e52 100644 --- a/scripts/fresh-wallet.ts +++ b/scripts/fresh-wallet.ts @@ -2,10 +2,10 @@ // that never authorized a delegate) and must still run every night. FUNDER_PRIVATE_KEY's wallet funds it. After the // script, pass or fail, the fresh wallet withdraws its prepaid balance and sends all it holds back to the funder. // -// FUNDER_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b node scripts/fresh-wallet.ts acceptance:x402 +// FUNDER_PRIVATE_KEY=0x... LIGHTCHAIN_MODEL=gemma4:e2b node scripts/fresh-wallet.ts acceptance:per-call // // The fresh key exists only in this process and the script's environment. LIGHTCHAIN_FUND_WEI (default 0.11 LCAI: -// acceptance:x402's 0.1 deposit, plus gas) is what a run killed before the sweep leaves behind. LIGHTCHAIN_NETWORK +// acceptance:per-call's 0.1 deposit, plus gas) is what a run killed before the sweep leaves behind. LIGHTCHAIN_NETWORK // picks the network (default testnet). import { spawnSync } from 'node:child_process'; import { createPublicClient, createWalletClient, defineChain, formatEther, type Hex, http, parseAbi } from 'viem'; diff --git a/src/client.ts b/src/client.ts index 5209d04..c5c1346 100644 --- a/src/client.ts +++ b/src/client.ts @@ -24,7 +24,7 @@ export type LightchainOptions = { fetch?: typeof fetch; /** The chain's JSON-RPC transport; HTTP to the network's rpcUrl by default. */ transport?: Transport; -} & (DelegatePayment | X402Payment); +} & (DelegatePayment | PerCallPayment); /** * The default. With an account, `fetch` pays a 402 by authorizing the API's @@ -47,40 +47,38 @@ type DelegatePayment = { onDeposit?: (deposit: Deposit) => void; maxPaymentWei?: never; onPayment?: never; - keyless?: never; }; /** - * `fetch` pays each 402 with an x402 payment: a debit authorization the - * account signs against its own prepaid balance. No transaction, no delegate. + * Experimental: may change in any release. `fetch` pays each call on its own: + * on a 402 it signs a debit authorization against the account's prepaid + * balance (x402 v2 headers, LightChain's `prepaid-debit` scheme). No + * transaction, no delegate; the SDK never deposits in this mode. + * @experimental */ -type X402Payment = { - payment: 'x402'; +type PerCallPayment = { + payment: 'per-call'; + /** + * Optional. With a key, `fetch` sends it and the API holds the call to the + * key's limits. Without one, no request carries an Authorization header: the + * payment alone pays, and the API holds the account, as payer, to its + * per-payer limits. + */ + apiKey?: string; /** A viem local account: it signs every payment. */ account: LocalAccount; /** The most one request may pay, in wei. A 402 asking for more is not paid. */ maxPaymentWei: bigint; - /** Told of every x402 payment the server settled. */ + /** Told of every payment the server settled. */ onPayment?: (payment: Payment) => void; depositWei?: never; onDeposit?: never; -} & ( - | { apiKey: string; keyless?: false } - | { - /** - * Call with no API key: `fetch` drops the Authorization header, so the - * payment alone pays, and the API holds the account, as payer, to its - * per-payer limits. - */ - keyless: true; - apiKey?: never; - } -); +}; /** A depositAndAuthorize the SDK sent on a 402. */ export type Deposit = { hash: Hex; value: bigint; delegate: Address }; -/** An x402 payment the SDK signed on a 402: the settlement transaction, which submitted the job, and the fee it debited. */ +/** A per-call payment the SDK signed on a 402: the settlement transaction, which submitted the job, and the fee it debited. */ export type Payment = { hash: Hex; amount: bigint }; /** The wallet's prepaid balance; with a delegate, what that delegate may spend of it. */ @@ -170,8 +168,8 @@ export class Lightchain { readonly #publicClient: PublicClient; readonly #walletClient: WalletClient | undefined; readonly #registry: { address: Address; abi: typeof jobRegistryAbi }; - /** How a 402 is paid: by a transaction (delegate) or by a signature (x402), never both. */ - readonly #payment: 'delegate' | 'x402'; + /** How a 402 is paid: by a transaction (delegate) or by a signature (per-call), never both. */ + readonly #payment: 'delegate' | 'per-call'; readonly #depositWei: bigint | undefined; readonly #onDeposit: ((deposit: Deposit) => void) | undefined; readonly #maxPaymentWei: bigint = 0n; @@ -190,23 +188,22 @@ export class Lightchain { this.#fetch = (input, init) => fetch(input, init); this.#registry = { address: this.network.jobRegistry, abi: jobRegistryAbi }; // Who needs what (the README's table): the default mode needs apiKey, and - // pays its 402s only with an account. x402 needs account, to sign, and - // apiKey or keyless. An option that would be silently ignored is refused. + // pays its 402s only with an account. per-call needs account, to sign, and + // calls keyless without apiKey. An option that would be silently ignored is refused. const given = (names: string[]) => names.filter((n) => (options as Record)[n] !== undefined); - if (options.payment === 'x402') { + if (options.payment === 'per-call') { if (given(['depositWei', 'onDeposit']).length) throw new Error('depositWei and onDeposit belong to payment "delegate".'); - if (!options.account) throw new Error('payment "x402" needs account: it signs each payment.'); + if (!options.account) throw new Error('payment "per-call" needs account: it signs each payment.'); // No default: what a 402 may take is the builder's call. if (typeof options.maxPaymentWei !== 'bigint' || options.maxPaymentWei <= 0n) { - throw new Error('payment "x402" needs maxPaymentWei: the most one request may pay, in wei.'); + throw new Error('payment "per-call" needs maxPaymentWei: the most one request may pay, in wei.'); } this.#maxPaymentWei = options.maxPaymentWei; this.#onPayment = options.onPayment; - this.#keyless = options.keyless === true; - if (this.#keyless === (options.apiKey !== undefined)) throw new Error('payment "x402" takes apiKey or keyless: true, one of the two.'); + this.#keyless = !options.apiKey; } else if (options.payment === undefined || options.payment === 'delegate') { - if (given(['maxPaymentWei', 'onPayment', 'keyless']).length) { - throw new Error('maxPaymentWei, onPayment and keyless belong to payment "x402".'); + if (given(['maxPaymentWei', 'onPayment']).length) { + throw new Error('maxPaymentWei and onPayment belong to payment "per-call".'); } if (!options.account && given(['depositWei', 'onDeposit']).length) { throw new Error('depositWei and onDeposit need account: the wallet that deposits on a 402.'); @@ -214,14 +211,14 @@ export class Lightchain { this.#depositWei = options.depositWei; this.#onDeposit = options.onDeposit; } else { - throw new Error(`payment is "delegate" or "x402", not ${JSON.stringify(options.payment)}.`); + throw new Error(`payment is "delegate" or "per-call", not ${JSON.stringify(options.payment)}.`); } this.#payment = options.payment ?? 'delegate'; if (!this.#keyless && (typeof options.apiKey !== 'string' || !options.apiKey)) { throw new Error('needs apiKey: a key created in the chat, under Developer, API keys.'); } // The OpenAI SDK insists on some apiKey. - this.#apiKey = options.apiKey ?? 'keyless'; + this.#apiKey = options.apiKey || 'keyless'; const chain = defineChain({ id: this.network.chainId, name: `LightChain ${this.network.chainId}`, @@ -235,7 +232,7 @@ export class Lightchain { } /** - * The API key, for the OpenAI SDK's `apiKey`; keyless, a placeholder that + * The API key, for the OpenAI SDK's `apiKey`; with no key, a placeholder that * `fetch` never sends. A getter, so that logging the client prints no key. */ get apiKey(): string { @@ -253,13 +250,13 @@ export class Lightchain { * against this network, and sends the request again. With no account, the * 402 comes back as it is. * - * `x402`: when the 402 lists the `prepaid-debit` requirements, it signs a + * `per-call` (experimental): when the 402 lists the `prepaid-debit` requirements, it signs a * debit authorization for their amount against the account's prepaid * balance, once they check out against this network and maxPaymentWei, and * sends the request again with it in a PAYMENT-SIGNATURE header. It never * sends a transaction. * - * With `keyless`, no request carries an Authorization header. + * In per-call mode with no apiKey, no request carries an Authorization header. * * Every other answer comes back as it is, a 402 for a limit the key's owner * set included. A 402 it does not pay comes back with the reason prepended @@ -285,16 +282,16 @@ export class Lightchain { // 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; const body = (await response.clone().json().catch(() => null)) as PaymentRequired | null; - const x402 = this.#payment === 'x402'; + const perCall = this.#payment === 'per-call'; const accepts = body?.error?.accepts ?? []; - const accept = accepts.find((a) => a?.scheme === (x402 ? X402_SCHEME : 'delegate')); - // In x402 mode a 402 that only offers the delegate way is a missing payment too: say why it stays unpaid. - if (!accept && !(x402 && accepts.some((a) => a?.scheme === 'delegate'))) return response; + const accept = accepts.find((a) => a?.scheme === (perCall ? X402_SCHEME : 'delegate')); + // In per-call mode a 402 that only offers the delegate way is a missing payment too: say why it stays unpaid. + if (!accept && !(perCall && accepts.some((a) => a?.scheme === 'delegate'))) return response; await response.body?.cancel(); let payment = ''; try { - if (!accept) throw new Error('payment is "x402", and the 402 offers no prepaid-debit payment.'); - if (x402) payment = await this.#signPayment(account, accept as X402Accept); + 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); else { const deposit = this.#checkDelegateOffer(account, accept as DelegateAccept); const hash = await this.depositAndAuthorize(deposit.delegate, deposit.value); @@ -309,7 +306,7 @@ export class Lightchain { } // Sent again once. A 402 on that retry (the fee rose meanwhile) // comes back to the caller; loop, with a bound, if that shows up. - if (!x402) return this.#fetch(again, init); + if (!perCall) return this.#fetch(again, init); const paidHeaders = new Headers(headers); paidHeaders.set('payment-signature', payment); const paid = await this.#fetch(again, { ...init, headers: paidHeaders }); @@ -332,7 +329,7 @@ export class Lightchain { /** * Sends JobRegistry.deposit() with `value`: adds it to the wallet's prepaid - * balance and authorizes nobody. x402 payments are paid from it. Resolves + * balance and authorizes nobody. per-call payments are paid from it. Resolves * with the transaction hash once it succeeded on chain. */ deposit(value: bigint): Promise { diff --git a/test/keys.test.ts b/test/keys.test.ts index a8e9cb3..909cb39 100644 --- a/test/keys.test.ts +++ b/test/keys.test.ts @@ -42,15 +42,13 @@ test('without an account, hands back the 402 of an unpaid wallet as it is, sendi http.done(); }); -const x402 = { payment: 'x402', maxPaymentWei: 1n } as const; +const perCall = { payment: 'per-call', maxPaymentWei: 1n } as const; const misconfigured: [string, object, RegExp][] = [ ['the delegate mode with no API key', { account }, /needs apiKey/], ['an empty API key', { apiKey: '' }, /needs apiKey/], ['depositWei with no account to deposit from', { apiKey: 'lcai_x', depositWei: 1n }, /need account/], ['onDeposit with no account to deposit from', { apiKey: 'lcai_x', onDeposit: () => {} }, /need account/], - ['x402 with no account to sign', { ...x402, apiKey: 'lcai_x' }, /payment "x402" needs account/], - ['x402 with an API key and keyless both', { ...x402, account, apiKey: 'lcai_x', keyless: true }, /apiKey or keyless/], - ['x402 with neither an API key nor keyless', { ...x402, account }, /apiKey or keyless/], + ['per-call with no account to sign', { ...perCall, apiKey: 'lcai_x' }, /payment "per-call" needs account/], ]; for (const [what, options, reason] of misconfigured) { test(`refuses ${what}`, () => { diff --git a/test/payment.test.ts b/test/payment.test.ts index 5e6b69a..9e4f845 100644 --- a/test/payment.test.ts +++ b/test/payment.test.ts @@ -150,7 +150,7 @@ for (const [what, answer, reason, depositWei] of refusals) { }); } -test('deposits into the prepaid balance alone, authorizing no delegate: what x402 pays from', async () => { +test('deposits into the prepaid balance alone, authorizing no delegate: what per-call payments pay from', async () => { const rpc = replayRpc(pay.rpc); const lc = new Lightchain({ network, apiKey: devnet.apiKey, account, transport: rpc.transport }); diff --git a/test/x402.test.ts b/test/per-call.test.ts similarity index 78% rename from test/x402.test.ts rename to test/per-call.test.ts index 2d20483..c635c59 100644 --- a/test/x402.test.ts +++ b/test/per-call.test.ts @@ -42,12 +42,12 @@ function signAs(t: TestContext, v: Vector) { }); } -const x402 = (fetch: typeof globalThis.fetch, transport = replayRpc([]).transport) => ({ +const perCall = (fetch: typeof globalThis.fetch, transport = replayRpc([]).transport) => ({ account: privateKeyToAccount(payerKey), apiKey: 'lcai_test', fetch, transport, - payment: 'x402' as const, + payment: 'per-call' as const, maxPaymentWei: 10n ** 18n, }); const networkOf = (v: Vector) => (v.chain.chainId === networks.mainnet.chainId ? 'mainnet' : 'testnet'); @@ -74,7 +74,7 @@ for (const name of signedBySdk) { signAs(t, v); const http = replayHttp([paymentRequired(v.paymentRequirements), completed]); const rpc = replayRpc([]); - const lc = new Lightchain({ network: networkOf(v), ...x402(http.fetch, rpc.transport) }); + const lc = new Lightchain({ network: networkOf(v), ...perCall(http.fetch, rpc.transport) }); const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); @@ -96,7 +96,7 @@ for (const [name, reason] of refusedVectors) { test(`does not sign the requirements of the ${name} vector, and says why`, async () => { const v = vector(name); const http = replayHttp([paymentRequired(v.paymentRequirements)]); - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch) }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch) }); const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); @@ -114,7 +114,7 @@ const fee = BigInt(testnetRequirements.amount); /** The payment an x402-mode client with `maxPaymentWei` sends for requirements, or its 402 if it sends none. */ async function payWith(maxPaymentWei: bigint, requirements: object) { const http = replayHttp([paymentRequired(requirements), completed]); - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch), maxPaymentWei }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch), maxPaymentWei }); const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); if (response.status === 402) return { refused: ((await response.json()) as { error: { message: string } }).error.message }; return { payment: paymentOf(http.sent[1]) }; @@ -170,7 +170,7 @@ test('tells onPayment of the settlement the answer reports in PAYMENT-RESPONSE', const settled = { ...completed, headers: { 'payment-response': Buffer.from(JSON.stringify(settlement)).toString('base64') } }; const http = replayHttp([paymentRequired(testnetRequirements), settled]); const payments: unknown[] = []; - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch), onPayment: (p: Payment) => payments.push(p) }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch), onPayment: (p: Payment) => payments.push(p) }); await lc.fetch(`${lc.baseURL}/chat/completions`, request); @@ -181,7 +181,7 @@ test('hands back a refused payment as the server answered it, telling onPayment const refusal = { ...paymentRequired(testnetRequirements), headers: { 'payment-response': Buffer.from(JSON.stringify({ success: false, errorReason: 'insufficient_funds', transaction: '', network: 'eip155:8200' })).toString('base64') } }; const http = replayHttp([paymentRequired(testnetRequirements), refusal]); const payments: unknown[] = []; - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch), onPayment: (p: Payment) => payments.push(p) }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch), onPayment: (p: Payment) => payments.push(p) }); const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); @@ -191,39 +191,39 @@ test('hands back a refused payment as the server answered it, telling onPayment http.done(); }); -test('in x402 mode, does not deposit on a 402 that only offers the delegate way, and says why', async () => { +test('in per-call mode, does not deposit on a 402 that only offers the delegate way, and says why', async () => { const http = replayHttp([delegateRequired]); - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch) }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch) }); const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); assert.equal(response.status, 402); - assert.match(((await response.json()) as { error: { message: string } }).error.message, /^Not paid by the SDK: payment is "x402", and the 402 offers no prepaid-debit payment/); + assert.match(((await response.json()) as { error: { message: string } }).error.message, /^Not paid by the SDK: payment is "per-call", and the 402 offers no prepaid-debit payment/); http.done(); }); -test('asks for maxPaymentWei in x402 mode, with no default', () => { - const options = { network: 'testnet', account: privateKeyToAccount(payerKey), apiKey: 'lcai_test', payment: 'x402' } as const; +test('asks for maxPaymentWei in per-call mode, with no default', () => { + const options = { network: 'testnet', account: privateKeyToAccount(payerKey), apiKey: 'lcai_test', payment: 'per-call' } as const; assert.throws(() => new Lightchain(options as unknown as LightchainOptions), /maxPaymentWei/); assert.throws(() => new Lightchain({ ...options, maxPaymentWei: 0n }), /maxPaymentWei/); }); -test('refuses a payment mode it does not know', () => { - const options = { network: 'testnet', account: privateKeyToAccount(payerKey), apiKey: 'lcai_test', payment: 'X402' }; - assert.throws(() => new Lightchain(options as unknown as LightchainOptions), /"delegate" or "x402"/); +test('refuses a payment mode it does not know, the former name "x402" included', () => { + const options = { network: 'testnet', account: privateKeyToAccount(payerKey), apiKey: 'lcai_test', payment: 'x402' }; + assert.throws(() => new Lightchain(options as unknown as LightchainOptions), /"delegate" or "per-call"/); }); test('refuses the options of one mode in the other, so a cap is never silently ignored', () => { const base = { network: 'testnet', account: privateKeyToAccount(payerKey), apiKey: 'lcai_test' } as const; - assert.throws(() => new Lightchain({ ...base, maxPaymentWei: 1n } as unknown as LightchainOptions), /payment "x402"/); - assert.throws(() => new Lightchain({ ...base, onPayment: () => {} } as unknown as LightchainOptions), /payment "x402"/); - const x402Mode = { ...base, payment: 'x402', maxPaymentWei: 1n } as const; - assert.throws(() => new Lightchain({ ...x402Mode, depositWei: 1n } as unknown as LightchainOptions), /payment "delegate"/); + assert.throws(() => new Lightchain({ ...base, maxPaymentWei: 1n } as unknown as LightchainOptions), /payment "per-call"/); + assert.throws(() => new Lightchain({ ...base, onPayment: () => {} } as unknown as LightchainOptions), /payment "per-call"/); + const perCallMode = { ...base, payment: 'per-call', maxPaymentWei: 1n } as const; + assert.throws(() => new Lightchain({ ...perCallMode, depositWei: 1n } as unknown as LightchainOptions), /payment "delegate"/); }); test('carries the payment on a Request sent again, keeping its own headers', async () => { const http = replayHttp([paymentRequired(testnetRequirements), completed]); - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch) }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch) }); const response = await lc.fetch(new Request(`${lc.baseURL}/chat/completions`, request)); @@ -239,23 +239,26 @@ function keylessPaymentRequired(requirements: object): Exchange { return { ...delegateRequired, body: { error: { ...error, code: 'payment_required', accepts: [requirements] } } }; } -test('with keyless, sends no Authorization header, on the call or on its paid retry', async () => { - const http = replayHttp([keylessPaymentRequired(testnetRequirements), completed]); - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch), apiKey: undefined, keyless: true }); +for (const apiKey of [undefined, '']) { + test(`with apiKey ${JSON.stringify(apiKey)}, calls keyless: no Authorization header, on the call or on its paid retry`, async () => { + const http = replayHttp([keylessPaymentRequired(testnetRequirements), completed]); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch), apiKey }); - // As the OpenAI SDK sends it: with the placeholder key it insists on. - const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); + // As the OpenAI SDK sends it: with some key in Authorization, which fetch drops. + const response = await lc.fetch(`${lc.baseURL}/chat/completions`, request); - assert.equal(response.status, 200); - assert.deepEqual(http.sent.map((s) => s.headers.get('authorization')), [null, null]); - assert.equal(http.sent[0].headers.get('content-type'), 'application/json', 'the other headers go out as given'); - assert.equal(paymentOf(http.sent[1]).accepted.asset, testnetRequirements.asset); - http.done(); -}); + assert.equal(response.status, 200); + assert.equal(lc.apiKey, 'keyless', 'a placeholder for the OpenAI SDK, which insists on a key'); + assert.deepEqual(http.sent.map((s) => s.headers.get('authorization')), [null, null]); + assert.equal(http.sent[0].headers.get('content-type'), 'application/json', 'the other headers go out as given'); + assert.equal(paymentOf(http.sent[1]).accepted.asset, testnetRequirements.asset); + http.done(); + }); +} -test('with keyless, sends no Authorization header from a Request either', async () => { +test('with no apiKey, sends no Authorization header from a Request either', async () => { const http = replayHttp([keylessPaymentRequired(testnetRequirements), completed]); - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch), apiKey: undefined, keyless: true }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch), apiKey: undefined }); const response = await lc.fetch(new Request(`${lc.baseURL}/chat/completions`, request)); @@ -264,16 +267,11 @@ test('with keyless, sends no Authorization header from a Request either', async assert.deepEqual(http.sent[1].body, http.sent[0].body); }); -test('without keyless, the API key goes out on the call and on its paid retry', async () => { +test('with an apiKey, the key goes out on the call and on its paid retry', async () => { const http = replayHttp([paymentRequired(testnetRequirements), completed]); - const lc = new Lightchain({ network: 'testnet', ...x402(http.fetch) }); + const lc = new Lightchain({ network: 'testnet', ...perCall(http.fetch) }); await lc.fetch(`${lc.baseURL}/chat/completions`, request); assert.deepEqual(http.sent.map((s) => s.headers.get('authorization')), ['Bearer lcai_test', 'Bearer lcai_test']); }); - -test('refuses keyless outside x402 mode: the delegate way needs the key', () => { - const base = { network: 'testnet', account: privateKeyToAccount(payerKey), apiKey: 'lcai_test' } as const; - assert.throws(() => new Lightchain({ ...base, keyless: true } as unknown as LightchainOptions), /payment "x402"/); -});