openzoo 0.5.0 → 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 +13 -7
- package/bin/openzoo.js +11 -3
- package/lib/config.js +35 -3
- package/lib/evm.js +25 -3
- package/lib/info.js +54 -10
- package/lib/mcp.js +1 -1
- package/lib/pay.js +25 -5
- package/lib/x402.js +27 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,8 +168,9 @@ 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
172
|
- **Spend caps.** The proxy refuses any single quote above `OPENZOO_MAX_USD_PER_CALL` (default $0.50).
|
|
172
|
-
- `npx openzoo balance`
|
|
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.
|
|
173
174
|
|
|
174
175
|
## Honest pricing note
|
|
175
176
|
|
|
@@ -182,15 +183,17 @@ The receipt names which base you got; `extra.directUsd` / `extra.savesVsDirect`
|
|
|
182
183
|
|
|
183
184
|
## Payment rails
|
|
184
185
|
|
|
186
|
+
All three rails have settled real payments (2026-08-14):
|
|
187
|
+
|
|
185
188
|
| rail | network | status |
|
|
186
189
|
|---|---|---|
|
|
187
|
-
| **Solana** (default) | `solana:5eykt…` | **live** — Token-2022 `TransferChecked`, partial-signed, gateway pays fees.
|
|
188
|
-
| Base | `eip155:8453` | **
|
|
189
|
-
| Robinhood Chain | `eip155:4663` |
|
|
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. |
|
|
190
193
|
|
|
191
|
-
`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.
|
|
192
195
|
|
|
193
|
-
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
|
|
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.)
|
|
194
197
|
|
|
195
198
|
## Configuration
|
|
196
199
|
|
|
@@ -200,12 +203,15 @@ The rail is chosen from the 402's `accepts[]` itself (Solana first). Amounts are
|
|
|
200
203
|
| `OPENZOO_API_BASE` | `https://x402-tokens.fly.dev` | the zoo's door |
|
|
201
204
|
| `OPENZOO_RPC` | mainnet-beta public RPC | Solana RPC |
|
|
202
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) |
|
|
203
209
|
| `OPENZOO_WALLET` | `~/.openzoo/wallet.json` | wallet path |
|
|
204
210
|
| `OPENZOO_MAX_USD_PER_CALL` | `0.5` | refuse quotes above this |
|
|
205
211
|
| `OPENZOO_DEMO_MAX_USD` | `0.01` | demo spend cap |
|
|
206
212
|
| `OPENZOO_CONTEXT_MIN_CHARS` | `16384` | bodies bigger than this bind once + reuse |
|
|
207
213
|
| `OPENZOO_NO_CONTEXT_CACHE` | `0` | set `1` to always ship the full body |
|
|
208
|
-
| `OPENZOO_ENABLE_RH` | `0` |
|
|
214
|
+
| `OPENZOO_ENABLE_RH` | `0` | let default selection fall through to the Robinhood rail (`OPENZOO_RAIL=robinhood` forces it without this) |
|
|
209
215
|
|
|
210
216
|
## What's tested
|
|
211
217
|
|
package/bin/openzoo.js
CHANGED
|
@@ -12,21 +12,29 @@ usage:
|
|
|
12
12
|
(run it twice — the second run reuses the bound corpus and is near-free)
|
|
13
13
|
npx openzoo contexts list corpora bound to the zoo (never re-uploaded)
|
|
14
14
|
npx openzoo contexts --forget <hash|all> drop manifest entries
|
|
15
|
-
npx openzoo balance wallet balance
|
|
16
|
-
|
|
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)
|
|
17
18
|
npx openzoo help this text
|
|
18
19
|
|
|
19
20
|
point any OpenAI-compatible harness at:
|
|
20
21
|
base_url = http://localhost:8402/v1
|
|
21
22
|
api_key = sk-openzoo (any value; the zoo takes payment, not keys)
|
|
22
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
|
+
|
|
23
28
|
env:
|
|
24
29
|
OPENZOO_PORT (8402) OPENZOO_API_BASE (https://x402-tokens.fly.dev)
|
|
25
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)
|
|
26
33
|
OPENZOO_MAX_USD_PER_CALL (0.5) OPENZOO_DEMO_MAX_USD (0.01)
|
|
27
34
|
OPENZOO_CONTEXT_MIN_CHARS (16384 — bodies bigger than this bind once + reuse)
|
|
28
35
|
OPENZOO_NO_CONTEXT_CACHE (0 — set 1 to always ship the full body)
|
|
29
|
-
OPENZOO_ENABLE_RH (0 — Robinhood
|
|
36
|
+
OPENZOO_ENABLE_RH (0 — let DEFAULT selection fall through to the Robinhood rail;
|
|
37
|
+
OPENZOO_RAIL=robinhood forces it without this)
|
|
30
38
|
OPENZOO_TUNNEL_MAX_USD (1.00 — tunnel session ceiling) OPENZOO_TUNNEL_TOKEN (pin the api key)`;
|
|
31
39
|
|
|
32
40
|
async function main() {
|
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:
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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:
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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(`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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/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.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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 (
|
|
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.
|
|
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",
|