openzoo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -40,7 +40,25 @@ curl http://localhost:8402/v1/chat/completions -H 'Content-Type: application/jso
40
40
  -d '{"model":"nvidia/nemotron-3.5-lightning","messages":[{"role":"user","content":"hi"}]}'
41
41
  ```
42
42
 
43
- Model ids come from `GET http://localhost:8402/v1/models` (free, no payment).
43
+ **OpenAI SDK (Python):**
44
+ ```python
45
+ from openai import OpenAI
46
+ client = OpenAI(base_url="http://localhost:8402/v1", api_key="sk-openzoo")
47
+ r = client.chat.completions.create(model="nvidia/nemotron-3.5-lightning",
48
+ messages=[{"role": "user", "content": "hi"}])
49
+ ```
50
+
51
+ **OpenAI SDK (JS/TS):**
52
+ ```js
53
+ import OpenAI from "openai";
54
+ const client = new OpenAI({ baseURL: "http://localhost:8402/v1", apiKey: "sk-openzoo" });
55
+ const r = await client.chat.completions.create({
56
+ model: "nvidia/nemotron-3.5-lightning",
57
+ messages: [{ role: "user", content: "hi" }],
58
+ });
59
+ ```
60
+
61
+ Model ids come from `GET http://localhost:8402/v1/models` (free, no payment). Requires Node ≥ 18 (for `npx openzoo` itself; your harness can be anything).
44
62
 
45
63
  ## Use as MCP
46
64
 
@@ -50,12 +68,16 @@ The same package is an MCP server sharing the same wallet and payment core:
50
68
  claude mcp add openzoo -- npx -y openzoo mcp
51
69
  ```
52
70
 
53
- Other MCP hosts:
71
+ **Claude Desktop** — add to `claude_desktop_config.json` (macOS: `~/Library/Application Support/Claude/claude_desktop_config.json`, Windows: `%APPDATA%\Claude\claude_desktop_config.json`), then restart the app:
54
72
 
55
73
  ```json
56
74
  { "mcpServers": { "openzoo": { "command": "npx", "args": ["-y", "openzoo", "mcp"] } } }
57
75
  ```
58
76
 
77
+ **Cursor** — Settings → MCP → *Add new global MCP server*, or drop the same JSON into `~/.cursor/mcp.json` (per-project: `.cursor/mcp.json`).
78
+
79
+ **Windsurf / Cline / any MCP host** — same shape: command `npx`, args `["-y", "openzoo", "mcp"]`, stdio transport.
80
+
59
81
  Tools:
60
82
  - **`zoo_ask`** — `{prompt, corpus?, model?, max_tokens?}`. The flagship: hand it a *huge* `corpus` (hundreds of thousands to ~1M tokens — a body the model itself would refuse) and a question; the zoo's leCore memory spills the corpus so the model reads only a few thousand tokens. Returns the answer plus the receipt (`billedUsd`, `savesVsDirect`, tokens actually read). An MCP-capable agent can delegate a giant-context question without touching its own model config.
61
83
  - **`zoo_models`** — list the zoo's models and pricing (free).
@@ -70,18 +92,18 @@ npx openzoo demo
70
92
  Builds a ~965k-token document with one planted fact, shows that buying direct refuses it (*"The input token count exceeds the maximum number of tokens allowed."* — recorded, see [benches.openzoo.fun](https://benches.openzoo.fun)), then fetches the live x402 quote for the same body — the quote is free:
71
93
 
72
94
  ```
73
- quote: $0.009747 yUSDCx · pricing=counterfactual · direct would be $0.097474 · savesVsDirect=10.0×
95
+ quote: $0.009747 · pricing=counterfactual · direct would be $0.097474 · savesVsDirect=10.0×
74
96
  ```
75
97
 
76
- If the wallet is funded it pays (capped at `OPENZOO_DEMO_MAX_USD`, default $0.01) and prints the answer, the tokens the model actually read, and the receipt. If not, it prints exactly what to fund.
98
+ If the wallet is funded with USDC it pays (capped at `OPENZOO_DEMO_MAX_USD`, default $0.01) and prints the answer, the tokens the model actually read, and the receipt. If not, it prints exactly what to fund. Long waits (the multi-MB upload, pricing, payment, the answer) show a live progress line with stage + elapsed seconds.
77
99
 
78
100
  ## The wallet model
79
101
 
80
102
  - **Burner, local, yours.** A keypair in `~/.openzoo/wallet.json`, created on first run, chmod 600. Keys never leave your machine — the zoo only ever sees signed transfers.
81
- - **Fund it with yUSDCx** (mint `6ZjjxcoicqM4nniddkuPVwew4PDwY3swbfHsGbCuLuTv`), the $1-stable rail: wrap USDC at [x402.accrue.fund/start](https://x402.accrue.fund/start) and send to the address `npx openzoo address` prints. A few cents goes a long way.
82
- - **You don't need SOL.** The gateway is the transaction feePayer; you only sign the token transfer.
103
+ - **Fund it with plain USDC.** Send a few cents of USDC to the address `npx openzoo address` prints. That's the whole funding story the shim converts whatever the 402 quotes internally, at payment time, for exactly the amount needed.
104
+ - **SOL is optional but nice.** The gateway sponsors payment-transaction fees. If the wallet holds a pinch of SOL (~0.003), the internal conversion settles as its own transaction first; with zero SOL the conversion rides inside the gateway-sponsored payment transaction instead.
83
105
  - **Spend caps.** The proxy refuses any single quote above `OPENZOO_MAX_USD_PER_CALL` (default $0.50).
84
- - `npx openzoo balance` / `npx openzoo address` — check funds / print the address.
106
+ - `npx openzoo balance` / `npx openzoo address` — check funds (USDC + SOL + USD value) / print the address.
85
107
 
86
108
  ## Honest pricing note
87
109
 
@@ -95,7 +117,7 @@ The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect`
95
117
 
96
118
  | rail | network | status |
