openzoo 0.50.13 → 0.50.15

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 +63 -1
  2. package/lib/x402.js +25 -2
  3. package/package.json +1 -1
package/lib/pay.js CHANGED
@@ -339,10 +339,72 @@ export class PayClient {
339
339
  });
340
340
  }
341
341
  onStage?.('paying');
342
- const response = await fetchHeaders(url, {
342
+ let response = await fetchHeaders(url, {
343
343
  ...init,
344
344
  headers: { ...stripAuthorization(init.headers || {}), ...paymentHeaders(payment.header) },
345
345
  });
346
+
347
+ // A REFUSED ATOMIC ROW MUST NOT TAKE THE WHOLE CALL DOWN.
348
+ //
349
+ // The candidate loop above only recovers from UnderfundedError; a payment
350
+ // the SERVER refuses happens out here, and used to surface as
351
+ // "zoo returned HTTP 402" with no second attempt. That is survivable for a
352
+ // row every client has always paid, and not survivable for the AtomicSettle
353
+ // row, which is newer, opt-in, and asks for a different signature — one
354
+ // gateway-side disagreement and every Base caller is dead in the water.
355
+ //
356
+ // So: if the row we chose was the atomic one and the server would not take
357
+ // it, drop back to the ordinary rows and pay again. We lose the
358
+ // response-bound receipt for this call and keep the call. Only the atomic
359
+ // row gets this treatment — a plain row being refused is a real failure and
360
+ // still surfaces.
361
+ if (response.status === 402 && accept?.extra?.settlement === 'atomic') {
362
+ const plain = candidates.filter((c) => c?.extra?.settlement !== 'atomic');
363
+ for (const cand of plain) {
364
+ try {
365
+ const retry = await this.buildPaymentFor(cand, onStage, quote);
366
+ const again = await fetchHeaders(url, {
367
+ ...init,
368
+ headers: { ...stripAuthorization(init.headers || {}), ...paymentHeaders(retry.header) },
369
+ });
370
+ if (again.status !== 402) {
371
+ accept = cand; payment = retry; response = again;
372
+ lastGoodAsset = memoKey(cand);
373
+ break;
374
+ }
375
+ } catch (e) {
376
+ if (!(e instanceof UnderfundedError)) throw e;
377
+ }
378
+ }
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
+ }
346
408
  // NEVER PRESENT OUR OWN SIGNATURE AS THE TRANSACTION.
347
409
  //
348
410
  // This fell back to `{ signature: payment.ownerSignature }`, and on Solana
package/lib/x402.js CHANGED
@@ -320,10 +320,33 @@ export function buildPayment({ accept, decimals, programId, recentBlockhash, key
320
320
  };
321
321
  }
322
322
 
323
- /** Resolve chain state (decimals, blockhash) and build the payment payload. */
323
+ /**
324
+ * Resolve chain state (decimals, blockhash) and build the payment payload.
325
+ *
326
+ * FINALIZED, NOT CONFIRMED — WE SIGN HERE AND SOMEONE ELSE SENDS.
327
+ *
328
+ * A blockhash is only useful to the node that ultimately submits the
329
+ * transaction, and that is the FACILITATOR, on its own RPC. `confirmed` gives
330
+ * the freshest hash our RPC knows, which is precisely the problem: if our node
331
+ * is even slightly ahead, that block does not exist yet for theirs, and
332
+ * `isBlockhashValid` returns false for "never seen" exactly as it does for
333
+ * "expired". A finalized hash is known cluster-wide by definition.
334
+ *
335
+ * MEASURED 2026-08-28, once the facilitator was made to report the reason:
336
+ * blockhash CmHMKGre… valid=false | feePayer 1.167 SOL | ixs 4 | bytes 526
337
+ * Not funding, not size. And the round trip was ~10s against a ~60s window, so
338
+ * it was not aging out either — the hash was simply never recognised. Every
339
+ * settle from one client failed for forty minutes on a wallet holding $111.
340
+ *
341
+ * The cost is ~13s of a ~60s window, which is the right trade when the whole
342
+ * round trip is ~10s: a slightly older hash that always works beats a fresher
343
+ * one that sometimes does not. Override with OPENZOO_BLOCKHASH_COMMITMENT if a
344
+ * future facilitator wants otherwise.
345
+ */
324
346
  export async function buildPaymentOnline(connection, keypair, accept) {
325
347
  const { programId, decimals } = await getMintInfo(connection, accept.asset);
326
- const { blockhash } = await connection.getLatestBlockhash('confirmed');
348
+ const commitment = process.env.OPENZOO_BLOCKHASH_COMMITMENT || 'finalized';
349
+ const { blockhash } = await connection.getLatestBlockhash(commitment);
327
350
  return buildPayment({ accept, decimals, programId, recentBlockhash: blockhash, keypair });
328
351
  }
329
352
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openzoo",
3
- "version": "0.50.13",
3
+ "version": "0.50.15",
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",