openzoo 0.50.14 → 0.50.16

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.
Files changed (3) hide show
  1. package/lib/pay.js +28 -0
  2. package/lib/x402.js +78 -6
  3. package/package.json +1 -1
package/lib/pay.js CHANGED
@@ -377,6 +377,34 @@ export class PayClient {
377
377
  }
378
378
  }
379
379
  }
380
+
381
+ // A SOLANA BLOCKHASH EXPIRES, AND THE GATEWAY EXPECTS US TO REBUILD.
382
+ //
383
+ // Its failed-settle branch says so in as many words: "clean 402, retryable:
384
+ // the client rebuilds (fresh blockhash / topped-up balance) and pays against
385
+ // the re-quote". We never did. One expired blockhash was one dead request,
386
+ // and it surfaced to the user as a bare HTTP 402.
387
+ //
388
+ // MEASURED 2026-08-28, with the facilitator finally reporting the reason:
389
+ // blockhash CmHMKGre… valid=false | feePayer 1.167 SOL | ixs 4 | bytes 526
390
+ // Not funding, not size — the signature had simply aged out. A client on a
391
+ // slow uplink sending a 109KB body round-trips past the ~60s window every
392
+ // single time, so it never recovered on its own: every call failed, for
393
+ // forty minutes, on a wallet holding $111.
394
+ //
395
+ // ONE retry, and only on a re-quote. Re-sending the SAME signed payment
396
+ // would carry the same dead blockhash; the point is to sign a new one
397
+ // against a fresh quote. More than once would turn a genuinely broken rail
398
+ // into a silent spend loop.
399
+ if (response.status === 402 && !this._rebuilt) {
400
+ this._rebuilt = true;
401
+ try {
402
+ const fresh = await this.fetch(url, init, { onStage });
403
+ return fresh;
404
+ } finally {
405
+ this._rebuilt = false;
406
+ }
407
+ }
380
408
  // NEVER PRESENT OUR OWN SIGNATURE AS THE TRANSACTION.
381
409
  //
382
410
  // This fell back to `{ signature: payment.ownerSignature }`, and on Solana
package/lib/x402.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  ComputeBudgetProgram,
4
4
  Connection,
5
5
  PublicKey,
6
+ SystemProgram,
6
7
  Transaction,
7
8
  TransactionInstruction,
8
9
  } from '@solana/web3.js';
@@ -271,7 +272,7 @@ export const MEMO_PROGRAM_ID = new PublicKey('MemoSq4gqABAXKb96qnH8TysNcWxMyWCqX
271
272
  * them in one window under 10 workers). The spec now puts uniqueness in the
272
273
  * memo, so the CU limit is a plain constant and the nonce is the memo body.
273
274
  */
274
- export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair }) {
275
+ export function buildPayment({ accept, decimals, programId, recentBlockhash, keypair, nonce = null }) {
275
276
  const mint = new PublicKey(accept.asset);
276
277
  const payTo = new PublicKey(accept.payTo);
277
278
  const feePayer = new PublicKey(accept.extra.feePayer);
@@ -281,6 +282,25 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
281
282
  const dest = getAssociatedTokenAddressSync(mint, payTo, true, programId);
282
283
 
283
284
  const tx = new Transaction({ feePayer, recentBlockhash });
285
+ // A DURABLE NONCE, WHEN THE FACILITATOR OFFERS ONE — and `nonceAdvance` MUST
286
+ // be the first instruction, which is why this precedes the compute budget.
287
+ //
288
+ // `recentBlockhash` is inside the signed message and expires after ~150 slots
289
+ // (~60s). x402 signs here and settles over there, so a slow uplink or a large
290
+ // body kills a valid payment and nobody downstream can refresh the hash.
291
+ // MEASURED 2026-08-28: every settle from one client failed for forty minutes
292
+ // with `blockhash … valid=false`, funded fee payer, 526 bytes — nothing wrong
293
+ // with it but age. With a nonce in that slot the transaction never expires.
294
+ //
295
+ // The authority is a dedicated facilitator key, NOT the fee payer:
296
+ // scheme_exact_svm forbids the fee payer from appearing in any instruction's
297
+ // accounts, and an advance names its authority as a signer.
298
+ if (nonce?.account && nonce?.authority) {
299
+ tx.add(SystemProgram.nonceAdvance({
300
+ noncePubkey: new PublicKey(nonce.account),
301
+ authorizedPubkey: new PublicKey(nonce.authority),
302
+ }));
303
+ }
284
304
  tx.add(ComputeBudgetProgram.setComputeUnitLimit({ units: 200_000 }));
285
305
  tx.add(ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }));
286
306
  tx.add(createTransferCheckedInstruction(
@@ -294,8 +314,8 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
294
314
  programId: MEMO_PROGRAM_ID,
295
315
  data: Buffer.from(accept.extra?.memo || crypto.randomBytes(16).toString('hex'), 'utf8'),
296
316
  }));
