openzoo 0.4.2 → 0.6.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
@@ -126,6 +126,39 @@ npx openzoo contexts --forget all # drop everything
126
126
 
127
127
  Opt out with `OPENZOO_NO_CONTEXT_CACHE=1` (always ship the full body); tune the threshold with `OPENZOO_CONTEXT_MIN_CHARS` (default 16384 chars).
128
128
 
129
+ ## Using openzoo from a cloud IDE (Cursor, Windsurf, hosted agents)
130
+
131
+ Some IDEs run their model calls **from their own servers**, not your machine. Point one of those at `http://localhost:8402` and you get, verbatim:
132
+
133
+ ```
134
+ Provider returned error: Access to private networks is forbidden
135
+ ```
136
+
137
+ That is reachability, not payment — their cloud cannot dial your laptop. Give it a public URL instead:
138
+
139
+ ```bash
140
+ npx openzoo tunnel
141
+ ```
142
+
143
+ It installs `cloudflared` itself (one-time, cached in `~/.openzoo/bin`; a Cloudflare account is not needed), opens a quick tunnel to your local proxy, and prints:
144
+
145
+ ```
146
+ base_url = https://<random>.trycloudflare.com/v1
147
+ api_key = oz_<random> # in tunnel mode the key is REAL auth
148
+ ```
149
+
150
+ **Tunnel mode is the one mode where the api key matters.** A public URL in front of a wallet is a public URL in front of your money, so:
151
+
152
+ - every request without `Authorization: Bearer <that key>` is refused **401** before anything is forwarded, quoted or paid;
153
+ - the per-call cap (`OPENZOO_MAX_USD_PER_CALL`, default $0.50) still applies;
154
+ - a session ceiling stops all spending at `OPENZOO_TUNNEL_MAX_USD` (default **$1.00**);
155
+ - every served request prints its receipt and the running session total;
156
+ - the URL dies when you Ctrl-C, and the session's total spend is printed on exit.
157
+
158
+ Pin the key with `OPENZOO_TUNNEL_TOKEN` if your IDE stores it. Keys never leave your machine either way — the tunnel forwards to the same local proxy, which signs with the same local wallet.
159
+
160
+ **MCP clients don't need any of this.** If your tool speaks MCP, use the hosted server at `https://mcp.openzoo.fun/mcp` — cloud-reachable, no local process, mint a wallet with `zoo_wallet`.
161
+
129
162
  ## The wallet model
130
163
 
131
164
  - **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.
@@ -135,8 +168,9 @@ Opt out with `OPENZOO_NO_CONTEXT_CACHE=1` (always ship the full body); tune the
135
168
 
136
169
  Both are first-class: each is the underlying of a live Solana rail the zoo quotes (USDC → yUSDCx, TOKEN → wTOKENx). That's the whole funding story — the shim converts whichever the 402 quotes internally, at payment time, for exactly the amount needed.
137
170
  - **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.
