openzoo 0.6.0 → 0.7.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
@@ -168,7 +168,7 @@ Pin the key with `OPENZOO_TUNNEL_TOKEN` if your IDE stores it. Keys never leave
168
168
 
169
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.
170
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.
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, hold the plain token you want to pay with (USDG or a quoted memecoin) plus a sliver of RH ETH for gas — conversion happens automatically at payment time.
172
172
  - **Spend caps.** The proxy refuses any single quote above `OPENZOO_MAX_USD_PER_CALL` (default $0.50).
173
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.
174
174
 
@@ -189,7 +189,7 @@ All three rails have settled real payments (2026-08-14):
189
189
  |---|---|---|
190
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
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. |
192
+ | Robinhood Chain | `eip155:4663` | **live** — EIP-3009, batched settle through the facilitator. Hold the plain token a row is quoted in (USDG, or the ODDBALLER / IOU / ROBINHOODS memecoins) and the shim converts exactly enough at payment time, automatically — two small on-chain steps paid from the wallet's own RH ETH. No gas? The error says exactly how much ETH to send and where. Default rail selection skips this chain unless `OPENZOO_ENABLE_RH=1`; `OPENZOO_RAIL=robinhood` forces it outright. |
193
193
 
194
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.
195
195
 
package/lib/config.js CHANGED
@@ -75,16 +75,20 @@ export function fundingLine(address) {
75
75
  * Solana: quoted in settlement mints, converted from plain USDC / TOKEN at
76
76
  * payment time (lib/wrap.js).
77
77
  * Base: quoted in native USDC — funded and spent as-is, no conversion.
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.
78
+ * Robinhood: the rail settles (real payments 2026-08-14) and the shim
79
+ * auto-converts at payment time (lib/evmwrap.js: approve + deposit into the
80
+ * quoted vault, discovered on-chain via asset() never hardcoded). Fund
81
+ * with the PLAIN tokens; the two conversion txs are the wallet's own, so a
82
+ * sliver of RH ETH for gas is also needed the `note` rides the hint.
83
83
  */
84
84
  export const RAIL_FUNDING = {
85
85
  solana: { label: 'Solana', assets: ['USDC', 'TOKEN'] },
86
86
  base: { label: 'Base', assets: ['USDC'] },
87
- robinhood: { label: 'Robinhood Chain', assets: [] },
87
+ robinhood: {
88
+ label: 'Robinhood Chain',
89
+ assets: ['USDG', 'ODDBALLER', 'ROBINHOODS'],
90
+ note: 'plus a sliver of RH ETH for the conversion gas',
91
+ },
88
92
  };
89
93
 
90
94
  /**
@@ -98,7 +102,7 @@ export function railFundingHint(liveRailNames) {
98
102
  return (liveRailNames || [])
99
103
  .map((rail) => RAIL_FUNDING[rail])
100
104
  .filter((spec) => spec?.assets.length)
101
- .map((spec) => `${spec.assets.join(' or ')} on ${spec.label}`)
105
+ .map((spec) => `${spec.assets.join(' or ')} on ${spec.label}${spec.note ? ` (${spec.note})` : ''}`)
102
106
  .join(' · ');
103
107
  }
104
108
 
package/lib/evmwrap.js ADDED
@@ -0,0 +1,163 @@
1
+ import { createPublicClient, createWalletClient, http, formatEther } from 'viem';
2
+ import { privateKeyToAccount } from 'viem/accounts';
3
+ import { evmChainId } from './x402.js';
4
+
5
+ /**
6
+ * EVM auto-acquire — the Robinhood Chain mirror of wrap.js.
7
+ *
8
+ * The 402's settlement asset on eip155:4663 is an X402Wrapper twin: an
9
+ * ERC-4626-shaped vault + EIP-3009 over the plain token the user actually
10
+ * holds (source + deploy record: /Users/stacc/x402-wrappers, e.g. ODDBALLER /
11
+ * IOU / ROBINHOODS twins, and wUSDGx over USDG). Users hold the PLAIN token;
12
+ * this module converts exactly enough at payment time — approve + deposit —
13
+ * so the wrapper never appears in anything user-facing. Copy in every error
14
+ * names only the unwrapped token.
15
+ *
16
+ * Unlike Solana (where the gateway's feePayer can sponsor the conversion
17
+ * inside the payment tx), the approve+deposit here are the wallet's own
18
+ * transactions: the wallet must hold a sliver of native RH ETH. When it does
19
+ * not, the error says EXACTLY what to send and where.
20
+ */
21
+
22
+ const WRAPPER_ABI = [
23
+ { type: 'function', name: 'asset', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
24
+ { type: 'function', name: 'previewMint', stateMutability: 'view', inputs: [{ type: 'uint256' }], outputs: [{ type: 'uint256' }] },
25
+ { type: 'function', name: 'deposit', stateMutability: 'nonpayable', inputs: [{ type: 'uint256' }, { type: 'address' }], outputs: [{ type: 'uint256' }] },
26
+ { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }] },
27
+ ];
28
+
29
+ const ERC20_ABI = [
30
+ { type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ type: 'address' }], outputs: [{ type: 'uint256' }] },
31
+ { type: 'function', name: 'allowance', stateMutability: 'view', inputs: [{ type: 'address' }, { type: 'address' }], outputs: [{ type: 'uint256' }] },
32
+ { type: 'function', name: 'approve', stateMutability: 'nonpayable', inputs: [{ type: 'address' }, { type: 'uint256' }], outputs: [{ type: 'bool' }] },
33
+ { type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
34
+ ];
35
+
36
+ /** Rough ceiling for approve + deposit on an Arbitrum-Orbit chain. */
37
+ const ACQUIRE_GAS_UNITS = 400_000n;
38
+
39
+ export class NeedsGasError extends Error {
40
+ constructor({ address, shortWei, symbol }) {
41
+ // round the ask up to a friendly margin so one top-up is enough
42
+ const ask = (shortWei * 3n) / 2n;
43
+ super(
44
+ `openzoo: converting your ${symbol} for this payment takes two small on-chain steps, `
45
+ + `and the wallet is short of gas. Send at least ${formatEther(ask)} ETH on Robinhood Chain `
46
+ + `(eip155:4663) to ${address}, then retry.`,
47
+ );
48
+ this.name = 'NeedsGasError';
49
+ this.address = address;
50
+ this.shortWei = shortWei;
51
+ }
52
+ }
53
+
54
+ export class UnderlyingShortError extends Error {
55
+ constructor({ symbol, haveRaw, needRaw, decimals, address }) {
56
+ const ui = (raw) => {
57
+ const d = BigInt(10) ** BigInt(decimals ?? 18);
58
+ return `${raw / d}.${(raw % d).toString().padStart(Number(decimals ?? 18), '0').slice(0, 4)}`;
59
+ };
60
+ super(
61
+ `openzoo wallet underfunded: this call needs ≈${ui(needRaw)} ${symbol} but the wallet `
62
+ + `holds ${ui(haveRaw)} ${symbol} (${address} on Robinhood Chain).`,
63
+ );
64
+ this.name = 'UnderlyingShortError';
65
+ this.symbol = symbol;
66
+ this.haveRaw = haveRaw;
67
+ this.needRaw = needRaw;
68
+ }
69
+ }
70
+
71
+ function chainFor(chainId, rpcUrl) {
72
+ return {
73
+ id: chainId,
74
+ name: `eip155:${chainId}`,
75
+ nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
76
+ rpcUrls: { default: { http: [rpcUrl] } },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Ensure the wallet holds >= `need` raw units of the 402's settlement asset,
82
+ * converting the plain underlying token via approve + deposit when necessary.
83
+ *
84
+ * Resolution order:
85
+ * - already funded -> { acquired: false }
86
+ * - not a wrapper (no asset() view) -> { acquired: false, wrapper: false } —
87
+ * the caller falls back to its plain underfunded message
88
+ * - wrapper, wallet holds enough underlying + gas -> converts, waits for
89
+ * receipts, returns { acquired: true, txs: [approveHash?, depositHash] }
90
+ * - short of underlying -> UnderlyingShortError (names the plain token only)
91
+ * - short of native gas -> NeedsGasError (says exactly what to send where)
92
+ */
93
+ export async function acquireWrappedIfNeeded({ rpcUrl, accept, evmPrivateKey, onStage }) {
94
+ const chainId = evmChainId(accept.network);
95
+ const account = privateKeyToAccount(evmPrivateKey);
96
+ const pc = createPublicClient({ transport: http(rpcUrl) });
97
+ const need = BigInt(accept.maxAmountRequired);
98
+
99
+ const bal = await pc.readContract({ address: accept.asset, abi: WRAPPER_ABI, functionName: 'balanceOf', args: [account.address] });
100
+ if (bal >= need) return { acquired: false, wrapper: null };
101
+
102
+ let underlying;
103
+ try {
104
+ underlying = await pc.readContract({ address: accept.asset, abi: WRAPPER_ABI, functionName: 'asset', args: [] });
105
+ } catch {
106
+ return { acquired: false, wrapper: false }; // plain token — nothing to convert from
107
+ }
108
+
109
+ const short = need - bal;
110
+ // previewMint(short): underlying needed so the vault mints >= short shares
111
+ // (entry fee + rounding included by the contract itself — never re-derived here).
112
+ const assetsNeeded = await pc.readContract({ address: accept.asset, abi: WRAPPER_ABI, functionName: 'previewMint', args: [short] });
113
+
114
+ const symbol = await pc
115
+ .readContract({ address: underlying, abi: ERC20_ABI, functionName: 'symbol', args: [] })
116
+ .catch(() => 'underlying token');
117
+ const uBal = await pc.readContract({ address: underlying, abi: ERC20_ABI, functionName: 'balanceOf', args: [account.address] });
118
+ if (uBal < assetsNeeded) {
119
+ throw new UnderlyingShortError({
120
+ symbol,
121
+ haveRaw: uBal,
122
+ needRaw: assetsNeeded,
123
+ decimals: accept.extra?.decimals ?? 18,
124
+ address: account.address,
125
+ });
126
+ }
127
+
128
+ // Gas preflight — these two txs are the wallet's own, unlike the sponsored
129
+ // Solana path. Fail with the exact shortfall before signing anything.
130
+ const [gasPrice, native] = await Promise.all([pc.getGasPrice(), pc.getBalance({ address: account.address })]);
131
+ const gasBudget = ACQUIRE_GAS_UNITS * gasPrice;
132
+ if (native < gasBudget) {
133
+ throw new NeedsGasError({ address: account.address, shortWei: gasBudget - native, symbol });
134
+ }
135
+
136
+ onStage?.('funding');
137
+ const chain = chainFor(chainId, rpcUrl);
138
+ const wc = createWalletClient({ account, chain, transport: http(rpcUrl) });
139
+ const txs = [];
140
+
141
+ const allowance = await pc.readContract({
142
+ address: underlying, abi: ERC20_ABI, functionName: 'allowance', args: [account.address, accept.asset],
143
+ });
144
+ if (allowance < assetsNeeded) {
145
+ const approveHash = await wc.writeContract({
146
+ address: underlying, abi: ERC20_ABI, functionName: 'approve', args: [accept.asset, assetsNeeded],
147
+ });
148
+ await pc.waitForTransactionReceipt({ hash: approveHash });
149
+ txs.push(approveHash);
150
+ }
151
+
152
+ const depositHash = await wc.writeContract({
153
+ address: accept.asset, abi: WRAPPER_ABI, functionName: 'deposit', args: [assetsNeeded, account.address],
154
+ });
155
+ const receipt = await pc.waitForTransactionReceipt({ hash: depositHash });
156
+ txs.push(depositHash);
157
+ if (receipt.status !== 'success') throw new Error(`openzoo: converting ${symbol} for payment failed on-chain (tx ${depositHash})`);
158
+
159
+ const after = await pc.readContract({ address: accept.asset, abi: WRAPPER_ABI, functionName: 'balanceOf', args: [account.address] });
160
+ if (after < need) throw new Error(`openzoo: conversion landed but the balance still cannot cover the quote — retry, or fund more ${symbol}`);
161
+ if (process.env.OPENZOO_DEBUG) console.error(`[openzoo debug] evm acquire txs ${txs.join(', ')}`);
162
+ return { acquired: true, txs };
163
+ }
package/lib/pay.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  receiptLine, decodeSettleHeader,
8
8
  } from './x402.js';
9
9
  import { buildEvmPayment, evmTokenBalance } from './evm.js';
10
+ import { acquireWrappedIfNeeded } from './evmwrap.js';
10
11
  import { privateKeyToAccount } from 'viem/accounts';
11
12
  import {
12
13
  resolvePool, poolState, depositForShares, buildWrapInstructions, sendWrap,
@@ -85,10 +86,29 @@ export class PayClient {
85
86
  if (owner) {
86
87
  const bal = await evmTokenBalance({ rpcUrl, token: accept.asset, owner }).catch(() => null);
87
88
  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 });
89
+ if (rail === 'robinhood') {
90
+ // The RH settlement asset is a vault twin over the plain token the
91
+ // user actually holds. Convert exactly enough at payment time
92
+ // (approve + deposit) the EVM mirror of the Solana top-up above.
93
+ // Throws NeedsGasError / UnderlyingShortError with exact, plain-
94
+ // token-named instructions when the wallet cannot convert. An RPC
95
+ // hiccup mid-probe falls through to the flat underfunded message.
96
+ let converted = null;
97
+ try {
98
+ converted = await acquireWrappedIfNeeded({
99
+ rpcUrl, accept, evmPrivateKey: this.evmPrivateKey, onStage,
100
+ });
101
+ } catch (e) {
102
+ if (e?.name === 'NeedsGasError' || e?.name === 'UnderlyingShortError' || /openzoo/.test(e?.message || '')) throw e;
103
+ converted = null;
104
+ }
105
+ if (!converted || converted.wrapper === false || (!converted.acquired && converted.wrapper !== null)) {
106
+ const line = `The wallet must hold the token this row is quoted in — fund ${owner} on Robinhood Chain, or see https://x402.accrue.fund/start.`;
107
+ throw new UnderfundedError(accept, null, owner, { line });
108
+ }
109
+ } else {
110
+ throw new UnderfundedError(accept, null, owner, { line: `Send a few cents of USDC on Base to ${owner}.` });
111
+ }
92
112
  }
93
113
  }
94
114
  return buildEvmPayment({ accept, evmPrivateKey: this.evmPrivateKey });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.6.0",
3
+ "version": "0.7.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",