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.
- package/lib/pay.js +63 -1
- package/lib/x402.js +25 -2
- 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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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.
|
|
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",
|