97
119
  |---|---|---|
98
- | **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees. Tested end-to-end against the production 402. |
120
+ | **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees. Tested end-to-end against the production 402. Settlement uses a wrapped settlement mint as internal plumbing; you only ever hold and send USDC. |
99
121
  | Base | `eip155:8453` | implemented (standard x402 EIP-3009 `transferWithAuthorization`), **live-untested** — the zoo's 402s currently offer only Solana rows. |
100
122
  | Robinhood Chain | `eip155:4663` | experimental, behind `OPENZOO_ENABLE_RH=1` — the zoo ships this rail dark and facilitator settlement there is unverified. |
101
123
 
@@ -108,7 +130,7 @@ The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are
108
130
  | `OPENZOO_PORT` | `8402` | proxy port |
109
131
  | `OPENZOO_API_BASE` | `https://x402-tokens.fly.dev` | the zoo's door |
110
132
  | `OPENZOO_RPC` | mainnet-beta public RPC | Solana RPC |
111
- | `OPENZOO_TOKEN` | `yUSDCx` | which 402 row to pay |
133
+ | `OPENZOO_TOKEN` | (internal) | preferred 402 rail leave unset |
112
134
  | `OPENZOO_WALLET` | `~/.openzoo/wallet.json` | wallet path |
113
135
  | `OPENZOO_MAX_USD_PER_CALL` | `0.5` | refuse quotes above this |
114
136
  | `OPENZOO_DEMO_MAX_USD` | `0.01` | demo spend cap |
package/bin/openzoo.js CHANGED
@@ -7,7 +7,7 @@ usage:
7
7
  npx openzoo start the proxy on http://localhost:8402/v1
8
8
  npx openzoo mcp stdio MCP server (tools: zoo_ask, zoo_models, zoo_wallet)
9
9
  npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
10
- npx openzoo balance wallet balances (yUSDCx / wTOKENx)
10
+ npx openzoo balance wallet balance (USDC + SOL, with USD value)
11
11
  npx openzoo address print the funding address
12
12
  npx openzoo help this text
13
13
 
@@ -17,7 +17,7 @@ point any OpenAI-compatible harness at:
17
17
 
18
18
  env:
19
19
  OPENZOO_PORT (8402) OPENZOO_API_BASE (https://x402-tokens.fly.dev)
20
- OPENZOO_RPC (mainnet-beta) OPENZOO_TOKEN (yUSDCx) OPENZOO_WALLET (~/.openzoo/wallet.json)
20
+ OPENZOO_RPC (mainnet-beta) OPENZOO_TOKEN (402 rail preference) OPENZOO_WALLET (~/.openzoo/wallet.json)
21
21
  OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
22
22
  OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)`;
23
23
 
package/lib/config.js CHANGED
@@ -5,7 +5,7 @@ export const config = {
5
5
  port: Number(process.env.OPENZOO_PORT || 8402),
6
6
  apiBase: (process.env.OPENZOO_API_BASE || 'https://x402-tokens.fly.dev').replace(/\/+$/, ''),
7
7
  rpcUrl: process.env.OPENZOO_RPC || 'https://api.mainnet-beta.solana.com',
8
- // Which accepts[] row to pay with, by extra.symbol. yUSDCx is the $1-stable rail.
8
+ // Which accepts[] row to pay with, by extra.symbol (internal rail selector).
9
9
  token: process.env.OPENZOO_TOKEN || 'yUSDCx',
10
10
  walletPath: process.env.OPENZOO_WALLET || path.join(os.homedir(), '.openzoo', 'wallet.json'),
11
11
  // Refuse to auto-pay any single 402 quote above this many USD (extra.billedUsd).
package/lib/demo.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { config } from './config.js';
2
- import { PayClient } from './pay.js';
2
+ import { PayClient, UnderfundedError } from './pay.js';
3
3
  import { parse402, pickAccept, tokenBalance } from './x402.js';
4
+ import { startSpinner, postWithUploadSignal } from './spinner.js';
5
+ import { resolvePool, poolState, depositForShares } from './wrap.js';
4
6
 
5
7
  const DEMO_MODEL = process.env.OPENZOO_DEMO_MODEL || 'nvidia/nemotron-3.5-lightning';
6
8
  const NEEDLE = 'The zebra vault code is 7-ALPHA-9243.';
@@ -28,13 +30,32 @@ function bigBody(targetTokens) {
28
30
  }
29
31
 
30
32
  async function quoteFor(bodyObj) {
31
- const r = await fetch(`${config.apiBase}/v1/chat/completions`, {
32
- method: 'POST',
33
- headers: { 'content-type': 'application/json' },
34
- body: JSON.stringify(bodyObj),
35
- });
36
- if (r.status !== 402) throw new Error(`expected 402 quote, got HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
37
- return parse402(await r.json());
33
+ const body = JSON.stringify(bodyObj);
34
+ const mb = (body.length / 1048576).toFixed(1);
35
+ const approxTokens = Math.round(body.length / 4 / 1000);
36
+ const spin = startSpinner(`uploading ${mb}MB body`);
37
+ try {
38
+ const r = await postWithUploadSignal(`${config.apiBase}/v1/chat/completions`, body, {
39
+ onUploaded: () => spin.update(`zoo pricing ~${approxTokens}k tokens`),
40
+ });
41
+ if (r.status !== 402) throw new Error(`expected 402 quote, got HTTP ${r.status}: ${(await r.text()).slice(0, 300)}`);
42
+ return parse402(await r.json());
43
+ } finally {
44
+ spin.stop();
45
+ }
46
+ }
47
+
48
+ /** Can the wallet cover this quote (directly or by converting its USDC)? */
49
+ async function coverage(connection, owner, accept) {
50
+ const need = BigInt(accept.maxAmountRequired);
51
+ const have = await tokenBalance(connection, owner, accept.asset);
52
+ if (have.raw >= need) return { covered: true, usdcUi: null };
53
+ const pool = await resolvePool(connection, accept.asset).catch(() => null);
54
+ if (!pool) return { covered: false, usdcUi: 0 };
55
+ const { reserves, supply } = await poolState(connection, pool);
56
+ const deposit = depositForShares(need - have.raw, reserves, supply);
57
+ const underlying = await tokenBalance(connection, owner, pool.underlying.toBase58());
58
+ return { covered: underlying.raw >= deposit, usdcUi: underlying.ui ?? 0 };
38
59
  }
