@dvmkit/sdk 0.1.0-rc.2 → 0.1.0-rc.3
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/README.md +10 -2
- package/dist/{revenue-reporter-GB4WKLDC.js → chunk-2ABMGUDS.js} +1 -0
- package/dist/chunk-5SO7ZVOH.js +375 -0
- package/dist/chunk-66HGCPBU.js +25 -0
- package/dist/{chunk-KQAJVVZT.js → chunk-AZBXSXQT.js} +27 -287
- package/dist/chunk-C2DC4FKR.js +21424 -0
- package/dist/{chunk-H25M54MI.js → chunk-C3MTFLC6.js} +16 -0
- package/dist/{chunk-OJ5WFIB2.js → chunk-EXHBXA4U.js} +1 -1
- package/dist/chunk-FT7IM66W.js +1557 -0
- package/dist/{chunk-KXWROQGK.js → chunk-FUJ36YDV.js} +1 -24
- package/dist/{tempo-charge-store-6GJEMNUU.js → chunk-JZWELPFH.js} +1 -0
- package/dist/{chunk-365P52XQ.js → chunk-LWUR4CGG.js} +2 -1
- package/dist/chunk-RHP3BRTH.js +1090 -0
- package/dist/{tempo-session-store-FTEEGZXA.js → chunk-RU7SXHLO.js} +2 -1
- package/dist/{chunk-7IH5SG2A.js → chunk-TKA6ZP4M.js} +62 -41
- package/dist/chunk-TVI4V7GF.js +283 -0
- package/dist/{payout-reporter-4TNWRS5F.js → chunk-X3IKFWJA.js} +3 -2
- package/dist/{chunk-DCNT4PJS.js → chunk-XY5Y5REG.js} +6 -258
- package/dist/chunk-XYTSDAPH.js +232 -0
- package/dist/chunk-YD3TZNXV.js +1042 -0
- package/dist/{credit-ledger-RO4FGSHG.js → credit-ledger-ED6JXKVD.js} +2 -2
- package/dist/credit-menu-DONAtGVf.d.ts +5076 -0
- package/dist/{ssrf-DZi-xJyn.d.ts → fx-Bq4cvn16.d.ts} +37 -119
- package/dist/index.d.ts +8 -66
- package/dist/index.js +11 -219
- package/dist/internal/index.d.ts +5745 -0
- package/dist/internal/index.js +6379 -0
- package/dist/{job-store-6gR4pZRP.d.ts → job-store-m2pYmvbr.d.ts} +1493 -12
- package/dist/{memory-credit-ledger-I2G64DDK.js → memory-credit-ledger-XJ5VQEVP.js} +3 -3
- package/dist/payout-reporter-3UB5WRCV.js +13 -0
- package/dist/revenue-reporter-JIKUPXOK.js +7 -0
- package/dist/server/index.d.ts +14 -3590
- package/dist/server/index.js +309 -20796
- package/dist/ssrf-DbFkpDv0.d.ts +118 -0
- package/dist/tempo-charge-store-RIFTALZK.js +8 -0
- package/dist/tempo-session-store-DALMRIWN.js +11 -0
- package/dist/testing/index.d.ts +3 -2
- package/dist/testing/index.js +3 -2
- package/dist/usd-DjVAPMlf.d.ts +97 -0
- package/dist/x402-5EVIUSEP.js +81 -0
- package/package.json +6 -2
- package/dist/x402-35VLYFKZ.js +0 -1272
|
@@ -0,0 +1,1090 @@
|
|
|
1
|
+
import {
|
|
2
|
+
authorizeProtectedRemoval,
|
|
3
|
+
fundingReachableAt,
|
|
4
|
+
info,
|
|
5
|
+
isValidEvmPrivateKey,
|
|
6
|
+
listCredits,
|
|
7
|
+
loadConfig,
|
|
8
|
+
maxAdvertisedMicro,
|
|
9
|
+
updateConfig,
|
|
10
|
+
withinAdvertisedTolerance
|
|
11
|
+
} from "./chunk-FT7IM66W.js";
|
|
12
|
+
import {
|
|
13
|
+
X402_DEFAULT_NETWORK,
|
|
14
|
+
X402_V1_VERSION,
|
|
15
|
+
X402_VERSION,
|
|
16
|
+
assertSupportedX402Network,
|
|
17
|
+
buildPaymentRequirements,
|
|
18
|
+
chainIdFromCaip2,
|
|
19
|
+
createX402FacilitatorAuthHeaders,
|
|
20
|
+
decodePayment,
|
|
21
|
+
encodePayment,
|
|
22
|
+
encodeSettleResponseHeader,
|
|
23
|
+
exactEvmAuthorization,
|
|
24
|
+
settleWithFacilitator,
|
|
25
|
+
usdcContractFor,
|
|
26
|
+
usdcDomainNameFor,
|
|
27
|
+
usdcDomainVersionFor,
|
|
28
|
+
verifyWithFacilitator,
|
|
29
|
+
x402NetworkByCaip2,
|
|
30
|
+
x402NetworkToCaip2,
|
|
31
|
+
x402SupportedNetworksHint
|
|
32
|
+
} from "./chunk-XY5Y5REG.js";
|
|
33
|
+
import {
|
|
34
|
+
DvmError,
|
|
35
|
+
X402_CHANNELS_FILE,
|
|
36
|
+
costAnchor,
|
|
37
|
+
ensureConfigDir,
|
|
38
|
+
fetchBtcUsdRate,
|
|
39
|
+
formatStaleness,
|
|
40
|
+
formatUsd,
|
|
41
|
+
getLastKnownBtcRate,
|
|
42
|
+
msatsToUsdc,
|
|
43
|
+
resolveRpcHttpTransportOptions,
|
|
44
|
+
resolveRpcOverride,
|
|
45
|
+
usdcToMsats
|
|
46
|
+
} from "./chunk-5SO7ZVOH.js";
|
|
47
|
+
import {
|
|
48
|
+
callerLoggers
|
|
49
|
+
} from "./chunk-66HGCPBU.js";
|
|
50
|
+
import {
|
|
51
|
+
redactUrl,
|
|
52
|
+
redactUrlsInText
|
|
53
|
+
} from "./chunk-FUJ36YDV.js";
|
|
54
|
+
|
|
55
|
+
// src/lib/x402/asset.ts
|
|
56
|
+
function resolveX402Asset(network = X402_DEFAULT_NETWORK) {
|
|
57
|
+
const spec = x402NetworkByCaip2(network);
|
|
58
|
+
if (!spec) return null;
|
|
59
|
+
return {
|
|
60
|
+
address: spec.usdc,
|
|
61
|
+
symbol: "USDC",
|
|
62
|
+
// "native" is the load-bearing word: every contract in the table is
|
|
63
|
+
// Circle's own USDC, and the dollars that read as zero here are the
|
|
64
|
+
// bridged ones.
|
|
65
|
+
label: "native USDC",
|
|
66
|
+
unit: "USDC",
|
|
67
|
+
networkLabel: spec.label,
|
|
68
|
+
confusable: spec.confusable
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function x402WrongAssetHint(asset) {
|
|
72
|
+
const wrongToken = asset.confusable ? `Another dollar on ${asset.networkLabel} \u2014 ${asset.confusable}, the legacy bridged dollar, or any other stablecoin \u2014 is a different contract and will never show up here, however much of it your wallet says you hold.` : `Another dollar on ${asset.networkLabel} is a different contract and will never show up here, however much of it your wallet says you hold.`;
|
|
73
|
+
return `This rail reads only ${asset.label} (${asset.address}) on ${asset.networkLabel}. ${wrongToken} USDC on a different chain is invisible here too \u2014 this wallet reads ${asset.networkLabel} alone. If you funded the wrong one, swap it to ${asset.label} on ${asset.networkLabel}, or bridge the funds across to ${asset.networkLabel}; either way they land where this rail can spend them.`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// src/lib/x402/client.ts
|
|
77
|
+
import { randomBytes } from "crypto";
|
|
78
|
+
|
|
79
|
+
// src/lib/btc-rate.ts
|
|
80
|
+
async function resolveBtcUsdRate(purpose) {
|
|
81
|
+
try {
|
|
82
|
+
return { rate: await fetchBtcUsdRate(), stale: false };
|
|
83
|
+
} catch {
|
|
84
|
+
const cached = getLastKnownBtcRate();
|
|
85
|
+
if (cached) {
|
|
86
|
+
warnStaleRate(cached.cachedAt);
|
|
87
|
+
return { rate: cached.rate, stale: true, cachedAt: cached.cachedAt };
|
|
88
|
+
}
|
|
89
|
+
throw rateUnavailable(purpose);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async function tryResolveBtcUsdRate() {
|
|
93
|
+
try {
|
|
94
|
+
return await resolveBtcUsdRate("conversion");
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
var warnedStale = false;
|
|
100
|
+
function warnStaleRate(cachedAt) {
|
|
101
|
+
if (warnedStale) return;
|
|
102
|
+
warnedStale = true;
|
|
103
|
+
info(
|
|
104
|
+
`Warning: the BTC/USD rate provider is unreachable \u2014 using the last rate this machine saw (cached ${formatStaleness(cachedAt)}). Dollar figures may have drifted.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
function rateUnavailable(purpose) {
|
|
108
|
+
const outcome = purpose === "payment" ? "nothing was paid" : "no conversion was performed";
|
|
109
|
+
const noun = purpose === "payment" ? "payment" : "conversion";
|
|
110
|
+
const capped = outcome.charAt(0).toUpperCase() + outcome.slice(1);
|
|
111
|
+
return new DvmError(
|
|
112
|
+
"rate_unavailable",
|
|
113
|
+
`Could not price this ${noun}: the BTC/USD rate provider is unreachable and no cached rate is available.`,
|
|
114
|
+
`${capped}. The rate provider (CoinGecko) is rate-limiting or down, and this machine has never cached a rate. Retry in a minute; if it keeps failing, check network access to api.coingecko.com.${purpose === "payment" ? " Budgets are denominated in dollars, so a payment cannot be authorised without a rate." : ""}`,
|
|
115
|
+
{
|
|
116
|
+
display: `BTC/USD rate unavailable \u2014 ${outcome}.`,
|
|
117
|
+
anchor: "rate_unavailable",
|
|
118
|
+
purpose,
|
|
119
|
+
provider: "coingecko",
|
|
120
|
+
retryable: true
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// src/lib/payment-authorization-bounds.ts
|
|
126
|
+
var X402_EXACT_AUTHORIZATION_TTL_SECONDS = 600;
|
|
127
|
+
var X402_REFUSAL_TRANSACTION_DISCOVERY_WINDOW_MS = 15 * 6e4;
|
|
128
|
+
|
|
129
|
+
// src/lib/x402/channel-store.ts
|
|
130
|
+
import { AsyncLocalStorage } from "async_hooks";
|
|
131
|
+
import { existsSync, readFileSync, renameSync, writeFileSync } from "fs";
|
|
132
|
+
import lockfile from "proper-lockfile";
|
|
133
|
+
function x402ExactFundingDead(pending, now = Date.now()) {
|
|
134
|
+
const seconds = pending.authorizationValidBefore ? Number(pending.authorizationValidBefore) : NaN;
|
|
135
|
+
return Number.isFinite(seconds) ? now >= seconds * 1e3 : now >= pending.requestedAt + X402_EXACT_AUTHORIZATION_TTL_SECONDS * 1e3;
|
|
136
|
+
}
|
|
137
|
+
function recordPendingX402ExactFunding(pending) {
|
|
138
|
+
const { file, credits } = loadFundingFile();
|
|
139
|
+
file.exact = [
|
|
140
|
+
...file.exact.filter((entry) => !sameFundingCredit(entry, pending, credits)),
|
|
141
|
+
pending
|
|
142
|
+
];
|
|
143
|
+
saveFile(file);
|
|
144
|
+
}
|
|
145
|
+
function loadResumableX402ExactFunding(args) {
|
|
146
|
+
const { file, credits } = loadFundingFile();
|
|
147
|
+
return file.exact.find(
|
|
148
|
+
(entry) => sameFundingCredit(entry, args, credits) && !x402ExactFundingDead(entry, args.now)
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
function listResumableX402ExactFundings(filter = {}) {
|
|
152
|
+
const endpoint = filter.endpoint ? normalizeEndpoint(filter.endpoint) : void 0;
|
|
153
|
+
const { file, credits } = loadFundingFile();
|
|
154
|
+
return file.exact.filter(
|
|
155
|
+
(entry) => (!endpoint || fundingReachableAt(entry, endpoint, credits)) && (!filter.callerPubkey || entry.callerPubkey === filter.callerPubkey) && !x402ExactFundingDead(entry, filter.now)
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
function clearPendingX402ExactFunding(pending) {
|
|
159
|
+
const { file } = loadFundingFile();
|
|
160
|
+
const key = pendingKey(pending);
|
|
161
|
+
file.exact = file.exact.filter(
|
|
162
|
+
(entry) => pendingKey(entry) !== key || entry.fundId !== pending.fundId
|
|
163
|
+
);
|
|
164
|
+
saveFile(file);
|
|
165
|
+
}
|
|
166
|
+
var X402_PENDING_STALE_MS = 24 * 60 * 60 * 1e3;
|
|
167
|
+
function x402PendingStale(pending, now = Date.now()) {
|
|
168
|
+
return now - pending.requestedAt >= X402_PENDING_STALE_MS;
|
|
169
|
+
}
|
|
170
|
+
function x402PendingRetired(pending) {
|
|
171
|
+
return pending.refusedAt !== void 0 || pending.supersededAt !== void 0;
|
|
172
|
+
}
|
|
173
|
+
function x402PendingBlockExpiresAt(pending) {
|
|
174
|
+
return x402PendingDepositExpiresAt(pending) ?? pending.requestedAt + X402_PENDING_STALE_MS;
|
|
175
|
+
}
|
|
176
|
+
function x402PendingDepositExpiresAt(pending) {
|
|
177
|
+
const seconds = pending.authorizationValidBefore ? Number(pending.authorizationValidBefore) : NaN;
|
|
178
|
+
return Number.isFinite(seconds) ? seconds * 1e3 : void 0;
|
|
179
|
+
}
|
|
180
|
+
function x402PendingUnsettled(pending, now = Date.now()) {
|
|
181
|
+
return !x402PendingRetired(pending) || now <= x402PendingBlockExpiresAt(pending);
|
|
182
|
+
}
|
|
183
|
+
function x402PendingFundingJson(entry) {
|
|
184
|
+
return {
|
|
185
|
+
credit_id: entry.creditId,
|
|
186
|
+
fund_id: entry.fundId,
|
|
187
|
+
endpoint: entry.endpoint,
|
|
188
|
+
channel_id: entry.channelId,
|
|
189
|
+
amount_micro: entry.amountMicro,
|
|
190
|
+
// What the ERC-3009 authorization can still pull, which is the figure a
|
|
191
|
+
// `--force` retirement is really deciding about: a deposit's authorized
|
|
192
|
+
// value can exceed the amount funded (internal-review), so `amount_micro` alone
|
|
193
|
+
// understates it. Null on a voucher-only payload, which authorizes no
|
|
194
|
+
// deposit at all.
|
|
195
|
+
deposit_micro: entry.depositMicro ?? null,
|
|
196
|
+
signed_max_claimable_micro: entry.maxClaimableMicro ?? null,
|
|
197
|
+
requested_at: new Date(entry.requestedAt).toISOString(),
|
|
198
|
+
stale: x402PendingStale(entry),
|
|
199
|
+
...entry.authorizationValidBefore ? {
|
|
200
|
+
authorization_valid_before: new Date(
|
|
201
|
+
Number(entry.authorizationValidBefore) * 1e3
|
|
202
|
+
).toISOString()
|
|
203
|
+
} : {},
|
|
204
|
+
...entry.refusedAt !== void 0 ? {
|
|
205
|
+
refused_at: new Date(entry.refusedAt).toISOString(),
|
|
206
|
+
funding_blocked_until: new Date(x402PendingBlockExpiresAt(entry)).toISOString()
|
|
207
|
+
} : {},
|
|
208
|
+
...entry.supersededAt !== void 0 ? {
|
|
209
|
+
superseded_at: new Date(entry.supersededAt).toISOString(),
|
|
210
|
+
funding_blocked_until: new Date(x402PendingBlockExpiresAt(entry)).toISOString()
|
|
211
|
+
} : {}
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
var EMPTY = { version: 1, channels: {}, pending: [], exact: [] };
|
|
215
|
+
var x402LockOwner = new AsyncLocalStorage();
|
|
216
|
+
async function withX402ChannelLock(operation) {
|
|
217
|
+
if (x402LockOwner.getStore()?.active) return operation();
|
|
218
|
+
ensureX402ChannelFile();
|
|
219
|
+
const release = await lockfile.lock(X402_CHANNELS_FILE, {
|
|
220
|
+
realpath: false,
|
|
221
|
+
retries: { retries: 20, minTimeout: 25, maxTimeout: 250 }
|
|
222
|
+
});
|
|
223
|
+
const owner = { active: true };
|
|
224
|
+
try {
|
|
225
|
+
return await x402LockOwner.run(owner, operation);
|
|
226
|
+
} finally {
|
|
227
|
+
owner.active = false;
|
|
228
|
+
await release();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
var DvmX402ChannelStorage = class {
|
|
232
|
+
nextBalanceProvenance;
|
|
233
|
+
get(key) {
|
|
234
|
+
return fromSynchronousStore(() => loadFile().channels[canonicalChannelId(key)]?.context);
|
|
235
|
+
}
|
|
236
|
+
/** Read the highest cumulative amount a DVM has acknowledged for this channel. */
|
|
237
|
+
getAcknowledgedCumulativeAmount(key) {
|
|
238
|
+
return fromSynchronousStore(
|
|
239
|
+
() => readAcknowledgedCumulativeAmount(loadFile().channels[canonicalChannelId(key)])
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Upstream's ordinary write. Its chain-recovery paths are authoritative and
|
|
244
|
+
* clear the unreconciled stamp. DVM-response paths scope this same write to
|
|
245
|
+
* persist their weaker provenance atomically with the balance (internal-review).
|
|
246
|
+
*/
|
|
247
|
+
set(key, context) {
|
|
248
|
+
return fromSynchronousStore(() => {
|
|
249
|
+
const provenance = this.nextBalanceProvenance?.provenance ?? "observed";
|
|
250
|
+
this.nextBalanceProvenance = void 0;
|
|
251
|
+
writeContext(key, context, provenance, "advance");
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Apply non-chain provenance to the next upstream `set` on this instance.
|
|
256
|
+
* Returns a cleanup for paths where upstream rejects or deletes instead, so a
|
|
257
|
+
* later chain recovery cannot inherit a stale provenance decision.
|
|
258
|
+
*/
|
|
259
|
+
stageNextUnobservedBalanceProvenance(provenance) {
|
|
260
|
+
const staged = { provenance };
|
|
261
|
+
this.nextBalanceProvenance = staged;
|
|
262
|
+
return () => {
|
|
263
|
+
if (this.nextBalanceProvenance === staged) this.nextBalanceProvenance = void 0;
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Write a context whose `balance` carries a deposit this machine added on its
|
|
268
|
+
* own authority, and stamp the record as unreconciled (internal-review).
|
|
269
|
+
*
|
|
270
|
+
* Separate from {@link set} because the two differ in exactly one thing that
|
|
271
|
+
* matters later: where the figure came from. Nothing else about the write
|
|
272
|
+
* differs.
|
|
273
|
+
*/
|
|
274
|
+
setLocallyAppliedDeposit(key, context) {
|
|
275
|
+
return fromSynchronousStore(() => {
|
|
276
|
+
writeContext(key, context, "unreconciled", "advance");
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Write a context whose `balance` is byte-for-byte the one already stored,
|
|
281
|
+
* leaving the unreconciled stamp exactly as it found it (internal-review).
|
|
282
|
+
*
|
|
283
|
+
* The replay acknowledgement's idempotent second pass is what needs this: it
|
|
284
|
+
* restates a cumulative base without adding to the balance, so it observes
|
|
285
|
+
* nothing and must not certify the local sum it left alone. The caller owes
|
|
286
|
+
* the precondition in the name — a write that does move `balance` belongs on
|
|
287
|
+
* one of the other two.
|
|
288
|
+
*/
|
|
289
|
+
setUnchangedBalance(key, context) {
|
|
290
|
+
return fromSynchronousStore(() => {
|
|
291
|
+
writeContext(key, context, "unchanged", "advance");
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Seed upstream's cooperative-refund probe without changing the acknowledged
|
|
296
|
+
* base. Records written before internal-review have only the upstream context, so
|
|
297
|
+
* `hold` adopts its acknowledged value rather than losing that base to the
|
|
298
|
+
* chain floor this seeds. The seed itself is a `channels()` read, so it is
|
|
299
|
+
* `observed` and settles the unreconciled question like any other.
|
|
300
|
+
*/
|
|
301
|
+
setRefundProbe(key, context) {
|
|
302
|
+
return fromSynchronousStore(() => {
|
|
303
|
+
writeContext(key, context, "observed", "hold");
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Replace the stored projection with a successful `channels()` read.
|
|
308
|
+
*
|
|
309
|
+
* Only `balance` and `totalClaimed` move. The highest DVM-acknowledged
|
|
310
|
+
* cumulative base stays fail-closed, and the chain observation clears the
|
|
311
|
+
* unreconciled stamp.
|
|
312
|
+
*/
|
|
313
|
+
reconcileChainState(key, state) {
|
|
314
|
+
return fromSynchronousStore(() => {
|
|
315
|
+
const file = loadFile();
|
|
316
|
+
const id = canonicalChannelId(key);
|
|
317
|
+
const record = file.channels[id] ?? {};
|
|
318
|
+
file.channels[id] = channelRecordWithContext(
|
|
319
|
+
record,
|
|
320
|
+
{ ...record.context ?? {}, ...state },
|
|
321
|
+
"observed",
|
|
322
|
+
"hold"
|
|
323
|
+
);
|
|
324
|
+
saveFile(file);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
delete(key) {
|
|
328
|
+
return fromSynchronousStore(() => {
|
|
329
|
+
const file = loadFile();
|
|
330
|
+
const id = canonicalChannelId(key);
|
|
331
|
+
const record = file.channels[id];
|
|
332
|
+
if (!record) return;
|
|
333
|
+
if (record.metadata) file.channels[id] = { metadata: record.metadata };
|
|
334
|
+
else {
|
|
335
|
+
const { [id]: _removed, ...channels } = file.channels;
|
|
336
|
+
file.channels = channels;
|
|
337
|
+
}
|
|
338
|
+
saveFile(file);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
function recordX402ChannelMetadata(metadata) {
|
|
343
|
+
const file = loadFile();
|
|
344
|
+
const id = canonicalChannelId(metadata.channelId);
|
|
345
|
+
const acknowledged = file.channels[id]?.metadata;
|
|
346
|
+
const sameOwner = acknowledged?.endpoint === metadata.endpoint && acknowledged.callerPubkey === metadata.callerPubkey && acknowledged.creditId === metadata.creditId;
|
|
347
|
+
file.channels[id] = {
|
|
348
|
+
...file.channels[id],
|
|
349
|
+
metadata: !acknowledged || sameOwner ? { ...metadata, channelId: id } : { ...acknowledged, channelId: id }
|
|
350
|
+
};
|
|
351
|
+
saveFile(file);
|
|
352
|
+
}
|
|
353
|
+
function acknowledgeX402ChannelOwner(owner) {
|
|
354
|
+
const file = loadFile();
|
|
355
|
+
const id = canonicalChannelId(owner.channelId);
|
|
356
|
+
const record = file.channels[id];
|
|
357
|
+
if (!record?.metadata) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
`Cannot acknowledge credit ${owner.creditId}: x402 channel ${id} has no metadata`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
file.channels[id] = {
|
|
363
|
+
...record,
|
|
364
|
+
metadata: {
|
|
365
|
+
...record.metadata,
|
|
366
|
+
endpoint: owner.endpoint,
|
|
367
|
+
callerPubkey: owner.callerPubkey,
|
|
368
|
+
creditId: owner.creditId,
|
|
369
|
+
updatedAt: Date.now()
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
saveFile(file);
|
|
373
|
+
}
|
|
374
|
+
function listX402Channels(filter = {}) {
|
|
375
|
+
const payer = filter.payer?.toLowerCase();
|
|
376
|
+
return allChannelMetadata().filter((entry) => !payer || entry.config.payer.toLowerCase() === payer).sort((a, b) => b.updatedAt - a.updatedAt);
|
|
377
|
+
}
|
|
378
|
+
function findX402Channel(args) {
|
|
379
|
+
const channels = allChannelMetadata();
|
|
380
|
+
if (args.channelId) {
|
|
381
|
+
const channelId = canonicalChannelId(args.channelId);
|
|
382
|
+
return channels.find((entry) => entry.channelId === channelId);
|
|
383
|
+
}
|
|
384
|
+
const endpoint = args.endpoint ? normalizeEndpoint(args.endpoint) : void 0;
|
|
385
|
+
return channels.filter(
|
|
386
|
+
(entry) => (!endpoint || entry.endpoint === endpoint) && (!args.callerPubkey || entry.callerPubkey === args.callerPubkey) && (!args.creditId || entry.creditId === args.creditId)
|
|
387
|
+
).sort((a, b) => b.updatedAt - a.updatedAt)[0];
|
|
388
|
+
}
|
|
389
|
+
function x402ChannelBalanceUnreconciledAt(channelId) {
|
|
390
|
+
return loadFile().channels[canonicalChannelId(channelId)]?.unreconciledBalanceAt;
|
|
391
|
+
}
|
|
392
|
+
function recordPendingX402Funding(pending) {
|
|
393
|
+
const { file, credits } = loadFundingFile();
|
|
394
|
+
const pendingOrigin = normalizeEndpoint(pending.endpoint);
|
|
395
|
+
file.pending = [
|
|
396
|
+
...file.pending.flatMap((entry) => {
|
|
397
|
+
if (!sameFundingCredit(entry, pending, credits) || x402PendingRetired(entry)) return [entry];
|
|
398
|
+
if (normalizeEndpoint(entry.endpoint) === pendingOrigin) return [];
|
|
399
|
+
return [{ ...entry, supersededAt: pending.requestedAt }];
|
|
400
|
+
}),
|
|
401
|
+
pending
|
|
402
|
+
];
|
|
403
|
+
saveFile(file);
|
|
404
|
+
}
|
|
405
|
+
function loadResumableX402Funding(args) {
|
|
406
|
+
const { file, credits } = loadFundingFile();
|
|
407
|
+
return file.pending.find(
|
|
408
|
+
(entry) => sameFundingCredit(entry, args, credits) && !x402PendingRetired(entry)
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
function listPendingX402Fundings(filter = {}) {
|
|
412
|
+
const endpoint = filter.endpoint ? normalizeEndpoint(filter.endpoint) : void 0;
|
|
413
|
+
const { file, credits } = loadFundingFile();
|
|
414
|
+
return file.pending.filter(
|
|
415
|
+
(entry) => (!endpoint || fundingReachableAt(entry, endpoint, credits)) && (!filter.callerPubkey || entry.callerPubkey === filter.callerPubkey)
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
function listResumableX402Fundings(filter = {}) {
|
|
419
|
+
return listPendingX402Fundings(filter).filter((entry) => !x402PendingRetired(entry));
|
|
420
|
+
}
|
|
421
|
+
function listPendingX402FundingsForChannel(channelId) {
|
|
422
|
+
const canonical = canonicalChannelId(channelId);
|
|
423
|
+
return loadFundingFile().file.pending.filter(
|
|
424
|
+
(entry) => canonicalChannelId(entry.channelId) === canonical
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
function retirePendingX402Funding(pending) {
|
|
428
|
+
const { file } = loadFundingFile();
|
|
429
|
+
const key = pendingKey(pending);
|
|
430
|
+
const target = file.pending.find(
|
|
431
|
+
(entry) => pendingKey(entry) === key && !x402PendingRetired(entry)
|
|
432
|
+
);
|
|
433
|
+
if (!target) return;
|
|
434
|
+
const refusedAt = Date.now();
|
|
435
|
+
file.pending = file.pending.map((entry) => entry === target ? { ...entry, refusedAt } : entry);
|
|
436
|
+
saveFile(file);
|
|
437
|
+
}
|
|
438
|
+
function clearPendingX402Funding(pending) {
|
|
439
|
+
const { file } = loadFundingFile();
|
|
440
|
+
const next = file.pending.filter((entry) => !sameJournalEntry(entry, pending));
|
|
441
|
+
if (next.length === file.pending.length) return;
|
|
442
|
+
file.pending = next;
|
|
443
|
+
saveFile(file);
|
|
444
|
+
}
|
|
445
|
+
function writeContext(key, context, provenance, base) {
|
|
446
|
+
const file = loadFile();
|
|
447
|
+
const id = canonicalChannelId(key);
|
|
448
|
+
const record = file.channels[id] ?? {};
|
|
449
|
+
file.channels[id] = channelRecordWithContext(record, context, provenance, base);
|
|
450
|
+
saveFile(file);
|
|
451
|
+
}
|
|
452
|
+
function channelRecordWithContext(record, context, provenance, base) {
|
|
453
|
+
const { unreconciledBalanceAt: previousStamp, ...kept } = record;
|
|
454
|
+
const stamp = provenance === "unreconciled" ? Date.now() : provenance === "unchanged" ? previousStamp : void 0;
|
|
455
|
+
const stored = readAcknowledgedCumulativeAmount(record);
|
|
456
|
+
const acknowledgedCumulativeAmount = base === "advance" ? highestCumulativeAmount(stored, context.chargedCumulativeAmount) : stored;
|
|
457
|
+
return {
|
|
458
|
+
...kept,
|
|
459
|
+
context,
|
|
460
|
+
...acknowledgedCumulativeAmount !== void 0 ? { acknowledgedCumulativeAmount } : {},
|
|
461
|
+
...stamp !== void 0 ? { unreconciledBalanceAt: stamp } : {}
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
function allChannelMetadata() {
|
|
465
|
+
return Object.values(loadFile().channels).filter((record) => record !== void 0).map((record) => record.metadata).filter((entry) => entry !== void 0);
|
|
466
|
+
}
|
|
467
|
+
function pendingKey(entry) {
|
|
468
|
+
return `${normalizeEndpoint(entry.endpoint)}\0${entry.callerPubkey}\0${entry.creditId}`;
|
|
469
|
+
}
|
|
470
|
+
function sameFundingCredit(entry, target, credits) {
|
|
471
|
+
return entry.callerPubkey === target.callerPubkey && entry.creditId === target.creditId && fundingReachableAt(entry, normalizeEndpoint(target.endpoint), credits);
|
|
472
|
+
}
|
|
473
|
+
function loadFundingFile() {
|
|
474
|
+
const snapshot = collapseFundingFile();
|
|
475
|
+
if (!snapshot.changed) return snapshot;
|
|
476
|
+
const persisted = tryWithX402ChannelLockSync(() => {
|
|
477
|
+
const current = collapseFundingFile();
|
|
478
|
+
if (current.changed) saveFile(current.file);
|
|
479
|
+
return current;
|
|
480
|
+
});
|
|
481
|
+
return persisted ?? snapshot;
|
|
482
|
+
}
|
|
483
|
+
function collapseFundingFile() {
|
|
484
|
+
const file = loadFile();
|
|
485
|
+
const credits = listCredits();
|
|
486
|
+
const pending = dedupeX402FundingCredits(file.pending, credits);
|
|
487
|
+
const exact = dedupeFundingCredits(file.exact, credits);
|
|
488
|
+
if (pending.changed) file.pending = pending.entries;
|
|
489
|
+
if (exact.changed) file.exact = exact.entries;
|
|
490
|
+
return { file, credits, changed: pending.changed || exact.changed };
|
|
491
|
+
}
|
|
492
|
+
function dedupeX402FundingCredits(entries, credits) {
|
|
493
|
+
const kept = [];
|
|
494
|
+
let changed = false;
|
|
495
|
+
for (const entry of entries) {
|
|
496
|
+
if (x402PendingRetired(entry)) {
|
|
497
|
+
kept.push(entry);
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
const index = kept.findIndex(
|
|
501
|
+
(candidate) => !x402PendingRetired(candidate) && sameFundingCredit(candidate, entry, credits)
|
|
502
|
+
);
|
|
503
|
+
if (index === -1) {
|
|
504
|
+
kept.push(entry);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
const seen = kept[index];
|
|
508
|
+
const seenAt = Number.isFinite(seen.requestedAt) ? seen.requestedAt : 0;
|
|
509
|
+
const entryAt = Number.isFinite(entry.requestedAt) ? entry.requestedAt : 0;
|
|
510
|
+
const sameOrigin = normalizeEndpoint(seen.endpoint) === normalizeEndpoint(entry.endpoint);
|
|
511
|
+
if (entryAt > seenAt) {
|
|
512
|
+
kept[index] = entry;
|
|
513
|
+
if (!sameOrigin) kept.push({ ...seen, supersededAt: entry.requestedAt });
|
|
514
|
+
} else if (!sameOrigin) {
|
|
515
|
+
kept.push({ ...entry, supersededAt: seen.requestedAt });
|
|
516
|
+
}
|
|
517
|
+
changed = true;
|
|
518
|
+
}
|
|
519
|
+
return { entries: kept, changed };
|
|
520
|
+
}
|
|
521
|
+
function dedupeFundingCredits(entries, credits) {
|
|
522
|
+
const kept = [];
|
|
523
|
+
let changed = false;
|
|
524
|
+
for (const entry of entries) {
|
|
525
|
+
const index = kept.findIndex((candidate) => sameFundingCredit(candidate, entry, credits));
|
|
526
|
+
if (index === -1) {
|
|
527
|
+
kept.push(entry);
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const seenAt = Number.isFinite(kept[index].requestedAt) ? kept[index].requestedAt : 0;
|
|
531
|
+
const entryAt = Number.isFinite(entry.requestedAt) ? entry.requestedAt : 0;
|
|
532
|
+
if (entryAt > seenAt) kept[index] = entry;
|
|
533
|
+
changed = true;
|
|
534
|
+
}
|
|
535
|
+
return { entries: kept, changed };
|
|
536
|
+
}
|
|
537
|
+
function sameJournalEntry(entry, target) {
|
|
538
|
+
return pendingKey(entry) === pendingKey(target) && entry.fundId === target.fundId && x402PendingRetired(entry) === x402PendingRetired(target);
|
|
539
|
+
}
|
|
540
|
+
function fromSynchronousStore(operation) {
|
|
541
|
+
try {
|
|
542
|
+
return Promise.resolve(operation());
|
|
543
|
+
} catch (error) {
|
|
544
|
+
return Promise.reject(error instanceof Error ? error : new Error(String(error)));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function highestCumulativeAmount(previous, next) {
|
|
548
|
+
if (previous === void 0) return next;
|
|
549
|
+
if (next === void 0) return previous;
|
|
550
|
+
return BigInt(next) > BigInt(previous) ? next : previous;
|
|
551
|
+
}
|
|
552
|
+
function readAcknowledgedCumulativeAmount(record) {
|
|
553
|
+
return record?.acknowledgedCumulativeAmount ?? record?.context?.chargedCumulativeAmount;
|
|
554
|
+
}
|
|
555
|
+
function canonicalChannelId(value) {
|
|
556
|
+
return value.toLowerCase();
|
|
557
|
+
}
|
|
558
|
+
function normalizeEndpoint(endpoint) {
|
|
559
|
+
try {
|
|
560
|
+
return new URL(endpoint).origin;
|
|
561
|
+
} catch {
|
|
562
|
+
return endpoint.replace(/\/+$/, "");
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
function tryWithX402ChannelLockSync(operation) {
|
|
566
|
+
if (x402LockOwner.getStore()?.active) return operation();
|
|
567
|
+
ensureX402ChannelFile();
|
|
568
|
+
let release;
|
|
569
|
+
try {
|
|
570
|
+
release = lockfile.lockSync(X402_CHANNELS_FILE, { realpath: false });
|
|
571
|
+
} catch (error) {
|
|
572
|
+
if (error.code === "ELOCKED") return void 0;
|
|
573
|
+
throw error;
|
|
574
|
+
}
|
|
575
|
+
const owner = { active: true };
|
|
576
|
+
try {
|
|
577
|
+
return x402LockOwner.run(owner, operation);
|
|
578
|
+
} finally {
|
|
579
|
+
owner.active = false;
|
|
580
|
+
release();
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
function ensureX402ChannelFile() {
|
|
584
|
+
ensureConfigDir();
|
|
585
|
+
if (existsSync(X402_CHANNELS_FILE)) return;
|
|
586
|
+
try {
|
|
587
|
+
writeFileSync(X402_CHANNELS_FILE, `${JSON.stringify(EMPTY, null, 2)}
|
|
588
|
+
`, {
|
|
589
|
+
mode: 384,
|
|
590
|
+
flag: "wx"
|
|
591
|
+
});
|
|
592
|
+
} catch {
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
function loadFile() {
|
|
596
|
+
if (!existsSync(X402_CHANNELS_FILE)) return { ...EMPTY, channels: {}, pending: [], exact: [] };
|
|
597
|
+
try {
|
|
598
|
+
const raw = JSON.parse(readFileSync(X402_CHANNELS_FILE, "utf8"));
|
|
599
|
+
if (raw.version !== 1 || !raw.channels || typeof raw.channels !== "object" || Array.isArray(raw.channels) || !Array.isArray(raw.pending)) {
|
|
600
|
+
throw new Error("unsupported x402 channel store shape");
|
|
601
|
+
}
|
|
602
|
+
return {
|
|
603
|
+
version: 1,
|
|
604
|
+
channels: raw.channels,
|
|
605
|
+
pending: raw.pending,
|
|
606
|
+
// Tolerated rather than required, unlike its two siblings: every file
|
|
607
|
+
// written before internal-review lacks the key, and failing closed on it would
|
|
608
|
+
// read as a corrupt journal on a store that is perfectly intact.
|
|
609
|
+
exact: Array.isArray(raw.exact) ? raw.exact : []
|
|
610
|
+
};
|
|
611
|
+
} catch (cause) {
|
|
612
|
+
throw new DvmError(
|
|
613
|
+
"x402_channel_store_invalid",
|
|
614
|
+
"The saved x402 channel journal cannot be read safely.",
|
|
615
|
+
`Do not delete ${X402_CHANNELS_FILE}; restore or repair it before signing another channel voucher.`,
|
|
616
|
+
{ cause: cause instanceof Error ? cause.message : String(cause) }
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
function saveFile(file) {
|
|
621
|
+
ensureConfigDir();
|
|
622
|
+
const tmp = `${X402_CHANNELS_FILE}.${String(process.pid)}.tmp`;
|
|
623
|
+
writeFileSync(tmp, `${JSON.stringify(file, null, 2)}
|
|
624
|
+
`, { mode: 384 });
|
|
625
|
+
renameSync(tmp, X402_CHANNELS_FILE);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// src/lib/x402/wallet.ts
|
|
629
|
+
function loadX402Wallet() {
|
|
630
|
+
const config = loadConfig();
|
|
631
|
+
const x402 = config?.x402;
|
|
632
|
+
const privateKey = x402?.privateKey;
|
|
633
|
+
if (!privateKey || !isValidEvmPrivateKey(privateKey)) return null;
|
|
634
|
+
return {
|
|
635
|
+
privateKey,
|
|
636
|
+
address: "",
|
|
637
|
+
network: x402.network ?? X402_DEFAULT_NETWORK
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
async function ensureAddress(wallet) {
|
|
641
|
+
if (!isValidEvmPrivateKey(wallet.privateKey)) throw invalidX402PrivateKey();
|
|
642
|
+
if (wallet.address) return wallet;
|
|
643
|
+
const { privateKeyToAccount } = await import("viem/accounts");
|
|
644
|
+
const account = privateKeyToAccount(wallet.privateKey);
|
|
645
|
+
return { ...wallet, address: account.address };
|
|
646
|
+
}
|
|
647
|
+
async function connectedX402Payer() {
|
|
648
|
+
const wallet = loadX402Wallet();
|
|
649
|
+
if (!wallet) return null;
|
|
650
|
+
return (await ensureAddress(wallet)).address;
|
|
651
|
+
}
|
|
652
|
+
async function generateX402PrivateKey() {
|
|
653
|
+
const { generatePrivateKey } = await import("viem/accounts");
|
|
654
|
+
return generatePrivateKey();
|
|
655
|
+
}
|
|
656
|
+
async function repinX402WalletNetwork(network) {
|
|
657
|
+
assertSupportedX402Network(network);
|
|
658
|
+
return withX402ChannelLock(async () => {
|
|
659
|
+
const { previous } = await updateConfig({
|
|
660
|
+
reason: "x402_wallet_repin",
|
|
661
|
+
patch: (current) => {
|
|
662
|
+
if (!current) throw new DvmError("no_config", "No config found.", "Run: dvm init");
|
|
663
|
+
const privateKey = current.x402?.privateKey;
|
|
664
|
+
if (privateKey === void 0) throw x402WalletMissingError();
|
|
665
|
+
if (!isValidEvmPrivateKey(privateKey)) throw invalidX402PrivateKey();
|
|
666
|
+
return { set: { x402: { privateKey, network } } };
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
const stored = previous?.x402;
|
|
670
|
+
if (stored === void 0) throw x402WalletMissingError();
|
|
671
|
+
if (!isValidEvmPrivateKey(stored.privateKey)) throw invalidX402PrivateKey();
|
|
672
|
+
const { privateKeyToAccount } = await import("viem/accounts");
|
|
673
|
+
return {
|
|
674
|
+
address: privateKeyToAccount(stored.privateKey).address,
|
|
675
|
+
previousNetwork: stored.network ?? X402_DEFAULT_NETWORK,
|
|
676
|
+
network
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
async function saveX402Wallet(privateKey, network) {
|
|
681
|
+
if (network !== void 0) assertSupportedX402Network(network);
|
|
682
|
+
if (!isValidEvmPrivateKey(privateKey)) throw invalidX402PrivateKey();
|
|
683
|
+
const { privateKeyToAccount } = await import("viem/accounts");
|
|
684
|
+
const account = privateKeyToAccount(privateKey);
|
|
685
|
+
let resolved;
|
|
686
|
+
await updateConfig({
|
|
687
|
+
reason: "x402_wallet_connect",
|
|
688
|
+
patch: (current) => {
|
|
689
|
+
resolved = network ?? current?.x402?.network;
|
|
690
|
+
return {
|
|
691
|
+
set: { x402: resolved === void 0 ? { privateKey } : { privateKey, network: resolved } }
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
});
|
|
695
|
+
return { address: account.address, network: resolved ?? X402_DEFAULT_NETWORK };
|
|
696
|
+
}
|
|
697
|
+
async function removeX402Wallet() {
|
|
698
|
+
await updateConfig({
|
|
699
|
+
reason: "x402_wallet_disconnect",
|
|
700
|
+
authorize: authorizeProtectedRemoval("x402"),
|
|
701
|
+
patch: () => ({ unset: ["x402"] })
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
function invalidX402PrivateKey() {
|
|
705
|
+
return new DvmError(
|
|
706
|
+
"invalid_key",
|
|
707
|
+
"Invalid secp256k1 private key.",
|
|
708
|
+
"Reconnect with `dvm wallet x402-connect --key-file <path>`, set DVM_X402_KEY, or pipe a valid 0x-prefixed 32-byte secp256k1 private key through stdin."
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
function x402WalletMissingError() {
|
|
712
|
+
return new DvmError(
|
|
713
|
+
"x402_wallet_missing",
|
|
714
|
+
"No x402 wallet is connected.",
|
|
715
|
+
"Run 'dvm wallet x402-connect' to create one, or add --key-file <path> to import a key you already hold."
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
async function getX402Balance(wallet) {
|
|
719
|
+
const w = await ensureAddress(wallet);
|
|
720
|
+
const spec = x402NetworkByCaip2(w.network);
|
|
721
|
+
if (!spec) {
|
|
722
|
+
throw new DvmError(
|
|
723
|
+
"x402_unsupported_network",
|
|
724
|
+
`The x402 wallet is configured for ${w.network}, a chain this rail can't settle on.`,
|
|
725
|
+
`${x402SupportedNetworksHint()} Re-pin the connected wallet without handling its key: 'dvm wallet x402-network ${X402_DEFAULT_NETWORK}'.`
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
const chains = await import("viem/chains");
|
|
729
|
+
const { createPublicClient, http, erc20Abi } = await import("viem");
|
|
730
|
+
const chain = chains[spec.viemChain];
|
|
731
|
+
const { url: rpcUrl, source: rpcSource } = resolveRpcOverride(
|
|
732
|
+
process.env.DVM_X402_RPC_URL,
|
|
733
|
+
chain?.rpcUrls.default.http[0]
|
|
734
|
+
);
|
|
735
|
+
try {
|
|
736
|
+
const transportOptions = resolveRpcHttpTransportOptions(
|
|
737
|
+
rpcSource,
|
|
738
|
+
process.env.DVM_X402_RPC_HEADERS_JSON
|
|
739
|
+
);
|
|
740
|
+
const client = createPublicClient({
|
|
741
|
+
chain,
|
|
742
|
+
transport: transportOptions ? http(rpcUrl, transportOptions) : http(rpcUrl)
|
|
743
|
+
});
|
|
744
|
+
const balance = await client.readContract({
|
|
745
|
+
address: spec.usdc,
|
|
746
|
+
abi: erc20Abi,
|
|
747
|
+
functionName: "balanceOf",
|
|
748
|
+
args: [w.address]
|
|
749
|
+
});
|
|
750
|
+
return {
|
|
751
|
+
address: w.address,
|
|
752
|
+
usdc: Number(balance) / 1e6,
|
|
753
|
+
network: w.network,
|
|
754
|
+
tokenAddress: spec.usdc,
|
|
755
|
+
// internal-review: redacted here rather than at a display surface, so the clear
|
|
756
|
+
// endpoint never leaves this function on the success path either.
|
|
757
|
+
rpcUrlRedacted: rpcUrl ? redactUrl(rpcUrl) : void 0,
|
|
758
|
+
rpcSource
|
|
759
|
+
};
|
|
760
|
+
} catch (err) {
|
|
761
|
+
const cause = redactUrlsInText(err instanceof Error ? err.message : String(err));
|
|
762
|
+
throw new DvmError(
|
|
763
|
+
"x402_rpc_error",
|
|
764
|
+
`x402 USDC balance lookup on ${spec.label} failed: ${cause}`,
|
|
765
|
+
// A caller who set the override doesn't need to be told to set it. Which
|
|
766
|
+
// knob is theirs to turn depends on whose endpoint this was.
|
|
767
|
+
rpcSource === "override" ? `DVM_X402_RPC_URL sent this read to ${redactUrl(rpcUrl)}. Correct it, or unset it to fall back to ${spec.label}'s bundled endpoint.` : `Verify connectivity to ${spec.label}'s bundled endpoint${rpcUrl ? ` (${redactUrl(rpcUrl)})` : ""}, or send the read somewhere else with DVM_X402_RPC_URL.`
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// src/lib/x402/client.ts
|
|
773
|
+
async function tryX402Payment(request, wallet) {
|
|
774
|
+
const selection = selectPaymentRequirement(request);
|
|
775
|
+
let networkSlug;
|
|
776
|
+
let networkCaip2;
|
|
777
|
+
let chainId;
|
|
778
|
+
let usdc;
|
|
779
|
+
try {
|
|
780
|
+
networkCaip2 = x402NetworkToCaip2(selection.network);
|
|
781
|
+
const mapped = x402NetworkByCaip2(networkCaip2);
|
|
782
|
+
networkSlug = mapped?.slug ?? selection.network;
|
|
783
|
+
chainId = chainIdFromCaip2(networkCaip2);
|
|
784
|
+
usdc = usdcContractFor(networkSlug);
|
|
785
|
+
} catch (err) {
|
|
786
|
+
throw new DvmError(
|
|
787
|
+
"x402_build_failed",
|
|
788
|
+
`Could not resolve x402 network from '${selection.network}': ${err instanceof Error ? err.message : String(err)}`,
|
|
789
|
+
"Check that the DVM's advertised x402.network is a CAIP-2 chain id (e.g. 'eip155:8453' for Base)."
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
if (!usdc) return null;
|
|
793
|
+
const rate = await priceableRate(request.btcRate);
|
|
794
|
+
if (rate === null) return null;
|
|
795
|
+
const localUsdcMicro = BigInt(msatsToUsdc(request.amountMsats, rate));
|
|
796
|
+
const requiredUsdcMicro = resolveRequiredUsdcMicro(selection.amount, localUsdcMicro);
|
|
797
|
+
const signedAmountMsats = usdcToMsats(Number(requiredUsdcMicro) / 1e6, rate);
|
|
798
|
+
try {
|
|
799
|
+
const w = await ensureAddress(wallet);
|
|
800
|
+
const validAfter = 0n;
|
|
801
|
+
const validBefore = BigInt(
|
|
802
|
+
Math.floor(Date.now() / 1e3) + X402_EXACT_AUTHORIZATION_TTL_SECONDS
|
|
803
|
+
);
|
|
804
|
+
const nonce = request.nonce ?? `0x${randomBytes(32).toString("hex")}`;
|
|
805
|
+
const domainName = selectedDomainField(selection.accepted?.extra?.name, "name") ?? usdcDomainNameFor(networkSlug) ?? "USD Coin";
|
|
806
|
+
const domainVersion = selectedDomainField(selection.accepted?.extra?.version, "version") ?? usdcDomainVersionFor(networkSlug) ?? "2";
|
|
807
|
+
const { signTypedData } = await import("viem/accounts");
|
|
808
|
+
const signature = await signTypedData({
|
|
809
|
+
privateKey: w.privateKey,
|
|
810
|
+
domain: {
|
|
811
|
+
name: domainName,
|
|
812
|
+
version: domainVersion,
|
|
813
|
+
chainId,
|
|
814
|
+
verifyingContract: usdc
|
|
815
|
+
},
|
|
816
|
+
types: {
|
|
817
|
+
TransferWithAuthorization: [
|
|
818
|
+
{ name: "from", type: "address" },
|
|
819
|
+
{ name: "to", type: "address" },
|
|
820
|
+
{ name: "value", type: "uint256" },
|
|
821
|
+
{ name: "validAfter", type: "uint256" },
|
|
822
|
+
{ name: "validBefore", type: "uint256" },
|
|
823
|
+
{ name: "nonce", type: "bytes32" }
|
|
824
|
+
]
|
|
825
|
+
},
|
|
826
|
+
primaryType: "TransferWithAuthorization",
|
|
827
|
+
message: {
|
|
828
|
+
from: w.address,
|
|
829
|
+
to: selection.payTo,
|
|
830
|
+
value: requiredUsdcMicro,
|
|
831
|
+
validAfter,
|
|
832
|
+
validBefore,
|
|
833
|
+
nonce
|
|
834
|
+
}
|
|
835
|
+
});
|
|
836
|
+
const exactPayload = {
|
|
837
|
+
signature,
|
|
838
|
+
payload: {
|
|
839
|
+
authorization: {
|
|
840
|
+
from: w.address,
|
|
841
|
+
to: selection.payTo,
|
|
842
|
+
value: requiredUsdcMicro.toString(),
|
|
843
|
+
validAfter: validAfter.toString(),
|
|
844
|
+
validBefore: validBefore.toString(),
|
|
845
|
+
nonce
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
const payload = selection.version === X402_V1_VERSION ? {
|
|
850
|
+
x402Version: X402_V1_VERSION,
|
|
851
|
+
scheme: "exact",
|
|
852
|
+
network: networkSlug,
|
|
853
|
+
payload: { signature: exactPayload.signature, ...exactPayload.payload }
|
|
854
|
+
} : {
|
|
855
|
+
x402Version: X402_VERSION,
|
|
856
|
+
...selection.resource ? { resource: selection.resource } : {},
|
|
857
|
+
accepted: selection.accepted ?? buildPaymentRequirements({
|
|
858
|
+
x402Config: {
|
|
859
|
+
payTo: selection.payTo,
|
|
860
|
+
network: networkCaip2,
|
|
861
|
+
...selection.asset ? { asset: selection.asset } : {}
|
|
862
|
+
},
|
|
863
|
+
requiredUsdcMicro,
|
|
864
|
+
resource: selection.resource?.url ?? "",
|
|
865
|
+
version: X402_VERSION
|
|
866
|
+
}),
|
|
867
|
+
payload: { signature: exactPayload.signature, ...exactPayload.payload },
|
|
868
|
+
extensions: selection.extensions ?? {}
|
|
869
|
+
};
|
|
870
|
+
return {
|
|
871
|
+
paymentHeader: encodePayment(payload),
|
|
872
|
+
x402Version: selection.version,
|
|
873
|
+
amountUsdcMicro: requiredUsdcMicro.toString(),
|
|
874
|
+
amountMsats: signedAmountMsats,
|
|
875
|
+
btcRate: rate
|
|
876
|
+
};
|
|
877
|
+
} catch (err) {
|
|
878
|
+
throw new DvmError(
|
|
879
|
+
"x402_build_failed",
|
|
880
|
+
`Failed to build x402 payment: ${err instanceof Error ? err.message : String(err)}`,
|
|
881
|
+
"Check the configured x402 private key and RPC reachability for the target chain."
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
function isSignableExactRequirement(requirement) {
|
|
886
|
+
const transferMethod = requirement.extra?.assetTransferMethod;
|
|
887
|
+
return requirement.scheme === "exact" && (transferMethod === void 0 || transferMethod === "eip3009");
|
|
888
|
+
}
|
|
889
|
+
function selectPaymentRequirement(request) {
|
|
890
|
+
const challenge = request.paymentRequired;
|
|
891
|
+
if (challenge?.x402Version === X402_VERSION) {
|
|
892
|
+
const accepted = challenge.accepts.find(isSignableExactRequirement);
|
|
893
|
+
if (!accepted) {
|
|
894
|
+
throw invalidChallenge("The DVM's v2 x402 challenge has no exact EIP-3009 requirement.");
|
|
895
|
+
}
|
|
896
|
+
return {
|
|
897
|
+
version: X402_VERSION,
|
|
898
|
+
network: accepted.network,
|
|
899
|
+
amount: accepted.amount,
|
|
900
|
+
payTo: accepted.payTo,
|
|
901
|
+
asset: accepted.asset,
|
|
902
|
+
accepted,
|
|
903
|
+
resource: challenge.resource,
|
|
904
|
+
extensions: challenge.extensions
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
if (challenge?.x402Version === X402_V1_VERSION) {
|
|
908
|
+
const accepted = challenge.accepts.find((candidate) => candidate.scheme === "exact");
|
|
909
|
+
if (!accepted) throw invalidChallenge("The DVM's v1 x402 challenge has no exact requirement.");
|
|
910
|
+
return {
|
|
911
|
+
version: X402_V1_VERSION,
|
|
912
|
+
network: accepted.network,
|
|
913
|
+
amount: accepted.maxAmountRequired,
|
|
914
|
+
payTo: accepted.payTo,
|
|
915
|
+
asset: accepted.asset
|
|
916
|
+
};
|
|
917
|
+
}
|
|
918
|
+
return {
|
|
919
|
+
version: request.x402Version ?? X402_V1_VERSION,
|
|
920
|
+
network: request.network,
|
|
921
|
+
amount: request.requiredUsdcMicro,
|
|
922
|
+
payTo: request.payTo,
|
|
923
|
+
asset: request.asset,
|
|
924
|
+
resource: request.resource
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
function invalidChallenge(message) {
|
|
928
|
+
return new DvmError(
|
|
929
|
+
"x402_build_failed",
|
|
930
|
+
message,
|
|
931
|
+
"Ask the DVM operator to advertise an exact x402 payment requirement."
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
function selectedDomainField(value, field) {
|
|
935
|
+
if (value === void 0 || value === null) return void 0;
|
|
936
|
+
if (typeof value === "string" && value.length > 0) return value;
|
|
937
|
+
throw invalidChallenge(`The DVM advertised an invalid x402 ${field}.`);
|
|
938
|
+
}
|
|
939
|
+
function resolveRequiredUsdcMicro(advertised, localUsdcMicro) {
|
|
940
|
+
if (advertised === void 0) return localUsdcMicro;
|
|
941
|
+
if (!/^[1-9]\d*$/.test(advertised)) {
|
|
942
|
+
throw new DvmError(
|
|
943
|
+
"x402_amount_invalid",
|
|
944
|
+
`The DVM advertised an invalid x402 maxAmountRequired: '${advertised}'.`,
|
|
945
|
+
"Do not sign this payment. Ask the DVM operator to return a positive integer number of USDC micro-units."
|
|
946
|
+
);
|
|
947
|
+
}
|
|
948
|
+
const required = BigInt(advertised);
|
|
949
|
+
if (withinAdvertisedTolerance(required, localUsdcMicro)) return required;
|
|
950
|
+
const advertisedUsd = Number(required) / 1e6;
|
|
951
|
+
const localUsd = Number(localUsdcMicro) / 1e6;
|
|
952
|
+
throw new DvmError(
|
|
953
|
+
"x402_amount_exceeds_limit",
|
|
954
|
+
`The DVM's x402 charge ($${formatUsd(advertisedUsd)} USDC) is more than 10% above the caller's local price ($${formatUsd(localUsd)} USDC).`,
|
|
955
|
+
"The payment was not signed. Refresh the quote or ask the DVM operator to check its BTC/USD rate.",
|
|
956
|
+
{
|
|
957
|
+
advertised_usdc_microunits: required.toString(),
|
|
958
|
+
local_usdc_microunits: localUsdcMicro.toString(),
|
|
959
|
+
max_usdc_microunits: maxAdvertisedMicro(localUsdcMicro).toString(),
|
|
960
|
+
anchor: costAnchor(advertisedUsd),
|
|
961
|
+
display: `Payment refused: $${formatUsd(advertisedUsd)} USDC exceeds the 10% safety margin over the caller's $${formatUsd(localUsd)} USDC estimate.`
|
|
962
|
+
}
|
|
963
|
+
);
|
|
964
|
+
}
|
|
965
|
+
async function priceableRate(btcRate) {
|
|
966
|
+
if (btcRate && btcRate > 0) return btcRate;
|
|
967
|
+
try {
|
|
968
|
+
return (await resolveBtcUsdRate("payment")).rate;
|
|
969
|
+
} catch (err) {
|
|
970
|
+
if (err instanceof DvmError && err.code === "rate_unavailable") {
|
|
971
|
+
info(
|
|
972
|
+
"Skipping the x402 rail: it prices in USDC and the BTC/USD rate is unavailable (no cached rate on this machine). Trying the remaining rails."
|
|
973
|
+
);
|
|
974
|
+
return null;
|
|
975
|
+
}
|
|
976
|
+
throw err;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/lib/x402/verify.ts
|
|
981
|
+
async function verifyX402Payment(paymentHeader, config, requiredUsdcMicro, resource, btcUsdRate, exactVersions = { v1: true, v2: true }) {
|
|
982
|
+
return callerLoggers.x402.span(
|
|
983
|
+
"x402.verify_payment",
|
|
984
|
+
{
|
|
985
|
+
resource,
|
|
986
|
+
required_usdc_micro: Number(requiredUsdcMicro),
|
|
987
|
+
facilitator_url: config.facilitator
|
|
988
|
+
},
|
|
989
|
+
async () => {
|
|
990
|
+
let payload;
|
|
991
|
+
try {
|
|
992
|
+
payload = decodePayment(paymentHeader);
|
|
993
|
+
} catch {
|
|
994
|
+
return { verified: false, amountMsats: 0, invalidReason: "decode_failed" };
|
|
995
|
+
}
|
|
996
|
+
if (payload.x402Version === 1 && !exactVersions.v1 || payload.x402Version === 2 && !exactVersions.v2) {
|
|
997
|
+
return { verified: false, amountMsats: 0, invalidReason: "unsupported_version" };
|
|
998
|
+
}
|
|
999
|
+
const requirementOpts = { x402Config: config, requiredUsdcMicro, resource };
|
|
1000
|
+
const requirements = payload.x402Version === 1 ? buildPaymentRequirements({ ...requirementOpts, version: 1 }) : buildPaymentRequirements({ ...requirementOpts, version: 2 });
|
|
1001
|
+
const createAuthHeaders = createX402FacilitatorAuthHeaders(config);
|
|
1002
|
+
const verifyRes = await verifyWithFacilitator(
|
|
1003
|
+
payload,
|
|
1004
|
+
requirements,
|
|
1005
|
+
config,
|
|
1006
|
+
createAuthHeaders
|
|
1007
|
+
);
|
|
1008
|
+
if (!verifyRes.isValid) {
|
|
1009
|
+
return {
|
|
1010
|
+
verified: false,
|
|
1011
|
+
amountMsats: 0,
|
|
1012
|
+
invalidReason: verifyRes.invalidReason ?? "verify_failed"
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
const settleRes = await settleWithFacilitator(
|
|
1016
|
+
payload,
|
|
1017
|
+
requirements,
|
|
1018
|
+
config,
|
|
1019
|
+
createAuthHeaders
|
|
1020
|
+
);
|
|
1021
|
+
if (!settleRes.success) {
|
|
1022
|
+
return {
|
|
1023
|
+
verified: false,
|
|
1024
|
+
amountMsats: 0,
|
|
1025
|
+
invalidReason: settleRes.errorReason ?? "settle_failed"
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
const amountUsdcMicro = Number.parseInt(exactEvmAuthorization(payload).value, 10);
|
|
1029
|
+
const amountMsats = usdcToMsats(amountUsdcMicro / 1e6, btcUsdRate);
|
|
1030
|
+
callerLoggers.x402.info("x402.verify_payment.result", {
|
|
1031
|
+
verified: true,
|
|
1032
|
+
tx_hash: settleRes.transaction,
|
|
1033
|
+
amount_msats: amountMsats
|
|
1034
|
+
});
|
|
1035
|
+
return {
|
|
1036
|
+
verified: true,
|
|
1037
|
+
amountMsats,
|
|
1038
|
+
amountUsdcMicro,
|
|
1039
|
+
txHash: settleRes.transaction,
|
|
1040
|
+
settleResponseHeader: encodeSettleResponseHeader(settleRes)
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
export {
|
|
1047
|
+
resolveX402Asset,
|
|
1048
|
+
x402WrongAssetHint,
|
|
1049
|
+
resolveBtcUsdRate,
|
|
1050
|
+
tryResolveBtcUsdRate,
|
|
1051
|
+
X402_EXACT_AUTHORIZATION_TTL_SECONDS,
|
|
1052
|
+
X402_REFUSAL_TRANSACTION_DISCOVERY_WINDOW_MS,
|
|
1053
|
+
x402ExactFundingDead,
|
|
1054
|
+
recordPendingX402ExactFunding,
|
|
1055
|
+
loadResumableX402ExactFunding,
|
|
1056
|
+
listResumableX402ExactFundings,
|
|
1057
|
+
clearPendingX402ExactFunding,
|
|
1058
|
+
X402_PENDING_STALE_MS,
|
|
1059
|
+
x402PendingStale,
|
|
1060
|
+
x402PendingRetired,
|
|
1061
|
+
x402PendingBlockExpiresAt,
|
|
1062
|
+
x402PendingDepositExpiresAt,
|
|
1063
|
+
x402PendingUnsettled,
|
|
1064
|
+
x402PendingFundingJson,
|
|
1065
|
+
withX402ChannelLock,
|
|
1066
|
+
DvmX402ChannelStorage,
|
|
1067
|
+
recordX402ChannelMetadata,
|
|
1068
|
+
acknowledgeX402ChannelOwner,
|
|
1069
|
+
listX402Channels,
|
|
1070
|
+
findX402Channel,
|
|
1071
|
+
x402ChannelBalanceUnreconciledAt,
|
|
1072
|
+
recordPendingX402Funding,
|
|
1073
|
+
loadResumableX402Funding,
|
|
1074
|
+
listPendingX402Fundings,
|
|
1075
|
+
listResumableX402Fundings,
|
|
1076
|
+
listPendingX402FundingsForChannel,
|
|
1077
|
+
retirePendingX402Funding,
|
|
1078
|
+
clearPendingX402Funding,
|
|
1079
|
+
loadX402Wallet,
|
|
1080
|
+
ensureAddress,
|
|
1081
|
+
connectedX402Payer,
|
|
1082
|
+
generateX402PrivateKey,
|
|
1083
|
+
repinX402WalletNetwork,
|
|
1084
|
+
saveX402Wallet,
|
|
1085
|
+
removeX402Wallet,
|
|
1086
|
+
getX402Balance,
|
|
1087
|
+
tryX402Payment,
|
|
1088
|
+
isSignableExactRequirement,
|
|
1089
|
+
verifyX402Payment
|
|
1090
|
+
};
|