171
+ - **The same wallet file also holds an EVM key.** `npx openzoo address` prints both addresses: the Solana one, and the EVM one used by the Base and Robinhood Chain rails. Fund Base with plain **USDC on Base** (nothing is converted); for Robinhood Chain, deposit USDG at [x402.accrue.fund/start](https://x402.accrue.fund/start) so the wallet holds the settlement asset.
138
172
  - **Spend caps.** The proxy refuses any single quote above `OPENZOO_MAX_USD_PER_CALL` (default $0.50).
139
- - `npx openzoo balance` / `npx openzoo address` check funds (USDC + TOKEN + SOL + USD value) / print the address.
173
+ - `npx openzoo balance` funds on every rail, grouped per chain: Solana (USDC + TOKEN + SOL), Base (USDC + ETH), Robinhood Chain (USDG, the ODDBALLER / IOU / ROBINHOODS memecoins, ETH). USD value where a price is known; `$?` where not. `npx openzoo address` — both funding addresses.
140
174
 
141
175
  ## Honest pricing note
142
176
 
@@ -149,15 +183,17 @@ The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect`
149
183
 
150
184
  ## Payment rails
151
185
 
186
+ All three rails have settled real payments (2026-08-14):
187
+
152
188
  | rail | network | status |
153
189
  |---|---|---|
154
- | **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 or TOKEN. |
155
- | Base | `eip155:8453` | **offered by the zoo** — standard x402 EIP-3009 `transferWithAuthorization` against native USDC. Fund the wallet's EVM address with USDC on Base; nothing is converted. Settlement from this package is live-untested. |
156
- | Robinhood Chain | `eip155:4663` | experimental, behind `OPENZOO_ENABLE_RH=1`. The zoo quotes it, but its settlement asset has no conversion path here (conversion is Solana-only), so there is no plain balance you can fund and have the shim spend use Solana or Base. |
190
+ | **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees. Settles daily; tested end-to-end against the production 402. Settlement uses a wrapped settlement mint as internal plumbing; you only ever hold and send USDC or TOKEN. |
191
+ | Base | `eip155:8453` | **live** — standard x402 EIP-3009 `transferWithAuthorization` against native USDC, batched settle through the facilitator. Fund the wallet's EVM address with USDC on Base; nothing is converted. |
192
+ | Robinhood Chain | `eip155:4663` | **live** EIP-3009 against the RH settlement asset, batched settle through the facilitator. There is no auto-conversion path here (conversion is Solana-only), so the wallet must hold the settlement asset itself: deposit USDG at [x402.accrue.fund/start](https://x402.accrue.fund/start). Default rail selection skips it unless `OPENZOO_ENABLE_RH=1`; `OPENZOO_RAIL=robinhood` forces it outright. |
157
193
 
158
- `npx openzoo` prints the rails off a live 402 at startup, and the funding line is derived from those rails — so a new chain shows up without this package shipping again.
194
+ `npx openzoo` prints the rails off a live 402 at startup, and the funding line is derived from those rails — so a new chain (the wrapped RH memecoin twins are in-deploy, for example) shows up without this package shipping again.
159
195
 
160
- The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are always taken as raw units from the 402, and Solana decimals are read from the mint **on-chain** — never hardcoded. (The zoo's own pasted prompt hardcodes `decimals = 6`; that's wrong for 18-decimal mints and this package deliberately does not copy the bug.)
196
+ The rail is chosen from the 402's `accepts[]` itself (Solana first). **Steer it with `OPENZOO_RAIL=solana|base|robinhood`** — the picker then uses only that rail, and errors clearly if the live 402 does not offer it. Amounts are always taken as raw units from the 402, and Solana decimals are read from the mint **on-chain** — never hardcoded. (The zoo's own pasted prompt used to hardcode `decimals = 6`; this package deliberately does not copy the bug.)
161
197
 
162
198
  ## Configuration
163
199
 
@@ -167,12 +203,15 @@ The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are
167
203
  | `OPENZOO_API_BASE` | `https://x402-tokens.fly.dev` | the zoo's door |
168
204
  | `OPENZOO_RPC` | mainnet-beta public RPC | Solana RPC |
169
205
  | `OPENZOO_TOKEN` | (internal) | preferred 402 rail — leave unset |
206
+ | `OPENZOO_RAIL` | (unset) | force a rail: `solana` \| `base` \| `robinhood`. Errors if the live 402 doesn't offer it |
207
+ | `OPENZOO_BASE_RPC` | `https://mainnet.base.org` | Base RPC (balances / preflight) |
208
+ | `OPENZOO_RH_RPC` | `https://rpc.mainnet.chain.robinhood.com` | Robinhood Chain RPC (balances / preflight) |
170
209
  | `OPENZOO_WALLET` | `~/.openzoo/wallet.json` | wallet path |
171
210
  | `OPENZOO_MAX_USD_PER_CALL` | `0.5` | refuse quotes above this |
172
211
  | `OPENZOO_DEMO_MAX_USD` | `0.01` | demo spend cap |
173
212
  | `OPENZOO_CONTEXT_MIN_CHARS` | `16384` | bodies bigger than this bind once + reuse |
174
213
  | `OPENZOO_NO_CONTEXT_CACHE` | `0` | set `1` to always ship the full body |
175
- | `OPENZOO_ENABLE_RH` | `0` | allow the Robinhood rail |
214
+ | `OPENZOO_ENABLE_RH` | `0` | let default selection fall through to the Robinhood rail (`OPENZOO_RAIL=robinhood` forces it without this) |
176
215
 
177
216
  ## What's tested
178
217
 
package/bin/openzoo.js CHANGED
@@ -6,25 +6,36 @@ const HELP = `openzoo — local x402-paying proxy + MCP server for openzoo.fun
6
6
  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
+ npx openzoo tunnel public HTTPS url for cloud IDEs that cannot reach localhost
10
+ (installs cloudflared itself; mints a required api key)
9
11
  npx openzoo demo ~1M-token needle demo: direct refuses, the zoo answers
10
12
  (run it twice — the second run reuses the bound corpus and is near-free)
11
13
  npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
12
14
  npx openzoo contexts --forget <hash|all> drop manifest entries
13
- npx openzoo balance wallet balance (USDC + TOKEN + SOL, with USD value)
14
- npx openzoo address print the funding address
15
+ npx openzoo balance wallet balance on every rail Solana (USDC/TOKEN/SOL),
16
+ Base (USDC/ETH), Robinhood Chain (USDG/memecoins/ETH)
17
+ npx openzoo address print both funding addresses (Solana + EVM)
15
18
  npx openzoo help this text
16
19
 
17
20
  point any OpenAI-compatible harness at:
18
21
  base_url = http://localhost:8402/v1
19
22
  api_key = sk-openzoo (any value; the zoo takes payment, not keys)
20
23
 
24
+ rails (all three settle real payments; Solana is the default):
25
+ OPENZOO_RAIL=solana|base|robinhood forces one rail — errors clearly if the
26
+ live 402 does not offer it. Unset = Solana first.
27
+
21
28
  env:
22
29
  OPENZOO_PORT (8402) OPENZOO_API_BASE (https://x402-tokens.fly.dev)
23
30
  OPENZOO_RPC (mainnet-beta) OPENZOO_TOKEN (402 rail preference) OPENZOO_WALLET (~/.openzoo/wallet.json)
31
+ OPENZOO_RAIL (unset — force a rail: solana | base | robinhood)
32
+ OPENZOO_BASE_RPC (https://mainnet.base.org) OPENZOO_RH_RPC (rpc.mainnet.chain.robinhood.com)
24
33
  OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
25
34
  OPENZOO_CONTEXT_MIN_CHARS (16384 — bodies bigger than this bind once + reuse)
26
35
  OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
27
- OPENZOO_ENABLE_RH (0 — Robinhood Chain rail, experimental)`;
36
+ OPENZOO_ENABLE_RH (0 — let DEFAULT selection fall through to the Robinhood rail;
37
+ OPENZOO_RAIL=robinhood forces it without this)
38
+ OPENZOO_TUNNEL_MAX_USD (1.00 — tunnel session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)`;
28
39
 
29
40
  async function main() {
30
41
  switch (cmd) {
@@ -35,6 +46,9 @@ async function main() {
35
46
  case 'mcp':
36
47
  await (await import('../lib/mcp.js')).startMcp();
37
48
  break;
49
+ case 'tunnel':
50
+ await (await import('../lib/tunnel.js')).runTunnel();
51
+ break;
38
52
  case 'demo':
39
53
  await (await import('../lib/demo.js')).runDemo();
40
54
  break;
package/lib/config.js CHANGED
@@ -7,6 +7,12 @@ export const config = {
7
7
  rpcUrl: process.env.OPENZOO_RPC || 'https://api.mainnet-beta.solana.com',
8
8
  // Which accepts[] row to pay with, by extra.symbol (internal rail selector).
9
9
  token: process.env.OPENZOO_TOKEN || 'yUSDCx',
10
+ // Force a rail: 'solana' | 'base' | 'robinhood'. Unset -> Solana-first order.
11
+ // pickAccept errors clearly when the live 402 does not offer the forced rail.
12
+ rail: (process.env.OPENZOO_RAIL || '').toLowerCase() || null,
13
+ // EVM RPCs for balance reads / preflight checks on the Base and RH rails.
14
+ baseRpcUrl: process.env.OPENZOO_BASE_RPC || 'https://mainnet.base.org',
15
+ rhRpcUrl: process.env.OPENZOO_RH_RPC || 'https://rpc.mainnet.chain.robinhood.com',
10
16
  walletPath: process.env.OPENZOO_WALLET || path.join(os.homedir(), '.openzoo', 'wallet.json'),
11
17
  // Refuse to auto-pay any single 402 quote above this many USD (extra.billedUsd).
12
18
  maxUsdPerCall: Number(process.env.OPENZOO_MAX_USD_PER_CALL || 0.5),
@@ -28,6 +34,30 @@ export const FUNDING_ASSETS = [
28
34
  export const USDC_MINT = FUNDING_ASSETS[0].mint;
29
35
  export const TOKEN_MINT = FUNDING_ASSETS[1].mint;
30
36
 
37
+ /**
38
+ * EVM-side balances `openzoo balance` reports, grouped by rail. `usd` is a
39
+ * known price (stables); null means "no honest price here" and the CLI prints
40
+ * `?` rather than inventing one. The memecoins are Robinhood Chain ERC-20s
41
+ * (18 decimals, verified on-chain 2026-08-14); their wrapped x402 twins are
42
+ * in-deploy and will appear in the 402 itself when live.
43
+ */
44
+ export const EVM_FUNDING_ASSETS = {
45
+ base: [
46
+ { symbol: 'USDC', address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', decimals: 6, usd: 1 },
47
+ ],
48
+ robinhood: [
49
+ { symbol: 'USDG', address: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168', decimals: 6, usd: 1 },
50
+ { symbol: 'ODDBALLER', address: '0x923eb7BD5B84a1a114CB57212cE2F2e87AE60E2A', decimals: 18, usd: null },
51
+ { symbol: 'IOU', address: '0xf391999FACbEE613D4024191Dd31060540BF0bEd', decimals: 18, usd: null },
52
+ { symbol: 'ROBINHOODS', address: '0xC42cF61C16aaC797b991cf9C1ac8Ae70bA74A286', decimals: 18, usd: null },
53
+ ],
54
+ };
55
+
56
+ /** RPC endpoint for an EVM rail name. */
57
+ export function evmRpcFor(rail) {
58
+ return rail === 'base' ? config.baseRpcUrl : rail === 'robinhood' ? config.rhRpcUrl : null;
59
+ }
60
+
31
61
  /**
32
62
  * The one canonical way to tell a user how to fund the SOLANA rail. Callers
33
63
  * pass the Solana address; the rail is named explicitly because the wallet
@@ -45,9 +75,11 @@ export function fundingLine(address) {
45
75
  * Solana: quoted in settlement mints, converted from plain USDC / TOKEN at
46
76
  * payment time (lib/wrap.js).
47
77
  * Base: quoted in native USDC — funded and spent as-is, no conversion.
48
- * Robinhood: quoted in a settlement asset with no conversion path on EVM
49
- * (wrapping is Solana-only), so there is no plain balance a user can fund
50
- * and have the shim pay from hence no assets, and no funding line.
78
+ * Robinhood: the rail settles (real payments 2026-08-14), but it is quoted in
79
+ * a settlement asset with no conversion path in this shim (wrapping is
80
+ * Solana-only). To pay here, deposit USDG into the vault yourself at
81
+ * https://x402.accrue.fund/start so the wallet holds the settlement asset —
82
+ * there is no PLAIN balance the shim can auto-convert, hence no assets here.
51
83
  */
52
84
  export const RAIL_FUNDING = {
53
85
  solana: { label: 'Solana', assets: ['USDC', 'TOKEN'] },
package/lib/evm.js CHANGED
@@ -1,7 +1,28 @@
1
1
  import crypto from 'node:crypto';
2
+ import { createPublicClient, http } from 'viem';
2
3
  import { privateKeyToAccount } from 'viem/accounts';
3
4
  import { evmChainId } from './x402.js';
4
5
 
6
+ const ERC20_ABI = [
7
+ { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'a', type: 'address' }], outputs: [{ type: 'uint256' }] },
8
+ ];
9
+
10
+ const evmClients = new Map();
11
+ function clientFor(rpcUrl) {
12
+ if (!evmClients.has(rpcUrl)) evmClients.set(rpcUrl, createPublicClient({ transport: http(rpcUrl) }));
13
+ return evmClients.get(rpcUrl);
14
+ }
15
+
16
+ /** ERC-20 balance (raw bigint) of `owner` for `token` on the chain at `rpcUrl`. */
17
+ export async function evmTokenBalance({ rpcUrl, token, owner }) {
18
+ return clientFor(rpcUrl).readContract({ address: token, abi: ERC20_ABI, functionName: 'balanceOf', args: [owner] });
19
+ }
20
+
21
+ /** Native gas balance (wei bigint) of `owner` on the chain at `rpcUrl`. */
22
+ export async function evmNativeBalance({ rpcUrl, owner }) {
23
+ return clientFor(rpcUrl).getBalance({ address: owner });
24
+ }
25
+
5
26
  /**
6
27
  * EVM rails (Base eip155:8453; Robinhood Chain eip155:4663 behind OPENZOO_ENABLE_RH).
7
28
  *
@@ -15,9 +36,10 @@ import { evmChainId } from './x402.js';
15
36
  * We never scale by decimals, so the zoo's /prompt.txt "decimals = 6" bug
16
37
  * (wrong for the 18-decimal Robinhood mints) cannot bite this path.
17
38
  *
18
- * STATUS: code-present, live-UNTESTED the zoo's 402s currently offer only
19
- * Solana rows, and whether its facilitator settles eip155 chains at all is
20
- * unverified upstream.
39
+ * STATUS: live. Real payments have settled on both EVM rails (2026-08-14):
40
+ * native USDC on Base (eip155:8453) and the RH settlement asset on Robinhood
41
+ * Chain (eip155:4663), both via EIP-3009 batched settlement through the
42
+ * facilitator. Steer to a rail with OPENZOO_RAIL.
21
43
  */
22
44
  export async function buildEvmPayment({ accept, evmPrivateKey }) {
23
45
  const chainId = evmChainId(accept.network);
package/lib/info.js CHANGED
@@ -1,31 +1,75 @@
1
1
  import { Connection } from '@solana/web3.js';
2
2
  import { privateKeyToAccount } from 'viem/accounts';
3
- import { config, FUNDING_ASSETS, fundingLine } from './config.js';
3
+ import { config, FUNDING_ASSETS, EVM_FUNDING_ASSETS, evmRpcFor, fundingLine } from './config.js';
4
4
  import { loadOrCreateWallet } from './wallet.js';
5
5
  import { tokenBalance } from './x402.js';
6
+ import { evmTokenBalance, evmNativeBalance } from './evm.js';
6
7
 
7
8
  export function printAddress() {
8
9
  const { keypair, evmPrivateKey, created, path } = loadOrCreateWallet();
9
10
  if (created) console.log(`new burner wallet created at ${path} (chmod 600)`);
10
- console.log(keypair.publicKey.toBase58());
11
- console.log(`(evm, for the Base / Robinhood rails: ${privateKeyToAccount(evmPrivateKey).address})`);
11
+ console.log(`solana : ${keypair.publicKey.toBase58()}`);
12
+ console.log(`evm (Base · Robinhood) : ${privateKeyToAccount(evmPrivateKey).address}`);
12
13
  }
13
14
 
15
+ const CHAIN_LABEL = { base: 'Base', robinhood: 'Robinhood Chain' };
16
+
17
+ function fmtUi(raw, decimals) {
18
+ return Number(raw) / 10 ** decimals;
19
+ }
20
+
21
+ /**
22
+ * Balance across every rail: Solana (USDC + TOKEN + SOL) and each EVM chain
23
+ * (stables, the RH memecoins, native gas). USD value is shown only where a
24
+ * price is known ($1 stables); everything else gets an honest `?`.
25
+ * A chain whose RPC does not answer prints as unreachable — never as zero.
26
+ */
14
27
  export async function printBalance() {
15
- const { keypair } = loadOrCreateWallet();
28
+ const { keypair, evmPrivateKey } = loadOrCreateWallet();
29
+ const evmAddress = privateKeyToAccount(evmPrivateKey).address;
16
30
  const connection = new Connection(config.rpcUrl, 'confirmed');
31
+
32
+ // Solana ------------------------------------------------------------------
17
33
  const [balances, lamports] = await Promise.all([
18
34
  Promise.all(FUNDING_ASSETS.map((a) => tokenBalance(connection, keypair.publicKey, a.mint))),
19
35
  connection.getBalance(keypair.publicKey),
20
36
  ]);
21
37
  const usdcUi = balances[0].ui ?? 0;
22
- console.log(`wallet : ${keypair.publicKey.toBase58()}`);
38
+ let knownUsd = usdcUi;
39
+ let anyFunds = balances.some((b) => b.raw);
40
+
41
+ console.log(`Solana — ${keypair.publicKey.toBase58()}`);
23
42
  FUNDING_ASSETS.forEach((a, i) => {
24
- console.log(`${a.symbol.padEnd(7)}: ${balances[i].ui ?? 0}`);
43
+ const usd = a.symbol === 'USDC' ? ` ($${(balances[i].ui ?? 0).toFixed(2)})` : ' ($?, valued at the 402)';
44
+ console.log(` ${a.symbol.padEnd(11)}: ${balances[i].ui ?? 0}${usd}`);
25
45
  });
26
- console.log(`SOL : ${lamports / 1e9}`);
27
- console.log(`value : ≈ $${usdcUi.toFixed(2)} (USDC leg; TOKEN valued at the 402)`);
28
- if (!balances.some((b) => b.raw)) {
29
- console.log(`fund : ${fundingLine(keypair.publicKey.toBase58())}`);
46
+ console.log(` ${'SOL'.padEnd(11)}: ${lamports / 1e9} (gas — optional, payments are sponsored)`);
47
+
48
+ // EVM chains --------------------------------------------------------------
49
+ for (const [rail, assets] of Object.entries(EVM_FUNDING_ASSETS)) {
50
+ const rpcUrl = evmRpcFor(rail);
51
+ console.log(`${CHAIN_LABEL[rail] ?? rail} — ${evmAddress}`);
52
+ try {
53
+ const [native, ...raws] = await Promise.all([
54
+ evmNativeBalance({ rpcUrl, owner: evmAddress }),
55
+ ...assets.map((a) => evmTokenBalance({ rpcUrl, token: a.address, owner: evmAddress })),
56
+ ]);
57
+ assets.forEach((a, i) => {
58
+ const ui = fmtUi(raws[i], a.decimals);
59
+ if (raws[i] > 0n) anyFunds = true;
60
+ if (a.usd != null) knownUsd += ui * a.usd;
61
+ const usd = a.usd != null ? ` ($${(ui * a.usd).toFixed(2)})` : ' ($?)';
62
+ console.log(` ${a.symbol.padEnd(11)}: ${ui}${usd}`);
63
+ });
64
+ console.log(` ${'ETH'.padEnd(11)}: ${fmtUi(native, 18)} (gas — optional, payments are sponsored)`);
65
+ } catch {
66
+ console.log(' (rpc unreachable — balances not checked, funds unaffected)');
67
+ }
68
+ }
69
+
70
+ console.log(`value : ≈ $${knownUsd.toFixed(2)} known (stable legs; TOKEN and the RH memecoins are priced at the 402, shown as $?)`);
71
+ if (!anyFunds) {
72
+ console.log(`fund : ${fundingLine(keypair.publicKey.toBase58())}`);
73
+ console.log(` or USDC on Base to ${evmAddress}`);
30
74
  }
31
75
  }
package/lib/mcp.js CHANGED
@@ -104,7 +104,7 @@ export async function startMcp() {
104
104
  railsLiveNow: rails?.live ?? null,
105
105
  fundWith: hint || `${FUNDING_ASSETS.map((a) => a.symbol).join(' or ')} on Solana`,
106
106
  solanaMints: Object.fromEntries(FUNDING_ASSETS.map((a) => [a.symbol, a.mint])),
107
- fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time.',
107
+ fundHint: 'send a few cents of a listed asset to the address for that rail — Solana assets to solanaAddress, Base assets to evmAddress. The shim converts to whatever the 402 quotes, at payment time. Force a rail with OPENZOO_RAIL=solana|base|robinhood.',
108
108
  balances,
109
109
  receipts: client.receipts.map((r) => ({ at: r.at, line: r.line })),
110
110
  });
package/lib/pay.js CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  parse402, pickAccept, railOf, buildPaymentOnline, tokenBalance,
7
7
  receiptLine, decodeSettleHeader,
8
8
  } from './x402.js';
9
- import { buildEvmPayment } from './evm.js';
9
+ import { buildEvmPayment, evmTokenBalance } from './evm.js';
10
10
  import { privateKeyToAccount } from 'viem/accounts';
11
11
  import {
12
12
  resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
@@ -21,11 +21,13 @@ export class QuoteTooHighError extends Error {
21
21
  }
22
22
 
23
23
  export class UnderfundedError extends Error {
24
- constructor(accept, usdcUi, address) {
24
+ constructor(accept, usdcUi, address, { line } = {}) {
25
25
  const usd = Number(accept?.extra?.billedUsd);
26
26
  const needs = Number.isFinite(usd) ? `this call needs ≈$${usd.toFixed(6)}` : 'this call needs more than the wallet holds';
27
27
  const holds = usdcUi != null ? ` — the wallet holds $${Number(usdcUi).toFixed(2)} USDC` : '';
28
- super(`openzoo wallet underfunded: ${needs}${holds}. ${fundingLine(address).replace(/^s/, 'S')}.`);
28
+ super(line
29
+ ? `openzoo wallet underfunded: ${needs}. ${line}`
30
+ : `openzoo wallet underfunded: ${needs}${holds}. ${fundingLine(address).replace(/^s/, 'S')}.`);
29
31
  this.accept = accept;
30
32
  this.usdcUi = usdcUi;
31
33
  this.address = address;
@@ -72,7 +74,23 @@ export class PayClient {
72
74
  }
73
75
  return buildPaymentOnline(this.connection, this.keypair, accept);
74
76
  }
75
- if (rail === 'base' || rail === 'evm' || (rail === 'robinhood' && this.allowRH)) {
77
+ // OPENZOO_RAIL=robinhood is explicit intent it opens the RH gate the
78
+ // same way OPENZOO_ENABLE_RH=1 does.
79
+ if (rail === 'base' || rail === 'evm' || (rail === 'robinhood' && (this.allowRH || config.rail === 'robinhood'))) {
80
+ // Preflight: check the settlement-asset balance so an unfundable payment
81
+ // fails HERE with funding instructions instead of at the facilitator.
82
+ // Advisory only — an unreachable RPC never blocks a payment attempt.
83
+ const rpcUrl = rail === 'base' ? config.baseRpcUrl : config.rhRpcUrl;
84
+ const owner = this.evmAddress;
85
+ if (owner) {
86
+ const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => null);
87
+ if (bal !== null && bal < BigInt(accept.maxAmountRequired)) {
88
+ const line = rail === 'base'
89
+ ? `Send a few cents of USDC on Base to ${owner}.`
90
+ : `The wallet must hold the Robinhood Chain settlement asset — deposit USDG at https://x402.accrue.fund/start (wallet: ${owner}).`;
91
+ throw new UnderfundedError(accept, null, owner, { line });
92
+ }
93
+ }
76
94
  return buildEvmPayment({ accept, evmPrivateKey: this.evmPrivateKey });