297
- if (tx.instructions.length < 3 || tx.instructions.length > 6) {
298
- throw new Error(`x402 svm payment has ${tx.instructions.length} instructions, outside the required 3..6`);
317
+ if (tx.instructions.length < 3 || tx.instructions.length > 7) {
318
+ throw new Error(`x402 svm payment has ${tx.instructions.length} instructions, outside the required 3..7`);
299
319
  }
300
320
  // THE FEE PAYER MUST NOT APPEAR IN ANY INSTRUCTION'S ACCOUNTS. It is the
301
321
  // facilitator's gas signer; letting it in means their signature can be made
@@ -320,11 +340,63 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
320
340
  };
321
341
  }
322
342
 
323
- /** Resolve chain state (decimals, blockhash) and build the payment payload. */
343
+ /**
344
+ * Resolve chain state (decimals, blockhash) and build the payment payload.
345
+ *
346
+ * FINALIZED, NOT CONFIRMED — WE SIGN HERE AND SOMEONE ELSE SENDS.
347
+ *
348
+ * A blockhash is only useful to the node that ultimately submits the
349
+ * transaction, and that is the FACILITATOR, on its own RPC. `confirmed` gives
350
+ * the freshest hash our RPC knows, which is precisely the problem: if our node
351
+ * is even slightly ahead, that block does not exist yet for theirs, and
352
+ * `isBlockhashValid` returns false for "never seen" exactly as it does for
353
+ * "expired". A finalized hash is known cluster-wide by definition.
354
+ *
355
+ * MEASURED 2026-08-28, once the facilitator was made to report the reason:
356
+ * blockhash CmHMKGre… valid=false | feePayer 1.167 SOL | ixs 4 | bytes 526
357
+ * Not funding, not size. And the round trip was ~10s against a ~60s window, so
358
+ * it was not aging out either — the hash was simply never recognised. Every
359
+ * settle from one client failed for forty minutes on a wallet holding $111.
360
+ *
361
+ * The cost is ~13s of a ~60s window, which is the right trade when the whole
362
+ * round trip is ~10s: a slightly older hash that always works beats a fresher
363
+ * one that sometimes does not. Override with OPENZOO_BLOCKHASH_COMMITMENT if a
364
+ * future facilitator wants otherwise.
365
+ */
324
366
  export async function buildPaymentOnline(connection, keypair, accept) {
325
367
  const { programId, decimals } = await getMintInfo(connection, accept.asset);
326
- const { blockhash } = await connection.getLatestBlockhash('confirmed');
327
- return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair });
368
+
369
+ // PREFER A DURABLE NONCE. The facilitator leases one from a pool; with it in
370
+ // the blockhash slot the signed payment never expires, which removes the
371
+ // whole class of "valid=false, no logs" failures. Best-effort by design: no
372
+ // nonce, a slow facilitator, or an exhausted pool all fall through to a
373
+ // recent blockhash, because a 60-second window beats no payment at all.
374
+ let nonce = null;
375
+ const facilitator = accept?.extra?.facilitator;
376
+ // OPT-IN UNTIL IT HAS SETTLED ONCE FOR REAL.
377
+ //
378
+ // The fallback below covers a missing/slow/exhausted nonce, but NOT a
379
+ // malformed nonce transaction — if the instruction order or the account metas
380
+ // are wrong, the payment fails and the fallback never runs, because we only
381
+ // learn at settle time. This has not yet settled a single real payment, and
382
+ // shipping it on by default would bet every Solana payer on that. Set
383
+ // OPENZOO_NONCE=1 to use it; the default stays on recent blockhashes.
384
+ if (facilitator && process.env.OPENZOO_NONCE === '1') {
385
+ try {
386
+ const r = await fetch(`${facilitator}/nonce?network=${encodeURIComponent(accept.network)}`, {
387
+ signal: AbortSignal.timeout(4000),
388
+ });
389
+ if (r.ok) {
390
+ const j = await r.json();
391
+ if (j?.account && j?.nonce && j?.authority) nonce = j;
392
+ }
393
+ } catch { /* fall through to a blockhash */ }
394
+ }
395
+
396
+ const recentBlockhash = nonce
397
+ ? nonce.nonce
398
+ : (await connection.getLatestBlockhash(process.env.OPENZOO_BLOCKHASH_COMMITMENT || 'finalized')).blockhash;
399
+ return buildPayment({ accept, decimals, programId, recentBlockhash, keypair, nonce });
328
400
  }
329
401
 
330
402
  /** Token balance of our ATA for a mint. Returns { raw: bigint, ui: number|null }. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.14",
3
+ "version": "0.50.16",
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",