@pay-skill/sdk 0.1.8 → 0.1.10

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.
Files changed (58) hide show
  1. package/README.md +80 -91
  2. package/dist/auth.d.ts +11 -6
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +19 -7
  5. package/dist/auth.js.map +1 -1
  6. package/dist/errors.d.ts +4 -2
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +8 -3
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +2 -13
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -6
  13. package/dist/index.js.map +1 -1
  14. package/dist/keychain.d.ts +8 -0
  15. package/dist/keychain.d.ts.map +1 -0
  16. package/dist/keychain.js +17 -0
  17. package/dist/keychain.js.map +1 -0
  18. package/dist/wallet.d.ts +135 -104
  19. package/dist/wallet.d.ts.map +1 -1
  20. package/dist/wallet.js +683 -280
  21. package/dist/wallet.js.map +1 -1
  22. package/jsr.json +1 -1
  23. package/package.json +5 -2
  24. package/src/auth.ts +28 -18
  25. package/src/errors.ts +10 -3
  26. package/src/index.ts +12 -39
  27. package/src/keychain.ts +18 -0
  28. package/src/wallet.ts +1074 -355
  29. package/tests/test_auth_rejection.ts +43 -95
  30. package/tests/test_crypto.ts +59 -172
  31. package/tests/test_e2e.ts +46 -105
  32. package/tests/test_errors.ts +9 -1
  33. package/tests/test_ows.ts +153 -0
  34. package/tests/test_wallet.ts +194 -0
  35. package/dist/client.d.ts +0 -94
  36. package/dist/client.d.ts.map +0 -1
  37. package/dist/client.js +0 -443
  38. package/dist/client.js.map +0 -1
  39. package/dist/models.d.ts +0 -78
  40. package/dist/models.d.ts.map +0 -1
  41. package/dist/models.js +0 -2
  42. package/dist/models.js.map +0 -1
  43. package/dist/ows-signer.d.ts +0 -75
  44. package/dist/ows-signer.d.ts.map +0 -1
  45. package/dist/ows-signer.js +0 -130
  46. package/dist/ows-signer.js.map +0 -1
  47. package/dist/signer.d.ts +0 -46
  48. package/dist/signer.d.ts.map +0 -1
  49. package/dist/signer.js +0 -111
  50. package/dist/signer.js.map +0 -1
  51. package/src/client.ts +0 -644
  52. package/src/models.ts +0 -77
  53. package/src/ows-signer.ts +0 -223
  54. package/src/signer.ts +0 -147
  55. package/tests/test_ows_integration.ts +0 -92
  56. package/tests/test_ows_signer.ts +0 -365
  57. package/tests/test_signer.ts +0 -47
  58. package/tests/test_validation.ts +0 -66
package/src/wallet.ts CHANGED
@@ -1,30 +1,155 @@
1
1
  /**
2
- * Wallet — high-level write client for agents.
3
- * Wraps PayClient with private key signing and balance tracking.
2
+ * Wallet — the single entry point for the pay SDK.
4
3
  *
5
- * This is the primary entry point for the playground and agent integrations.
6
- * The PayClient is lower-level (HTTP only); Wallet adds signing + state.
4
+ * Zero-config for agents: new Wallet() (reads PAYSKILL_KEY env)
5
+ * Explicit key: new Wallet({ privateKey: "0x..." })
6
+ * OS keychain (CLI key): await Wallet.create()
7
+ * OWS wallet extension: await Wallet.fromOws({ walletId: "..." })
7
8
  */
8
9
 
9
10
  import { type Hex, type Address } from "viem";
