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