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 +2 -2
- package/lib/config.js +11 -7
- package/lib/evmwrap.js +163 -0
- package/lib/pay.js +24 -4
- package/package.json +1 -1
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,
|
|
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
|
|
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)
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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: {
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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.
|
|
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",
|