10
11
  import { privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts";
11
- import { buildAuthHeaders, type AuthConfig } from "./auth.js";
12
+ import { buildAuthHeaders, buildAuthHeadersSigned } from "./auth.js";
13
+ import { signTransferAuthorization, combinedSignature } from "./eip3009.js";
14
+ import { readFromKeychain } from "./keychain.js";
15
+ import {
16
+ PayError,
17
+ PayValidationError,
18
+ PayNetworkError,
19
+ PayServerError,
20
+ PayInsufficientFundsError,
21
+ } from "./errors.js";
22
+
23
+ // ── Constants ────────────────────────────────────────────────────────
24
+
25
+ const MAINNET_API_URL = "https://pay-skill.com/api/v1";
26
+ const TESTNET_API_URL = "https://testnet.pay-skill.com/api/v1";
27
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
28
+ const KEY_RE = /^0x[0-9a-fA-F]{64}$/;
29
+ const DIRECT_MIN_MICRO = 1_000_000; // $1.00
30
+ const TAB_MIN_MICRO = 5_000_000; // $5.00
31
+ const TAB_MULTIPLIER = 10;
32
+ const DEFAULT_TIMEOUT = 30_000;
33
+
34
+ // Internal sentinel for OWS construction
35
+ const _OWS_INIT = Symbol("ows-init");
36
+
37
+ // ── Public Types ─────────────────────────────────────────────────────
38
+
39
+ /** Dollar amount (default) or micro-USDC for precision. */
40
+ export type Amount = number | { micro: number };
12
41
 
13
42
  export interface WalletOptions {
14
- privateKey: string;
15
- chain: string;
16
- apiUrl: string;
17
- routerAddress: string;
18
- /** Numeric chain ID for EIP-712 domain. If omitted, parsed from `chain`. */
19
- chainId?: number;
43
+ /** Hex private key. If omitted, reads from PAYSKILL_KEY env var. */
44
+ privateKey?: string;
45
+ /** Use Base Sepolia testnet. Default: false (mainnet). Also reads PAYSKILL_TESTNET env. */
46
+ testnet?: boolean;
47
+ /** Request timeout in ms. Default: 30000. */
48
+ timeout?: number;
49
+ }
50
+
51
+ export interface OwsWalletOptions {
52
+ /** OWS wallet name or UUID (e.g. "pay-my-agent"). */
53
+ walletId: string;
54
+ /** OWS API key token (passed as passphrase to OWS signing calls). */
55
+ owsApiKey?: string;
56
+ /** Use Base Sepolia testnet. Default: false (mainnet). */
57
+ testnet?: boolean;
58
+ /** Request timeout in ms. Default: 30000. */
59
+ timeout?: number;
60
+ /** @internal Inject OWS module for testing. */
61
+ _owsModule?: unknown;
62
+ }
63
+
64
+ export interface SendResult {
65
+ txHash: string;
66
+ status: string;
67
+ amount: number;
68
+ fee: number;
69
+ }
70
+
71
+ export interface Tab {
72
+ id: string;
73
+ provider: string;
74
+ amount: number;
75
+ balanceRemaining: number;
76
+ totalCharged: number;
77
+ chargeCount: number;
78
+ maxChargePerCall: number;
79
+ totalWithdrawn: number;
80
+ status: "open" | "closed";
81
+ pendingChargeCount: number;
82
+ pendingChargeTotal: number;
83
+ effectiveBalance: number;
84
+ }
85
+
86
+ export interface ChargeResult {
87
+ chargeId: string;
88
+ status: string;
89
+ }
90
+
91
+ export interface Balance {
92
+ total: number;
93
+ locked: number;
94
+ available: number;
95
+ }
96
+
97
+ export interface Status {
98
+ address: string;
99
+ balance: Balance;
100
+ openTabs: number;
101
+ }
102
+
103
+ export interface DiscoverService {
104
+ name: string;
105
+ description: string;
106
+ baseUrl: string;
107
+ category: string;
108
+ keywords: string[];
109
+ routes: {
110
+ path: string;
111
+ method?: string;
112
+ price?: string;
113
+ settlement?: string;
114
+ }[];
115
+ docsUrl?: string;
116
+ }
117
+
118
+ export interface DiscoverOptions {
119
+ sort?: string;
120
+ category?: string;
121
+ settlement?: string;
20
122
  }
21
123
 
22
124
  export interface FundLinkOptions {
23
- messages?: unknown[];
125
+ message?: string;
24
126
  agentName?: string;
25
127
  }
26
128
 
27
- export interface PermitResult {
129
+ export interface WebhookRegistration {
130
+ id: string;
131
+ url: string;
132
+ events: string[];
133
+ }
134
+
135
+ export interface MintResult {
136
+ txHash: string;
137
+ amount: number;
138
+ }
139
+
140
+ // ── Private Types ────────────────────────────────────────────────────
141
+
142
+ interface Contracts {
143
+ router: Address;
144
+ tab: Address;
145
+ direct: Address;
146
+ fee: Address;
147
+ usdc: Address;
148
+ chainId: number;
149
+ relayer: Address;
150
+ }
151
+
152
+ interface Permit {
28
153
  nonce: string;
29
154
  deadline: number;
30
155
  v: number;
@@ -32,414 +157,1008 @@ export interface PermitResult {
32
157
  s: string;
33
158
  }
34
159
 
35
- /** Map well-known chain names to numeric IDs. */
36
- const CHAIN_IDS: Record<string, number> = {
37
- "base": 8453,
38
- "base-sepolia": 84532,
39
- };
160
+ type SignTypedDataFn = (params: {
161
+ domain: Record<string, unknown>;
162
+ types: Record<string, readonly { name: string; type: string }[]>;
163
+ primaryType: string;
164
+ message: Record<string, unknown>;
165
+ }) => Promise<string>;
166
+
167
+ // Raw server response (snake_case)
168
+ interface RawTab {
169
+ tab_id: string;
170
+ provider: string;
171
+ amount: number;
172
+ balance_remaining: number;
173
+ total_charged: number;
174
+ charge_count: number;
175
+ max_charge_per_call: number;
176
+ total_withdrawn: number;
177
+ status: "open" | "closed";
178
+ pending_charge_count: number;
179
+ pending_charge_total: number;
180
+ effective_balance: number;
181
+ }
182
+
183
+ /** Subset of @open-wallet-standard/core we call at runtime. */
184
+ interface OwsModule {
185
+ getWallet(
186
+ nameOrId: string,
187
+ vaultPath?: string,
188
+ ): {
189
+ id: string;
190
+ name: string;
191
+ accounts: Array<{
192
+ chainId: string;
193
+ address: string;
194
+ derivationPath: string;
195
+ }>;
196
+ };
197
+ signTypedData(
198
+ wallet: string,
199
+ chain: string,
200
+ typedDataJson: string,
201
+ passphrase?: string,
202
+ index?: number,
203
+ vaultPath?: string,
204
+ ): { signature: string; recoveryId?: number };
205
+ }
206
+
207
+ // ── Helpers ──────────────────────────────────────────────────────────
208
+
209
+ function normalizeKey(key: string): Hex {
210
+ const clean = key.startsWith("0x") ? key : "0x" + key;
211
+ if (!KEY_RE.test(clean)) {
212
+ throw new PayValidationError(
213
+ "Invalid private key: must be 32 bytes hex",
214
+ "privateKey",
215
+ );
216
+ }
217
+ return clean as Hex;
218
+ }
219
+
220
+ function validateAddress(address: string): void {
221
+ if (!ADDRESS_RE.test(address)) {
222
+ throw new PayValidationError(
223
+ `Invalid Ethereum address: ${address}`,
224
+ "address",
225
+ );
226
+ }
227
+ }
228
+
229
+ function toMicro(amount: Amount): number {
230
+ if (typeof amount === "number") {
231
+ if (!Number.isFinite(amount) || amount < 0) {
232
+ throw new PayValidationError(
233
+ "Amount must be a positive finite number",
234
+ "amount",
235
+ );
236
+ }
237
+ return Math.round(amount * 1_000_000);
238
+ }
239
+ if (!Number.isInteger(amount.micro) || amount.micro < 0) {
240
+ throw new PayValidationError(
241
+ "Micro amount must be a non-negative integer",
242
+ "amount",
243
+ );
244
+ }
245
+ return amount.micro;
246
+ }
247
+
248
+ function toDollars(micro: number): number {
249
+ return micro / 1_000_000;
250
+ }
251
+
252
+ function parseTab(raw: RawTab): Tab {
253
+ return {
254
+ id: raw.tab_id,
255
+ provider: raw.provider,
256
+ amount: toDollars(raw.amount),
257
+ balanceRemaining: toDollars(raw.balance_remaining),
258
+ totalCharged: toDollars(raw.total_charged),
259
+ chargeCount: raw.charge_count,
260
+ maxChargePerCall: toDollars(raw.max_charge_per_call),
261
+ totalWithdrawn: toDollars(raw.total_withdrawn),
262
+ status: raw.status,
263
+ pendingChargeCount: raw.pending_charge_count,
264
+ pendingChargeTotal: toDollars(raw.pending_charge_total),
265
+ effectiveBalance: toDollars(raw.effective_balance),
266
+ };
267
+ }
268
+
269
+ function parseSig(signature: string): { v: number; r: string; s: string } {
270
+ const sig = signature.startsWith("0x")
271
+ ? signature.slice(2)
272
+ : signature;
273
+ return {
274
+ v: parseInt(sig.slice(128, 130), 16),
275
+ r: "0x" + sig.slice(0, 64),
276
+ s: "0x" + sig.slice(64, 128),
277
+ };
278
+ }
279
+
280
+ function resolveApiUrl(testnet: boolean): string {
281
+ return (
282
+ process.env.PAYSKILL_API_URL ??
283
+ (testnet ? TESTNET_API_URL : MAINNET_API_URL)
284
+ );
285
+ }
286
+
287
+ function createOwsSignTypedData(
288
+ ows: OwsModule,
289
+ walletId: string,
290
+ owsApiKey?: string,
291
+ ): SignTypedDataFn {
292
+ return async (params) => {
293
+ // Build EIP712Domain type from domain fields
294
+ const domainType: Array<{ name: string; type: string }> = [];
295
+ const d = params.domain;
296
+ if (d.name !== undefined)
297
+ domainType.push({ name: "name", type: "string" });
298
+ if (d.version !== undefined)
299
+ domainType.push({ name: "version", type: "string" });
300
+ if (d.chainId !== undefined)
301
+ domainType.push({ name: "chainId", type: "uint256" });
302
+ if (d.verifyingContract !== undefined)
303
+ domainType.push({ name: "verifyingContract", type: "address" });
304
+
305
+ const fullTypedData = {
306
+ types: {
307
+ EIP712Domain: domainType,
308
+ ...Object.fromEntries(
309
+ Object.entries(params.types).map(([k, v]) => [k, [...v]]),
310
+ ),
311
+ },
312
+ primaryType: params.primaryType,
313
+ domain: params.domain,
314
+ message: params.message,
315
+ };
316
+
317
+ const json = JSON.stringify(fullTypedData, (_key, v) =>
318
+ typeof v === "bigint" ? v.toString() : (v as unknown),
319
+ );
320
+
321
+ const result = ows.signTypedData(walletId, "evm", json, owsApiKey);
322
+
323
+ const sig = result.signature.startsWith("0x")
324
+ ? result.signature.slice(2)
325
+ : result.signature;
326
+ if (sig.length === 130) return `0x${sig}` as `0x${string}`;
327
+ const v = (result.recoveryId ?? 0) + 27;
328
+ return `0x${sig}${v.toString(16).padStart(2, "0")}` as `0x${string}`;
329
+ };
330
+ }
331
+
332
+ // ── Standalone discover (no wallet needed) ───────────────────────────
333
+
334
+ export async function discover(
335
+ query?: string,
336
+ options?: DiscoverOptions & { testnet?: boolean },
337
+ ): Promise<DiscoverService[]> {
338
+ const testnet = options?.testnet ?? !!process.env.PAYSKILL_TESTNET;
339
+ const apiUrl = resolveApiUrl(testnet);
340
+ return discoverImpl(apiUrl, DEFAULT_TIMEOUT, query, options);
341
+ }
342
+
343
+ async function discoverImpl(
344
+ apiUrl: string,
345
+ timeout: number,
346
+ query?: string,
347
+ options?: DiscoverOptions,
348
+ ): Promise<DiscoverService[]> {
349
+ const params = new URLSearchParams();
350
+ if (query) params.set("q", query);
351
+ if (options?.sort) params.set("sort", options.sort);
352
+ if (options?.category) params.set("category", options.category);
353
+ if (options?.settlement) params.set("settlement", options.settlement);
354
+ const qs = params.toString();
355
+ const url = `${apiUrl}/discover${qs ? `?${qs}` : ""}`;
356
+ const resp = await fetch(url, { signal: AbortSignal.timeout(timeout) });
357
+ if (!resp.ok) {
358
+ throw new PayServerError(`discover failed: ${resp.status}`, resp.status);
359
+ }
360
+ const data = (await resp.json()) as {
361
+ services: Array<Record<string, unknown>>;
362
+ };
363
+ return data.services.map((s) => ({
364
+ name: String(s.name ?? ""),
365
+ description: String(s.description ?? ""),
366
+ baseUrl: String(s.base_url ?? s.baseUrl ?? ""),
367
+ category: String(s.category ?? ""),
368
+ keywords: (s.keywords as string[]) ?? [],
369
+ routes: (s.routes as DiscoverService["routes"]) ?? [],
370
+ docsUrl: s.docs_url != null || s.docsUrl != null
371
+ ? String(s.docs_url ?? s.docsUrl)
372
+ : undefined,
373
+ }));
374
+ }
375
+
376
+ // ── Wallet ───────────────────────────────────────────────────────────
40
377
 
41
378
  export class Wallet {
42
379
  readonly address: string;
43
- private readonly _privateKey: Hex;
44
- private readonly _apiUrl: string;
45
- private readonly _chain: string;
46
- private readonly _chainId: number;
47
- private readonly _routerAddress: Address;
48
- private readonly _account: PrivateKeyAccount;
49
-
50
- /** URL path prefix extracted from apiUrl (e.g., "/api/v1"). */
51
- private readonly _basePath: string;
52
-
53
- /** Cached contracts response. */
54
- private _contractsCache: { router: string; tab: string; direct: string; fee: string; usdc: string; chainId: number } | null = null;
55
-
56
- constructor(options: WalletOptions) {
57
- this._privateKey = normalizeKey(options.privateKey);
58
- this._apiUrl = options.apiUrl;
59
- this._chain = options.chain;
60
- this._chainId = options.chainId ?? CHAIN_IDS[options.chain] ?? (parseInt(options.chain, 10) || 8453);
61
- this._routerAddress = options.routerAddress as Address;
62
- this._account = privateKeyToAccount(this._privateKey);
63
- this.address = this._account.address;
380
+
381
+ // Signing: signTypedData works for both private key and OWS.
382
+ // rawKey is non-null only for private-key wallets (needed for x402 direct / EIP-3009).
383
+ #signTypedData: SignTypedDataFn;
384
+ #rawKey: Hex | null;
385
+ #apiUrl: string;
386
+ #basePath: string;
387
+ #testnet: boolean;
388
+ #timeout: number;
389
+ #contracts: Contracts | null = null;
390
+
391
+ /**
392
+ * Sync constructor. Resolves key from: privateKey arg -> PAYSKILL_KEY env -> error.
393
+ * For OS keychain, use `await Wallet.create()`.
394
+ * For OWS, use `await Wallet.fromOws({ walletId })`.
395
+ */
396
+ constructor(options?: WalletOptions) {
397
+ // Check for internal OWS init (symbol key hidden from public API)
398
+ const raw = options as Record<symbol, unknown> | undefined;
399
+ if (raw && raw[_OWS_INIT]) {
400
+ const init = raw as unknown as {
401
+ [_OWS_INIT]: true;
402
+ _address: string;
403
+ _signTypedData: SignTypedDataFn;
404
+ _testnet: boolean;
405
+ _timeout: number;
406
+ };
407
+ this.address = init._address;
408
+ this.#signTypedData = init._signTypedData;
409
+ this.#rawKey = null;
410
+ this.#testnet = init._testnet;
411
+ this.#timeout = init._timeout;
412
+ this.#apiUrl = resolveApiUrl(this.#testnet);
413
+ try {
414
+ this.#basePath = new URL(this.#apiUrl).pathname.replace(
415
+ /\/+$/,
416
+ "",
417
+ );
418
+ } catch {
419
+ this.#basePath = "";
420
+ }
421
+ return;
422
+ }
423
+
424
+ const key = options?.privateKey ?? process.env.PAYSKILL_KEY;
425
+ if (!key) {
426
+ throw new PayError(
427
+ "No private key found. Provide { privateKey }, set PAYSKILL_KEY env var, " +
428
+ "or use Wallet.create() to read from OS keychain.",
429
+ );
430
+ }
431
+ this.#rawKey = normalizeKey(key);
432
+ const account = privateKeyToAccount(this.#rawKey);
433
+ this.address = account.address;
434
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
+ this.#signTypedData = (p) => account.signTypedData(p as any);
436
+ this.#testnet = options?.testnet ?? !!process.env.PAYSKILL_TESTNET;
437
+ this.#timeout = options?.timeout ?? DEFAULT_TIMEOUT;
438
+ this.#apiUrl = resolveApiUrl(this.#testnet);
64
439
  try {
65
- this._basePath = new URL(options.apiUrl).pathname.replace(/\/+$/, "");
440
+ this.#basePath = new URL(this.#apiUrl).pathname.replace(/\/+$/, "");
66
441
  } catch {
67
- this._basePath = "";
442
+ this.#basePath = "";
68
443
  }
69
444
  }
70
445
 
71
- private get _authConfig(): AuthConfig {
72
- return {
73
- chainId: this._chainId,
74
- routerAddress: this._routerAddress,
446
+ /** Async factory. Resolves key from: privateKey arg -> OS keychain -> PAYSKILL_KEY env -> error. */
447
+ static async create(options?: WalletOptions): Promise<Wallet> {
448
+ if (options?.privateKey) return new Wallet(options);
449
+ const keychainKey = await readFromKeychain();
450
+ if (keychainKey) {
451
+ return new Wallet({ ...options, privateKey: keychainKey });
452
+ }
453
+ return new Wallet(options);
454
+ }
455
+
456
+ /** Sync factory. Reads key from PAYSKILL_KEY env var only. */
457
+ static fromEnv(options?: { testnet?: boolean }): Wallet {
458
+ const key = process.env.PAYSKILL_KEY;
459
+ if (!key) throw new PayError("PAYSKILL_KEY env var not set");
460
+ return new Wallet({ privateKey: key, testnet: options?.testnet });
461
+ }
462
+
463
+ /** Async factory. Creates a wallet backed by an OWS (Open Wallet Standard) wallet. */
464
+ static async fromOws(options: OwsWalletOptions): Promise<Wallet> {
465
+ let owsModule: OwsModule;
466
+ if (options._owsModule) {
467
+ owsModule = options._owsModule as OwsModule;
468
+ } else {
469
+ try {
470
+ const moduleName = "@open-wallet-standard/core";
471
+ owsModule = (await import(moduleName)) as unknown as OwsModule;
472
+ } catch {
473
+ throw new PayError(
474
+ "@open-wallet-standard/core is not installed. " +
475
+ "Install it with: npm install @open-wallet-standard/core",
476
+ );
477
+ }
478
+ }
479
+
480
+ const walletInfo = owsModule.getWallet(options.walletId);
481
+ const evmAccount = walletInfo.accounts.find(
482
+ (a) => a.chainId === "evm" || a.chainId.startsWith("eip155:"),
483
+ );
484
+ if (!evmAccount) {
485
+ throw new PayError(
486
+ `No EVM account found in OWS wallet '${options.walletId}'. ` +
487
+ `Available chains: ${walletInfo.accounts.map((a) => a.chainId).join(", ") || "none"}.`,
488
+ );
489
+ }
490
+
491
+ const signFn = createOwsSignTypedData(
492
+ owsModule,
493
+ options.walletId,
494
+ options.owsApiKey,
495
+ );
496
+
497
+ // Use the internal init path through the constructor
498
+ return new Wallet({
499
+ [_OWS_INIT]: true,
500
+ _address: evmAccount.address,
501
+ _signTypedData: signFn,
502
+ _testnet: options.testnet ?? !!process.env.PAYSKILL_TESTNET,
503
+ _timeout: options.timeout ?? DEFAULT_TIMEOUT,
504
+ } as unknown as WalletOptions);
505
+ }
506
+
507
+ // ── Internal: contracts ──────────────────────────────────────────
508
+
509
+ private async ensureContracts(): Promise<Contracts> {
510
+ if (this.#contracts) return this.#contracts;
511
+ let resp: Response;
512
+ try {
513
+ resp = await fetch(`${this.#apiUrl}/contracts`, {
514
+ signal: AbortSignal.timeout(this.#timeout),
515
+ });
516
+ } catch (e) {
517
+ throw new PayNetworkError(`Failed to reach server: ${e}`);
518
+ }
519
+ if (!resp.ok) {
520
+ throw new PayNetworkError(
521
+ `Failed to fetch contracts: ${resp.status}`,
522
+ );
523
+ }
524
+ const data = (await resp.json()) as Record<string, unknown>;
525
+ this.#contracts = {
526
+ router: String(data.router ?? "") as Address,
527
+ tab: String(data.tab ?? "") as Address,
528
+ direct: String(data.direct ?? "") as Address,
529
+ fee: String(data.fee ?? "") as Address,
530
+ usdc: String(data.usdc ?? "") as Address,
531
+ chainId: Number(data.chain_id ?? 0),
532
+ relayer: String(data.relayer ?? "") as Address,
75
533
  };
534
+ return this.#contracts;
76
535
  }
77
536
 
78
- /** Build authenticated fetch headers for an API request. */
79
- private async _authFetch(
537
+ // ── Internal: HTTP ───────────────────────────────────────────────
538
+
539
+ private async authFetch(
80
540
  path: string,
81
- init: RequestInit = {}
541
+ init: RequestInit = {},
82
542
  ): Promise<Response> {
543
+ const contracts = await this.ensureContracts();
83
544
  const method = (init.method ?? "GET").toUpperCase();
84
- // Sign only the path portion (no query string) — server verifies against uri.path().
85
545
  const pathOnly = path.split("?")[0];
86
- const signPath = this._basePath + pathOnly;
87
- const authHeaders = await buildAuthHeaders(
88
- this._privateKey,
89
- method,
90
- signPath,
91
- this._authConfig
92
- );
93
- const resp = await fetch(`${this._apiUrl}${path}`, {
546
+ const signPath = this.#basePath + pathOnly;
547
+ const config = {
548
+ chainId: contracts.chainId,
549
+ routerAddress: contracts.router,
550
+ };
551
+
552
+ const headers = this.#rawKey
553
+ ? await buildAuthHeaders(this.#rawKey, method, signPath, config)
554
+ : await buildAuthHeadersSigned(
555
+ this.address,
556
+ this.#signTypedData,
557
+ method,
558
+ signPath,
559
+ config,
560
+ );
561
+
562
+ return fetch(`${this.#apiUrl}${path}`, {
94
563
  ...init,
564
+ signal: init.signal ?? AbortSignal.timeout(this.#timeout),
95
565
  headers: {
96
566
  "Content-Type": "application/json",
97
- ...authHeaders,
567
+ ...headers,
98
568
  ...(init.headers as Record<string, string> | undefined),
99
569
  },
100
570
  });
101
- return resp;
102
571
  }
103
572
 
104
- /** Get USDC balance in human-readable units (e.g., 142.50). */
105
- async balance(): Promise<number> {
106
- const resp = await this._authFetch("/status");
107
- if (!resp.ok) throw new Error(`balance fetch failed: ${resp.status}`);
108
- const data = (await resp.json()) as { balance_usdc?: string };
109
- if (!data.balance_usdc) return 0;
110
- const raw = parseFloat(data.balance_usdc);
111
- // Server returns raw micro-units (USDC 6 decimals). Convert to dollars.
112
- return raw / 1_000_000;
573
+ private async get<T>(path: string): Promise<T> {
574
+ let resp: Response;
575
+ try {
576
+ resp = await this.authFetch(path);
577
+ } catch (e) {
578
+ if (e instanceof PayError) throw e;
579
+ throw new PayNetworkError(String(e));
580
+ }
581
+ return this.handleResponse<T>(resp);
113
582
  }
114
583
 
115
- /**
116
- * Sign an EIP-2612 permit for a given spender and amount.
117
- * Uses the server's /permit/prepare endpoint to get the nonce and hash,
118
- * then signs the hash locally.
119
- * @param flow — "direct" or "tab" (used to look up the spender contract address)
120
- * @param amount — micro-USDC amount
121
- */
122
- async signPermit(flow: string, amount: number): Promise<PermitResult> {
123
- const contracts = await this.getContracts();
124
- const spender = flow === "tab" ? contracts.tab : contracts.direct;
125
-
126
- // Server prepares the permit: reads nonce via its own RPC, computes EIP-712 hash
127
- const prepResp = await this._authFetch("/permit/prepare", {
128
- method: "POST",
129
- body: JSON.stringify({ amount, spender }),
130
- });
131
- if (!prepResp.ok) {
132
- const err = (await prepResp.json().catch(() => ({}))) as { error?: string };
133
- throw new Error(err.error ?? `permit/prepare failed: ${prepResp.status}`);
584
+ private async post<T>(path: string, body: unknown): Promise<T> {
585
+ let resp: Response;
586
+ try {
587
+ resp = await this.authFetch(path, {
588
+ method: "POST",
589
+ body: JSON.stringify(body),
590
+ });
591
+ } catch (e) {
592
+ if (e instanceof PayError) throw e;
593
+ throw new PayNetworkError(String(e));
594
+ }
595
+ return this.handleResponse<T>(resp);
596
+ }
597
+
598
+ private async del(path: string): Promise<void> {
599
+ let resp: Response;
600
+ try {
601
+ resp = await this.authFetch(path, { method: "DELETE" });
602
+ } catch (e) {
603
+ if (e instanceof PayError) throw e;
604
+ throw new PayNetworkError(String(e));
605
+ }
606
+ if (resp.status >= 400) {
607
+ const text = await resp.text();
608
+ throw new PayServerError(text, resp.status);
609
+ }
610
+ }
611
+
612
+ private async handleResponse<T>(resp: Response): Promise<T> {
613
+ if (resp.status >= 400) {
614
+ let msg: string;
615
+ try {
616
+ const body = (await resp.json()) as {
617
+ error?: string;
618
+ code?: string;
619
+ };
620
+ msg = body.error ?? `Server error: ${resp.status}`;
621
+ if (
622
+ body.code === "insufficient_funds" ||
623
+ msg.toLowerCase().includes("insufficient")
624
+ ) {
625
+ throw new PayInsufficientFundsError(msg);
626
+ }
627
+ } catch (e) {
628
+ if (e instanceof PayInsufficientFundsError) throw e;
629
+ msg = `Server error: ${resp.status}`;
630
+ }
631
+ throw new PayServerError(msg, resp.status);
134
632
  }
135
- const prep = (await prepResp.json()) as {
633
+ if (resp.status === 204) return undefined as T;
634
+ return (await resp.json()) as T;
635
+ }
636
+
637
+ // ── Internal: permits ────────────────────────────────────────────
638
+
639
+ private async signPermit(
640
+ flow: "direct" | "tab" | "withdraw",
641
+ microAmount: number,
642
+ ): Promise<Permit> {
643
+ const contracts = await this.ensureContracts();
644
+ const spender =
645
+ flow === "tab"
646
+ ? contracts.tab
647
+ : flow === "withdraw"
648
+ ? contracts.relayer
649
+ : contracts.direct;
650
+ const prep = await this.post<{
136
651
  hash: string;
137
652
  nonce: string;
138
653
  deadline: number;
139
- spender: string;
140
- amount: number;
141
- };
654
+ }>("/permit/prepare", { amount: microAmount, spender });
142
655
 
143
- // Sign the pre-computed EIP-712 hash locally (raw ECDSA, no EIP-191 prefix)
144
- const signature = await this._account.sign({
145
- hash: prep.hash as `0x${string}`,
656
+ if (this.#rawKey) {
657
+ // Private key path: sign the pre-computed hash directly
658
+ const account = privateKeyToAccount(this.#rawKey);
659
+ const signature = await account.sign({
660
+ hash: prep.hash as `0x${string}`,
661
+ });
662
+ return { nonce: prep.nonce, deadline: prep.deadline, ...parseSig(signature) };
663
+ }
664
+
665
+ // OWS path: sign full EIP-2612 permit typed data
666
+ const signature = await this.#signTypedData({
667
+ domain: {
668
+ name: "USD Coin",
669
+ version: "2",
670
+ chainId: contracts.chainId,
671
+ verifyingContract: contracts.usdc as string,
672
+ },
673
+ types: {
674
+ Permit: [
675
+ { name: "owner", type: "address" },
676
+ { name: "spender", type: "address" },
677
+ { name: "value", type: "uint256" },
678
+ { name: "nonce", type: "uint256" },
679
+ { name: "deadline", type: "uint256" },
680
+ ],
681
+ },
682
+ primaryType: "Permit",
683
+ message: {
684
+ owner: this.address,
685
+ spender: spender as string,
686
+ value: BigInt(microAmount),
687
+ nonce: BigInt(prep.nonce),
688
+ deadline: BigInt(prep.deadline),
689
+ },
146
690
  });
691
+ return { nonce: prep.nonce, deadline: prep.deadline, ...parseSig(signature) };
692
+ }
147
693
 
148
- // Parse 65-byte signature into v, r, s
149
- const sigClean = signature.startsWith("0x") ? signature.slice(2) : signature;
150
- const r = "0x" + sigClean.slice(0, 64);
151
- const s = "0x" + sigClean.slice(64, 128);
152
- const v = parseInt(sigClean.slice(128, 130), 16);
694
+ // ── Internal: x402 ───────────────────────────────────────────────
153
695
 
154
- return { nonce: prep.nonce, deadline: prep.deadline, v, r, s };
696
+ private async parse402(resp: Response): Promise<{
697
+ settlement: string;
698
+ amount: number;
699
+ to: string;
700
+ accepted?: Record<string, unknown>;
701
+ }> {
702
+ const prHeader = resp.headers.get("payment-required");
703
+ if (prHeader) {
704
+ try {
705
+ const decoded = JSON.parse(atob(prHeader)) as Record<
706
+ string,
707
+ unknown
708
+ >;
709
+ return extract402(decoded);
710
+ } catch {
711
+ /* fall through to body */
712
+ }
713
+ }
714
+ const body = (await resp.json()) as Record<string, unknown>;
715
+ return extract402(
716
+ (body.requirements ?? body) as Record<string, unknown>,
717
+ );
155
718
  }
156
719
 
157
- /** Send a direct payment. Auto-signs permit if not provided. */
158
- async payDirect(
159
- to: string,
160
- amount: number,
161
- memo: string,
162
- options?: { permit?: PermitResult }
163
- ): Promise<{ tx_hash: string; status: string }> {
164
- const microAmount = Math.round(amount * 1_000_000);
165
-
166
- // Auto-sign permit if not provided
167
- let permit = options?.permit;
168
- if (!permit) {
169
- permit = await this.signPermit("direct", microAmount);
720
+ private async handle402(
721
+ resp: Response,
722
+ url: string,
723
+ method: string,
724
+ body: string | undefined,
725
+ headers: Record<string, string>,
726
+ ): Promise<Response> {
727
+ const reqs = await this.parse402(resp);
728
+ if (reqs.settlement === "tab") {
729
+ return this.settleViaTab(url, method, body, headers, reqs);
170
730
  }
731
+ return this.settleViaDirect(url, method, body, headers, reqs);
732
+ }
171
733
 
172
- const resp = await this._authFetch("/direct", {
173
- method: "POST",
174
- body: JSON.stringify({
175
- to,
176
- amount: microAmount,
177
- memo,
178
- permit,
179
- }),
180
- });
181
- if (!resp.ok) {
182
- const err = (await resp.json().catch(() => ({}))) as { error?: string };
183
- throw new Error(err.error ?? `payDirect failed: ${resp.status}`);
734
+ private async settleViaDirect(
735
+ url: string,
736
+ method: string,
737
+ body: string | undefined,
738
+ headers: Record<string, string>,
739
+ reqs: {
740
+ amount: number;
741
+ to: string;
742
+ accepted?: Record<string, unknown>;
743
+ },
744
+ ): Promise<Response> {
745
+ if (!this.#rawKey) {
746
+ throw new PayError(
747
+ "x402 direct settlement requires a private key. " +
748
+ "OWS wallets only support tab settlement. " +
749
+ "Ask the provider to enable tab settlement, or use a private key wallet.",
750
+ );
184
751
  }
185
- return (await resp.json()) as { tx_hash: string; status: string };
752
+ const contracts = await this.ensureContracts();
753
+ const auth = await signTransferAuthorization(
754
+ this.#rawKey,
755
+ reqs.to as Address,
756
+ reqs.amount,
757
+ contracts.chainId,
758
+ contracts.usdc,
759
+ );
760
+ const paymentPayload = {
761
+ x402Version: 2,
762
+ accepted: reqs.accepted ?? {
763
+ scheme: "exact",
764
+ network: `eip155:${contracts.chainId}`,
765
+ amount: String(reqs.amount),
766
+ payTo: reqs.to,
767
+ },
768
+ payload: {
769
+ signature: combinedSignature(auth),
770
+ authorization: {
771
+ from: auth.from,
772
+ to: auth.to,
773
+ value: String(reqs.amount),
774
+ validAfter: "0",
775
+ validBefore: "0",
776
+ nonce: auth.nonce,
777
+ },
778
+ },
779
+ extensions: {},
780
+ };
781
+ return fetch(url, {
782
+ method,
783
+ body,
784
+ signal: AbortSignal.timeout(this.#timeout),
785
+ headers: {
786
+ ...headers,
787
+ "Content-Type": "application/json",
788
+ "PAYMENT-SIGNATURE": btoa(JSON.stringify(paymentPayload)),
789
+ },
790
+ });
186
791
  }
187
792
 
188
- /** Create a one-time fund link via the server. Returns the dashboard URL. */
189
- async createFundLink(options?: FundLinkOptions): Promise<string> {
190
- const resp = await this._authFetch("/links/fund", {
191
- method: "POST",
192
- body: JSON.stringify({
193
- messages: options?.messages ?? [],
194
- agent_name: options?.agentName,
195
- }),
196
- });
197
- if (!resp.ok) {
198
- const err = (await resp.json().catch(() => ({}))) as { error?: string };
199
- throw new Error(err.error ?? `createFundLink failed: ${resp.status}`);
793
+ private async settleViaTab(
794
+ url: string,
795
+ method: string,
796
+ body: string | undefined,
797
+ headers: Record<string, string>,
798
+ reqs: {
799
+ amount: number;
800
+ to: string;
801
+ accepted?: Record<string, unknown>;
802
+ },
803
+ ): Promise<Response> {
804
+ const contracts = await this.ensureContracts();
805
+ const rawTabs = await this.get<RawTab[]>("/tabs");
806
+ let tab = rawTabs.find(
807
+ (t) => t.provider === reqs.to && t.status === "open",
808
+ );
809
+
810
+ if (!tab) {
811
+ const tabMicro = Math.max(
812
+ reqs.amount * TAB_MULTIPLIER,
813
+ TAB_MIN_MICRO,
814
+ );
815
+ const bal = await this.balance();
816
+ const tabDollars = toDollars(tabMicro);
817
+ if (bal.available < tabDollars) {
818
+ throw new PayInsufficientFundsError(
819
+ `Insufficient balance for tab: have $${bal.available.toFixed(2)}, need $${tabDollars.toFixed(2)}`,
820
+ bal.available,
821
+ tabDollars,
822
+ );
823
+ }
824
+ const permit = await this.signPermit("tab", tabMicro);
825
+ tab = await this.post<RawTab>("/tabs", {
826
+ provider: reqs.to,
827
+ amount: tabMicro,
828
+ max_charge_per_call: reqs.amount,
829
+ permit,
830
+ });
200
831
  }
201
- const data = (await resp.json()) as { url: string };
202
- return data.url;
203
- }
204
832
 
205
- /** Create a one-time withdraw link via the server. Returns the dashboard URL. */
206
- async createWithdrawLink(options?: FundLinkOptions): Promise<string> {
207
- const resp = await this._authFetch("/links/withdraw", {
208
- method: "POST",
209
- body: JSON.stringify({
210
- messages: options?.messages ?? [],
211
- agent_name: options?.agentName,
212
- }),
833
+ const charge = await this.post<{ charge_id?: string }>(
834
+ `/tabs/${tab.tab_id}/charge`,
835
+ { amount: reqs.amount },
836
+ );
837
+
838
+ const paymentPayload = {
839
+ x402Version: 2,
840
+ accepted: reqs.accepted ?? {
841
+ scheme: "exact",
842
+ network: `eip155:${contracts.chainId}`,
843
+ amount: String(reqs.amount),
844
+ payTo: reqs.to,
845
+ },
846
+ payload: {
847
+ authorization: { from: this.address },
848
+ },
849
+ extensions: {
850
+ pay: {
851
+ settlement: "tab",
852
+ tabId: tab.tab_id,
853
+ chargeId: charge.charge_id ?? "",
854
+ },
855
+ },
856
+ };
857
+ return fetch(url, {
858
+ method,
859
+ body,
860
+ signal: AbortSignal.timeout(this.#timeout),
861
+ headers: {
862
+ ...headers,
863
+ "Content-Type": "application/json",
864
+ "PAYMENT-SIGNATURE": btoa(JSON.stringify(paymentPayload)),
865
+ },
213
866
  });
214
- if (!resp.ok) {
215
- const err = (await resp.json().catch(() => ({}))) as { error?: string };
216
- throw new Error(err.error ?? `createWithdrawLink failed: ${resp.status}`);
217
- }
218
- const data = (await resp.json()) as { url: string };
219
- return data.url;
220
867
  }
221
868
 
222
- /** Register a webhook for this wallet. */
223
- async registerWebhook(
224
- url: string,
225
- events: string[],
226
- secret?: string
227
- ): Promise<{ id: string }> {
228
- const payload: Record<string, unknown> = { url, events };
229
- if (secret) payload.secret = secret;
230
- const resp = await this._authFetch("/webhooks", {
231
- method: "POST",
232
- body: JSON.stringify(payload),
869
+ // ── Public: Direct Payment ───────────────────────────────────────
870
+
871
+ async send(
872
+ to: string,
873
+ amount: Amount,
874
+ memo?: string,
875
+ ): Promise<SendResult> {
876
+ validateAddress(to);
877
+ const micro = toMicro(amount);
878
+ if (micro < DIRECT_MIN_MICRO) {
879
+ throw new PayValidationError(
880
+ "Amount below minimum ($1.00)",
881
+ "amount",
882
+ );
883
+ }
884
+ const permit = await this.signPermit("direct", micro);
885
+ const raw = await this.post<{
886
+ tx_hash: string;
887
+ status: string;
888
+ amount: number;
889
+ fee: number;
890
+ }>("/direct", {
891
+ to,
892
+ amount: micro,
893
+ memo: memo ?? "",
894
+ permit,
233
895
  });
234
- if (!resp.ok) throw new Error(`registerWebhook failed: ${resp.status}`);
235
- return (await resp.json()) as { id: string };
896
+ return {
897
+ txHash: raw.tx_hash,
898
+ status: raw.status,
899
+ amount: toDollars(raw.amount),
900
+ fee: toDollars(raw.fee),
901
+ };
236
902
  }
237
903
 
238
- /** Open a tab with a provider (positional or object form). */
904
+ // ── Public: Tabs ─────────────────────────────────────────────────
905
+
239
906
  async openTab(
240
- providerOrOpts:
241
- | string
242
- | {
243
- to: string;
244
- limit: number;
245
- perUnit: number;
246
- permit?: PermitResult;
247
- },
248
- amount?: number,
249
- maxChargePerCall?: number,
250
- options?: { permit?: PermitResult }
251
- ): Promise<{ id: string; tab_id: string }> {
252
- let provider: string;
253
- let amt: number;
254
- let maxCharge: number;
255
- let permit: PermitResult | undefined;
256
-
257
- if (typeof providerOrOpts === "string") {
258
- if (amount === undefined || maxChargePerCall === undefined) {
259
- throw new Error("amount and maxChargePerCall are required when provider is a string");
260
- }
261
- provider = providerOrOpts;
262
- amt = amount;
263
- maxCharge = maxChargePerCall;
264
- permit = options?.permit;
265
- } else {
266
- provider = providerOrOpts.to;
267
- amt = providerOrOpts.limit;
268
- maxCharge = providerOrOpts.perUnit;
269
- permit = providerOrOpts.permit;
907
+ provider: string,
908
+ amount: Amount,
909
+ maxChargePerCall: Amount,
910
+ ): Promise<Tab> {
911
+ validateAddress(provider);
912
+ const microAmount = toMicro(amount);
913
+ const microMax = toMicro(maxChargePerCall);
914
+ if (microAmount < TAB_MIN_MICRO) {
915
+ throw new PayValidationError(
916
+ "Tab amount below minimum ($5.00)",
917
+ "amount",
918
+ );
919
+ }
920
+ if (microMax <= 0) {
921
+ throw new PayValidationError(
922
+ "maxChargePerCall must be positive",
923
+ "maxChargePerCall",
924
+ );
270
925
  }
926
+ const permit = await this.signPermit("tab", microAmount);
927
+ const raw = await this.post<RawTab>("/tabs", {
928
+ provider,
929
+ amount: microAmount,
930
+ max_charge_per_call: microMax,
931
+ permit,
932
+ });
933
+ return parseTab(raw);
934
+ }
271
935
 
272
- const microAmount = Math.round(amt * 1_000_000);
936
+ async closeTab(tabId: string): Promise<Tab> {
937
+ const raw = await this.post<RawTab>(`/tabs/${tabId}/close`, {});
938
+ return parseTab(raw);
939
+ }
273
940
 
274
- // Auto-sign permit if not provided
275
- if (!permit) {
276
- permit = await this.signPermit("tab", microAmount);
941
+ async topUpTab(tabId: string, amount: Amount): Promise<Tab> {
942
+ const micro = toMicro(amount);
943
+ if (micro <= 0) {
944
+ throw new PayValidationError(
945
+ "Amount must be positive",
946
+ "amount",
947
+ );
277
948
  }
278
-
279
- const resp = await this._authFetch("/tabs", {
280
- method: "POST",
281
- body: JSON.stringify({
282
- provider,
283
- amount: microAmount,
284
- max_charge_per_call: Math.round(maxCharge * 1_000_000),
285
- permit,
286
- }),
287
- });
288
- if (!resp.ok) throw new Error(`openTab failed: ${resp.status}`);
289
- const data = (await resp.json()) as { tab_id: string };
290
- return { id: data.tab_id, tab_id: data.tab_id };
291
- }
292
-
293
- /** Charge a tab (provider-side). */
294
- async chargeTab(
295
- tabId: string,
296
- amountOrOpts:
297
- | number
298
- | {
299
- amount: number;
300
- cumulative: number;
301
- callCount: number;
302
- providerSig: string;
303
- }
304
- ): Promise<{ status: string }> {
305
- const body =
306
- typeof amountOrOpts === "number"
307
- ? { amount: Math.round(amountOrOpts * 1_000_000) }
308
- : {
309
- amount: Math.round(amountOrOpts.amount * 1_000_000),
310
- cumulative: Math.round(amountOrOpts.cumulative * 1_000_000),
311
- call_count: amountOrOpts.callCount,
312
- provider_sig: amountOrOpts.providerSig,
313
- };
314
- const resp = await this._authFetch(`/tabs/${tabId}/charge`, {
315
- method: "POST",
316
- body: JSON.stringify(body),
949
+ const permit = await this.signPermit("tab", micro);
950
+ const raw = await this.post<RawTab>(`/tabs/${tabId}/topup`, {
951
+ amount: micro,
952
+ permit,
317
953
  });
318
- if (!resp.ok) throw new Error(`chargeTab failed: ${resp.status}`);
319
- return (await resp.json()) as { status: string };
320
- }
321
-
322
- /** Close a tab. */
323
- async closeTab(
324
- tabId: string,
325
- options?: { finalAmount?: number; providerSig?: string }
326
- ): Promise<{ status: string }> {
327
- const body: Record<string, unknown> = {};
328
- if (options?.finalAmount !== undefined)
329
- body.final_amount = Math.round(options.finalAmount * 1_000_000);
330
- if (options?.providerSig) body.provider_sig = options.providerSig;
331
- const resp = await this._authFetch(`/tabs/${tabId}/close`, {
332
- method: "POST",
333
- body: JSON.stringify(body),
954
+ return parseTab(raw);
955
+ }
956
+
957
+ async listTabs(): Promise<Tab[]> {
958
+ const raw = await this.get<RawTab[]>("/tabs");
959
+ return raw.map(parseTab);
960
+ }
961
+
962
+ async getTab(tabId: string): Promise<Tab> {
963
+ const raw = await this.get<RawTab>(`/tabs/${tabId}`);
964
+ return parseTab(raw);
965
+ }
966
+
967
+ async chargeTab(tabId: string, amount: Amount): Promise<ChargeResult> {
968
+ const micro = toMicro(amount);
969
+ const raw = await this.post<{ charge_id?: string; status: string }>(
970
+ `/tabs/${tabId}/charge`,
971
+ { amount: micro },
972
+ );
973
+ return { chargeId: raw.charge_id ?? "", status: raw.status };
974
+ }
975
+
976
+ // ── Public: x402 ─────────────────────────────────────────────────
977
+
978
+ async request(
979
+ url: string,
980
+ options?: {
981
+ method?: string;
982
+ body?: unknown;
983
+ headers?: Record<string, string>;
984
+ },
985
+ ): Promise<Response> {
986
+ const method = options?.method ?? "GET";
987
+ const headers = options?.headers ?? {};
988
+ const bodyStr = options?.body
989
+ ? JSON.stringify(options.body)
990
+ : undefined;
991
+ const resp = await fetch(url, {
992
+ method,
993
+ body: bodyStr,
994
+ headers,
995
+ signal: AbortSignal.timeout(this.#timeout),
334
996
  });
335
- if (!resp.ok) throw new Error(`closeTab failed: ${resp.status}`);
336
- return (await resp.json()) as { status: string };
337
- }
338
-
339
- /** Fetch contract addresses from the API (public, no auth). */
340
- async getContracts(): Promise<{
341
- router: string;
342
- tab: string;
343
- direct: string;
344
- fee: string;
345
- usdc: string;
346
- chainId: number;
347
- }> {
348
- if (this._contractsCache) return this._contractsCache;
349
- const resp = await fetch(`${this._apiUrl}/contracts`);
350
- if (!resp.ok) throw new Error(`getContracts failed: ${resp.status}`);
351
- const data = (await resp.json()) as Record<string, unknown>;
352
- this._contractsCache = {
353
- router: (data.router as string) ?? "",
354
- tab: (data.tab as string) ?? "",
355
- direct: (data.direct as string) ?? "",
356
- fee: (data.fee as string) ?? "",
357
- usdc: (data.usdc as string) ?? "",
358
- chainId: (data.chain_id as number) ?? 0,
997
+ if (resp.status !== 402) return resp;
998
+ return this.handle402(resp, url, method, bodyStr, headers);
999
+ }
1000
+
1001
+ // ── Public: Wallet ───────────────────────────────────────────────
1002
+
1003
+ async balance(): Promise<Balance> {
1004
+ const raw = await this.get<{
1005
+ balance_usdc: string | null;
1006
+ total_locked: number;
1007
+ }>("/status");
1008
+ const total = raw.balance_usdc
1009
+ ? Number(raw.balance_usdc) / 1_000_000
1010
+ : 0;
1011
+ const locked = (raw.total_locked ?? 0) / 1_000_000;
1012
+ return { total, locked, available: total - locked };
1013
+ }
1014
+
1015
+ async status(): Promise<Status> {
1016
+ const raw = await this.get<{
1017
+ wallet: string;
1018
+ balance_usdc: string | null;
1019
+ total_locked: number;
1020
+ open_tabs: number;
1021
+ }>("/status");
1022
+ const total = raw.balance_usdc
1023
+ ? Number(raw.balance_usdc) / 1_000_000
1024
+ : 0;
1025
+ const locked = (raw.total_locked ?? 0) / 1_000_000;
1026
+ return {
1027
+ address: raw.wallet,
1028
+ balance: { total, locked, available: total - locked },
1029
+ openTabs: raw.open_tabs,
359
1030
  };
360
- return this._contractsCache;
361
1031
  }
362
1032
 
363
- /** Sign a tab charge (provider-side EIP-712 signature). */
364
- async signTabCharge(
365
- contractAddr: string,
366
- tabId: string,
367
- cumulativeUnits: bigint | number,
368
- callCount: number
369
- ): Promise<string> {
370
- return this._account.signTypedData({
371
- domain: {
372
- name: "pay",
373
- version: "0.1",
374
- chainId: this._chainId,
375
- verifyingContract: contractAddr as Address,
376
- },
377
- types: {
378
- TabCharge: [
379
- { name: "tabId", type: "string" },
380
- { name: "cumulativeUnits", type: "uint256" },
381
- { name: "callCount", type: "uint256" },
382
- ],
383
- },
384
- primaryType: "TabCharge",
385
- message: {
386
- tabId,
387
- cumulativeUnits: BigInt(cumulativeUnits),
388
- callCount: BigInt(callCount),
1033
+ // ── Public: Discovery ────────────────────────────────────────────
1034
+
1035
+ async discover(
1036
+ query?: string,
1037
+ options?: DiscoverOptions,
1038
+ ): Promise<DiscoverService[]> {
1039
+ return discoverImpl(this.#apiUrl, this.#timeout, query, options);
1040
+ }
1041
+
1042
+ // ── Public: Funding ──────────────────────────────────────────────
1043
+
1044
+ async createFundLink(options?: FundLinkOptions): Promise<string> {
1045
+ // Sign permit so dashboard withdraw tab works from any link.
1046
+ const payload: Record<string, unknown> = {
1047
+ messages: options?.message ? [{ text: options.message }] : [],
1048
+ agent_name: options?.agentName,
1049
+ };
1050
+ try {
1051
+ const st = await this.status();
1052
+ const microBalance = Math.round(st.balance.total * 1_000_000);
1053
+ if (microBalance > 0) {
1054
+ const permit = await this.signPermit("withdraw", microBalance);
1055
+ payload.permit = {
1056
+ value: microBalance,
1057
+ deadline: permit.deadline,
1058
+ v: permit.v,
1059
+ r: permit.r,
1060
+ s: permit.s,
1061
+ };
1062
+ }
1063
+ } catch {
1064
+ // Best effort — fund link still works without permit
1065
+ }
1066
+ const data = await this.post<{ url: string }>("/links/fund", payload);
1067
+ return data.url;
1068
+ }
1069
+
1070
+ async createWithdrawLink(options?: FundLinkOptions): Promise<string> {
1071
+ // Sign a USDC permit granting the relayer allowance for the full balance.
1072
+ const status = await this.status();
1073
+ const microBalance = Math.round(status.balance.total * 1_000_000);
1074
+ if (microBalance <= 0) {
1075
+ throw new PayError("no USDC balance to create withdraw link");
1076
+ }
1077
+
1078
+ const permit = await this.signPermit("withdraw", microBalance);
1079
+
1080
+ const data = await this.post<{ url: string }>("/links/withdraw", {
1081
+ messages: options?.message ? [{ text: options.message }] : [],
1082
+ agent_name: options?.agentName,
1083
+ permit: {
1084
+ value: microBalance,
1085
+ deadline: permit.deadline,
1086
+ v: permit.v,
1087
+ r: permit.r,
1088
+ s: permit.s,
389
1089
  },
390
1090
  });
1091
+ return data.url;
391
1092
  }
392
1093
 
393
- /** Sign a raw hash with the wallet's private key. */
394
- private async _signHash(
395
- hash: string
396
- ): Promise<{ v: number; r: string; s: string }> {
397
- const signature = await this._account.signMessage({
398
- message: { raw: hash as Hex },
399
- });
400
- // Parse 65-byte signature into v, r, s
401
- const sigClean = signature.startsWith("0x")
402
- ? signature.slice(2)
403
- : signature;
404
- const r = "0x" + sigClean.slice(0, 64);
405
- const s = "0x" + sigClean.slice(64, 128);
406
- const v = parseInt(sigClean.slice(128, 130), 16);
407
- return { v, r, s };
1094
+ // ── Public: Webhooks ─────────────────────────────────────────────
1095
+
1096
+ async registerWebhook(
1097
+ url: string,
1098
+ events?: string[],
1099
+ secret?: string,
1100
+ ): Promise<WebhookRegistration> {
1101
+ const payload: Record<string, unknown> = { url };
1102
+ if (events) payload.events = events;
1103
+ if (secret) payload.secret = secret;
1104
+ const raw = await this.post<{
1105
+ id: string;
1106
+ url: string;
1107
+ events: string[];
1108
+ }>("/webhooks", payload);
1109
+ return { id: raw.id, url: raw.url, events: raw.events };
408
1110
  }
409
- }
410
1111
 
411
- /** PrivateKeySigner for manual EIP-712 signing in the playground. */
412
- export class PrivateKeySigner {
413
- private readonly _account: PrivateKeyAccount;
414
- readonly address: string;
1112
+ async listWebhooks(): Promise<WebhookRegistration[]> {
1113
+ const raw = await this.get<
1114
+ { id: string; url: string; events: string[] }[]
1115
+ >("/webhooks");
1116
+ return raw.map((w) => ({ id: w.id, url: w.url, events: w.events }));
1117
+ }
415
1118
 
416
- constructor(privateKey: string) {
417
- const key = normalizeKey(privateKey);
418
- this._account = privateKeyToAccount(key);
419
- this.address = this._account.address;
420
- }
421
-
422
- /** Sign EIP-712 typed data. Returns hex signature. */
423
- async signTypedData(
424
- domain: Record<string, unknown>,
425
- types: Record<string, Array<{ name: string; type: string }>>,
426
- message: Record<string, unknown>
427
- ): Promise<string> {
428
- return this._account.signTypedData({
429
- domain: domain as Parameters<
430
- PrivateKeyAccount["signTypedData"]
431
- >[0]["domain"],
432
- types: types as Parameters<
433
- PrivateKeyAccount["signTypedData"]
434
- >[0]["types"],
435
- primaryType: Object.keys(types)[0],
436
- message,
437
- });
1119
+ async deleteWebhook(webhookId: string): Promise<void> {
1120
+ await this.del(`/webhooks/${webhookId}`);
1121
+ }
1122
+
1123
+ // ── Public: Testnet ──────────────────────────────────────────────
1124
+
1125
+ async mint(amount: Amount): Promise<MintResult> {
1126
+ if (!this.#testnet) {
1127
+ throw new PayError("mint is only available on testnet");
1128
+ }
1129
+ const micro = toMicro(amount);
1130
+ const raw = await this.post<{ tx_hash: string; amount: number }>(
1131
+ "/mint",
1132
+ { amount: micro },
1133
+ );
1134
+ return { txHash: raw.tx_hash, amount: toDollars(raw.amount) };
438
1135
  }
439
1136
  }
440
1137
 
441
- /** Normalize a private key to 0x-prefixed Hex. */
442
- function normalizeKey(key: string): Hex {
443
- if (key.startsWith("0x")) return key as Hex;
444
- return ("0x" + key) as Hex;
1138
+ // ── x402 helpers ─────────────────────────────────────────────────────
1139
+
1140
+ function extract402(obj: Record<string, unknown>): {
1141
+ settlement: string;
1142
+ amount: number;
1143
+ to: string;
1144
+ accepted?: Record<string, unknown>;
1145
+ } {
1146
+ const accepts = obj.accepts as
1147
+ | Array<Record<string, unknown>>
1148
+ | undefined;
1149
+ if (Array.isArray(accepts) && accepts.length > 0) {
1150
+ const offer = accepts[0];
1151
+ const extra = (offer.extra ?? {}) as Record<string, unknown>;
1152
+ return {
1153
+ settlement: String(extra.settlement ?? "direct"),
1154
+ amount: Number(offer.amount ?? 0),
1155
+ to: String(offer.payTo ?? ""),
1156
+ accepted: offer,
1157
+ };
1158
+ }
1159
+ return {
1160
+ settlement: String(obj.settlement ?? "direct"),
1161
+ amount: Number(obj.amount ?? 0),
1162
+ to: String(obj.to ?? ""),
1163
+ };
445
1164
  }