@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 +25 -0
- package/README.md +1 -1
- package/dist/cli.js +159 -20
- package/dist/index.js +141 -14
- package/dist/types/shared/solana.d.ts +52 -1
- package/dist/types/shared/x402client.d.ts +10 -0
- package/dist/types/src/core.d.ts +15 -3
- package/dist/types/src/index.d.ts +3 -1
- package/package.json +1 -1
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
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 "
|
|
581
|
-
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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"
|
|
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 "
|
|
540
|
-
|
|
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
|
};
|
package/dist/types/src/core.d.ts
CHANGED
|
@@ -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.
|
|
88
|
-
*
|
|
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"
|
|
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.
|
|
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": {
|