77
95
  }
78
96
  throw new Error(`no payment builder for rail ${rail} (network ${accept.network})`);
@@ -141,7 +159,9 @@ export class PayClient {
141
159
  if (first.status !== 402) return { response: first, paid: false };
142
160
 
143
161
  const quote = parse402(await first.json());
144
- const accept = pickAccept(quote, config.token, { allowRH: this.allowRH });
162
+ // config.rail (OPENZOO_RAIL) steers every front — proxy, demo, MCP since
163
+ // they all pay through this one call site.
164
+ const accept = pickAccept(quote, config.token, { allowRH: this.allowRH, forceRail: config.rail });
145
165
  const billedUsd = Number(accept?.extra?.billedUsd ?? NaN);
146
166
  if (Number.isFinite(billedUsd) && billedUsd > config.maxUsdPerCall) {
147
167
  throw new QuoteTooHighError(billedUsd, quote);
package/lib/proxy.js CHANGED
@@ -104,12 +104,32 @@ async function maybeCacheCorpus(req, bodyBuf, log) {
104
104
  };
105
105
  }
106
106
 
107
- export async function startProxy({ silent = false } = {}) {
107
+ /**
108
+ * `requireToken` / `sessionMaxUsd` are TUNNEL MODE (see lib/tunnel.js): once the
109
+ * proxy is reachable from the internet, the api key stops being decorative and
110
+ * becomes the only thing between a stranger and your wallet. Both are off by
111
+ * default, so localhost behaviour is unchanged.
112
+ */
113
+ export async function startProxy({ silent = false, requireToken = null, sessionMaxUsd = null } = {}) {
108
114
  const client = new PayClient();
109
115
  const log = silent ? () => {} : (...a) => console.log(...a);
116
+ let sessionSpent = 0;
110
117
 
111
118
  const server = http.createServer(async (req, res) => {
112
119
  const url = `${config.apiBase}${req.url}`;
120
+ // Auth first: refuse before reading a body, forwarding, quoting or paying.
121
+ if (requireToken) {
122
+ const got = (req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
123
+ if (got !== requireToken) {
124
+ log(`tunnel: 401 ${req.method} ${req.url} from ${req.socket.remoteAddress}`);
125
+ jsonErr(res, 401, 'unauthorized: this openzoo tunnel requires the api key printed at startup');
126
+ return;
127
+ }
128
+ if (sessionMaxUsd != null && sessionSpent >= sessionMaxUsd) {
129
+ jsonErr(res, 402, `openzoo tunnel session cap reached ($${sessionMaxUsd}) — restart the tunnel or raise OPENZOO_TUNNEL_MAX_USD`);
130
+ return;
131
+ }
132
+ }
113
133
  let bodyBuf;
114
134
  try {
115
135
  bodyBuf = await readBody(req);
@@ -154,7 +174,13 @@ export async function startProxy({ silent = false } = {}) {
154
174
  result = await client.fetch(url, init);
155
175
  }
156
176
  const { response, paid, receipt } = result;
157
- if (paid && receipt) log(receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`);
177
+ if (paid && receipt) {
178
+ if (receipt.ok && typeof receipt.billedUsd === 'number') sessionSpent += receipt.billedUsd;
179
+ const line = receipt.ok ? receipt.line : `paid retry -> HTTP ${receipt.status}`;
180
+ // In tunnel mode the running total is the thing you actually want to
181
+ // watch, so it rides on every receipt.
182
+ log(requireToken ? `${line} · session $${sessionSpent.toFixed(6)}` : line);
183
+ }
158
184
  await relay(res, response);
159
185
  } catch (err) {
160
186
  if (err instanceof QuoteTooHighError) {
@@ -218,5 +244,5 @@ export async function startProxy({ silent = false } = {}) {
218
244
  console.log(` base_url = http://localhost:${config.port}/v1`);
219
245
  console.log(' api_key = sk-openzoo (any value works; the zoo takes payment, not keys)');
220
246
  }
221
- return { server, client };
247
+ return { server, client, spent: () => sessionSpent };
222
248
  }
package/lib/tunnel.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * `npx openzoo tunnel` — make the local paying proxy reachable from a CLOUD-run
3
+ * harness.
4
+ *
5
+ * WHY THIS EXISTS: IDEs that execute model calls from their own servers cannot
6
+ * dial your laptop. Pointing one at http://localhost:8402 returns, verbatim:
7
+ * "Provider returned error: Access to private networks is forbidden"
8
+ * The payment path is fine — the harness just can't reach it. A tunnel gives
9
+ * that harness a public HTTPS URL while your keys stay on this machine.
10
+ *
11
+ * SELF-PROVISIONING: we do not ask you to install anything. If `cloudflared`
12
+ * isn't on PATH we fetch the single static binary for this platform into
13
+ * ~/.openzoo/bin and cache it. Quick tunnels need no Cloudflare account.
14
+ *
15
+ * SECURITY IS THE DESIGN. A public URL in front of a wallet is a public URL in
16
+ * front of your money, so tunnel mode is the ONE mode where the api key is real:
17
+ * - a random bearer token is minted per session and REQUIRED (401 otherwise),
18
+ * checked before anything is forwarded, quoted, or paid;
19
+ * - the per-call cap (OPENZOO_MAX_USD_PER_CALL) still applies, and a session
20
+ * ceiling (OPENZOO_TUNNEL_MAX_USD, default $1) stops paying when reached;
21
+ * - every tunnel-served request is logged with its running spend.
22
+ */
23
+ import { spawn } from 'node:child_process';
24
+ import { createWriteStream } from 'node:fs';
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import os from 'node:os';
28
+ import crypto from 'node:crypto';
29
+ import { pipeline } from 'node:stream/promises';
30
+ import { Readable } from 'node:stream';
31
+
32
+ const BIN_DIR = path.join(os.homedir(), '.openzoo', 'bin');
33
+
34
+ /** cloudflared publishes one static binary per platform; pick ours. */
35
+ function cloudflaredAsset() {
36
+ const p = process.platform;
37
+ const a = process.arch;
38
+ if (p === 'darwin') return { name: 'cloudflared-darwin-amd64.tgz', tgz: true };
39
+ if (p === 'linux') {
40
+ const arch = a === 'arm64' ? 'arm64' : a === 'arm' ? 'arm' : 'amd64';
41
+ return { name: `cloudflared-linux-${arch}`, tgz: false };
42
+ }
43
+ if (p === 'win32') return { name: 'cloudflared-windows-amd64.exe', tgz: false };
44
+ return null;
45
+ }
46
+
47
+ function onPath(bin) {
48
+ const dirs = (process.env.PATH || '').split(path.delimiter);
49
+ for (const d of dirs) {
50
+ const f = path.join(d, bin);
51
+ try { fs.accessSync(f, fs.constants.X_OK); return f; } catch { /* keep looking */ }
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /**
57
+ * Return a usable cloudflared path, downloading it if needed. Order: PATH, our
58
+ * cache, then the official GitHub release. macOS ships a .tgz; the others are
59
+ * bare binaries.
60
+ */
61
+ export async function ensureCloudflared(log = console.log) {
62
+ const found = onPath(process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
63
+ if (found) return found;
64
+
65
+ const cached = path.join(BIN_DIR, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
66
+ try { fs.accessSync(cached, fs.constants.X_OK); return cached; } catch { /* fetch it */ }
67
+
68
+ const asset = cloudflaredAsset();
69
+ if (!asset) throw new Error(`no cloudflared build for ${process.platform}/${process.arch} — install it manually and re-run`);
70
+
71
+ fs.mkdirSync(BIN_DIR, { recursive: true, mode: 0o700 });
72
+ const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset.name}`;
73
+ log(`fetching cloudflared for ${process.platform}/${process.arch} (one-time, ~35MB)...`);
74
+ const r = await fetch(url, { redirect: 'follow' });
75
+ if (!r.ok) throw new Error(`cloudflared download failed: HTTP ${r.status}`);
76
+
77
+ if (asset.tgz) {
78
+ const tgz = path.join(BIN_DIR, 'cloudflared.tgz');
79
+ await pipeline(Readable.fromWeb(r.body), createWriteStream(tgz));
80
+ await new Promise((resolve, reject) => {
81
+ const t = spawn('tar', ['xzf', tgz, '-C', BIN_DIR], { stdio: 'ignore' });
82
+ t.on('exit', (c) => (c === 0 ? resolve() : reject(new Error(`tar exited ${c}`))));
83
+ t.on('error', reject);
84
+ });
85
+ fs.rmSync(tgz, { force: true });
86
+ } else {
87
+ await pipeline(Readable.fromWeb(r.body), createWriteStream(cached));
88
+ }
89
+ fs.chmodSync(cached, 0o755);
90
+ log(`cloudflared cached at ${cached}`);
91
+ return cached;
92
+ }
93
+
94
+ /** Start a quick tunnel to localhost:<port>; resolve with its public URL. */
95
+ export function startCloudflared(bin, port, log) {
96
+ return new Promise((resolve, reject) => {
97
+ const proc = spawn(bin, ['tunnel', '--url', `http://localhost:${port}`, '--no-autoupdate'], {
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+ let settled = false;
101
+ const scan = (buf) => {
102
+ const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i.exec(buf.toString());
103
+ if (m && !settled) { settled = true; resolve({ url: m[0], proc }); }
104
+ };
105
+ proc.stdout.on('data', scan);
106
+ proc.stderr.on('data', scan); // cloudflared prints the URL on stderr
107
+ proc.on('error', reject);
108
+ proc.on('exit', (c) => { if (!settled) reject(new Error(`cloudflared exited ${c} before printing a URL`)); });
109
+ setTimeout(() => { if (!settled) reject(new Error('cloudflared did not produce a URL in 60s')); }, 60000);
110
+ });
111
+ }
112
+
113
+ export function mintToken() {
114
+ return process.env.OPENZOO_TUNNEL_TOKEN || `oz_${crypto.randomBytes(24).toString('base64url')}`;
115
+ }
116
+
117
+ export async function runTunnel() {
118
+ const { startProxy } = await import('./proxy.js');
119
+ const { config } = await import('./config.js');
120
+
121
+ const token = mintToken();
122
+ const sessionCap = Number(process.env.OPENZOO_TUNNEL_MAX_USD || 1);
123
+
124
+ // The proxy enforces the gate itself: nothing is forwarded, quoted or paid
125
+ // without the bearer token, and the session ceiling stops spend cold.
126
+ const { spent } = await startProxy({
127
+ silent: true,
128
+ requireToken: token,
129
+ sessionMaxUsd: sessionCap,
130
+ });
131
+
132
+ const bin = await ensureCloudflared();
133
+ const { url, proc } = await startCloudflared(bin, config.port, console.log);
134
+
135
+ console.log('');
136
+ console.log(' ┌─────────────────────────────────────────────────────────────');
137
+ console.log(' │ THIS URL SPENDS REAL MONEY FROM YOUR WALLET.');
138
+ console.log(' │ The token below is the only thing protecting it.');
139
+ console.log(` │ Session ceiling: $${sessionCap.toFixed(2)} — then it stops paying.`);
140
+ console.log(' │ Ctrl-C when you are done; the URL dies with this process.');
141
+ console.log(' └─────────────────────────────────────────────────────────────');
142
+ console.log('');
143
+ console.log('point your cloud IDE at:');
144
+ console.log(` base_url = ${url}/v1`);
145
+ console.log(` api_key = ${token}`);
146
+ console.log('');
147
+
148
+ const shutdown = () => {
149
+ try { proc.kill('SIGTERM'); } catch { /* already gone */ }
150
+ const total = typeof spent === 'function' ? spent() : 0;
151
+ console.log(`\ntunnel closed — session spend $${total.toFixed(6)}`);
152
+ process.exit(0);
153
+ };
154
+ process.on('SIGINT', shutdown);
155
+ process.on('SIGTERM', shutdown);
156
+ }
package/lib/x402.js CHANGED
@@ -62,13 +62,34 @@ export function evmChainId(network) {
62
62
  }
63
63
 
64
64
  /**
65
- * Choose the accepts[] row to pay with. Rail order: Solana first (preferring
66
- * extra.symbol === preferredSymbol), then Base, then other EVM chains.
67
- * Robinhood Chain (eip155:4663) only when allowRH — the zoo ships it dark
68
- * (RH_RAILS=0) and facilitator settlement there is unverified.
65
+ * Choose the accepts[] row to pay with.
66
+ *
67
+ * `forceRail` (env OPENZOO_RAIL=solana|base|robinhood) pins the selection to
68
+ * one rail and errors clearly when the live 402 does not offer it — forcing is
69
+ * explicit intent, so it also bypasses the allowRH gate.
70
+ *
71
+ * Default order: Solana first (preferring extra.symbol === preferredSymbol),
72
+ * then Base, then other EVM chains. Robinhood Chain (eip155:4663) is last and
73
+ * only when allowRH: all three rails have settled real payments (2026-08-14),
74
+ * but RH stays opt-in for DEFAULT selection because its settlement asset has
75
+ * no auto-conversion path here.
69
76
  */
70
- export function pickAccept(body, preferredSymbol, { allowRH = false } = {}) {
77
+ export function pickAccept(body, preferredSymbol, { allowRH = false, forceRail = null } = {}) {
71
78
  const rows = parse402(body).accepts.filter((a) => a?.scheme === 'exact');
79
+ if (forceRail) {
80
+ const want = String(forceRail).toLowerCase();
81
+ if (!['solana', 'base', 'robinhood', 'evm'].includes(want)) {
82
+ throw new Error(`OPENZOO_RAIL=${forceRail} is not a rail — use solana, base or robinhood`);
83
+ }
84
+ const match = rows.filter((a) => railOf(a) === want);
85
+ if (!match.length) {
86
+ const offered = [...new Set(rows.map(railOf).filter(Boolean))];
87
+ throw new Error(
88
+ `OPENZOO_RAIL=${want} but the live 402 offers no ${want} rail (offered: ${offered.join(', ') || 'none'})`,
89
+ );
90
+ }
91
+ return match.find((a) => a?.extra?.symbol === preferredSymbol) || match[0];
92
+ }
72
93
  const sol = rows.filter((a) => railOf(a) === 'solana');
73
94
  if (sol.length) return sol.find((a) => a?.extra?.symbol === preferredSymbol) || sol[0];
74
95
  const base = rows.filter((a) => railOf(a) === 'base');
@@ -78,7 +99,7 @@ export function pickAccept(body, preferredSymbol, { allowRH = false } = {}) {
78
99
  const rh = rows.filter((a) => railOf(a) === 'robinhood');
79
100
  if (allowRH && rh.length) return rh[0];
80
101
  throw new Error(rh.length
81
- ? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 to use them (experimental, settlement unverified)'
102
+ ? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 or OPENZOO_RAIL=robinhood to use them (the rail settles; you must hold its settlement asset, see https://x402.accrue.fund/start)'
82
103
  : 'no payable rail in 402 accepts[]');
83
104
  }
84
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.4.2",
3
+ "version": "0.6.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 and Base rails live; Robinhood experimental.",
5
5
  "license": "MIT",
6
6
  "type": "module",