openzoo 0.6.0 → 0.6.1

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/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.6.1",
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",