@veil-cash/sdk 0.6.4 → 0.7.0
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 +47 -5
- package/SDK.md +77 -11
- package/dist/cli/index.cjs +229 -237
- package/dist/index.cjs +386 -46
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +131 -38
- package/dist/index.d.ts +131 -38
- package/dist/index.js +355 -40
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
- package/skills/veil/SKILL.md +1 -3
- package/skills/veil/reference.md +3 -3
- package/src/abi.ts +0 -8
- package/src/balance.ts +0 -48
- package/src/cli/commands/deposit.ts +8 -27
- package/src/compat.ts +23 -0
- package/src/ffjavascript.d.ts +13 -8
- package/src/index.ts +21 -1
- package/src/keypair.ts +1 -1
- package/src/types.ts +5 -0
- package/src/utils.ts +4 -2
- package/src/withdraw.ts +2 -1
- package/src/x402.ts +546 -0
package/src/x402.ts
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
import { x402Client, x402HTTPClient } from '@x402/core/client';
|
|
2
|
+
import type { PaymentRequired, PaymentRequirements, SettleResponse } from '@x402/core/types';
|
|
3
|
+
import { registerExactEvmScheme } from '@x402/evm/exact/client';
|
|
4
|
+
import { toClientEvmSigner } from '@x402/evm';
|
|
5
|
+
import {
|
|
6
|
+
createPublicClient,
|
|
7
|
+
encodePacked,
|
|
8
|
+
formatUnits,
|
|
9
|
+
http,
|
|
10
|
+
isAddress,
|
|
11
|
+
keccak256,
|
|
12
|
+
parseUnits,
|
|
13
|
+
} from 'viem';
|
|
14
|
+
import { privateKeyToAccount, privateKeyToAddress } from 'viem/accounts';
|
|
15
|
+
import { base } from 'viem/chains';
|
|
16
|
+
import { ADDRESSES, POOL_CONFIG, getRelayUrl } from './addresses.js';
|
|
17
|
+
import { ERC20_ABI } from './abi.js';
|
|
18
|
+
import { Keypair } from './keypair.js';
|
|
19
|
+
import { submitRelay } from './relay.js';
|
|
20
|
+
import { buildWithdrawProof } from './withdraw.js';
|
|
21
|
+
import type { ProvingKeyPath } from './prover.js';
|
|
22
|
+
|
|
23
|
+
const X402_PAYER_DOMAIN = 'veil-x402-payer';
|
|
24
|
+
const BASE_NETWORK = `eip155:${ADDRESSES.chainId}` as const;
|
|
25
|
+
const USDC_DECIMALS = POOL_CONFIG.usdc.decimals;
|
|
26
|
+
|
|
27
|
+
// The /x402 relay route enforces a minimum withdrawal (default 0.001 USDC =
|
|
28
|
+
// 1000 atomic). A top-up shortfall below this floor is bumped up to it; the payer
|
|
29
|
+
// then ends slightly above the price, leaving a sub-min residue drained next reuse.
|
|
30
|
+
const X402_MIN_WITHDRAW_ATOMIC = 1000n;
|
|
31
|
+
|
|
32
|
+
export interface PayX402ResourceOptions {
|
|
33
|
+
url: string;
|
|
34
|
+
rootPrivateKey: `0x${string}`;
|
|
35
|
+
payerIndex: bigint | number | string;
|
|
36
|
+
rpcUrl?: string;
|
|
37
|
+
relayUrl?: string;
|
|
38
|
+
/**
|
|
39
|
+
* Maximum USDC the caller will pay for this resource, as a human-readable
|
|
40
|
+
* decimal string (e.g. "0.10" or "10"). If the merchant's x402 requirement
|
|
41
|
+
* exceeds this cap, the payment is rejected before any proof is built or the
|
|
42
|
+
* payer EOA is funded. Prefer setting a tight per-request cap.
|
|
43
|
+
*/
|
|
44
|
+
maxPayment?: string;
|
|
45
|
+
fetchImpl?: typeof fetch;
|
|
46
|
+
init?: RequestInit;
|
|
47
|
+
provingKeyPath?: ProvingKeyPath;
|
|
48
|
+
onProgress?: (stage: string, detail?: string) => void;
|
|
49
|
+
/**
|
|
50
|
+
* Fired immediately after the payer EOA is funded by the relay, before the
|
|
51
|
+
* x402 payment is signed and submitted. Lets callers persist a funded-state
|
|
52
|
+
* record so that funds are traceable even if a later step (signing, the second
|
|
53
|
+
* fetch, settle-header parse) fails. Best-effort: callback errors are ignored.
|
|
54
|
+
*/
|
|
55
|
+
onPayerFunded?: (info: X402PayerFundedInfo) => void;
|
|
56
|
+
/**
|
|
57
|
+
* Controls funding when the derived payer EOA already holds USDC:
|
|
58
|
+
* - `true`: if the payer holds >= the required amount, skip the relay-funded
|
|
59
|
+
* withdrawal and pay from that balance; throws if the balance is insufficient.
|
|
60
|
+
* - `'topup'`: reuse the payer, withdrawing only the shortfall (amount - balance)
|
|
61
|
+
* when it holds less than the price. Drains stranded dust toward zero without a
|
|
62
|
+
* fresh full withdrawal; if the payer already holds enough, funding is skipped.
|
|
63
|
+
* - `false`/undefined: always withdraw the full amount to the payer.
|
|
64
|
+
* Use reuse to retry a payment whose funding succeeded but whose delivery failed.
|
|
65
|
+
*/
|
|
66
|
+
reuseExistingBalance?: boolean | 'topup';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface X402PayerFundedInfo {
|
|
70
|
+
payerAddress: `0x${string}`;
|
|
71
|
+
payerIndex: string;
|
|
72
|
+
amount: string;
|
|
73
|
+
amountAtomic: string;
|
|
74
|
+
relayTransactionHash: string;
|
|
75
|
+
relayBlockNumber: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface PayX402ResourceResult {
|
|
79
|
+
response: Response;
|
|
80
|
+
payerAddress: `0x${string}`;
|
|
81
|
+
payerIndex: string;
|
|
82
|
+
amount: string;
|
|
83
|
+
amountAtomic: string;
|
|
84
|
+
relayTransactionHash: string;
|
|
85
|
+
relayBlockNumber: string;
|
|
86
|
+
paymentResponse?: SettleResponse;
|
|
87
|
+
paymentTransactionHash?: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function assertPrivateKey(value: string, label: string): asserts value is `0x${string}` {
|
|
91
|
+
if (!/^0x[a-fA-F0-9]{64}$/.test(value)) {
|
|
92
|
+
throw new Error(`${label} must be a 0x-prefixed 32-byte hex string`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function normalizePayerIndex(index: bigint | number | string): bigint {
|
|
97
|
+
const normalized =
|
|
98
|
+
typeof index === 'bigint'
|
|
99
|
+
? index
|
|
100
|
+
: typeof index === 'number'
|
|
101
|
+
? BigInt(index)
|
|
102
|
+
: BigInt(index);
|
|
103
|
+
if (normalized < 0n) {
|
|
104
|
+
throw new Error('payerIndex must be non-negative');
|
|
105
|
+
}
|
|
106
|
+
return normalized;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function normalizeAtomicAmount(amount: string): string {
|
|
110
|
+
if (!/^\d+$/.test(amount)) {
|
|
111
|
+
throw new Error(`x402 amount must be an atomic integer string, received: ${amount}`);
|
|
112
|
+
}
|
|
113
|
+
if (BigInt(amount) <= 0n) {
|
|
114
|
+
throw new Error('x402 amount must be greater than 0');
|
|
115
|
+
}
|
|
116
|
+
return amount;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function usdcAtomicToDecimalString(amountAtomic: string | bigint): string {
|
|
120
|
+
const atomic = typeof amountAtomic === 'bigint' ? amountAtomic.toString() : normalizeAtomicAmount(amountAtomic);
|
|
121
|
+
return formatUnits(BigInt(atomic), USDC_DECIMALS);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Convert a human-readable USDC amount (e.g. "10" or "0.10") to atomic units.
|
|
126
|
+
*/
|
|
127
|
+
export function usdcDecimalToAtomic(amount: string): bigint {
|
|
128
|
+
if (!/^\d+(\.\d+)?$/.test(amount.trim())) {
|
|
129
|
+
throw new Error(`Invalid USDC amount: ${amount}`);
|
|
130
|
+
}
|
|
131
|
+
return parseUnits(amount.trim(), USDC_DECIMALS);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function deriveX402PayerKey(
|
|
135
|
+
rootPrivateKey: string,
|
|
136
|
+
index: bigint | number | string,
|
|
137
|
+
): `0x${string}` {
|
|
138
|
+
assertPrivateKey(rootPrivateKey, 'rootPrivateKey');
|
|
139
|
+
const normalizedIndex = normalizePayerIndex(index);
|
|
140
|
+
return keccak256(
|
|
141
|
+
encodePacked(
|
|
142
|
+
['bytes32', 'string', 'uint256'],
|
|
143
|
+
[rootPrivateKey, X402_PAYER_DOMAIN, normalizedIndex],
|
|
144
|
+
),
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function deriveX402PayerAddress(
|
|
149
|
+
rootPrivateKey: string,
|
|
150
|
+
index: bigint | number | string,
|
|
151
|
+
): `0x${string}` {
|
|
152
|
+
return privateKeyToAddress(deriveX402PayerKey(rootPrivateKey, index));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function selectBaseUsdcExactRequirement(paymentRequired: PaymentRequired): PaymentRequirements {
|
|
156
|
+
if (paymentRequired.x402Version !== 2) {
|
|
157
|
+
throw new Error(`Unsupported x402 version ${paymentRequired.x402Version}; expected v2`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const requirement = paymentRequired.accepts.find((candidate) =>
|
|
161
|
+
candidate.scheme === 'exact' &&
|
|
162
|
+
candidate.network === BASE_NETWORK &&
|
|
163
|
+
candidate.asset.toLowerCase() === ADDRESSES.usdcToken.toLowerCase()
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!requirement) {
|
|
167
|
+
throw new Error('No supported x402 payment requirement found. Veil supports x402 v2 exact Base USDC only.');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (!isAddress(requirement.payTo)) {
|
|
171
|
+
throw new Error('Selected x402 requirement has an invalid payTo address');
|
|
172
|
+
}
|
|
173
|
+
normalizeAtomicAmount(requirement.amount);
|
|
174
|
+
return requirement;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function parsePaymentRequired(response: Response, httpClient: x402HTTPClient): Promise<PaymentRequired> {
|
|
178
|
+
let body: unknown;
|
|
179
|
+
try {
|
|
180
|
+
body = await response.clone().json();
|
|
181
|
+
} catch {
|
|
182
|
+
body = undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return httpClient.getPaymentRequiredResponse(
|
|
186
|
+
(name) => response.headers.get(name),
|
|
187
|
+
body,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function payX402Resource(options: PayX402ResourceOptions): Promise<PayX402ResourceResult> {
|
|
192
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
193
|
+
if (!fetchImpl) {
|
|
194
|
+
throw new Error('fetch is not available; pass fetchImpl');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const payerIndex = normalizePayerIndex(options.payerIndex);
|
|
198
|
+
const payerPrivateKey = deriveX402PayerKey(options.rootPrivateKey, payerIndex);
|
|
199
|
+
const payerAccount = privateKeyToAccount(payerPrivateKey);
|
|
200
|
+
const publicClient = createPublicClient({
|
|
201
|
+
chain: base,
|
|
202
|
+
transport: http(options.rpcUrl),
|
|
203
|
+
});
|
|
204
|
+
const signer = toClientEvmSigner(payerAccount, publicClient);
|
|
205
|
+
|
|
206
|
+
const client = new x402Client((_version, requirements) =>
|
|
207
|
+
selectBaseUsdcExactRequirement({
|
|
208
|
+
x402Version: 2,
|
|
209
|
+
resource: { url: options.url },
|
|
210
|
+
accepts: requirements,
|
|
211
|
+
}),
|
|
212
|
+
);
|
|
213
|
+
registerExactEvmScheme(client, {
|
|
214
|
+
signer,
|
|
215
|
+
networks: [BASE_NETWORK],
|
|
216
|
+
schemeOptions: options.rpcUrl ? { rpcUrl: options.rpcUrl } : undefined,
|
|
217
|
+
});
|
|
218
|
+
const httpClient = new x402HTTPClient(client);
|
|
219
|
+
|
|
220
|
+
options.onProgress?.('Fetching x402 requirement...');
|
|
221
|
+
const initialResponse = await fetchImpl(options.url, options.init);
|
|
222
|
+
if (initialResponse.status !== 402) {
|
|
223
|
+
return {
|
|
224
|
+
response: initialResponse,
|
|
225
|
+
payerAddress: payerAccount.address,
|
|
226
|
+
payerIndex: payerIndex.toString(),
|
|
227
|
+
amount: '0',
|
|
228
|
+
amountAtomic: '0',
|
|
229
|
+
relayTransactionHash: '',
|
|
230
|
+
relayBlockNumber: '',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const paymentRequired = await parsePaymentRequired(initialResponse, httpClient);
|
|
235
|
+
const requirement = selectBaseUsdcExactRequirement(paymentRequired);
|
|
236
|
+
const amountAtomic = normalizeAtomicAmount(requirement.amount);
|
|
237
|
+
const amount = usdcAtomicToDecimalString(amountAtomic);
|
|
238
|
+
|
|
239
|
+
// Enforce the spend cap before building a proof or funding the payer EOA so a
|
|
240
|
+
// merchant cannot raise prices between calls and drain more than intended.
|
|
241
|
+
if (options.maxPayment !== undefined) {
|
|
242
|
+
const maxAtomic = usdcDecimalToAtomic(options.maxPayment);
|
|
243
|
+
if (BigInt(amountAtomic) > maxAtomic) {
|
|
244
|
+
throw new Error(
|
|
245
|
+
`x402 payment of ${amount} USDC exceeds maxPayment cap of ${usdcAtomicToDecimalString(maxAtomic)} USDC. Payment was not sent.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Decide whether to fund a fresh withdrawal or reuse an existing payer balance.
|
|
251
|
+
// Reuse lets a caller retry a payment whose funding succeeded but whose delivery
|
|
252
|
+
// failed (the USDC is still sitting on the payer EOA) without a second withdrawal.
|
|
253
|
+
let relayTransactionHash = '';
|
|
254
|
+
let relayBlockNumber = '';
|
|
255
|
+
// How much USDC to actually withdraw to the payer. For a fresh payer this is the
|
|
256
|
+
// full amount; when reusing, it is only the shortfall ('topup') or zero (the payer
|
|
257
|
+
// already holds enough).
|
|
258
|
+
let fundAtomic = BigInt(amountAtomic);
|
|
259
|
+
if (options.reuseExistingBalance) {
|
|
260
|
+
const payerBalance = (await publicClient.readContract({
|
|
261
|
+
address: ADDRESSES.usdcToken,
|
|
262
|
+
abi: ERC20_ABI,
|
|
263
|
+
functionName: 'balanceOf',
|
|
264
|
+
args: [payerAccount.address],
|
|
265
|
+
})) as bigint;
|
|
266
|
+
if (payerBalance >= BigInt(amountAtomic)) {
|
|
267
|
+
fundAtomic = 0n;
|
|
268
|
+
} else if (options.reuseExistingBalance === 'topup') {
|
|
269
|
+
// Drain dust: withdraw only the shortfall so the payer lands at ~exactly the
|
|
270
|
+
// price after this payment, driving a stranded balance toward zero without a
|
|
271
|
+
// fresh full withdrawal.
|
|
272
|
+
fundAtomic = BigInt(amountAtomic) - payerBalance;
|
|
273
|
+
} else {
|
|
274
|
+
throw new Error(
|
|
275
|
+
`Cannot reuse payer index ${payerIndex.toString()}: holds ${formatUnits(payerBalance, USDC_DECIMALS)} USDC but the resource requires ${amount} USDC. Pass reuseExistingBalance: 'topup' to fund the shortfall.`,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (fundAtomic === 0n) {
|
|
281
|
+
options.onProgress?.('Reusing funded x402 payer...', `${amount} USDC on ${payerAccount.address}`);
|
|
282
|
+
} else {
|
|
283
|
+
// Bump a sub-minimum shortfall up to the relay floor so the /x402 route accepts
|
|
284
|
+
// it; the payer ends slightly above the price (residue drained on next reuse).
|
|
285
|
+
if (fundAtomic < X402_MIN_WITHDRAW_ATOMIC) {
|
|
286
|
+
fundAtomic = X402_MIN_WITHDRAW_ATOMIC;
|
|
287
|
+
}
|
|
288
|
+
const fundAmount = usdcAtomicToDecimalString(fundAtomic);
|
|
289
|
+
options.onProgress?.('Funding x402 payer...', `${fundAmount} USDC to ${payerAccount.address}`);
|
|
290
|
+
const proof = await buildWithdrawProof({
|
|
291
|
+
amount: fundAmount,
|
|
292
|
+
recipient: payerAccount.address,
|
|
293
|
+
keypair: new Keypair(options.rootPrivateKey),
|
|
294
|
+
pool: 'usdc',
|
|
295
|
+
rpcUrl: options.rpcUrl,
|
|
296
|
+
provingKeyPath: options.provingKeyPath,
|
|
297
|
+
onProgress: options.onProgress,
|
|
298
|
+
});
|
|
299
|
+
const relayResult = await submitRelay({
|
|
300
|
+
type: 'withdraw',
|
|
301
|
+
pool: 'usdc',
|
|
302
|
+
// x402 funding must target the low-minimum /x402 relay route. Default to it
|
|
303
|
+
// so direct SDK consumers do not silently hit the main relay's 5 USDC floor.
|
|
304
|
+
relayUrl: options.relayUrl ?? `${getRelayUrl()}/x402`,
|
|
305
|
+
proofArgs: proof.proofArgs,
|
|
306
|
+
extData: proof.extData,
|
|
307
|
+
metadata: {
|
|
308
|
+
amount: fundAmount,
|
|
309
|
+
amountAtomic: fundAtomic.toString(),
|
|
310
|
+
recipient: payerAccount.address,
|
|
311
|
+
inputUtxoCount: proof.inputCount,
|
|
312
|
+
outputUtxoCount: proof.outputCount,
|
|
313
|
+
x402: true,
|
|
314
|
+
payerIndex: payerIndex.toString(),
|
|
315
|
+
},
|
|
316
|
+
});
|
|
317
|
+
relayTransactionHash = relayResult.transactionHash;
|
|
318
|
+
relayBlockNumber = relayResult.blockNumber;
|
|
319
|
+
|
|
320
|
+
// The payer is now funded. Notify the caller before signing so a funded-state
|
|
321
|
+
// record can be persisted even if a later step throws and strands funds.
|
|
322
|
+
if (options.onPayerFunded) {
|
|
323
|
+
try {
|
|
324
|
+
options.onPayerFunded({
|
|
325
|
+
payerAddress: payerAccount.address,
|
|
326
|
+
payerIndex: payerIndex.toString(),
|
|
327
|
+
amount,
|
|
328
|
+
amountAtomic,
|
|
329
|
+
relayTransactionHash,
|
|
330
|
+
relayBlockNumber,
|
|
331
|
+
});
|
|
332
|
+
} catch {
|
|
333
|
+
// Best-effort notification; never let a logging callback abort the payment.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
options.onProgress?.('Signing x402 payment...');
|
|
339
|
+
const paymentPayload = await httpClient.createPaymentPayload({
|
|
340
|
+
...paymentRequired,
|
|
341
|
+
accepts: [requirement],
|
|
342
|
+
});
|
|
343
|
+
const paymentHeaders = httpClient.encodePaymentSignatureHeader(paymentPayload);
|
|
344
|
+
const headers = new Headers(options.init?.headers);
|
|
345
|
+
for (const [key, value] of Object.entries(paymentHeaders)) {
|
|
346
|
+
headers.set(key, value);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
options.onProgress?.('Requesting paid resource...');
|
|
350
|
+
const paidResponse = await fetchImpl(options.url, {
|
|
351
|
+
...options.init,
|
|
352
|
+
headers,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
let paymentResponse: SettleResponse | undefined;
|
|
356
|
+
try {
|
|
357
|
+
paymentResponse = httpClient.getPaymentSettleResponse((name) => paidResponse.headers.get(name));
|
|
358
|
+
} catch {
|
|
359
|
+
paymentResponse = undefined;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return {
|
|
363
|
+
response: paidResponse,
|
|
364
|
+
payerAddress: payerAccount.address,
|
|
365
|
+
payerIndex: payerIndex.toString(),
|
|
366
|
+
amount,
|
|
367
|
+
amountAtomic,
|
|
368
|
+
relayTransactionHash,
|
|
369
|
+
relayBlockNumber,
|
|
370
|
+
paymentResponse,
|
|
371
|
+
paymentTransactionHash: paymentResponse?.transaction,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
export interface QuoteX402ResourceOptions {
|
|
376
|
+
url: string;
|
|
377
|
+
rpcUrl?: string;
|
|
378
|
+
/**
|
|
379
|
+
* Optional USDC spend cap (decimal string). When set, the result reports
|
|
380
|
+
* whether the merchant's price exceeds it so a caller can refuse before paying.
|
|
381
|
+
*/
|
|
382
|
+
maxPayment?: string;
|
|
383
|
+
fetchImpl?: typeof fetch;
|
|
384
|
+
init?: RequestInit;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export interface QuoteX402ResourceResult {
|
|
388
|
+
/** True when the endpoint returned HTTP 402 (payment required). */
|
|
389
|
+
requiresPayment: boolean;
|
|
390
|
+
/** True when the 402 offers a Veil-supported exact Base USDC requirement. */
|
|
391
|
+
supported: boolean;
|
|
392
|
+
/** Initial HTTP status from the unpaid probe. */
|
|
393
|
+
status: number;
|
|
394
|
+
amount?: string;
|
|
395
|
+
amountAtomic?: string;
|
|
396
|
+
payTo?: string;
|
|
397
|
+
network?: string;
|
|
398
|
+
asset?: string;
|
|
399
|
+
/** Set when maxPayment was supplied: true if the price exceeds the cap. */
|
|
400
|
+
exceedsMax?: boolean;
|
|
401
|
+
maxPayment?: string;
|
|
402
|
+
/** Parsed response body for a non-402 probe, so the caller can see the error. */
|
|
403
|
+
body?: unknown;
|
|
404
|
+
/** Populated when a 402 was returned but no supported requirement could be parsed. */
|
|
405
|
+
error?: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Probe an x402 resource WITHOUT funding a payer or signing a payment. Returns
|
|
410
|
+
* the price/requirement for a supported 402, or the raw response for anything
|
|
411
|
+
* else, so a caller can validate the request and confirm cost before committing
|
|
412
|
+
* a withdrawal. Note: a merchant that only validates the request body after
|
|
413
|
+
* payment will still return 402 here; this cannot catch post-payment errors.
|
|
414
|
+
*/
|
|
415
|
+
export async function quoteX402Resource(options: QuoteX402ResourceOptions): Promise<QuoteX402ResourceResult> {
|
|
416
|
+
const fetchImpl = options.fetchImpl ?? globalThis.fetch;
|
|
417
|
+
if (!fetchImpl) {
|
|
418
|
+
throw new Error('fetch is not available; pass fetchImpl');
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// A selector-only client is enough to parse the 402; no signer/scheme needed
|
|
422
|
+
// because we never sign or settle in a quote.
|
|
423
|
+
const client = new x402Client((_version, requirements) =>
|
|
424
|
+
selectBaseUsdcExactRequirement({
|
|
425
|
+
x402Version: 2,
|
|
426
|
+
resource: { url: options.url },
|
|
427
|
+
accepts: requirements,
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
const httpClient = new x402HTTPClient(client);
|
|
431
|
+
|
|
432
|
+
const initialResponse = await fetchImpl(options.url, options.init);
|
|
433
|
+
if (initialResponse.status !== 402) {
|
|
434
|
+
let body: unknown;
|
|
435
|
+
try {
|
|
436
|
+
body = await initialResponse.clone().json();
|
|
437
|
+
} catch {
|
|
438
|
+
try {
|
|
439
|
+
body = await initialResponse.clone().text();
|
|
440
|
+
} catch {
|
|
441
|
+
body = undefined;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return {
|
|
445
|
+
requiresPayment: false,
|
|
446
|
+
supported: false,
|
|
447
|
+
status: initialResponse.status,
|
|
448
|
+
body,
|
|
449
|
+
};
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let requirement: PaymentRequirements;
|
|
453
|
+
try {
|
|
454
|
+
const paymentRequired = await parsePaymentRequired(initialResponse, httpClient);
|
|
455
|
+
requirement = selectBaseUsdcExactRequirement(paymentRequired);
|
|
456
|
+
} catch (error) {
|
|
457
|
+
return {
|
|
458
|
+
requiresPayment: true,
|
|
459
|
+
supported: false,
|
|
460
|
+
status: 402,
|
|
461
|
+
error: error instanceof Error ? error.message : 'Unsupported x402 requirement',
|
|
462
|
+
};
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const amountAtomic = normalizeAtomicAmount(requirement.amount);
|
|
466
|
+
const amount = usdcAtomicToDecimalString(amountAtomic);
|
|
467
|
+
let exceedsMax: boolean | undefined;
|
|
468
|
+
if (options.maxPayment !== undefined) {
|
|
469
|
+
exceedsMax = BigInt(amountAtomic) > usdcDecimalToAtomic(options.maxPayment);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
requiresPayment: true,
|
|
474
|
+
supported: true,
|
|
475
|
+
status: 402,
|
|
476
|
+
amount,
|
|
477
|
+
amountAtomic,
|
|
478
|
+
payTo: requirement.payTo,
|
|
479
|
+
network: requirement.network,
|
|
480
|
+
asset: requirement.asset,
|
|
481
|
+
exceedsMax,
|
|
482
|
+
maxPayment: options.maxPayment,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export interface X402PayerBalance {
|
|
487
|
+
payerIndex: string;
|
|
488
|
+
payerAddress: `0x${string}`;
|
|
489
|
+
usdc: string;
|
|
490
|
+
usdcAtomic: string;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
export interface GetX402PayerBalancesOptions {
|
|
494
|
+
rootPrivateKey: `0x${string}`;
|
|
495
|
+
startIndex?: bigint | number | string;
|
|
496
|
+
count?: number;
|
|
497
|
+
rpcUrl?: string;
|
|
498
|
+
/** When true, only return payers that currently hold a non-zero USDC balance. */
|
|
499
|
+
nonZeroOnly?: boolean;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Inspect Base USDC balances held by deterministic x402 payer EOAs over an index
|
|
504
|
+
* range. Useful for surfacing dust or funds left on a payer when a payment failed
|
|
505
|
+
* after funding. This is read-only; it does not move funds or reuse payers.
|
|
506
|
+
*/
|
|
507
|
+
export async function getX402PayerBalances(
|
|
508
|
+
options: GetX402PayerBalancesOptions,
|
|
509
|
+
): Promise<X402PayerBalance[]> {
|
|
510
|
+
assertPrivateKey(options.rootPrivateKey, 'rootPrivateKey');
|
|
511
|
+
const start = normalizePayerIndex(options.startIndex ?? 0n);
|
|
512
|
+
const count = options.count ?? 16;
|
|
513
|
+
if (!Number.isInteger(count) || count <= 0 || count > 256) {
|
|
514
|
+
throw new Error('count must be an integer between 1 and 256');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
const publicClient = createPublicClient({
|
|
518
|
+
chain: base,
|
|
519
|
+
transport: http(options.rpcUrl),
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const results: X402PayerBalance[] = [];
|
|
523
|
+
for (let i = 0; i < count; i++) {
|
|
524
|
+
const index = start + BigInt(i);
|
|
525
|
+
const payerAddress = deriveX402PayerAddress(options.rootPrivateKey, index);
|
|
526
|
+
const balance = (await publicClient.readContract({
|
|
527
|
+
address: ADDRESSES.usdcToken,
|
|
528
|
+
abi: ERC20_ABI,
|
|
529
|
+
functionName: 'balanceOf',
|
|
530
|
+
args: [payerAddress],
|
|
531
|
+
})) as bigint;
|
|
532
|
+
|
|
533
|
+
if (options.nonZeroOnly && balance === 0n) {
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
results.push({
|
|
538
|
+
payerIndex: index.toString(),
|
|
539
|
+
payerAddress,
|
|
540
|
+
usdc: formatUnits(balance, USDC_DECIMALS),
|
|
541
|
+
usdcAtomic: balance.toString(),
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
return results;
|
|
546
|
+
}
|