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/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 for openzoo.fun (x402Version 1, Solana "exact" scheme).
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
- * acquire?: { method: "spl-token-wrap", steps: WRAP_ACQUIRE_STEPS } } } ],
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
- * Payment of the quoted (already-wrapped) mint: ONE Token-2022 TransferChecked
31
- * (payer ATA -> payTo ATA) for exactly maxAmountRequired, feePayer =
32
- * extra.feePayer (the gateway pays SOL fees), partial-signed by the payer,
33
- * serialized requireAllSignatures=false, base64.
34
- * Funding a short wrapped balance is a *separate* 9-account Wrap prepended as
35
- * preInstructions (lib/wrap.js WRAP_ACQUIRE_STEPS). Do not follow Wrap with a
36
- * deposit TransferChecked — the program pulls the underlying itself.
37
- * X-PAYMENT header = base64 of
38
- * {"x402Version":1,"scheme":"exact","network":"<network>","payload":{"transaction":"<b64 tx>"}}
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
- if (!body || body.x402Version !== 1 || !Array.isArray(body.accepts) || body.accepts.length === 0) {
47
- throw new Error('not an x402 v1 payment-required body');
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 its settlement asset has
82
- * no auto-conversion path here.
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; you must hold its settlement asset, see https://x402.accrue.fund/start)'
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
- * Build the partially-signed payment transaction + X-PAYMENT header value.
147
- * Pure given its inputs (no network) so it can be unit-tested offline.
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, preInstructions = [] }) {
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
- // UNIQUENESS. Everything else here is a pure function of (amount, accounts,
164
- // decimals, blockhash), so two calls quoting the same price inside one
165
- // blockhash window build a BYTE-IDENTICAL transaction — same signature, so
166
- // the second is a duplicate and the facilitator reports it as
167
- // `settle {success:false, "Simulation failed ... Logs: []"}`. Concurrent
168
- // callers hit this constantly (a 10-worker run produced 8 failed_settle in
169
- // one window) and each failure costs a full retry, which is most of the
170
- // per-call latency.
171
- //
172
- // A random compute-unit LIMIT makes each message distinct at no cost: unlike
173
- // setComputeUnitPrice it adds no priority fee, and an over-estimate is free
174
- // — it only caps execution. The range sits far above what a transferChecked
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
- for (const pre of preInstructions) tx.add(pre); // internal funding plumbing, if any
180
- tx.add(ix);
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 header. */
203
- export async function buildPaymentOnline(connection, keypair, accept, { preInstructions = [] } = {}) {
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, preInstructions });
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.16",
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.",
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
- }
@@ -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
- }