@wafermapxyz/cli 0.4.5 → 0.4.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.7 (4 Nov 2026)
4
+
5
+ **`history --verify` checks Solana payments.** Until now they showed `unknown`.
6
+
7
+ - Each Solana payment now logs what it can be found by later: the wallet's own signature (the second one in the transaction; the first, which names it on explorers, is the facilitator's), the memo, the blockhash and the slot it was read at.
8
+ - `wafermap history --verify` reads a Solana RPC, the public one by default (`WAFERMAP_RPC_SOLANA` for another), never Wafermap's, and takes no number from the server that was paid:
9
+ - The payment is anchored to the block its blockhash names: the block at the logged slot must carry exactly that hash, or the check stops with `error`. The payment can land only after that block, and only until the chain is 150 blocks past it.
10
+ - `used`: a transaction carrying our signature landed and succeeded (the one the server named, or one found in the wallet's USDC account history).
11
+ - `unused`: the finalized chain is past the blockhash's last valid height (read first), and the history, read from a node at least that far along and back to the blockhash's block, has no transaction with our signature. Such a payment can never execute. A transaction that landed with our signature and failed moved nothing: also `unused`.
12
+ - `pending`: not found, and still able to land.
13
+ - `error`: anything the RPC could not establish for sure (a block or history it cannot return, a time-out). Never `unused` on a doubtful read.
14
+ - Solana lines logged before 0.4.7 have no signature: they are `used` when the server's transaction shows this wallet signing and its USDC going down by exactly the signed amount (and no other line was given the same transaction), otherwise `unknown`.
15
+ - Reviewed adversarially twice before release. The fixes that came out of it: the expiry is read before the search; the cut-off is a block checked on-chain, not the local clock or a slot from Wafermap's RPC; a fresh blockhash the finalized chain has not reached yet reads `pending`, not `unused`.
16
+ - Solana RPC reads have a 15-second limit and back off on rate limits.
17
+ - SDK: `checkSolanaPayment()` and `SOLANA_PUBLIC_RPC` are exported; `checkPayment()` routes Solana lines to it. `SolanaPayer` gains an optional `blockhashInfo()`; `blockhash()` still returns a string.
18
+
19
+ ## 0.4.6 (3 Nov 2026)
20
+
21
+ **`history --verify` tells an executed payment from a cancelled one.** Thank you @GBLIN_Protocol for the catch.
22
+
23
+ - EIP-3009 `authorizationState` is true both after a payment is executed and after its nonce is cancelled (`cancelAuthorization`), so a cancelled nonce used to read as `used`. The wallet never cancels on its own, but a nonce cancelled by hand would have been shown as paid.
24
+ - A `true` is now confirmed by the token contract's `AuthorizationUsed` event for that payer and nonce: first in the receipt of the settlement transaction the server named, otherwise in the blocks between signing and expiry, the only time the payment could have executed. Found: `used`. Not found: `cancelled` (spent nonce, no transfer). If the RPC cannot answer, the result is `error`, never a guess. Base's public RPC refuses log searches, so a Base payment with no settlement transaction on record needs `WAFERMAP_RPC_BASE` set to another RPC.
25
+ - `history --verify` checks four payments at a time instead of all at once, so public RPCs stop refusing long histories.
26
+ - SDK: `checkPayment()` returns the new `"cancelled"` state and takes the entry's `at` and `tx` when given.
27
+
3
28
  ## 0.4.5 (2 Nov 2026)
4
29
 
5
30
  **Pay Wafermap in $WAFERMAP, at 50% off. The tokens are burned.**
package/README.md CHANGED
@@ -94,7 +94,7 @@ The caps sit with the key, on your machine. Before the wallet signs anything it
94
94
 
95
95
  **The daily cap counts what the wallet signed, not what a server says it charged.** Every payment is written to `spend.jsonl` at its full signed amount the moment it is signed, before it is sent, and stays counted whatever the server answers: an error, a receipt saying $0, no receipt, or no answer at all. Check, sign and record run one payment at a time within a process, so calls started together cannot share the same remaining budget. Across processes on the same wallet (two MCP clients, or the CLI next to the MCP server), the wallet re-reads the shared log right after recording a signed payment and, if the cap is now passed, drops that payment before it is sent. (Before 0.3.3 the cap trusted the server's receipt; see [CHANGELOG.md](CHANGELOG.md).)
96
96
 
97
- **What the wallet cannot enforce.** An x402 `exact` payment is a signed authorization (EIP-3009 on EVM chains, a partially signed transaction on Solana). Whoever receives it can execute it once until it expires. The wallet signs EVM authorizations for at most 90 seconds, or less when the server asks for less (300 before 0.4.0). "A failed call costs $0" is therefore a promise of the server you pay: Wafermap's server settles only after the resource worked and otherwise lets the authorization expire unused. You do not have to take that on trust: `wafermap history --verify` asks each token contract (`authorizationState`) whether every signed payment was executed, and shows the ones that expired unused (`error` means the RPC did not answer). Solana payments are not checked yet. The caps bound the worst case either way.
97
+ **What the wallet cannot enforce.** An x402 `exact` payment is a signed authorization (EIP-3009 on EVM chains, a partially signed transaction on Solana). Whoever receives it can execute it once until it expires. The wallet signs EVM authorizations for at most 90 seconds, or less when the server asks for less (300 before 0.4.0). "A failed call costs $0" is therefore a promise of the server you pay: Wafermap's server settles only after the resource worked and otherwise lets the authorization expire unused. You do not have to take that on trust: `wafermap history --verify` asks each token contract (`authorizationState`) whether every signed payment was executed, and shows the ones that expired unused (`error` means the RPC did not answer). Because `authorizationState` is also true after `cancelAuthorization`, a payment only shows as `used` once the contract's `AuthorizationUsed` event for that nonce is found (in the settlement transaction, or in the blocks between signing and expiry); a nonce that is spent but never executed shows as `cancelled`. The search needs `eth_getLogs`, which Base's public RPC refuses; set `WAFERMAP_RPC_BASE` to your own RPC if a Base payment without a transaction on record shows `error`. Solana payments are checked too, on the public Solana RPC by default (`WAFERMAP_RPC_SOLANA` to use another), not on Wafermap's: from 0.4.7 the wallet logs its own signature, the memo, the blockhash and its slot for each Solana payment. The check anchors the payment to the block its blockhash names (verified on-chain), and shows `used` when a transaction carrying the wallet's signature landed and succeeded, `unused` when the chain is past the blockhash's last valid height and the wallet's USDC history back to that block has no such transaction, and `pending` otherwise. Anything the RPC cannot establish for sure shows `error`, never `unused`. Solana payments logged before 0.4.7 can only be confirmed through the transaction the server named; otherwise they show `unknown`. The caps bound the worst case either way.
98
98
 
99
99
  **Time limits.** Requests to paid resources and services, to Wafermap's API, to the swap API and to Solana RPC give up after 60 seconds (`timeoutMs` in the SDK); EVM RPC calls keep viem's own time-outs. `wafermap resources` uses the same limit. Set `WAFERMAP_TIMEOUT_MS` to change it for the CLI and the MCP server. A paid call that times out after the payment was signed says so: the payment stays counted, and for EVM payments the message says until when it can be executed.
100
100
 
package/dist/cli.js CHANGED
@@ -202,7 +202,7 @@ function ataOf(owner, mint = SOLANA_USDC) {
202
202
  }
203
203
  throw new Error("No associated token account address.");
204
204
  }
205
- async function solanaPayment(payer, q) {
205
+ async function solanaPaymentSigned(payer, q) {
206
206
  if (q.asset !== SOLANA_USDC) throw new Error("Only USDC is paid on Solana.");
207
207
  if (!isSolanaAddress(q.feePayer) || !isSolanaAddress(q.payTo)) throw new Error("The quote has no valid Solana fee payer or recipient.");
208
208
  if (q.feePayer === payer.address) throw new Error("The fee payer cannot be the paying wallet.");
@@ -219,19 +219,26 @@ async function solanaPayment(payer, q) {
219
219
  { program: TOKEN_PROGRAM, accounts: [source, q.asset, dest, payer.address], data: concat(Uint8Array.of(12), le(BigInt(q.amount), 8), Uint8Array.of(USDC_DECIMALS)) },
220
220
  { program: MEMO_PROGRAM, accounts: [], data: memo }
221
221
  ];
222
+ const bh = payer.blockhashInfo ? await payer.blockhashInfo() : { blockhash: await payer.blockhash() };
222
223
  const message = concat(
223
224
  Uint8Array.of(128, 2, 1, 4),
224
225
  // v0; 2 signatures, 1 of them readonly; 4 readonly non-signers
225
226
  shortvec(keys.length),
226
227
  ...keys.map((k) => base58.decode(k)),
227
- base58.decode(await payer.blockhash()),
228
+ base58.decode(bh.blockhash),
228
229
  shortvec(ixs.length),
229
230
  ...ixs.map((ix) => concat(Uint8Array.of(at(ix.program)), shortvec(ix.accounts.length), Uint8Array.from(ix.accounts.map(at)), shortvec(ix.data.length), ix.data)),
230
231
  shortvec(0)
231
232
  // no address lookup tables
232
233
  );
233
234
  const signature = await payer.sign(message);
234
- return base64.encode(concat(shortvec(2), new Uint8Array(64), signature, message));
235
+ return {
236
+ transaction: base64.encode(concat(shortvec(2), new Uint8Array(64), signature, message)),
237
+ signature: base58.encode(signature),
238
+ memo: new TextDecoder().decode(memo),
239
+ blockhash: bh.blockhash,
240
+ ..."slot" in bh && Number.isFinite(bh.slot) ? { slot: bh.slot } : {}
241
+ };
235
242
  }
236
243
  async function solanaRpc(url, method, params, f = fetch) {
237
244
  const r = await f(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) });
@@ -240,6 +247,70 @@ async function solanaRpc(url, method, params, f = fetch) {
240
247
  if (j.error) throw new Error(j.error.message ?? "Solana RPC error.");
241
248
  return j.result;
242
249
  }
250
+ async function solanaRead(url, method, params, f = fetch) {
251
+ const timed = (input, init) => f(input, { ...init, signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
252
+ for (let attempt = 0; ; attempt++) {
253
+ try {
254
+ return await solanaRpc(url, method, params, timed);
255
+ } catch (e) {
256
+ if (attempt >= 6 || !/429|rate|too many/i.test(String(e?.message))) throw e;
257
+ await new Promise((r) => setTimeout(r, 1e3 * 2 ** attempt * (0.75 + Math.random() / 2)));
258
+ }
259
+ }
260
+ }
261
+ async function txOutcome(url, tx, e, f) {
262
+ const t = await solanaRead(url, "getTransaction", [tx, { encoding: "json", maxSupportedTransactionVersion: 0, commitment: "confirmed" }], f);
263
+ if (!t) return null;
264
+ const sigs = t.transaction?.signatures ?? [];
265
+ const keys = t.transaction?.message?.accountKeys ?? [];
266
+ if (e.sig ? !sigs.includes(e.sig) : keys[1] !== e.payer) return "other";
267
+ if (t.meta?.err) return "failed";
268
+ if (!e.sig) {
269
+ const bal = (list) => BigInt(list?.find((b) => b.owner === e.payer && b.mint === SOLANA_USDC)?.uiTokenAmount?.amount ?? "0");
270
+ const drop = bal(t.meta?.preTokenBalances) - bal(t.meta?.postTokenBalances);
271
+ if (drop <= 0n || e.usd !== void 0 && drop !== BigInt(Math.round(e.usd * 1e6))) return "other";
272
+ }
273
+ return "ok";
274
+ }
275
+ async function checkSolanaPayment(e, url = SOLANA_PUBLIC_RPC, f) {
276
+ if (e.tx) {
277
+ const o = await txOutcome(url, e.tx, e, f).catch(() => null);
278
+ if (o === "ok") return "used";
279
+ if (o === "failed" && e.sig) return "unused";
280
+ }
281
+ if (!e.sig || !e.blockhash || !Number.isSafeInteger(e.slot) || e.slot <= 0) return "unknown";
282
+ const slot = e.slot;
283
+ const block = await solanaRead(url, "getBlock", [slot, { commitment: "confirmed", transactionDetails: "none", rewards: false, maxSupportedTransactionVersion: 0 }], f);
284
+ if (!block || block.blockhash !== e.blockhash || !Number.isSafeInteger(block.blockHeight)) throw new Error("The chain has no block with this payment's blockhash at the logged slot.");
285
+ const lastValidHeight = block.blockHeight + BLOCKHASH_MAX_AGE;
286
+ const fin = await solanaRead(url, "getEpochInfo", [{ commitment: "finalized" }], f);
287
+ const S = Number(fin?.absoluteSlot);
288
+ const H = Number(fin?.blockHeight);
289
+ if (!Number.isSafeInteger(S) || !Number.isSafeInteger(H)) throw new Error("The RPC did not give the finalized height.");
290
+ const expired = H > lastValidHeight;
291
+ let before;
292
+ let reached = false;
293
+ search: for (let page = 0; page < 20; page++) {
294
+ const list = await solanaRead(url, "getSignaturesForAddress", [ataOf(e.payer), { limit: 1e3, commitment: "confirmed", minContextSlot: S, ...before ? { before } : {} }], f);
295
+ if (!Array.isArray(list)) throw new Error("The RPC did not return the wallet's history.");
296
+ for (const x of list) {
297
+ if (!Number.isSafeInteger(x.slot)) throw new Error("The RPC returned history without slots.");
298
+ if (x.slot <= slot) {
299
+ reached = true;
300
+ break search;
301
+ }
302
+ if (e.memo && typeof x.memo === "string" && x.memo && !x.memo.includes(e.memo)) continue;
303
+ const o = await txOutcome(url, x.signature, e, f);
304
+ if (o === "ok") return "used";
305
+ if (o === "failed") return "unused";
306
+ if (o === null) throw new Error("The RPC lists a transaction it cannot return.");
307
+ }
308
+ if (list.length < 1e3) break;
309
+ before = list[list.length - 1].signature;
310
+ }
311
+ if (!reached) throw new Error("Could not read the wallet's history back to this payment.");
312
+ return expired ? "unused" : "pending";
313
+ }
243
314
  async function solanaBalances(url, owner, f) {
244
315
  const [usdc, lamports] = await Promise.all([
245
316
  solanaRpc(url, "getAccountInfo", [ataOf(owner), { commitment: "confirmed", encoding: "jsonParsed" }], f).then((r) => String(r.value?.data?.parsed?.info?.tokenAmount?.uiAmountString ?? "0")),
@@ -247,7 +318,7 @@ async function solanaBalances(url, owner, f) {
247
318
  ]);
248
319
  return { usdc, sol: String(lamports / 1e9) };
249
320
  }
250
- var SOLANA_CAIP, SOLANA_USDC, USDC_DECIMALS, TOKEN_PROGRAM, ATA_PROGRAM, COMPUTE_BUDGET, MEMO_PROGRAM, CU_LIMIT, CU_PRICE_MICROLAMPORTS, MAX_MEMO_BYTES, utf8, concat, hexBytes, le, shortvec, onCurve, latestBlockhash;
321
+ var SOLANA_CAIP, SOLANA_USDC, USDC_DECIMALS, TOKEN_PROGRAM, ATA_PROGRAM, COMPUTE_BUDGET, MEMO_PROGRAM, CU_LIMIT, CU_PRICE_MICROLAMPORTS, MAX_MEMO_BYTES, utf8, concat, hexBytes, le, shortvec, onCurve, latestBlockhash, latestBlockhashInfo, SOLANA_PUBLIC_RPC, READ_TIMEOUT_MS, BLOCKHASH_MAX_AGE;
251
322
  var init_solana = __esm({
252
323
  "shared/solana.ts"() {
253
324
  "use strict";
@@ -291,7 +362,14 @@ var init_solana = __esm({
291
362
  return false;
292
363
  }
293
364
  };
294
- latestBlockhash = async (url, f) => (await solanaRpc(url, "getLatestBlockhash", [{ commitment: "confirmed" }], f)).value.blockhash;
365
+ latestBlockhash = async (url, f) => (await latestBlockhashInfo(url, f)).blockhash;
366
+ latestBlockhashInfo = async (url, f) => {
367
+ const r = await solanaRpc(url, "getLatestBlockhash", [{ commitment: "confirmed" }], f);
368
+ return { blockhash: r.value.blockhash, slot: Number(r.context?.slot) };
369
+ };
370
+ SOLANA_PUBLIC_RPC = "https://api.mainnet-beta.solana.com";
371
+ READ_TIMEOUT_MS = 15e3;
372
+ BLOCKHASH_MAX_AGE = 150;
295
373
  }
296
374
  });
297
375
 
@@ -375,14 +453,17 @@ async function signPayment(signer, req, resource, solana) {
375
453
  if (req.chain.vm === "svm") {
376
454
  if (!solana) throw new X402Error("no_solana_wallet", "This payment is on Solana and no Solana wallet was given.");
377
455
  let transaction;
456
+ let sol;
378
457
  try {
379
- transaction = await solanaPayment(solana, { amount: req.amount, payTo: req.payTo, asset: req.asset, feePayer: req.extra.feePayer, memo: req.extra.memo });
458
+ const s = await solanaPaymentSigned(solana, { amount: req.amount, payTo: req.payTo, asset: req.asset, feePayer: req.extra.feePayer, memo: req.extra.memo });
459
+ transaction = s.transaction;
460
+ sol = { sig: s.signature, memo: s.memo, blockhash: s.blockhash, ...s.slot !== void 0 ? { slot: s.slot } : {} };
380
461
  } catch (e) {
381
462
  if (e?.name === "TimeoutError" || e?.cause?.name === "TimeoutError") throw e;
382
463
  throw new X402Error("sign_failed", e?.message ?? "Could not build the Solana payment.");
383
464
  }
384
- if (req.version === 1) return { name: "X-PAYMENT", value: b64encode(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.raw.network, payload: { transaction } })) };
385
- return { name: "PAYMENT-SIGNATURE", value: b64encode(JSON.stringify({ x402Version: 2, resource, accepted: req.raw, payload: { transaction } })) };
465
+ if (req.version === 1) return { name: "X-PAYMENT", value: b64encode(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.raw.network, payload: { transaction } })), sol };
466
+ return { name: "PAYMENT-SIGNATURE", value: b64encode(JSON.stringify({ x402Version: 2, resource, accepted: req.raw, payload: { transaction } })), sol };
386
467
  }
387
468
  const nonce = randomNonce();
388
469
  const validBefore = BigInt(Math.floor(Date.now() / 1e3) + Math.min(req.maxTimeoutSeconds, MAX_VALIDITY_SECONDS));
@@ -436,7 +517,7 @@ async function x402fetch(url, init, opts) {
436
517
  const svm = req.chain.vm === "svm";
437
518
  const signer = svm ? null : typeof opts.signer === "function" ? await opts.signer() : opts.signer;
438
519
  const header = await signPayment(signer, req, quote.resource, opts.solana);
439
- const signed = { req, from: svm ? opts.solana.address : signer.address, ...header.auth ? { auth: header.auth } : {} };
520
+ const signed = { req, from: svm ? opts.solana.address : signer.address, ...header.auth ? { auth: header.auth } : {}, ...header.sol ? { sol: header.sol } : {} };
440
521
  opts.onSigned?.(signed);
441
522
  const headers = new Headers(init.headers);
442
523
  headers.set(header.name, header.value);
@@ -560,7 +641,7 @@ __export(core_exports, {
560
641
  listResources: () => listResources,
561
642
  mergeLog: () => mergeLog
562
643
  });
563
- import { createPublicClient, createWalletClient, erc20Abi as erc20Abi2, formatUnits as formatUnits2, http } from "viem";
644
+ import { createPublicClient, createWalletClient, erc20Abi as erc20Abi2, formatUnits as formatUnits2, http, pad, parseAbiItem, toEventSelector } from "viem";
564
645
  import { privateKeyToAccount } from "viem/accounts";
565
646
  import { arbitrum, base, polygon, robinhood } from "viem/chains";
566
647
  function mergeLog(entries) {
@@ -571,14 +652,57 @@ function mergeLog(entries) {
571
652
  return { ...e, status: o.status, tx: o.tx ?? e.tx, reported: o.reported ?? null, ...o.status === "aborted" ? { usd: 0 } : {} };
572
653
  });
573
654
  }
655
+ async function blockAt(client, ts, side, latest) {
656
+ if (ts >= Number(latest.timestamp)) return latest.number;
657
+ let hi = latest;
658
+ let lo = await client.getBlock({ blockNumber: latest.number > 100000n ? latest.number - 100000n : 0n });
659
+ while (Number(lo.timestamp) > ts && lo.number > 0n) {
660
+ const back = (hi.number - lo.number) * 4n;
661
+ hi = lo;
662
+ lo = await client.getBlock({ blockNumber: lo.number > back ? lo.number - back : 0n });
663
+ }
664
+ for (let i = 0; i < 20 && hi.number - lo.number > 20n; i++) {
665
+ const span = Number(hi.timestamp - lo.timestamp) || 1;
666
+ let guess = lo.number + BigInt(Math.floor(Number(hi.number - lo.number) * (ts - Number(lo.timestamp)) / span));
667
+ if (guess <= lo.number) guess = lo.number + 1n;
668
+ if (guess >= hi.number) guess = hi.number - 1n;
669
+ const b = await client.getBlock({ blockNumber: guess });
670
+ if (Number(b.timestamp) <= ts) lo = b;
671
+ else hi = b;
672
+ }
673
+ return side === "from" ? lo.number : hi.number;
674
+ }
574
675
  async function checkPayment(e, rpc) {
575
- if (e.network === "solana" || !e.nonce || !(e.network in CHAINS)) return "unknown";
676
+ if (e.network === "solana") return checkSolanaPayment(e, rpc).catch(() => "error");
677
+ if (!e.nonce || !(e.network in CHAINS)) return "unknown";
576
678
  const chain = PAY_CHAINS.find((c) => c.key === e.network);
577
679
  try {
578
680
  const client = createPublicClient({ chain: CHAINS[e.network], transport: http(rpc) });
579
681
  const used = await client.readContract({ address: chain.stable, abi: AUTH_STATE_ABI, functionName: "authorizationState", args: [e.payer, e.nonce] });
580
- if (used) return "used";
581
- return e.validBefore && Date.now() / 1e3 > e.validBefore ? "unused" : "pending";
682
+ if (!used) return e.validBefore && Date.now() / 1e3 > e.validBefore ? "unused" : "pending";
683
+ const isOurs = (l) => l.address.toLowerCase() === chain.stable.toLowerCase() && l.topics[0] === toEventSelector(AUTH_USED) && l.topics[1]?.toLowerCase() === pad(e.payer).toLowerCase() && l.topics[2]?.toLowerCase() === e.nonce.toLowerCase();
684
+ if (e.tx && /^0x[0-9a-fA-F]{64}$/.test(e.tx)) {
685
+ const receipt = await client.getTransactionReceipt({ hash: e.tx }).catch(() => null);
686
+ if (receipt?.status === "success" && receipt.logs.some(isOurs)) return "used";
687
+ }
688
+ if (!e.validBefore || !e.at) return "error";
689
+ const latest = await client.getBlock();
690
+ const fromBlock = await blockAt(client, Math.floor(e.at / 1e3) - 60, "from", latest);
691
+ const toBlock = await blockAt(client, e.validBefore + 30, "to", latest);
692
+ for (let start = fromBlock; start <= toBlock; start += 50n) {
693
+ const end = start + 49n < toBlock ? start + 49n : toBlock;
694
+ for (let attempt = 0; ; attempt++) {
695
+ try {
696
+ const logs = await client.getLogs({ address: chain.stable, event: AUTH_USED, args: { authorizer: e.payer, nonce: e.nonce }, fromBlock: start, toBlock: end });
697
+ if (logs.length) return "used";
698
+ break;
699
+ } catch (err) {
700
+ if (attempt >= 3) throw err;
701
+ await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
702
+ }
703
+ }
704
+ }
705
+ return "cancelled";
582
706
  } catch {
583
707
  return "error";
584
708
  }
@@ -606,7 +730,7 @@ async function listResources(baseUrl = "https://wafermap.xyz", timeoutMs = DEFAU
606
730
  if (!r.ok || !Array.isArray(j?.data)) throw new WafermapError("network_error", `${host} returned no catalog (HTTP ${r.status}).`);
607
731
  return j.data;
608
732
  }
609
- var NETS, isNet, isSwapNet, WafermapError, CHAINS, AUTH_STATE_ABI, DEFAULT_LIMITS, DEFAULT_TIMEOUT_MS, timedOut, Wafermap;
733
+ var NETS, isNet, isSwapNet, WafermapError, CHAINS, AUTH_STATE_ABI, AUTH_USED, DEFAULT_LIMITS, DEFAULT_TIMEOUT_MS, timedOut, Wafermap;
610
734
  var init_core = __esm({
611
735
  "src/core.ts"() {
612
736
  "use strict";
@@ -626,6 +750,7 @@ var init_core = __esm({
626
750
  };
627
751
  CHAINS = { robinhood, base, arbitrum, polygon };
628
752
  AUTH_STATE_ABI = [{ type: "function", name: "authorizationState", stateMutability: "view", inputs: [{ name: "authorizer", type: "address" }, { name: "nonce", type: "bytes32" }], outputs: [{ type: "bool" }] }];
753
+ AUTH_USED = parseAbiItem("event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)");
629
754
  DEFAULT_LIMITS = { perPayment: 0.1, perDay: 1, perSwap: 25 };
630
755
  DEFAULT_TIMEOUT_MS = 6e4;
631
756
  timedOut = (e) => e?.name === "TimeoutError" || e?.cause?.name === "TimeoutError";
@@ -660,7 +785,7 @@ var init_core = __esm({
660
785
  this.fetch = (input, init) => fetch(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs) });
661
786
  const sol = o.solana ?? (o.privateKey ? solanaKeyFromEvm(o.privateKey) : null);
662
787
  const solRpc = this.rpc.solana ?? `${this.baseUrl}/api/solana`;
663
- this.solana = sol ? { address: sol.address, sign: sol.sign, blockhash: () => latestBlockhash(solRpc, this.fetch) } : null;
788
+ this.solana = sol ? { address: sol.address, sign: sol.sign, blockhash: () => latestBlockhash(solRpc, this.fetch), blockhashInfo: () => latestBlockhashInfo(solRpc, this.fetch) } : null;
664
789
  this.solanaAddress = sol?.address ?? o.solanaAddress ?? null;
665
790
  }
666
791
  get address() {
@@ -699,7 +824,7 @@ var init_core = __esm({
699
824
  /** Write the signed payment to the ledger before it leaves the machine. */
700
825
  booked(kind, what, s) {
701
826
  const id = crypto.randomUUID();
702
- this.ledger.record({ at: Date.now(), kind, what, usd: s.req.usd, network: s.req.chain.key, tx: null, payer: s.from, id, status: "signed", ...s.auth ? { nonce: s.auth.nonce, validBefore: s.auth.validBefore } : {} });
827
+ this.ledger.record({ at: Date.now(), kind, what, usd: s.req.usd, network: s.req.chain.key, tx: null, payer: s.from, id, status: "signed", ...s.auth ? { nonce: s.auth.nonce, validBefore: s.auth.validBefore } : {}, ...s.sol ? { sig: s.sol.sig, memo: s.sol.memo, blockhash: s.sol.blockhash, ...s.sol.slot !== void 0 ? { slot: s.sol.slot } : {} } : {} });
703
828
  return id;
704
829
  }
705
830
  /** The outcome line for a booked payment. It adds nothing to the daily total. */
@@ -1119,7 +1244,7 @@ var VERSION;
1119
1244
  var init_version = __esm({
1120
1245
  "src/version.ts"() {
1121
1246
  "use strict";
1122
- VERSION = "0.4.5";
1247
+ VERSION = "0.4.7";
1123
1248
  }
1124
1249
  });
1125
1250
 
@@ -1869,8 +1994,21 @@ async function main() {
1869
1994
  const rows = mergeLog(spendLog()).filter((e) => e.at >= since).reverse();
1870
1995
  if (!rows.length) return console.log(`Nothing in the last ${days} days.`);
1871
1996
  const verify = a.flags.verify !== void 0;
1872
- const RPC = { robinhood: process.env.WAFERMAP_RPC_ROBINHOOD, base: process.env.WAFERMAP_RPC_BASE, arbitrum: process.env.WAFERMAP_RPC_ARBITRUM, polygon: process.env.WAFERMAP_RPC_POLYGON };
1873
- const chain = verify ? await Promise.all(rows.map((e) => e.kind === "swap" ? null : checkPayment(e, RPC[e.network]))) : [];
1997
+ const RPC = { robinhood: process.env.WAFERMAP_RPC_ROBINHOOD, base: process.env.WAFERMAP_RPC_BASE, arbitrum: process.env.WAFERMAP_RPC_ARBITRUM, polygon: process.env.WAFERMAP_RPC_POLYGON, solana: process.env.WAFERMAP_RPC_SOLANA };
1998
+ const chain = [];
1999
+ if (verify) {
2000
+ const txCount = /* @__PURE__ */ new Map();
2001
+ for (const e of rows) if (e.network === "solana" && e.tx) txCount.set(e.tx, (txCount.get(e.tx) ?? 0) + 1);
2002
+ let next = 0;
2003
+ await Promise.all(Array.from({ length: 4 }, async () => {
2004
+ while (next < rows.length) {
2005
+ const i = next++;
2006
+ const e = rows[i];
2007
+ const shared = e.network === "solana" && !e.sig && !!e.tx && (txCount.get(e.tx) ?? 0) > 1;
2008
+ chain[i] = e.kind === "swap" ? null : await checkPayment(shared ? { ...e, tx: null } : e, RPC[e.network]);
2009
+ }
2010
+ }));
2011
+ }
1874
2012
  rows.forEach((e, i) => {
1875
2013
  const state = [e.status, chain[i] ? `on-chain: ${chain[i]}` : null].filter(Boolean).join(", ");
1876
2014
  console.log(`${new Date(e.at).toISOString().slice(0, 16).replace("T", " ")} ${e.kind.padEnd(8)} ${e.kind === "swap" ? " " : `$${e.usd}`.padEnd(8)} ${e.network.padEnd(9)} ${e.what}${state ? ` [${state}]` : ""}${e.tx ? ` ${EXPLORER(e.network)}/tx/${e.tx}` : ""}`);
@@ -1881,7 +2019,8 @@ async function main() {
1881
2019
  Total $${total.toFixed(4)} in ${pays.length} payments. From 0.3.3 every payment counts at its full signed amount; older lines show what the server reported.`);
1882
2020
  if (verify) {
1883
2021
  const never = rows.filter((e, i) => e.kind !== "swap" && chain[i] === "unused").reduce((s, e) => s + e.usd, 0);
1884
- console.log(`On-chain: $${never.toFixed(4)} of that was signed but never executed and has expired. "unknown" = Solana or an older log line; "error" = the RPC did not answer, try again.`);
2022
+ const cancelled = rows.filter((e, i) => e.kind !== "swap" && chain[i] === "cancelled").reduce((s, e) => s + e.usd, 0);
2023
+ console.log(`On-chain: $${never.toFixed(4)} of that was signed but never executed and has expired${cancelled ? `, and $${cancelled.toFixed(4)} was cancelled by this wallet before it executed` : ""}. "used" = the token contract logged the transfer (AuthorizationUsed); "cancelled" = the nonce is spent but never executed (cancelAuthorization); "unknown" = an older log line with nothing to check by; "error" = the RPC did not answer, try again (Base's public RPC refuses log searches: for a payment with no transaction on record, set WAFERMAP_RPC_BASE to your own RPC).`);
1885
2024
  } else console.log("Add --verify to check on-chain which signed payments were executed.");
1886
2025
  return;
1887
2026
  }
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/core.ts
2
- import { createPublicClient, createWalletClient, erc20Abi as erc20Abi2, formatUnits as formatUnits2, http } from "viem";
2
+ import { createPublicClient, createWalletClient, erc20Abi as erc20Abi2, formatUnits as formatUnits2, http, pad, parseAbiItem, toEventSelector } from "viem";
3
3
  import { privateKeyToAccount } from "viem/accounts";
4
4
  import { arbitrum, base, polygon, robinhood } from "viem/chains";
5
5
 
@@ -225,7 +225,7 @@ function ataOf(owner, mint = SOLANA_USDC) {
225
225
  }
226
226
  throw new Error("No associated token account address.");
227
227
  }
228
- async function solanaPayment(payer, q) {
228
+ async function solanaPaymentSigned(payer, q) {
229
229
  if (q.asset !== SOLANA_USDC) throw new Error("Only USDC is paid on Solana.");
230
230
  if (!isSolanaAddress(q.feePayer) || !isSolanaAddress(q.payTo)) throw new Error("The quote has no valid Solana fee payer or recipient.");
231
231
  if (q.feePayer === payer.address) throw new Error("The fee payer cannot be the paying wallet.");
@@ -242,19 +242,26 @@ async function solanaPayment(payer, q) {
242
242
  { program: TOKEN_PROGRAM, accounts: [source, q.asset, dest, payer.address], data: concat(Uint8Array.of(12), le(BigInt(q.amount), 8), Uint8Array.of(USDC_DECIMALS)) },
243
243
  { program: MEMO_PROGRAM, accounts: [], data: memo }
244
244
  ];
245
+ const bh = payer.blockhashInfo ? await payer.blockhashInfo() : { blockhash: await payer.blockhash() };
245
246
  const message = concat(
246
247
  Uint8Array.of(128, 2, 1, 4),
247
248
  // v0; 2 signatures, 1 of them readonly; 4 readonly non-signers
248
249
  shortvec(keys.length),
249
250
  ...keys.map((k) => base58.decode(k)),
250
- base58.decode(await payer.blockhash()),
251
+ base58.decode(bh.blockhash),
251
252
  shortvec(ixs.length),
252
253
  ...ixs.map((ix) => concat(Uint8Array.of(at(ix.program)), shortvec(ix.accounts.length), Uint8Array.from(ix.accounts.map(at)), shortvec(ix.data.length), ix.data)),
253
254
  shortvec(0)
254
255
  // no address lookup tables
255
256
  );
256
257
  const signature = await payer.sign(message);
257
- return base64.encode(concat(shortvec(2), new Uint8Array(64), signature, message));
258
+ return {
259
+ transaction: base64.encode(concat(shortvec(2), new Uint8Array(64), signature, message)),
260
+ signature: base58.encode(signature),
261
+ memo: new TextDecoder().decode(memo),
262
+ blockhash: bh.blockhash,
263
+ ..."slot" in bh && Number.isFinite(bh.slot) ? { slot: bh.slot } : {}
264
+ };
258
265
  }
259
266
  async function solanaRpc(url, method, params, f = fetch) {
260
267
  const r = await f(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }) });
@@ -263,7 +270,78 @@ async function solanaRpc(url, method, params, f = fetch) {
263
270
  if (j.error) throw new Error(j.error.message ?? "Solana RPC error.");
264
271
  return j.result;
265
272
  }
266
- var latestBlockhash = async (url, f) => (await solanaRpc(url, "getLatestBlockhash", [{ commitment: "confirmed" }], f)).value.blockhash;
273
+ var latestBlockhash = async (url, f) => (await latestBlockhashInfo(url, f)).blockhash;
274
+ var latestBlockhashInfo = async (url, f) => {
275
+ const r = await solanaRpc(url, "getLatestBlockhash", [{ commitment: "confirmed" }], f);
276
+ return { blockhash: r.value.blockhash, slot: Number(r.context?.slot) };
277
+ };
278
+ var SOLANA_PUBLIC_RPC = "https://api.mainnet-beta.solana.com";
279
+ var READ_TIMEOUT_MS = 15e3;
280
+ async function solanaRead(url, method, params, f = fetch) {
281
+ const timed = (input, init) => f(input, { ...init, signal: AbortSignal.timeout(READ_TIMEOUT_MS) });
282
+ for (let attempt = 0; ; attempt++) {
283
+ try {
284
+ return await solanaRpc(url, method, params, timed);
285
+ } catch (e) {
286
+ if (attempt >= 6 || !/429|rate|too many/i.test(String(e?.message))) throw e;
287
+ await new Promise((r) => setTimeout(r, 1e3 * 2 ** attempt * (0.75 + Math.random() / 2)));
288
+ }
289
+ }
290
+ }
291
+ async function txOutcome(url, tx, e, f) {
292
+ const t = await solanaRead(url, "getTransaction", [tx, { encoding: "json", maxSupportedTransactionVersion: 0, commitment: "confirmed" }], f);
293
+ if (!t) return null;
294
+ const sigs = t.transaction?.signatures ?? [];
295
+ const keys = t.transaction?.message?.accountKeys ?? [];
296
+ if (e.sig ? !sigs.includes(e.sig) : keys[1] !== e.payer) return "other";
297
+ if (t.meta?.err) return "failed";
298
+ if (!e.sig) {
299
+ const bal = (list) => BigInt(list?.find((b) => b.owner === e.payer && b.mint === SOLANA_USDC)?.uiTokenAmount?.amount ?? "0");
300
+ const drop = bal(t.meta?.preTokenBalances) - bal(t.meta?.postTokenBalances);
301
+ if (drop <= 0n || e.usd !== void 0 && drop !== BigInt(Math.round(e.usd * 1e6))) return "other";
302
+ }
303
+ return "ok";
304
+ }
305
+ var BLOCKHASH_MAX_AGE = 150;
306
+ async function checkSolanaPayment(e, url = SOLANA_PUBLIC_RPC, f) {
307
+ if (e.tx) {
308
+ const o = await txOutcome(url, e.tx, e, f).catch(() => null);
309
+ if (o === "ok") return "used";
310
+ if (o === "failed" && e.sig) return "unused";
311
+ }
312
+ if (!e.sig || !e.blockhash || !Number.isSafeInteger(e.slot) || e.slot <= 0) return "unknown";
313
+ const slot = e.slot;
314
+ const block = await solanaRead(url, "getBlock", [slot, { commitment: "confirmed", transactionDetails: "none", rewards: false, maxSupportedTransactionVersion: 0 }], f);
315
+ if (!block || block.blockhash !== e.blockhash || !Number.isSafeInteger(block.blockHeight)) throw new Error("The chain has no block with this payment's blockhash at the logged slot.");
316
+ const lastValidHeight = block.blockHeight + BLOCKHASH_MAX_AGE;
317
+ const fin = await solanaRead(url, "getEpochInfo", [{ commitment: "finalized" }], f);
318
+ const S = Number(fin?.absoluteSlot);
319
+ const H = Number(fin?.blockHeight);
320
+ if (!Number.isSafeInteger(S) || !Number.isSafeInteger(H)) throw new Error("The RPC did not give the finalized height.");
321
+ const expired = H > lastValidHeight;
322
+ let before;
323
+ let reached = false;
324
+ search: for (let page = 0; page < 20; page++) {
325
+ const list = await solanaRead(url, "getSignaturesForAddress", [ataOf(e.payer), { limit: 1e3, commitment: "confirmed", minContextSlot: S, ...before ? { before } : {} }], f);
326
+ if (!Array.isArray(list)) throw new Error("The RPC did not return the wallet's history.");
327
+ for (const x of list) {
328
+ if (!Number.isSafeInteger(x.slot)) throw new Error("The RPC returned history without slots.");
329
+ if (x.slot <= slot) {
330
+ reached = true;
331
+ break search;
332
+ }
333
+ if (e.memo && typeof x.memo === "string" && x.memo && !x.memo.includes(e.memo)) continue;
334
+ const o = await txOutcome(url, x.signature, e, f);
335
+ if (o === "ok") return "used";
336
+ if (o === "failed") return "unused";
337
+ if (o === null) throw new Error("The RPC lists a transaction it cannot return.");
338
+ }
339
+ if (list.length < 1e3) break;
340
+ before = list[list.length - 1].signature;
341
+ }
342
+ if (!reached) throw new Error("Could not read the wallet's history back to this payment.");
343
+ return expired ? "unused" : "pending";
344
+ }
267
345
  async function solanaBalances(url, owner, f) {
268
346
  const [usdc, lamports] = await Promise.all([
269
347
  solanaRpc(url, "getAccountInfo", [ataOf(owner), { commitment: "confirmed", encoding: "jsonParsed" }], f).then((r) => String(r.value?.data?.parsed?.info?.tokenAmount?.uiAmountString ?? "0")),
@@ -406,14 +484,17 @@ async function signPayment(signer, req, resource, solana) {
406
484
  if (req.chain.vm === "svm") {
407
485
  if (!solana) throw new X402Error("no_solana_wallet", "This payment is on Solana and no Solana wallet was given.");
408
486
  let transaction;
487
+ let sol;
409
488
  try {
410
- transaction = await solanaPayment(solana, { amount: req.amount, payTo: req.payTo, asset: req.asset, feePayer: req.extra.feePayer, memo: req.extra.memo });
489
+ const s = await solanaPaymentSigned(solana, { amount: req.amount, payTo: req.payTo, asset: req.asset, feePayer: req.extra.feePayer, memo: req.extra.memo });
490
+ transaction = s.transaction;
491
+ sol = { sig: s.signature, memo: s.memo, blockhash: s.blockhash, ...s.slot !== void 0 ? { slot: s.slot } : {} };
411
492
  } catch (e) {
412
493
  if (e?.name === "TimeoutError" || e?.cause?.name === "TimeoutError") throw e;
413
494
  throw new X402Error("sign_failed", e?.message ?? "Could not build the Solana payment.");
414
495
  }
415
- if (req.version === 1) return { name: "X-PAYMENT", value: b64encode(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.raw.network, payload: { transaction } })) };
416
- return { name: "PAYMENT-SIGNATURE", value: b64encode(JSON.stringify({ x402Version: 2, resource, accepted: req.raw, payload: { transaction } })) };
496
+ if (req.version === 1) return { name: "X-PAYMENT", value: b64encode(JSON.stringify({ x402Version: 1, scheme: "exact", network: req.raw.network, payload: { transaction } })), sol };
497
+ return { name: "PAYMENT-SIGNATURE", value: b64encode(JSON.stringify({ x402Version: 2, resource, accepted: req.raw, payload: { transaction } })), sol };
417
498
  }
418
499
  const nonce = randomNonce();
419
500
  const validBefore = BigInt(Math.floor(Date.now() / 1e3) + Math.min(req.maxTimeoutSeconds, MAX_VALIDITY_SECONDS));
@@ -467,7 +548,7 @@ async function x402fetch(url, init, opts) {
467
548
  const svm = req.chain.vm === "svm";
468
549
  const signer = svm ? null : typeof opts.signer === "function" ? await opts.signer() : opts.signer;
469
550
  const header = await signPayment(signer, req, quote.resource, opts.solana);
470
- const signed = { req, from: svm ? opts.solana.address : signer.address, ...header.auth ? { auth: header.auth } : {} };
551
+ const signed = { req, from: svm ? opts.solana.address : signer.address, ...header.auth ? { auth: header.auth } : {}, ...header.sol ? { sol: header.sol } : {} };
471
552
  opts.onSigned?.(signed);
472
553
  const headers = new Headers(init.headers);
473
554
  headers.set(header.name, header.value);
@@ -530,14 +611,58 @@ function mergeLog(entries) {
530
611
  return { ...e, status: o.status, tx: o.tx ?? e.tx, reported: o.reported ?? null, ...o.status === "aborted" ? { usd: 0 } : {} };
531
612
  });
532
613
  }
614
+ async function blockAt(client, ts, side, latest) {
615
+ if (ts >= Number(latest.timestamp)) return latest.number;
616
+ let hi = latest;
617
+ let lo = await client.getBlock({ blockNumber: latest.number > 100000n ? latest.number - 100000n : 0n });
618
+ while (Number(lo.timestamp) > ts && lo.number > 0n) {
619
+ const back = (hi.number - lo.number) * 4n;
620
+ hi = lo;
621
+ lo = await client.getBlock({ blockNumber: lo.number > back ? lo.number - back : 0n });
622
+ }
623
+ for (let i = 0; i < 20 && hi.number - lo.number > 20n; i++) {
624
+ const span = Number(hi.timestamp - lo.timestamp) || 1;
625
+ let guess = lo.number + BigInt(Math.floor(Number(hi.number - lo.number) * (ts - Number(lo.timestamp)) / span));
626
+ if (guess <= lo.number) guess = lo.number + 1n;
627
+ if (guess >= hi.number) guess = hi.number - 1n;
628
+ const b = await client.getBlock({ blockNumber: guess });
629
+ if (Number(b.timestamp) <= ts) lo = b;
630
+ else hi = b;
631
+ }
632
+ return side === "from" ? lo.number : hi.number;
633
+ }
634
+ var AUTH_USED = parseAbiItem("event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce)");
533
635
  async function checkPayment(e, rpc) {
534
- if (e.network === "solana" || !e.nonce || !(e.network in CHAINS)) return "unknown";
636
+ if (e.network === "solana") return checkSolanaPayment(e, rpc).catch(() => "error");
637
+ if (!e.nonce || !(e.network in CHAINS)) return "unknown";
535
638
  const chain = PAY_CHAINS.find((c) => c.key === e.network);
536
639
  try {
537
640
  const client = createPublicClient({ chain: CHAINS[e.network], transport: http(rpc) });
538
641
  const used = await client.readContract({ address: chain.stable, abi: AUTH_STATE_ABI, functionName: "authorizationState", args: [e.payer, e.nonce] });
539
- if (used) return "used";
540
- return e.validBefore && Date.now() / 1e3 > e.validBefore ? "unused" : "pending";
642
+ if (!used) return e.validBefore && Date.now() / 1e3 > e.validBefore ? "unused" : "pending";
643
+ const isOurs = (l) => l.address.toLowerCase() === chain.stable.toLowerCase() && l.topics[0] === toEventSelector(AUTH_USED) && l.topics[1]?.toLowerCase() === pad(e.payer).toLowerCase() && l.topics[2]?.toLowerCase() === e.nonce.toLowerCase();
644
+ if (e.tx && /^0x[0-9a-fA-F]{64}$/.test(e.tx)) {
645
+ const receipt = await client.getTransactionReceipt({ hash: e.tx }).catch(() => null);
646
+ if (receipt?.status === "success" && receipt.logs.some(isOurs)) return "used";
647
+ }
648
+ if (!e.validBefore || !e.at) return "error";
649
+ const latest = await client.getBlock();
650
+ const fromBlock = await blockAt(client, Math.floor(e.at / 1e3) - 60, "from", latest);
651
+ const toBlock = await blockAt(client, e.validBefore + 30, "to", latest);
652
+ for (let start = fromBlock; start <= toBlock; start += 50n) {
653
+ const end = start + 49n < toBlock ? start + 49n : toBlock;
654
+ for (let attempt = 0; ; attempt++) {
655
+ try {
656
+ const logs = await client.getLogs({ address: chain.stable, event: AUTH_USED, args: { authorizer: e.payer, nonce: e.nonce }, fromBlock: start, toBlock: end });
657
+ if (logs.length) return "used";
658
+ break;
659
+ } catch (err) {
660
+ if (attempt >= 3) throw err;
661
+ await new Promise((r) => setTimeout(r, 1500 * (attempt + 1)));
662
+ }
663
+ }
664
+ }
665
+ return "cancelled";
541
666
  } catch {
542
667
  return "error";
543
668
  }
@@ -599,7 +724,7 @@ var Wafermap = class {
599
724
  this.fetch = (input, init) => fetch(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(this.timeoutMs) });
600
725
  const sol = o.solana ?? (o.privateKey ? solanaKeyFromEvm(o.privateKey) : null);
601
726
  const solRpc = this.rpc.solana ?? `${this.baseUrl}/api/solana`;
602
- this.solana = sol ? { address: sol.address, sign: sol.sign, blockhash: () => latestBlockhash(solRpc, this.fetch) } : null;
727
+ this.solana = sol ? { address: sol.address, sign: sol.sign, blockhash: () => latestBlockhash(solRpc, this.fetch), blockhashInfo: () => latestBlockhashInfo(solRpc, this.fetch) } : null;
603
728
  this.solanaAddress = sol?.address ?? o.solanaAddress ?? null;
604
729
  }
605
730
  get address() {
@@ -638,7 +763,7 @@ var Wafermap = class {
638
763
  /** Write the signed payment to the ledger before it leaves the machine. */
639
764
  booked(kind, what, s) {
640
765
  const id = crypto.randomUUID();
641
- this.ledger.record({ at: Date.now(), kind, what, usd: s.req.usd, network: s.req.chain.key, tx: null, payer: s.from, id, status: "signed", ...s.auth ? { nonce: s.auth.nonce, validBefore: s.auth.validBefore } : {} });
766
+ this.ledger.record({ at: Date.now(), kind, what, usd: s.req.usd, network: s.req.chain.key, tx: null, payer: s.from, id, status: "signed", ...s.auth ? { nonce: s.auth.nonce, validBefore: s.auth.validBefore } : {}, ...s.sol ? { sig: s.sol.sig, memo: s.sol.memo, blockhash: s.sol.blockhash, ...s.sol.slot !== void 0 ? { slot: s.sol.slot } : {} } : {} });
642
767
  return id;
643
768
  }
644
769
  /** The outcome line for a booked payment. It adds nothing to the daily total. */
@@ -865,6 +990,7 @@ export {
865
990
  PASS_HEADER,
866
991
  PAY_CHAINS,
867
992
  PERMIT2,
993
+ SOLANA_PUBLIC_RPC,
868
994
  SwapError,
869
995
  WAFERMAP_RELAYERS,
870
996
  WAFERMAP_TOKEN,
@@ -872,6 +998,7 @@ export {
872
998
  WafermapError,
873
999
  X402Error,
874
1000
  checkPayment,
1001
+ checkSolanaPayment,
875
1002
  choose,
876
1003
  decodePass,
877
1004
  decodeSwap,
@@ -1,10 +1,16 @@
1
1
  export declare const SOLANA_CAIP = "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp";
2
2
  export declare const SOLANA_USDC = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
3
- /** Signs as one Solana address. `blockhash` returns a recent blockhash from any mainnet RPC. */
3
+ /** Signs as one Solana address. `blockhash` returns a recent blockhash from any mainnet RPC. When
4
+ * `blockhashInfo` is given it is used instead, and the slot the blockhash was read at is kept with
5
+ * the payment: the payment cannot land before it, which bounds the search for it later. */
4
6
  export type SolanaPayer = {
5
7
  address: string;
6
8
  sign: (message: Uint8Array) => Uint8Array | Promise<Uint8Array>;
7
9
  blockhash: () => Promise<string>;
10
+ blockhashInfo?: () => Promise<{
11
+ blockhash: string;
12
+ slot: number;
13
+ }>;
8
14
  };
9
15
  export declare function isSolanaAddress(v: string): boolean;
10
16
  /** The agent's Solana keypair, derived from its EVM private key. */
@@ -25,8 +31,53 @@ export type SolanaQuote = {
25
31
  };
26
32
  /** The base64 wire transaction for an x402 `exact` payment on Solana, signed by `payer` only. */
27
33
  export declare function solanaPayment(payer: SolanaPayer, q: SolanaQuote): Promise<string>;
34
+ /** What a signed Solana payment leaves to check it by later: our signature (the second one in the
35
+ * transaction; the first, which names it on explorers, is the facilitator's and unknown until it
36
+ * submits), its memo, its blockhash (once that is no longer valid the payment can never execute)
37
+ * and, when known, the slot the blockhash was read at. */
38
+ export type SolanaSigned = {
39
+ transaction: string;
40
+ signature: string;
41
+ memo: string;
42
+ blockhash: string;
43
+ slot?: number;
44
+ };
45
+ export declare function solanaPaymentSigned(payer: SolanaPayer, q: SolanaQuote): Promise<SolanaSigned>;
28
46
  export declare function solanaRpc(url: string, method: string, params: unknown[], f?: typeof fetch): Promise<any>;
29
47
  export declare const latestBlockhash: (url: string, f?: typeof fetch) => Promise<string>;
48
+ export declare const latestBlockhashInfo: (url: string, f?: typeof fetch) => Promise<{
49
+ blockhash: string;
50
+ slot: number;
51
+ }>;
52
+ /** The public Solana RPC that `checkSolanaPayment` reads by default: not Wafermap's, so the answer
53
+ * does not depend on the server that was paid. */
54
+ export declare const SOLANA_PUBLIC_RPC = "https://api.mainnet-beta.solana.com";
55
+ export type SolanaCheck = {
56
+ payer: string;
57
+ usd?: number;
58
+ tx?: string | null;
59
+ sig?: string;
60
+ memo?: string;
61
+ blockhash?: string;
62
+ slot?: number;
63
+ };
64
+ /**
65
+ * Was a signed Solana payment ever executed? Read from a Solana RPC (by default the public one, not
66
+ * Wafermap's), and every number it rests on is checked on that RPC, not taken from the server
67
+ * that was paid.
68
+ * - The transaction the server named, if it carries our signature: "used" when it succeeded.
69
+ * - Otherwise the payment is anchored to the block its blockhash names: the block at the logged
70
+ * slot must have exactly that hash. The payment can only land after that slot, and only until
71
+ * the chain is BLOCKHASH_MAX_AGE blocks past it. Then, with the finalized height H at slot S:
72
+ * the wallet's USDC account history, read from a node at slot S or later, back to that slot,
73
+ * is searched for our signature. Found: "used" (or "unused" if it landed and failed: nothing
74
+ * moved, and it cannot land again). Not found: "unused" when H is past the last valid height
75
+ * (any landing would then be final and in that history), else "pending".
76
+ * - Anything that cannot be established that way throws (the CLI shows "error"); it is never
77
+ * "unused". Lines from before 0.4.7 have no signature: only the server's transaction can prove
78
+ * them ("used"), else "unknown".
79
+ */
80
+ export declare function checkSolanaPayment(e: SolanaCheck, url?: string, f?: typeof fetch): Promise<"used" | "unused" | "pending" | "unknown">;
30
81
  /** USDC (6 decimals) and SOL balances of a wallet. A wallet that never held USDC has no token
31
82
  * account: 0. Read with getAccountInfo, which free public RPCs serve (getTokenAccountBalance is
32
83
  * an "indexed" call some of them reserve for paid keys). */
@@ -98,6 +98,7 @@ export declare function signPayment(signer: Signer, req: Requirement, resource:
98
98
  name: string;
99
99
  value: string;
100
100
  auth?: SignedAuth;
101
+ sol?: SignedSol;
101
102
  }>;
102
103
  /** Settlement details a server returns after a paid call, when it sends them. */
103
104
  export declare function readSettlement(res: Response): {
@@ -111,10 +112,19 @@ export type SignedAuth = {
111
112
  nonce: Hex;
112
113
  validBefore: number;
113
114
  };
115
+ /** What a Solana payment can be found by on-chain later (see checkSolanaPayment): our signature, its
116
+ * memo, its blockhash and the slot that blockhash was read at. */
117
+ export type SignedSol = {
118
+ sig: string;
119
+ memo: string;
120
+ blockhash: string;
121
+ slot?: number;
122
+ };
114
123
  export type Signed = {
115
124
  req: Requirement;
116
125
  from: string;
117
126
  auth?: SignedAuth;
127
+ sol?: SignedSol;
118
128
  token?: {
119
129
  amount: string;
120
130
  };
@@ -31,6 +31,11 @@ export type SpendEntry = {
31
31
  /** EVM payments: the EIP-3009 nonce and expiry, to check on-chain whether it was ever used. */
32
32
  nonce?: string;
33
33
  validBefore?: number;
34
+ /** Solana payments (from 0.4.7): our signature, the memo, the blockhash and the slot it was read at. */
35
+ sig?: string;
36
+ memo?: string;
37
+ blockhash?: string;
38
+ slot?: number;
34
39
  /** What the server said it charged, for the record only. */
35
40
  reported?: number | null;
36
41
  };
@@ -84,10 +89,17 @@ export declare function mergeLog(entries: SpendEntry[]): SpendEntry[];
84
89
  /**
85
90
  * Was a signed payment ever executed? Reads the token contract (EIP-3009 authorizationState),
86
91
  * so the answer does not depend on any server and needs no key. "unused" after expiry means it
87
- * can never be charged. Solana payments and old log lines without a nonce come back "unknown"; an RPC
88
- * failure comes back "error".
92
+ * can never be charged. authorizationState is also true after cancelAuthorization, so a true is
93
+ * confirmed by the AuthorizationUsed event: first in the receipt of the transaction the server
94
+ * named, else in the blocks between signing and expiry, the only time it could have executed.
95
+ * None there means the nonce was cancelled, never executed ("cancelled"). Solana payments are
96
+ * checked by checkSolanaPayment (shared/solana.ts), by default on the public Solana RPC. Old log
97
+ * lines with nothing to check by come back "unknown"; an RPC failure comes back "error".
89
98
  */
90
- export declare function checkPayment(e: Pick<SpendEntry, "network" | "payer" | "nonce" | "validBefore">, rpc?: string): Promise<"used" | "unused" | "pending" | "unknown" | "error">;
99
+ export declare function checkPayment(e: Pick<SpendEntry, "network" | "payer" | "nonce" | "validBefore"> & Partial<Pick<SpendEntry, "sig" | "memo" | "blockhash" | "slot" | "usd">> & {
100
+ at?: number;
101
+ tx?: string | null;
102
+ }, rpc?: string): Promise<"used" | "cancelled" | "unused" | "pending" | "unknown" | "error">;
91
103
  export declare const DEFAULT_LIMITS: Limits;
92
104
  export declare const DEFAULT_TIMEOUT_MS = 60000;
93
105
  /** What a wallet's $WAFERMAP unlocks (holder price, Pro free calls left today), from /api/holder. Needs no key. */
@@ -3,4 +3,6 @@ export { PASS_HEADER, passMessage, encodePass, decodePass, makePass } from "../s
3
3
  export type { Approval, Limits, Net, PaymentStatus, SpendEntry, SwapQuote, Token, WafermapOptions } from "./core.js";
4
4
  export { x402fetch, readQuote, choose, signPayment, signTokenPayment, wafermapPriceUsd, PAY_CHAINS, MAX_VALIDITY_SECONDS, WAFERMAP_RELAYERS, WAFERMAP_TOKEN, PERMIT2, X402Error } from "../shared/x402client";
5
5
  export { quoteSwap, executeSwap, decodeSwap, KYBER_ROUTER, SwapError } from "../shared/swap";
6
- export type { Requirement, Quote, Signed, SignedAuth, Signer } from "../shared/x402client";
6
+ export type { Requirement, Quote, Signed, SignedAuth, SignedSol, Signer } from "../shared/x402client";
7
+ export { checkSolanaPayment, SOLANA_PUBLIC_RPC } from "../shared/solana";
8
+ export type { SolanaCheck } from "../shared/solana";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wafermapxyz/cli",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "Wafermap for agents: an encrypted local wallet that pays x402 resources, discovers and pays third-party x402 services (USDG on Robinhood Chain; USDC on Solana, Base, Arbitrum, Polygon), and swaps on Robinhood Chain and Base. CLI, SDK and MCP server.",
5
5
  "type": "module",
6
6
  "bin": {