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.
- package/lib/pay.js +28 -0
- package/lib/x402.js +78 -6
- 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 >
|
|
298
|
-
throw new Error(`x402 svm payment has ${tx.instructions.length} instructions, outside the required 3..
|
|
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
|
-
/**
|
|
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
|
-
|
|
327
|
-
|
|
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.
|
|
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",
|