39
60
 
40
61
  export async function runDemo() {
@@ -61,7 +82,7 @@ export async function runDemo() {
61
82
  const quote = await quoteFor(bodyObj);
62
83
  const accept = pickAccept(quote, config.token);
63
84
  const x = accept.extra || {};
64
- console.log(` quote: $${Number(x.billedUsd).toFixed(6)} ${x.symbol} · pricing=${x.pricing}`
85
+ console.log(` quote: $${Number(x.billedUsd).toFixed(6)} · pricing=${x.pricing}`
65
86
  + (x.directUsd != null ? ` · direct would be $${Number(x.directUsd).toFixed(6)} · savesVsDirect=${Number(x.savesVsDirect).toFixed(1)}×` : ''));
66
87
  console.log('');
67
88
 
@@ -78,20 +99,35 @@ export async function runDemo() {
78
99
  payAccept = pickAccept(q2, config.token);
79
100
  }
80
101
 
81
- const need = BigInt(payAccept.maxAmountRequired);
82
- const bal = await tokenBalance(connection, keypair.publicKey, payAccept.asset);
83
- if (bal.raw < need) {
102
+ const cov = await coverage(connection, keypair.publicKey, payAccept);
103
+ if (!cov.covered) {
84
104
  console.log('3) wallet not funded yet, so stopping before payment.');
85
- console.log(` need : ${need} raw units of ${payAccept.extra?.symbol} (mint ${payAccept.asset}) ≈ $${Number(payAccept.extra?.billedUsd).toFixed(6)}`);
86
- console.log(` have : ${bal.raw}`);
87
- console.log(` fund : ${pub}`);
88
- console.log(' get yUSDCx by wrapping USDC: https://x402.accrue.fund/start — then re-run `npx openzoo demo`.');
105
+ console.log(` quote : ≈ $${Number(payAccept.extra?.billedUsd).toFixed(6)}`);
106
+ console.log(` wallet: $${(cov.usdcUi ?? 0).toFixed(2)} USDC`);
107
+ console.log(` fund : send a few cents of USDC to ${pub}`);
108
+ console.log(' then re-run `npx openzoo demo`.');
89
109
  return;
90
110
  }
91
111
 
92
112
  console.log('3) paying and asking...');
113
+ const paidMb = (JSON.stringify(payBody).length / 1048576).toFixed(1);
114
+ const spin = startSpinner(`re-sending ${paidMb}MB body for the paid call`);
93
115
  const t0 = Date.now();
94
- const { data, receipt } = await client.chat(payBody);
116
+ let data; let receipt;
117
+ try {
118
+ ({ data, receipt } = await client.chat(payBody, {
119
+ onStage: (stage) => {
120
+ if (stage === 'quoted') spin.update('building the payment');
121
+ if (stage === 'funding') spin.update('preparing funds (one-time)');
122
+ if (stage === 'paying') spin.update('paying + waiting for the answer');
123
+ },
124
+ }));
125
+ } catch (err) {
126
+ spin.stop();
127
+ if (err instanceof UnderfundedError) { console.log(` ${err.message}`); return; }
128
+ throw err;
129
+ }
130
+ spin.stop();
95
131
  const secs = ((Date.now() - t0) / 1000).toFixed(1);
96
132
  const answer = data?.choices?.[0]?.message?.content ?? '';
97
133
  const usage = data?.usage || {};
package/lib/info.js CHANGED
@@ -4,8 +4,7 @@ import { config } from './config.js';
4
4
  import { loadOrCreateWallet } from './wallet.js';
5
5
  import { tokenBalance } from './x402.js';
6
6
 
7
- const YUSDCX = '6ZjjxcoicqM4nniddkuPVwew4PDwY3swbfHsGbCuLuTv';
8
- const WTOKENX = 'Bo7xBF7SY8EyUBPUxRP66SFafxoPf2n5uqiLjbxEebx9';
7
+ const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
9
8
 
10
9
  export function printAddress() {
11
10
  const { keypair, evmPrivateKey, created, path } = loadOrCreateWallet();
@@ -17,14 +16,16 @@ export function printAddress() {
17
16
  export async function printBalance() {
18
17
  const { keypair } = loadOrCreateWallet();
19
18
  const connection = new Connection(config.rpcUrl, 'confirmed');
20
- const [y, w] = await Promise.all([
21
- tokenBalance(connection, keypair.publicKey, YUSDCX),
22
- tokenBalance(connection, keypair.publicKey, WTOKENX),
19
+ const [usdc, lamports] = await Promise.all([
20
+ tokenBalance(connection, keypair.publicKey, USDC),
21
+ connection.getBalance(keypair.publicKey),
23
22
  ]);
23
+ const usdcUi = usdc.ui ?? 0;
24
24
  console.log(`wallet : ${keypair.publicKey.toBase58()}`);
25
- console.log(`yUSDCx : ${y.ui ?? 0} (mint ${YUSDCX})`);
26
- console.log(`wTOKENx: ${w.ui ?? 0} (mint ${WTOKENX})`);
27
- if (!y.raw && !w.raw) {
28
- console.log('unfunded — wrap USDC into yUSDCx at https://x402.accrue.fund/start and send it here.');
25
+ console.log(`USDC : ${usdcUi}`);
26
+ console.log(`SOL : ${lamports / 1e9}`);
27
+ console.log(`value : $${usdcUi.toFixed(2)}`);
28
+ if (!usdc.raw) {
29
+ console.log(`fund : send a few cents of USDC to ${keypair.publicKey.toBase58()}`);
29
30
  }
30
31
  }
package/lib/mcp.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readFileSync } from 'node:fs';
1
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
4
  import { z } from 'zod';
@@ -5,8 +6,8 @@ import { config } from './config.js';
5
6
  import { PayClient, QuoteTooHighError, UnderfundedError } from './pay.js';
6
7
  import { tokenBalance } from './x402.js';
7
8
 
8
- const YUSDCX = '6ZjjxcoicqM4nniddkuPVwew4PDwY3swbfHsGbCuLuTv';
9
- const WTOKENX = 'Bo7xBF7SY8EyUBPUxRP66SFafxoPf2n5uqiLjbxEebx9';
9
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
10
+ const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
10
11
  const DEFAULT_MODEL = process.env.OPENZOO_DEMO_MODEL || 'nvidia/nemotron-3.5-lightning';
11
12
 
12
13
  function text(obj) {
@@ -16,7 +17,7 @@ function text(obj) {
16
17
  /** `npx openzoo mcp` — stdio MCP server sharing the proxy's wallet + payment core. */
17
18
  export async function startMcp() {
18
19
  const client = new PayClient();
19
- const server = new McpServer({ name: 'openzoo', version: '0.1.0' });
20
+ const server = new McpServer({ name: 'openzoo', version: pkg.version });
20
21
 
21
22
  server.registerTool('zoo_ask', {
22
23
  description:
@@ -72,14 +73,14 @@ export async function startMcp() {
72
73
  description: 'The local burner wallet: funding address, balances, and this session\'s payment receipts.',
73
74
  inputSchema: {},
74
75
  }, async () => {
75
- const [y, w] = await Promise.all([
76
- tokenBalance(client.connection, client.keypair.publicKey, YUSDCX),
77
- tokenBalance(client.connection, client.keypair.publicKey, WTOKENX),
76
+ const [usdc, lamports] = await Promise.all([
77
+ tokenBalance(client.connection, client.keypair.publicKey, USDC),
78
+ client.connection.getBalance(client.keypair.publicKey),
78
79
  ]);
79
80
  return text({
80
81
  solanaAddress: client.address,
81
- fundWith: `yUSDCx (mint ${YUSDCX}) wrap USDC at https://x402.accrue.fund/start`,
82
- balances: { yUSDCx: y.ui ?? 0, wTOKENx: w.ui ?? 0 },
82
+ fundWith: 'USDC send a few cents of USDC to this address',
83
+ balances: { USDC: usdc.ui ?? 0, SOL: lamports / 1e9, usdValue: usdc.ui ?? 0 },
83
84
  receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
84
85
  });
85
86
  });
package/lib/pay.js CHANGED
@@ -1,4 +1,5 @@
1
- import { Connection } from '@solana/web3.js';
1
+ import { Connection, PublicKey } from '@solana/web3.js';
2
+ import { getAssociatedTokenAddressSync } from '@solana/spl-token';
2
3
  import { config } from './config.js';
3
4
  import { loadOrCreateWallet } from './wallet.js';
4
5
  import {
@@ -6,6 +7,9 @@ import {
6
7
  receiptLine, decodeSettleHeader,
7
8
  } from './x402.js';
8
9
  import { buildEvmPayment } from './evm.js';
10
+ import {
11
+ resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
12
+ } from './wrap.js';
9
13
 
10
14
  export class QuoteTooHighError extends Error {
11
15
  constructor(billedUsd, quote) {
@@ -16,10 +20,13 @@ export class QuoteTooHighError extends Error {
16
20
  }
17
21
 
18
22
  export class UnderfundedError extends Error {
19
- constructor(accept, have, address) {
20
- super(`openzoo wallet underfunded: need ${accept.maxAmountRequired} raw ${accept.extra?.symbol || ''} (mint ${accept.asset}), have ${have}. Fund ${address}`);
23
+ constructor(accept, usdcUi, address) {
24
+ const usd = Number(accept?.extra?.billedUsd);
25
+ const needs = Number.isFinite(usd) ? `this call needs ≈$${usd.toFixed(6)}` : 'this call needs more than the wallet holds';
26
+ const holds = usdcUi != null ? ` — the wallet holds $${Number(usdcUi).toFixed(2)} USDC` : '';
27
+ super(`openzoo wallet underfunded: ${needs}${holds}. Send a few cents of USDC to ${address}.`);
21
28
  this.accept = accept;
22
- this.have = have;
29
+ this.usdcUi = usdcUi;
23
30
  this.address = address;
24
31
  }
25
32
  }
@@ -43,12 +50,18 @@ export class PayClient {
43
50
 
44
51
  get address() { return this.keypair.publicKey.toBase58(); }
45
52
 
46
- async buildPaymentFor(accept) {
53
+ async buildPaymentFor(accept, onStage) {
47
54
  const rail = railOf(accept);
48
55
  if (rail === 'solana') {
56
+ const need = BigInt(accept.maxAmountRequired);
49
57
  const bal = await tokenBalance(this.connection, this.keypair.publicKey, accept.asset);
50
- if (bal.raw < BigInt(accept.maxAmountRequired)) {
51
- throw new UnderfundedError(accept, bal.raw, this.address);
58
+ if (bal.raw < need) {
59
+ const topUp = await this.topUpQuotedAsset(accept, need, onStage);
60
+ if (topUp.preInstructions) {
61
+ return buildPaymentOnline(this.connection, this.keypair, accept, {
62
+ preInstructions: topUp.preInstructions,
63
+ });
64
+ }
52
65
  }
53
66
  return buildPaymentOnline(this.connection, this.keypair, accept);
54
67
  }
@@ -58,12 +71,65 @@ export class PayClient {
58
71
  throw new Error(`no payment builder for rail ${rail} (network ${accept.network})`);
59
72
  }
60
73
 
74
+ /**
75
+ * Internal: make sure the wallet holds enough of the exact asset the 402
76
+ * quotes, by converting the user's plain USDC (or whatever underlying the
77
+ * pool takes) at payment time. Users never see or hold the quoted asset.
78
+ *
79
+ * Returns {} when the balance now covers the quote, or
80
+ * { preInstructions } when the wallet cannot pay network fees itself — the
81
+ * conversion then rides inside the gateway-sponsored payment transaction
82
+ * (the 402's feePayer covers fees and any account rent).
83
+ * Throws UnderfundedError when the underlying balance cannot cover it.
84
+ */
85
+ async topUpQuotedAsset(accept, need, onStage) {
86
+ const owner = this.keypair.publicKey;
87
+ const pool = await resolvePool(this.connection, accept.asset).catch(() => null);
88
+ if (!pool) throw new UnderfundedError(accept, null, this.address);
89
+
90
+ for (let attempt = 0; attempt < 3; attempt++) {
91
+ const bal = await tokenBalance(this.connection, owner, accept.asset);
92
+ const short = need - bal.raw;
93
+ if (short <= 0n) return {};
94
+
95
+ const { reserves, supply } = await poolState(this.connection, pool);
96
+ const deposit = depositForShares(short, reserves, supply);
97
+ const underlyingBal = await tokenBalance(this.connection, owner, pool.underlying.toBase58());
98
+ if (underlyingBal.raw < deposit) {
99
+ throw new UnderfundedError(accept, underlyingBal.ui, this.address);
100
+ }
101
+
102
+ onStage?.('funding');
103
+ const wrappedAta = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
104
+ const [lamports, ataInfo] = await Promise.all([
105
+ this.connection.getBalance(owner),
106
+ this.connection.getAccountInfo(wrappedAta),
107
+ ]);
108
+ const lamportsNeeded = 10000 + (ataInfo ? 0 : 2400000); // tx fee + possible ATA rent
109
+ if (lamports < lamportsNeeded && accept.extra?.feePayer) {
110
+ // No SOL for a standalone conversion — bundle it into the payment tx.
111
+ const rentPayer = new PublicKey(accept.extra.feePayer);
112
+ return {
113
+ preInstructions: buildWrapInstructions({ pool, owner, depositRaw: deposit, rentPayer }),
114
+ };
115
+ }
116
+ const sig = await sendWrap(this.connection, this.keypair, pool, deposit);
117
+ this.lastTopUpSig = sig; // internal breadcrumb, never printed to users
118
+ if (process.env.OPENZOO_DEBUG) console.error(`[openzoo debug] top-up tx ${sig}`);
119
+ }
120
+
121
+ const finalBal = await tokenBalance(this.connection, owner, accept.asset);
122
+ if (finalBal.raw < need) throw new UnderfundedError(accept, null, this.address);
123
+ return {};
124
+ }
125
+
61
126
  /**
62
127
  * fetch that transparently pays a 402 once.
63
128
  * Returns { response, paid, accept?, settle?, receipt? }.
64
129
  * Throws QuoteTooHighError / UnderfundedError before any value moves.
65
130
  */
66
- async fetch(url, init = {}) {
131
+ async fetch(url, init = {}, { onStage } = {}) {
132
+ onStage?.('request');
67
133
  const first = await fetch(url, init);
68
134
  if (first.status !== 402) return { response: first, paid: false };
69
135
 
@@ -74,7 +140,9 @@ export class PayClient {
74
140
  throw new QuoteTooHighError(billedUsd, quote);
75
141
  }
76
142
 
77
- const payment = await this.buildPaymentFor(accept);
143
+ onStage?.('quoted');
144
+ const payment = await this.buildPaymentFor(accept, onStage);
145
+ onStage?.('paying');
78
146
  const response = await fetch(url, {
79
147
  ...init,
80
148
  headers: { ...(init.headers || {}), 'X-PAYMENT': payment.header },
@@ -101,12 +169,12 @@ export class PayClient {
101
169
  }
102
170
 
103
171
  /** POST a chat completion, paying as needed. Returns { data, receipt }. */
104
- async chat(bodyObj) {
172
+ async chat(bodyObj, { onStage } = {}) {
105
173
  const { response, receipt } = await this.fetch(`${config.apiBase}/v1/chat/completions`, {
106
174
  method: 'POST',
107
175
  headers: { 'content-type': 'application/json' },
108
176
  body: JSON.stringify(bodyObj),
109
- });
177
+ }, { onStage });
110
178
  if (!response.ok) {
111
179
  const text = (await response.text()).slice(0, 500);
112
180
  throw new Error(`zoo returned HTTP ${response.status}: ${text}`);
package/lib/proxy.js CHANGED
@@ -93,12 +93,11 @@ export async function startProxy({ silent = false } = {}) {
93
93
  if (client.walletCreated) console.log(`new burner wallet created at ${client.walletPath} (chmod 600)`);
94
94
  console.log(`wallet (fund me): ${client.address}`);
95
95
  try {
96
- // yUSDCx the $1-stable rail this proxy pays with by default.
97
- const yusdcx = '6ZjjxcoicqM4nniddkuPVwew4PDwY3swbfHsGbCuLuTv';
98
- const bal = await tokenBalance(client.connection, client.keypair.publicKey, yusdcx);
99
- console.log(`balance: ${bal.ui ?? 0} yUSDCx (mint ${yusdcx})`);
96
+ const usdc = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v';
97
+ const bal = await tokenBalance(client.connection, client.keypair.publicKey, usdc);
98
+ console.log(`balance: ${bal.ui ?? 0} USDC ( $${(bal.ui ?? 0).toFixed(2)})`);
100
99
  if (!bal.raw) {
101
- console.log('fund it with yUSDCx (wrap USDC at https://x402.accrue.fund/start) — a few cents goes a long way.');
100
+ console.log('fund it: send a few cents of USDC to the address above — a few cents goes a long way.');
102
101
  }
103
102
  } catch { /* RPC hiccup: balance is advisory */ }
104
103
  console.log('');
package/lib/spinner.js ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Tiny TTY-aware progress line for long waits (big-body upload, zoo pricing,
3
+ * payment settle). No deps: setInterval + \r rewrite on a TTY, plain periodic
4
+ * status lines when piped.
5
+ *
6
+ * const spin = startSpinner('uploading 3.8MB body');
7
+ * spin.update('zoo pricing 965k tokens');
8
+ * spin.stop(); // clears the line; caller prints the result
9
+ */
10
+ const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
11
+
12
+ export function startSpinner(initialLabel, { stream = process.stdout } = {}) {
13
+ const t0 = Date.now();
14
+ let label = initialLabel;
15
+ let frame = 0;
16
+ let stopped = false;
17
+ const secs = () => Math.round((Date.now() - t0) / 1000);
18
+
19
+ let timer;
20
+ if (stream.isTTY) {
21
+ timer = setInterval(() => {
22
+ frame = (frame + 1) % FRAMES.length;
23
+ stream.write(`\r\x1b[2K${FRAMES[frame]} ${label}… ${secs()}s`);
24
+ }, 100);
25
+ } else {
26
+ let lastPrinted = -5;
27
+ timer = setInterval(() => {
28
+ if (secs() - lastPrinted >= 5) {
29
+ lastPrinted = secs();
30
+ stream.write(`${label}… ${lastPrinted}s\n`);
31
+ }
32
+ }, 1000);
33
+ }
34
+ timer.unref?.();
35
+
36
+ return {
37
+ update(newLabel) { label = newLabel; },
38
+ elapsed: secs,
39
+ stop() {
40
+ if (stopped) return;
41
+ stopped = true;
42
+ clearInterval(timer);
43
+ if (stream.isTTY) stream.write('\r\x1b[2K');
44
+ },
45
+ };
46
+ }
47
+
48
+ /**
49
+ * POST a large JSON body with an upload-completion callback, so callers can
50
+ * flip a spinner label from "uploading" to "server working". Tries a streamed
51
+ * (chunked) upload — the pull() callback tells us when the socket drained the
52
+ * body — and falls back to a plain buffered POST if the runtime or server
53
+ * doesn't take streamed request bodies.
54
+ */
55
+ export async function postWithUploadSignal(url, bodyString, { headers = {}, onUploaded } = {}) {
56
+ const buf = Buffer.from(bodyString);
57
+ if (typeof ReadableStream === 'function' && buf.length > 262144) {
58
+ try {
59
+ let off = 0;
60
+ const stream = new ReadableStream({
61
+ pull(c) {
62
+ if (off >= buf.length) { c.close(); onUploaded?.(); return; }
63
+ const end = Math.min(off + 65536, buf.length);
64
+ c.enqueue(buf.subarray(off, end));
65
+ off = end;
66
+ },
67
+ });
68
+ const r = await fetch(url, {
69
+ method: 'POST',
70
+ headers: { 'content-type': 'application/json', ...headers },
71
+ body: stream,
72
+ duplex: 'half',
73
+ });
74
+ // 411/413 = server refused the chunked upload; retry buffered below.
75
+ if (r.status !== 411 && r.status !== 413) return r;
76
+ } catch { /* runtime without duplex streams — fall through */ }
77
+ }
78
+ const r = await fetch(url, {
79
+ method: 'POST',
80
+ headers: { 'content-type': 'application/json', ...headers },
81
+ body: buf,
82
+ });
83
+ onUploaded?.();
84
+ return r;
85
+ }
package/lib/wrap.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Internal funding plumbing for Solana 402 rails.
3
+ *
4
+ * The zoo's Solana quotes settle in a NAV-wrapped Token-2022 mint issued by
5
+ * the wrap-nav program (FrSER…). Users only ever hold and send plain USDC —
6
+ * this module converts the exact amount needed, at payment time, inside the
7
+ * shim. Nothing here is user-facing; never surface wrapped tickers or mints
8
+ * in messages that reach the user.
9
+ *
10
+ * Verified against the deployed program's own e2e (solana-token-wrap/e2e/e2e.mjs)
11
+ * and live mainnet wrap transactions:
12
+ * wrap ix: data = [1][amount u64 LE][authority bump]
13
+ * keys = [escrow(w), wrappedMint(w), userWrappedAta(w),
14
+ * authorityPDA, wrappedTokenProgram]
15
+ * shares minted = floor(amount * supply / reserves), reserves read at ix
16
+ * execution — so the wrap ix is placed BEFORE the deposit TransferChecked.
17
+ * First deposit is 1:1 minus MINIMUM_LIQUIDITY (1000) locked forever.
18
+ * authority = PDA(['mint_authority', wrappedMint]); escrow = ATA(underlying,
19
+ * authority); registry = PDA(['backpointer', wrappedMint]) when present.
20
+ */
21
+ import { PublicKey, Transaction, TransactionInstruction, sendAndConfirmTransaction } from '@solana/web3.js';
22
+ import {
23
+ TOKEN_PROGRAM_ID,
24
+ TOKEN_2022_PROGRAM_ID,
25
+ createAssociatedTokenAccountIdempotentInstruction,
26
+ createTransferCheckedInstruction,
27
+ getAssociatedTokenAddressSync,
28
+ unpackAccount,
29
+ unpackMint,
30
+ } from '@solana/spl-token';
31
+
32
+ export const WRAP_PROGRAM_ID = new PublicKey('FrSERTNCPvTtaDS9AvQp9u1nYGzXDb3kC9MdL8Xxn2NE');
33
+ const AUTHORITY_SEED = Buffer.from('mint_authority');
34
+ const BACKPOINTER_SEED = Buffer.from('backpointer');
35
+ export const MINIMUM_LIQUIDITY = 1000n;
36
+
37
+ // Machine-readable per-asset acquire directory published by the facilitator.
38
+ // Consulted first so newly listed twins work with zero code changes; on-chain
39
+ // derivation below is the fallback when the endpoint is unreachable.
40
+ const SUPPORTED_URL = process.env.OPENZOO_SUPPORTED_URL || 'https://x402.accrue.fund/supported';
41
+ let directoryCache = { at: 0, kinds: null };
42
+
43
+ async function acquireDirectory() {
44
+ if (directoryCache.kinds && Date.now() - directoryCache.at < 300000) return directoryCache.kinds;
45
+ try {
46
+ const r = await fetch(SUPPORTED_URL, { signal: AbortSignal.timeout(8000) });
47
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
48
+ const body = await r.json();
49
+ if (Array.isArray(body?.kinds)) directoryCache = { at: Date.now(), kinds: body.kinds };
50
+ } catch { /* endpoint down — fall back to chain derivation */ }
51
+ return directoryCache.kinds;
52
+ }
53
+
54
+ /** /supported entry for a quoted Solana mint, or null. */
55
+ async function directoryEntryFor(wrappedMintStr) {
56
+ const kinds = await acquireDirectory();
57
+ if (!kinds) return null;
58
+ const row = kinds.find((k) => k?.network?.startsWith?.('solana:') && k?.extra?.asset === wrappedMintStr);
59
+ const acq = row?.extra?.acquire;
60
+ return acq?.method === 'spl-token-wrap' && acq.underlying?.address && acq.escrow ? acq : null;
61
+ }
62
+
63
+ function u64le(n) { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; }
64
+
65
+ /**
66
+ * Resolve the pool behind a quoted mint. Returns null when the mint is not a
67
+ * wrap-nav twin (then there is nothing this module can do for it).
68
+ * Generic over whatever mint the 402 quotes — nothing hardcoded per twin.
69
+ */
70
+ export async function resolvePool(connection, wrappedMintStr) {
71
+ const wrapped = new PublicKey(wrappedMintStr);
72
+ const info = await connection.getAccountInfo(wrapped);
73
+ if (!info) return null;
74
+ const wrappedProgram = info.owner;
75
+ let mint;
76
+ try { mint = unpackMint(wrapped, info, wrappedProgram); } catch { return null; }
77
+
78
+ // Preferred source: the facilitator's own /supported directory.
79
+ const acq = await directoryEntryFor(wrappedMintStr);
80
+ if (acq) {
81
+ const programId = new PublicKey(acq.program || WRAP_PROGRAM_ID);
82
+ const authority = new PublicKey(acq.mintAuthority);
83
+ let bump = acq.authorityBump;
84
+ if (bump == null) {
85
+ const [derived, derivedBump] = PublicKey.findProgramAddressSync(
86
+ [AUTHORITY_SEED, wrapped.toBuffer()], programId,
87
+ );
88
+ if (derived.equals(authority)) bump = derivedBump;
89
+ }
90
+ if (bump != null && mint.mintAuthority?.equals(authority)) {
91
+ return {
92
+ wrapped,
93
+ wrappedProgram,
94
+ programId,
95
+ authority,
96
+ bump,
97
+ escrow: new PublicKey(acq.escrow),
98
+ underlying: new PublicKey(acq.underlying.address),
99
+ underlyingProgram: new PublicKey(acq.underlying.tokenProgram || TOKEN_PROGRAM_ID),
100
+ underlyingDecimals: acq.underlying.decimals ?? 6,
101
+ };
102
+ }
103
+ }
104
+
105
+ // Fallback: derive everything from chain state.
106
+ const [authority, bump] = PublicKey.findProgramAddressSync(
107
+ [AUTHORITY_SEED, wrapped.toBuffer()], WRAP_PROGRAM_ID,
108
+ );
109
+ if (!mint.mintAuthority || !mint.mintAuthority.equals(authority)) return null;
110
+
111
+ // Prefer the on-chain backpointer registry; fall back to the authority's
112
+ // token accounts (older pools were created before registration existed).
113
+ let underlying; let escrow; let underlyingProgram;
114
+ const [backpointer] = PublicKey.findProgramAddressSync(
115
+ [BACKPOINTER_SEED, wrapped.toBuffer()], WRAP_PROGRAM_ID,
116
+ );
117
+ const bp = await connection.getAccountInfo(backpointer);
118
+ if (bp && bp.owner.equals(WRAP_PROGRAM_ID) && bp.data.length >= 96) {
119
+ underlying = new PublicKey(bp.data.subarray(0, 32));
120
+ escrow = new PublicKey(bp.data.subarray(32, 64));
121
+ underlyingProgram = new PublicKey(bp.data.subarray(64, 96));
122
+ } else {
123
+ for (const programId of [TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID]) {
124
+ const res = await connection.getTokenAccountsByOwner(authority, { programId });
125
+ if (res.value.length) {
126
+ const first = res.value[0];
127
+ const acc = unpackAccount(first.pubkey, first.account, programId);
128
+ escrow = first.pubkey;
129
+ underlying = acc.mint;
130
+ underlyingProgram = programId;
131
+ break;
132
+ }
133
+ }
134
+ if (!escrow) return null;
135
+ }
136
+
137
+ const uInfo = await connection.getAccountInfo(underlying);
138
+ if (!uInfo) return null;
139
+ const uMint = unpackMint(underlying, uInfo, underlyingProgram);
140
+ return {
141
+ wrapped,
142
+ wrappedProgram,
143
+ programId: WRAP_PROGRAM_ID,
144
+ authority,
145
+ bump,
146
+ escrow,
147
+ underlying,
148
+ underlyingProgram,
149
+ underlyingDecimals: uMint.decimals,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * Underlying deposit required so that floor(deposit * supply / reserves)
155
+ * covers `sharesNeeded`. A small margin absorbs NAV drift between the read
156
+ * and the landing slot (donations/burns only ever reduce shares-per-asset).
157
+ */
158
+ export function depositForShares(sharesNeeded, reserves, supply) {
159
+ if (supply === 0n || reserves === 0n) return sharesNeeded + MINIMUM_LIQUIDITY;
160
+ const exact = (sharesNeeded * reserves + supply - 1n) / supply; // ceil
161
+ return exact + exact / 200n + 2n; // +0.5% + 2 raw units of drift margin
162
+ }
163
+
164
+ /** Current pool state: escrow reserves and wrapped supply, both raw bigint. */
165
+ export async function poolState(connection, pool) {
166
+ const [esc, sup] = await Promise.all([
167
+ connection.getTokenAccountBalance(pool.escrow).then((r) => BigInt(r.value.amount)).catch(() => 0n),
168
+ connection.getTokenSupply(pool.wrapped).then((r) => BigInt(r.value.amount)),
169
+ ]);
170
+ return { reserves: esc, supply: sup };
171
+ }
172
+
173
+ /**
174
+ * The three instructions of a conversion, in the mainnet-proven order:
175
+ * ensure the wrapped ATA, mint shares (program reads pre-deposit reserves),
176
+ * then move the deposit into escrow. `rentPayer` funds ATA creation (defaults
177
+ * to the owner; the gateway feePayer when riding inside a payment tx).
178
+ */
179
+ export function buildWrapInstructions({ pool, owner, depositRaw, rentPayer = owner }) {
180
+ const userWrapped = getAssociatedTokenAddressSync(pool.wrapped, owner, false, pool.wrappedProgram);
181
+ const userUnderlying = getAssociatedTokenAddressSync(pool.underlying, owner, false, pool.underlyingProgram);
182
+ const wrapIx = new TransactionInstruction({
183
+ programId: pool.programId || WRAP_PROGRAM_ID,
184
+ keys: [
185
+ { pubkey: pool.escrow, isSigner: false, isWritable: true },
186
+ { pubkey: pool.wrapped, isSigner: false, isWritable: true },
187
+ { pubkey: userWrapped, isSigner: false, isWritable: true },
188
+ { pubkey: pool.authority, isSigner: false, isWritable: false },
189
+ { pubkey: pool.wrappedProgram, isSigner: false, isWritable: false },
190
+ ],
191
+ data: Buffer.concat([Buffer.from([1]), u64le(depositRaw), Buffer.from([pool.bump])]),
192
+ });
193
+ return [
194
+ createAssociatedTokenAccountIdempotentInstruction(rentPayer, userWrapped, owner, pool.wrapped, pool.wrappedProgram),
195
+ wrapIx,
196
+ createTransferCheckedInstruction(
197
+ userUnderlying, pool.underlying, pool.escrow, owner,
198
+ depositRaw, pool.underlyingDecimals, [], pool.underlyingProgram,
199
+ ),
200
+ ];
201
+ }
202
+
203
+ /** Send a self-paid conversion tx and wait for confirmation. Returns the signature. */
204
+ export async function sendWrap(connection, keypair, pool, depositRaw) {
205
+ const ixs = buildWrapInstructions({ pool, owner: keypair.publicKey, depositRaw });
206
+ const tx = new Transaction().add(...ixs);
207
+ return sendAndConfirmTransaction(connection, tx, [keypair], { commitment: 'confirmed' });
208
+ }
package/lib/x402.js CHANGED
@@ -101,7 +101,7 @@ export async function getMintInfo(connection, mintStr) {
101
101
  * Build the partially-signed payment transaction + X-PAYMENT header value.
102
102
  * Pure given its inputs (no network) so it can be unit-tested offline.
103
103
  */
104
- export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair }) {
104
+ export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair, preInstructions = [] }) {
105
105
  const mint = new PublicKey(accept.asset);
106
106
  const payTo = new PublicKey(accept.payTo);
107
107
  const feePayer = new PublicKey(accept.extra.feePayer);
@@ -115,6 +115,7 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
115
115
  );
116
116
 
117
117
  const tx = new Transaction({ feePayer, recentBlockhash });
118
+ for (const pre of preInstructions) tx.add(pre); // internal funding plumbing, if any
118
119
  tx.add(ix);
119
120
  tx.partialSign(keypair); // owner signs; feePayer slot left empty for the facilitator
120
121
  const txBase64 = tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64');
@@ -138,10 +139,10 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
138
139
  }
139
140
 
140
141
  /** Resolve chain state (decimals, blockhash) and build the header. */
141
- export async function buildPaymentOnline(connection, keypair, accept) {
142
+ export async function buildPaymentOnline(connection, keypair, accept, { preInstructions = [] } = {}) {
142
143
  const { programId, decimals } = await getMintInfo(connection, accept.asset);
143
144
  const { blockhash } = await connection.getLatestBlockhash('confirmed');
144
- return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair });
145
+ return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair, preInstructions });
145
146
  }
146
147
 
147
148
  /** Token balance of our ATA for a mint. Returns { raw: bigint, ui: number|null }. */
@@ -159,7 +160,7 @@ export async function tokenBalance(connection, owner, mintStr) {
159
160
  /** One-line human receipt for a paid call. */
160
161
  export function receiptLine(accept, settle) {
161
162
  const x = accept.extra || {};
162
- const usd = x.billedUsd != null ? `$${Number(x.billedUsd).toFixed(6)}` : `${accept.maxAmountRequired} raw ${x.symbol || ''}`;
163
+ const usd = x.billedUsd != null ? `$${Number(x.billedUsd).toFixed(6)}` : `${accept.maxAmountRequired} raw units`;
163
164
  const saves = x.savesVsDirect != null ? ` (${Number(x.savesVsDirect).toFixed(1)}× cheaper than direct)` : (x.markup != null ? ` (markup ${x.markup}×, short body)` : '');
164
165
  const tx = settle?.transaction || settle?.txHash || settle?.signature;
165
166
  const rail = railOf(accept);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Local x402-paying proxy + MCP server for openzoo.fun — point any OpenAI-compatible harness (Cursor, Claude Code, aider, SDKs) at localhost and it pays per call from a local burner wallet. Solana rail live; Base/Robinhood rails experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",