openzoo 0.49.16 → 0.49.17
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 +8 -8
- package/bin/openzoo.js +0 -11
- package/lib/config.js +18 -28
- package/lib/demo.js +7 -10
- package/lib/evm.js +22 -19
- package/lib/grokui.mjs +13 -317
- package/lib/info.js +9 -10
- package/lib/mcp.js +1 -3
- package/lib/pay.js +74 -270
- package/lib/podagent.mjs +5 -16
- package/lib/proxy.js +4 -10
- package/lib/x402.js +145 -54
- package/package.json +2 -2
- package/lib/evmwrap.js +0 -163
- package/lib/subscription.js +0 -207
- package/lib/wrap.js +0 -332
- package/lib/xbot.js +0 -1576
- package/lib/xburner.js +0 -80
package/lib/x402.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
1
2
|
import {
|
|
2
3
|
ComputeBudgetProgram,
|
|
3
4
|
Connection,
|
|
4
5
|
PublicKey,
|
|
5
6
|
Transaction,
|
|
7
|
+
TransactionInstruction,
|
|
6
8
|
} from '@solana/web3.js';
|
|
7
9
|
import {
|
|
8
10
|
TOKEN_2022_PROGRAM_ID,
|
|
@@ -12,7 +14,7 @@ import {
|
|
|
12
14
|
} from '@solana/spl-token';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
|
-
* x402 protocol client
|
|
17
|
+
* x402 protocol client (x402Version 1 and 2, "exact" scheme).
|
|
16
18
|
*
|
|
17
19
|
* Captured live 402 shape (test/fixtures/live-402.json):
|
|
18
20
|
* { x402Version: 1,
|
|
@@ -22,33 +24,101 @@ import {
|
|
|
22
24
|
* payTo: "<wallet>", resource, description, maxTimeoutSeconds,
|
|
23
25
|
* extra: { facilitator, feePayer, symbol, billedUsd, tokenUsd,
|
|
24
26
|
* pricedAt, pricing: "markup"|"counterfactual",
|
|
25
|
-
* billedUsd, directUsd?, savedUsd?, savesVsDirect
|
|
26
|
-
*
|
|
27
|
-
* error: "payment required",
|
|
28
|
-
* help: "Wrap ix has 9 accounts. Program CPIs the deposit — do not send a separate TransferChecked. 0x6a = NotEnoughAccounts (old 5-account wrap is dead)." }
|
|
27
|
+
* billedUsd, directUsd?, savedUsd?, savesVsDirect? } } ],
|
|
28
|
+
* error: "payment required" }
|
|
29
29
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
30
|
+
* `asset` is the RAW NATIVE MINT — canonical Circle USDC, or the project token
|
|
31
|
+
* as its own mint. It was briefly a NAV-wrapped Token-2022 twin the payer had
|
|
32
|
+
* to mint first, and the shim converted at payment time; the gateway dropped
|
|
33
|
+
* that because a stock x402 client will not run an approve+deposit into an
|
|
34
|
+
* unaudited vault to buy one API call. Nothing here converts anything: the
|
|
35
|
+
* wallet either holds the quoted mint or the row is unaffordable.
|
|
36
|
+
*
|
|
37
|
+
* Payment is a fixed four-instruction message — see buildPayment, where the
|
|
38
|
+
* order is the protocol. feePayer = extra.feePayer (the gateway pays SOL fees),
|
|
39
|
+
* partial-signed by the payer, serialized requireAllSignatures=false, base64.
|
|
40
|
+
*
|
|
41
|
+
* Both PAYMENT-SIGNATURE and X-PAYMENT carry base64 of paymentEnvelope().
|
|
39
42
|
*
|
|
40
43
|
* NOTE: decimals are NOT in the 402 payload and the site's pasted prompt
|
|
41
44
|
* hardcoding "decimals = 6" is a known bug. We always read decimals from the
|
|
42
45
|
* mint account on-chain (see getMintInfo) — never hardcode.
|
|
43
46
|
*/
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* BOTH VERSIONS, OR A v2 CHALLENGE FAILS CLOSED.
|
|
50
|
+
*
|
|
51
|
+
* Pinning to `=== 1` threw before a single field was read, so an upstream that
|
|
52
|
+
* has moved to v2 — every CDP-facilitated one has — was indistinguishable from
|
|
53
|
+
* a malformed body. The version selects the ENVELOPE shape (see
|
|
54
|
+
* paymentEnvelope); it is not an admission test.
|
|
55
|
+
*/
|
|
45
56
|
export function parse402(body) {
|
|
46
|
-
|
|
47
|
-
|
|
57
|
+
const v = Number(body?.x402Version);
|
|
58
|
+
if (!body || (v !== 1 && v !== 2) || !Array.isArray(body.accepts) || body.accepts.length === 0) {
|
|
59
|
+
throw new Error('not an x402 v1/v2 payment-required body');
|
|
48
60
|
}
|
|
49
61
|
return body;
|
|
50
62
|
}
|
|
51
63
|
|
|
64
|
+
/**
|
|
65
|
+
* The network the VERIFIER enforces, which is not always the one accepts[]
|
|
66
|
+
* advertised.
|
|
67
|
+
*
|
|
68
|
+
* MEASURED 2026-08-25: surplusintelligence advertises `eip155:8453` and then
|
|
69
|
+
* rejects that exact string with `invalid_exact_evm_network_mismatch`, wanting
|
|
70
|
+
* "base". The payment had already LANDED ON CHAIN when the claim was refused —
|
|
71
|
+
* $0.004172 gone. When the challenge carries a top-level `x402` summary whose
|
|
72
|
+
* `.network` disagrees with the row, the summary is what gets enforced.
|
|
73
|
+
*/
|
|
74
|
+
export function verifyNetworkFor(body, accept) {
|
|
75
|
+
const advertised = String(accept?.network || '');
|
|
76
|
+
const summary = body?.x402;
|
|
77
|
+
const enforced = summary && typeof summary === 'object' ? String(summary.network || '') : '';
|
|
78
|
+
return enforced && enforced !== advertised ? enforced : advertised;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The payment envelope both rails send, base64 of this JSON.
|
|
83
|
+
*
|
|
84
|
+
* `resource` and `accepted` are the two fields a CDP facilitator validates the
|
|
85
|
+
* payload's SHAPE against: without them it answers "'paymentPayload' is
|
|
86
|
+
* invalid: must match one of [x402V2Pay…]" and never looks at the money.
|
|
87
|
+
* `accepted` is the accepts[] row VERBATIM — providers compare fields we do not
|
|
88
|
+
* model, so a normalised reconstruction is not the same document.
|
|
89
|
+
*
|
|
90
|
+
* The version is echoed rather than pinned to 2: the openzoo gateway still
|
|
91
|
+
* issues v1 challenges and a v1 verifier is entitled to reject a v2 envelope.
|
|
92
|
+
* The two extra fields are inert to a v1 verifier, so echoing is safe in the
|
|
93
|
+
* direction that costs money.
|
|
94
|
+
*/
|
|
95
|
+
export function paymentEnvelope(body, accept, payload) {
|
|
96
|
+
return {
|
|
97
|
+
x402Version: Number(body?.x402Version) === 1 ? 1 : 2,
|
|
98
|
+
resource: body?.resource, // undefined drops out of JSON.stringify
|
|
99
|
+
accepted: accept,
|
|
100
|
+
scheme: accept?.scheme,
|
|
101
|
+
network: verifyNetworkFor(body, accept),
|
|
102
|
+
payload,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** base64 of the envelope — the value of BOTH payment headers. */
|
|
107
|
+
export function encodeEnvelope(envelope) {
|
|
108
|
+
return Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* BOTH HEADER NAMES, ALWAYS.
|
|
113
|
+
*
|
|
114
|
+
* PAYMENT-SIGNATURE is what the spec names; X-PAYMENT is what a large share of
|
|
115
|
+
* live implementations actually read. Sending one and guessing wrong is a 402
|
|
116
|
+
* loop against a server that would have served us.
|
|
117
|
+
*/
|
|
118
|
+
export function paymentHeaders(header) {
|
|
119
|
+
return { 'PAYMENT-SIGNATURE': header, 'X-PAYMENT': header };
|
|
120
|
+
}
|
|
121
|
+
|
|
52
122
|
const EVM_NAME_CHAINS = { base: 8453, 'base-sepolia': 84532 };
|
|
53
123
|
|
|
54
124
|
/** Which rail an accepts[] row settles on: 'solana' | 'base' | 'robinhood' | 'evm' | null. */
|
|
@@ -78,8 +148,8 @@ export function evmChainId(network) {
|
|
|
78
148
|
* Default order: Solana first (preferring extra.symbol === preferredSymbol),
|
|
79
149
|
* then Base, then other EVM chains. Robinhood Chain (eip155:4663) is last and
|
|
80
150
|
* only when allowRH: all three rails have settled real payments (2026-08-14),
|
|
81
|
-
* but RH stays opt-in for DEFAULT selection because
|
|
82
|
-
*
|
|
151
|
+
* but RH stays opt-in for DEFAULT selection because USDG is the one asset that
|
|
152
|
+
* settles there and far fewer wallets are holding it.
|
|
83
153
|
*/
|
|
84
154
|
/**
|
|
85
155
|
* ALL payable rows from a 402, best-first. The wallet pays with whatever it
|
|
@@ -117,7 +187,7 @@ export function orderAccepts(body, preferredSymbol, { allowRH = false, forceRail
|
|
|
117
187
|
];
|
|
118
188
|
if (!out.length) {
|
|
119
189
|
throw new Error(rows.some((a) => railOf(a) === 'robinhood')
|
|
120
|
-
? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 or OPENZOO_RAIL=robinhood to use them (the rail settles;
|
|
190
|
+
? 'only Robinhood Chain rails offered — set OPENZOO_ENABLE_RH=1 or OPENZOO_RAIL=robinhood to use them (the rail settles; the wallet must hold USDG on Robinhood Chain)'
|
|
121
191
|
: 'no payable rail in 402 accepts[]');
|
|
122
192
|
}
|
|
123
193
|
return out;
|
|
@@ -142,11 +212,36 @@ export async function getMintInfo(connection, mintStr) {
|
|
|
142
212
|
return out;
|
|
143
213
|
}
|
|
144
214
|
|
|
215
|
+
export const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqXgDLGmfcHr');
|
|
216
|
+
|
|
145
217
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
218
|
+
* THE INSTRUCTION LAYOUT IS THE PROTOCOL — specs/schemes/exact/scheme_exact_svm.md.
|
|
219
|
+
* A facilitator checks it before it looks at the money, and the transaction
|
|
220
|
+
* SIMULATES CLEAN either way, so the chain gives no hint that anything is wrong.
|
|
221
|
+
* Required, in this order, 3..6 instructions total:
|
|
222
|
+
*
|
|
223
|
+
* 1. ComputeBudget SetComputeUnitLimit
|
|
224
|
+
* 2. ComputeBudget SetComputeUnitPrice (<= 5 lamports/CU)
|
|
225
|
+
* 3. SPL TransferChecked <- MUST be third
|
|
226
|
+
* 4. Memo <- MANDATORY, not optional
|
|
227
|
+
*
|
|
228
|
+
* Every line below was bought by getting it wrong on mainnet 2026-08-25:
|
|
229
|
+
* - ONE instruction -> "instructions length mismatch: 1 < 3 or 1 > 6"
|
|
230
|
+
* - no SetComputeUnitPrice -> the same length mismatch, one short
|
|
231
|
+
* - TransferChecked at #4, pushed down by prepended ATA-create/wrap
|
|
232
|
+
* preInstructions -> `no_transfer_instruction`. The position is checked, not
|
|
233
|
+
* searched, which is why funding can no longer ride inside this transaction.
|
|
234
|
+
* - no Memo -> rejected outright.
|
|
235
|
+
*
|
|
236
|
+
* UNIQUENESS MOVED. It used to live in a randomised compute-unit limit here,
|
|
237
|
+
* because everything else is a pure function of (amount, accounts, decimals,
|
|
238
|
+
* blockhash) and two callers inside one blockhash window built a
|
|
239
|
+
* BYTE-IDENTICAL transaction — same signature, so the facilitator reported the
|
|
240
|
+
* second as `settle {success:false, "Simulation failed ... Logs: []"}` (8 of
|
|
241
|
+
* them in one window under 10 workers). The spec now puts uniqueness in the
|
|
242
|
+
* memo, so the CU limit is a plain constant and the nonce is the memo body.
|
|
148
243
|
*/
|
|
149
|
-
export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair
|
|
244
|
+
export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair }) {
|
|
150
245
|
const mint = new PublicKey(accept.asset);
|
|
151
246
|
const payTo = new PublicKey(accept.payTo);
|
|
152
247
|
const feePayer = new PublicKey(accept.extra.feePayer);
|
|
@@ -155,43 +250,39 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
|
|
|
155
250
|
const source = getAssociatedTokenAddressSync(mint, keypair.publicKey, false, programId);
|
|
156
251
|
const dest = getAssociatedTokenAddressSync(mint, payTo, true, programId);
|
|
157
252
|
|
|
158
|
-
const ix = createTransferCheckedInstruction(
|
|
159
|
-
source, mint, dest, keypair.publicKey, amount, decimals, [], programId,
|
|
160
|
-
);
|
|
161
|
-
|
|
162
253
|
const tx = new Transaction({ feePayer, recentBlockhash });
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
// plus any funding preInstructions actually consume.
|
|
176
|
-
tx.add(ComputeBudgetProgram.setComputeUnitLimit({
|
|
177
|
-
units: 300_000 + Math.floor(Math.random() * 200_000),
|
|
254
|
+
tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }));
|
|
255
|
+
tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }));
|
|
256
|
+
tx.add(createTransferCheckedInstruction(
|
|
257
|
+
source, mint, dest, keypair.publicKey, amount, decimals, [], programId,
|
|
258
|
+
));
|
|
259
|
+
// The seller's memo when they set one — some price it into the settlement —
|
|
260
|
+
// else a random 16-byte hex nonce, which is what makes two concurrent
|
|
261
|
+
// identical payments distinct messages.
|
|
262
|
+
tx.add(new TransactionInstruction({
|
|
263
|
+
keys: [],
|
|
264
|
+
programId: MEMO_PROGRAM_ID,
|
|
265
|
+
data: Buffer.from(accept.extra?.memo || crypto.randomBytes(16).toString('hex'), 'utf8'),
|
|
178
266
|
}));
|
|
179
|
-
|
|
180
|
-
|
|
267
|
+
if (tx.instructions.length < 3 || tx.instructions.length > 6) {
|
|
268
|
+
throw new Error(`x402 svm payment has ${tx.instructions.length} instructions, outside the required 3..6`);
|
|
269
|
+
}
|
|
270
|
+
// THE FEE PAYER MUST NOT APPEAR IN ANY INSTRUCTION'S ACCOUNTS. It is the
|
|
271
|
+
// facilitator's gas signer; letting it in means their signature can be made
|
|
272
|
+
// to fund something for us (an ATA create, say), which the spec bans outright.
|
|
273
|
+
for (const [i, ix] of tx.instructions.entries()) {
|
|
274
|
+
if (ix.keys.some((k) => k.pubkey.equals(feePayer))) {
|
|
275
|
+
throw new Error(`instruction ${i} names the facilitator feePayer in its accounts — forbidden by scheme_exact_svm`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
181
279
|
tx.partialSign(keypair); // owner signs; feePayer slot left empty for the facilitator
|
|
182
280
|
const txBase64 = tx.serialize({ requireAllSignatures: false, verifySignatures: false }).toString('base64');
|
|
183
281
|
|
|
184
|
-
const envelope = {
|
|
185
|
-
x402Version: 1,
|
|
186
|
-
scheme: accept.scheme,
|
|
187
|
-
network: accept.network,
|
|
188
|
-
payload: { transaction: txBase64 },
|
|
189
|
-
};
|
|
190
|
-
const header = Buffer.from(JSON.stringify(envelope), 'utf8').toString('base64');
|
|
191
282
|
const ownerSig = tx.signatures.find((s) => s.publicKey.equals(keypair.publicKey) && s.signature);
|
|
192
283
|
return {
|
|
193
|
-
header,
|
|
194
284
|
txBase64,
|
|
285
|
+
payload: { transaction: txBase64 },
|
|
195
286
|
ownerSignature: ownerSig ? base58encode(ownerSig.signature) : null,
|
|
196
287
|
amount,
|
|
197
288
|
source,
|
|
@@ -199,11 +290,11 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
|
|
|
199
290
|
};
|
|
200
291
|
}
|
|
201
292
|
|
|
202
|
-
/** Resolve chain state (decimals, blockhash) and build the
|
|
203
|
-
export async function buildPaymentOnline(connection, keypair, accept
|
|
293
|
+
/** Resolve chain state (decimals, blockhash) and build the payment payload. */
|
|
294
|
+
export async function buildPaymentOnline(connection, keypair, accept) {
|
|
204
295
|
const { programId, decimals } = await getMintInfo(connection, accept.asset);
|
|
205
296
|
const { blockhash } = await connection.getLatestBlockhash('confirmed');
|
|
206
|
-
return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair
|
|
297
|
+
return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair });
|
|
207
298
|
}
|
|
208
299
|
|
|
209
300
|
/** Token balance of our ATA for a mint. Returns { raw: bigint, ui: number|null }. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openzoo",
|
|
3
|
-
"version": "0.49.
|
|
4
|
-
"description": "Local x402-paying proxy + MCP server for openzoo.fun
|
|
3
|
+
"version": "0.49.17",
|
|
4
|
+
"description": "Local x402-paying proxy + MCP server for openzoo.fun \u2014 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",
|
|
7
7
|
"bin": {
|
package/lib/evmwrap.js
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
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/subscription.js
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stripe subscription keys for the zoo API — the other pay lane next to
|
|
3
|
-
* wallet/x402. The live billing API is zoo.openzoo.fun; this file does not
|
|
4
|
-
* invent a second backend.
|
|
5
|
-
*
|
|
6
|
-
* After checkout the site lands on /billing/done?session=<cs_…> and polls
|
|
7
|
-
* GET /api/billing/key?session=… until Stripe confirms. That response is how
|
|
8
|
-
* a desktop client receives the key (no Stripe cookie on Electron). A user
|
|
9
|
-
* who already subscribed can paste the same key, or that success URL.
|
|
10
|
-
*
|
|
11
|
-
* Use: Authorization: Bearer <key> against x402-tokens.fly.dev — no 402
|
|
12
|
-
* signing. Wallet/x402 stays if no key is stored.
|
|
13
|
-
*/
|
|
14
|
-
import fs from 'node:fs';
|
|
15
|
-
import os from 'node:os';
|
|
16
|
-
import path from 'node:path';
|
|
17
|
-
|
|
18
|
-
export const BILLING_ORIGIN = 'https://zoo.openzoo.fun';
|
|
19
|
-
export const SUBSCRIPTIONS_PAGE = 'https://zoo.openzoo.fun/subscriptions';
|
|
20
|
-
|
|
21
|
-
export function subscriptionFile(home = os.homedir()) {
|
|
22
|
-
return process.env.OPENZOO_SUBSCRIPTION_PATH
|
|
23
|
-
|| path.join(home, '.openzoo', 'subscription.json');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function titleCase(id) {
|
|
27
|
-
const s = String(id || '').trim();
|
|
28
|
-
if (!s) return '';
|
|
29
|
-
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function asKey(v) {
|
|
33
|
-
return String(v || '').trim();
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Persist a subscription key (chmod 600). Never log the value. */
|
|
37
|
-
export function saveSubscription(rec, file = subscriptionFile()) {
|
|
38
|
-
const key = asKey(rec?.key);
|
|
39
|
-
if (!key) return null;
|
|
40
|
-
const payload = {
|
|
41
|
-
key,
|
|
42
|
-
tier: rec.tier ? String(rec.tier) : null,
|
|
43
|
-
tierName: rec.tierName ? String(rec.tierName) : (rec.tier ? titleCase(rec.tier) : null),
|
|
44
|
-
sessionId: rec.sessionId ? String(rec.sessionId) : null,
|
|
45
|
-
savedAt: Date.now(),
|
|
46
|
-
};
|
|
47
|
-
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
48
|
-
const tmp = `${file}.tmp`;
|
|
49
|
-
fs.writeFileSync(tmp, JSON.stringify(payload), { mode: 0o600 });
|
|
50
|
-
fs.renameSync(tmp, file);
|
|
51
|
-
cached = { file, mtime: fs.statSync(file).mtimeMs, data: payload };
|
|
52
|
-
return payload;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function clearSubscription(file = subscriptionFile()) {
|
|
56
|
-
try { fs.unlinkSync(file); } catch { /* already gone */ }
|
|
57
|
-
if (cached.file === file) cached = { file: '', mtime: 0, data: null };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
let cached = { file: '', mtime: 0, data: null };
|
|
61
|
-
|
|
62
|
-
export function loadSubscription(file = subscriptionFile()) {
|
|
63
|
-
const envKey = asKey(process.env.OPENZOO_SUBSCRIPTION_KEY);
|
|
64
|
-
if (envKey) {
|
|
65
|
-
return {
|
|
66
|
-
key: envKey,
|
|
67
|
-
tier: process.env.OPENZOO_SUBSCRIPTION_TIER || null,
|
|
68
|
-
tierName: process.env.OPENZOO_SUBSCRIPTION_TIER
|
|
69
|
-
? titleCase(process.env.OPENZOO_SUBSCRIPTION_TIER)
|
|
70
|
-
: null,
|
|
71
|
-
sessionId: null,
|
|
72
|
-
source: 'env',
|
|
73
|
-
};
|
|
74
|
-
}
|
|
75
|
-
try {
|
|
76
|
-
const st = fs.statSync(file);
|
|
77
|
-
if (cached.file === file && cached.mtime === st.mtimeMs && cached.data) return cached.data;
|
|
78
|
-
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
79
|
-
if (!asKey(data?.key)) {
|
|
80
|
-
cached = { file, mtime: st.mtimeMs, data: null };
|
|
81
|
-
return null;
|
|
82
|
-
}
|
|
83
|
-
const rec = {
|
|
84
|
-
key: asKey(data.key),
|
|
85
|
-
tier: data.tier || null,
|
|
86
|
-
tierName: data.tierName || (data.tier ? titleCase(data.tier) : null),
|
|
87
|
-
sessionId: data.sessionId || null,
|
|
88
|
-
source: 'file',
|
|
89
|
-
};
|
|
90
|
-
cached = { file, mtime: st.mtimeMs, data: rec };
|
|
91
|
-
return rec;
|
|
92
|
-
} catch {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
/** Public HUD/wallet view — never includes the secret. */
|
|
98
|
-
export function subscriptionPublicView(sub = loadSubscription()) {
|
|
99
|
-
if (!asKey(sub?.key)) return { active: false };
|
|
100
|
-
const name = String(sub.tierName || titleCase(sub.tier) || '').trim();
|
|
101
|
-
return {
|
|
102
|
-
active: true,
|
|
103
|
-
tier: sub.tier || null,
|
|
104
|
-
tierName: name || null,
|
|
105
|
-
label: name ? `${name} · no x402` : 'Subscription key · no x402',
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* A paste is either the bearer key itself, or the site's success URL
|
|
111
|
-
* (`/billing/done?session=cs_…`). session_id is accepted too — Stripe's
|
|
112
|
-
* default query name — but the live page uses `session`.
|
|
113
|
-
*/
|
|
114
|
-
export function parseSubscriptionPaste(text) {
|
|
115
|
-
const raw = String(text || '').trim();
|
|
116
|
-
if (!raw) return { error: 'empty' };
|
|
117
|
-
let session = '';
|
|
118
|
-
try {
|
|
119
|
-
if (/^https?:\/\//i.test(raw) || raw.includes('session=')) {
|
|
120
|
-
const url = new URL(raw, BILLING_ORIGIN);
|
|
121
|
-
session = url.searchParams.get('session') || url.searchParams.get('session_id') || '';
|
|
122
|
-
}
|
|
123
|
-
} catch { /* not a URL */ }
|
|
124
|
-
if (!session) {
|
|
125
|
-
const m = /(?:session_id|session)=([A-Za-z0-9_]+)/.exec(raw);
|
|
126
|
-
if (m) session = m[1];
|
|
127
|
-
}
|
|
128
|
-
if (session) return { session };
|
|
129
|
-
if (/^https?:\/\//i.test(raw)) return { error: 'no session in URL' };
|
|
130
|
-
if (raw.length < 8 || /\s/.test(raw)) return { error: 'not a key' };
|
|
131
|
-
return { key: raw };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
export function applySubscriptionHeaders(headers = {}, sub = loadSubscription()) {
|
|
135
|
-
const key = asKey(sub?.key);
|
|
136
|
-
if (!key) return headers;
|
|
137
|
-
return { ...headers, authorization: `Bearer ${key}` };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function stripAuthorization(headers = {}) {
|
|
141
|
-
const out = { ...headers };
|
|
142
|
-
delete out.authorization;
|
|
143
|
-
delete out.Authorization;
|
|
144
|
-
return out;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
async function billingJson(url, init) {
|
|
148
|
-
const r = await fetch(url, init);
|
|
149
|
-
const body = await r.json().catch(() => ({}));
|
|
150
|
-
return { http: r.status, body };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export async function billingTiers() {
|
|
154
|
-
const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/tiers`);
|
|
155
|
-
if (!body?.ok || !Array.isArray(body.tiers)) {
|
|
156
|
-
throw new Error(body?.error || `tiers HTTP ${http}`);
|
|
157
|
-
}
|
|
158
|
-
return body;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export async function billingCheckout(tier) {
|
|
162
|
-
const id = String(tier || '').trim();
|
|
163
|
-
if (!id) throw new Error('tier required');
|
|
164
|
-
const { http, body } = await billingJson(`${BILLING_ORIGIN}/api/billing/checkout`, {
|
|
165
|
-
method: 'POST',
|
|
166
|
-
headers: { 'content-type': 'application/json' },
|
|
167
|
-
body: JSON.stringify({ tier: id }),
|
|
168
|
-
});
|
|
169
|
-
if (!body?.ok || !body.url) {
|
|
170
|
-
throw new Error(body?.error || `checkout HTTP ${http}`);
|
|
171
|
-
}
|
|
172
|
-
return { ok: true, url: body.url, sessionId: body.sessionId || null, tier: id };
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/** Poll the same endpoint the public /billing/done page uses. */
|
|
176
|
-
export async function fetchBillingKey(session) {
|
|
177
|
-
const sid = String(session || '').trim();
|
|
178
|
-
if (!sid) return { ok: false, error: 'session required' };
|
|
179
|
-
const { body } = await billingJson(
|
|
180
|
-
`${BILLING_ORIGIN}/api/billing/key?session=${encodeURIComponent(sid)}`,
|
|
181
|
-
);
|
|
182
|
-
return body && typeof body === 'object' ? body : { ok: false, error: 'empty key response' };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* If the live key endpoint returned a key, persist it and return a public
|
|
187
|
-
* view (the secret stays on disk). Pending/error bodies pass through.
|
|
188
|
-
*/
|
|
189
|
-
export function ingestBillingKeyResponse(body, extra = {}, file = subscriptionFile()) {
|
|
190
|
-
const key = asKey(body?.key);
|
|
191
|
-
if (!key) {
|
|
192
|
-
if (body?.pending) return { ok: true, pending: true, saved: false };
|
|
193
|
-
return {
|
|
194
|
-
ok: false,
|
|
195
|
-
pending: false,
|
|
196
|
-
saved: false,
|
|
197
|
-
error: body?.error || 'no key yet',
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
const rec = saveSubscription({
|
|
201
|
-
key,
|
|
202
|
-
tier: body.tier || extra.tier || null,
|
|
203
|
-
tierName: body.tierName || body.name || extra.tierName || null,
|
|
204
|
-
sessionId: extra.sessionId || extra.session || null,
|
|
205
|
-
}, file);
|
|
206
|
-
return { ok: true, pending: false, saved: true, ...subscriptionPublicView(rec) };
|
|
207
|
-
}
|