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 +11 -7
- package/lib/evmwrap.js +163 -0
- package/lib/pay.js +24 -4
- package/package.json +1 -1
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.6.
|
|
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",
|