@pay-skill/sdk 0.1.8 → 0.1.11

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 +136 -104
  19. package/dist/wallet.d.ts.map +1 -1
  20. package/dist/wallet.js +658 -275
  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 +1054 -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/dist/wallet.js CHANGED
@@ -1,331 +1,714 @@
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
  import { privateKeyToAccount } from "viem/accounts";
9
- import { buildAuthHeaders } from "./auth.js";
10
- /** Map well-known chain names to numeric IDs. */
11
- const CHAIN_IDS = {
12
- "base": 8453,
13
- "base-sepolia": 84532,
14
- };
10
+ import { buildAuthHeaders, buildAuthHeadersSigned } from "./auth.js";
11
+ import { signTransferAuthorization, combinedSignature } from "./eip3009.js";
12
+ import { readFromKeychain } from "./keychain.js";
13
+ import { PayError, PayValidationError, PayNetworkError, PayServerError, PayInsufficientFundsError, } from "./errors.js";
14
+ // ── Constants ────────────────────────────────────────────────────────
15
+ const MAINNET_API_URL = "https://pay-skill.com/api/v1";
16
+ const TESTNET_API_URL = "https://testnet.pay-skill.com/api/v1";
17
+ const ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/;
18
+ const KEY_RE = /^0x[0-9a-fA-F]{64}$/;
19
+ const DIRECT_MIN_MICRO = 1_000_000; // $1.00
20
+ const TAB_MIN_MICRO = 5_000_000; // $5.00
21
+ const TAB_MULTIPLIER = 10;
22
+ const DEFAULT_TIMEOUT = 30_000;
23
+ // Internal sentinel for OWS construction
24
+ const _OWS_INIT = Symbol("ows-init");
25
+ // ── Helpers ──────────────────────────────────────────────────────────
26
+ function normalizeKey(key) {
27
+ const clean = key.startsWith("0x") ? key : "0x" + key;
28
+ if (!KEY_RE.test(clean)) {
29
+ throw new PayValidationError("Invalid private key: must be 32 bytes hex", "privateKey");
30
+ }
31
+ return clean;
32
+ }
33
+ function validateAddress(address) {
34
+ if (!ADDRESS_RE.test(address)) {
35
+ throw new PayValidationError(`Invalid Ethereum address: ${address}`, "address");
36
+ }
37
+ }
38
+ function toMicro(amount) {
39
+ if (typeof amount === "number") {
40
+ if (!Number.isFinite(amount) || amount < 0) {
41
+ throw new PayValidationError("Amount must be a positive finite number", "amount");
42
+ }
43
+ return Math.round(amount * 1_000_000);
44
+ }
45
+ if (!Number.isInteger(amount.micro) || amount.micro < 0) {
46
+ throw new PayValidationError("Micro amount must be a non-negative integer", "amount");
47
+ }
48
+ return amount.micro;
49
+ }
50
+ function toDollars(micro) {
51
+ return micro / 1_000_000;
52
+ }
53
+ function parseTab(raw) {
54
+ return {
55
+ id: raw.tab_id,
56
+ provider: raw.provider,
57
+ amount: toDollars(raw.amount),
58
+ balanceRemaining: toDollars(raw.balance_remaining),
59
+ totalCharged: toDollars(raw.total_charged),
60
+ chargeCount: raw.charge_count,
61
+ maxChargePerCall: toDollars(raw.max_charge_per_call),
62
+ totalWithdrawn: toDollars(raw.total_withdrawn),
63
+ status: raw.status,
64
+ pendingChargeCount: raw.pending_charge_count,
65
+ pendingChargeTotal: toDollars(raw.pending_charge_total),
66
+ effectiveBalance: toDollars(raw.effective_balance),
67
+ };
68
+ }
69
+ function parseSig(signature) {
70
+ const sig = signature.startsWith("0x")
71
+ ? signature.slice(2)
72
+ : signature;
73
+ return {
74
+ v: parseInt(sig.slice(128, 130), 16),
75
+ r: "0x" + sig.slice(0, 64),
76
+ s: "0x" + sig.slice(64, 128),
77
+ };
78
+ }
79
+ function resolveApiUrl(testnet) {
80
+ return (process.env.PAYSKILL_API_URL ??
81
+ (testnet ? TESTNET_API_URL : MAINNET_API_URL));
82
+ }
83
+ function createOwsSignTypedData(ows, walletId, owsApiKey) {
84
+ return async (params) => {
85
+ // Build EIP712Domain type from domain fields
86
+ const domainType = [];
87
+ const d = params.domain;
88
+ if (d.name !== undefined)
89
+ domainType.push({ name: "name", type: "string" });
90
+ if (d.version !== undefined)
91
+ domainType.push({ name: "version", type: "string" });
92
+ if (d.chainId !== undefined)
93
+ domainType.push({ name: "chainId", type: "uint256" });
94
+ if (d.verifyingContract !== undefined)
95
+ domainType.push({ name: "verifyingContract", type: "address" });
96
+ const fullTypedData = {
97
+ types: {
98
+ EIP712Domain: domainType,
99
+ ...Object.fromEntries(Object.entries(params.types).map(([k, v]) => [k, [...v]])),
100
+ },
101
+ primaryType: params.primaryType,
102
+ domain: params.domain,
103
+ message: params.message,
104
+ };
105
+ const json = JSON.stringify(fullTypedData, (_key, v) => typeof v === "bigint" ? v.toString() : v);
106
+ const result = ows.signTypedData(walletId, "evm", json, owsApiKey);
107
+ const sig = result.signature.startsWith("0x")
108
+ ? result.signature.slice(2)
109
+ : result.signature;
110
+ if (sig.length === 130)
111
+ return `0x${sig}`;
112
+ const v = (result.recoveryId ?? 0) + 27;
113
+ return `0x${sig}${v.toString(16).padStart(2, "0")}`;
114
+ };
115
+ }
116
+ // ── Standalone discover (no wallet needed) ───────────────────────────
117
+ export async function discover(query, options) {
118
+ const testnet = options?.testnet ?? !!process.env.PAYSKILL_TESTNET;
119
+ const apiUrl = resolveApiUrl(testnet);
120
+ return discoverImpl(apiUrl, DEFAULT_TIMEOUT, query, options);
121
+ }
122
+ async function discoverImpl(apiUrl, timeout, query, options) {
123
+ const params = new URLSearchParams();
124
+ if (query)
125
+ params.set("q", query);
126
+ if (options?.sort)
127
+ params.set("sort", options.sort);
128
+ if (options?.category)
129
+ params.set("category", options.category);
130
+ if (options?.settlement)
131
+ params.set("settlement", options.settlement);
132
+ const qs = params.toString();
133
+ const url = `${apiUrl}/discover${qs ? `?${qs}` : ""}`;
134
+ const resp = await fetch(url, { signal: AbortSignal.timeout(timeout) });
135
+ if (!resp.ok) {
136
+ throw new PayServerError(`discover failed: ${resp.status}`, resp.status);
137
+ }
138
+ const data = (await resp.json());
139
+ return data.services.map((s) => ({
140
+ name: String(s.name ?? ""),
141
+ description: String(s.description ?? ""),
142
+ baseUrl: String(s.base_url ?? s.baseUrl ?? ""),
143
+ category: String(s.category ?? ""),
144
+ keywords: s.keywords ?? [],
145
+ routes: s.routes ?? [],
146
+ docsUrl: s.docs_url != null || s.docsUrl != null
147
+ ? String(s.docs_url ?? s.docsUrl)
148
+ : undefined,
149
+ }));
150
+ }
151
+ // ── Wallet ───────────────────────────────────────────────────────────
15
152
  export class Wallet {
16
153
  address;
17
- _privateKey;
18
- _apiUrl;
19
- _chain;
20
- _chainId;
21
- _routerAddress;
22
- _account;
23
- /** URL path prefix extracted from apiUrl (e.g., "/api/v1"). */
24
- _basePath;
25
- /** Cached contracts response. */
26
- _contractsCache = null;
154
+ // Signing: signTypedData works for both private key and OWS.
155
+ // rawKey is non-null only for private-key wallets (needed for x402 direct / EIP-3009).
156
+ #signTypedData;
157
+ #rawKey;
158
+ #apiUrl;
159
+ #basePath;
160
+ #testnet;
161
+ #timeout;
162
+ #contracts = null;
163
+ /**
164
+ * Sync constructor. Resolves key from: privateKey arg -> PAYSKILL_KEY env -> error.
165
+ * For OS keychain, use `await Wallet.create()`.
166
+ * For OWS, use `await Wallet.fromOws({ walletId })`.
167
+ */
27
168
  constructor(options) {
28
- this._privateKey = normalizeKey(options.privateKey);
29
- this._apiUrl = options.apiUrl;
30
- this._chain = options.chain;
31
- this._chainId = options.chainId ?? CHAIN_IDS[options.chain] ?? (parseInt(options.chain, 10) || 8453);
32
- this._routerAddress = options.routerAddress;
33
- this._account = privateKeyToAccount(this._privateKey);
34
- this.address = this._account.address;
169
+ // Check for internal OWS init (symbol key hidden from public API)
170
+ const raw = options;
171
+ if (raw && raw[_OWS_INIT]) {
172
+ const init = raw;
173
+ this.address = init._address;
174
+ this.#signTypedData = init._signTypedData;
175
+ this.#rawKey = null;
176
+ this.#testnet = init._testnet;
177
+ this.#timeout = init._timeout;
178
+ this.#apiUrl = resolveApiUrl(this.#testnet);
179
+ try {
180
+ this.#basePath = new URL(this.#apiUrl).pathname.replace(/\/+$/, "");
181
+ }
182
+ catch {
183
+ this.#basePath = "";
184
+ }
185
+ return;
186
+ }
187
+ const key = options?.privateKey ?? process.env.PAYSKILL_KEY;
188
+ if (!key) {
189
+ throw new PayError("No private key found. Provide { privateKey }, set PAYSKILL_KEY env var, " +
190
+ "or use Wallet.create() to read from OS keychain.");
191
+ }
192
+ this.#rawKey = normalizeKey(key);
193
+ const account = privateKeyToAccount(this.#rawKey);
194
+ this.address = account.address;
195
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
196
+ this.#signTypedData = (p) => account.signTypedData(p);
197
+ this.#testnet = options?.testnet ?? !!process.env.PAYSKILL_TESTNET;
198
+ this.#timeout = options?.timeout ?? DEFAULT_TIMEOUT;
199
+ this.#apiUrl = resolveApiUrl(this.#testnet);
35
200
  try {
36
- this._basePath = new URL(options.apiUrl).pathname.replace(/\/+$/, "");
201
+ this.#basePath = new URL(this.#apiUrl).pathname.replace(/\/+$/, "");
37
202
  }
38
203
  catch {
39
- this._basePath = "";
204
+ this.#basePath = "";
40
205
  }
41
206
  }
42
- get _authConfig() {
43
- return {
44
- chainId: this._chainId,
45
- routerAddress: this._routerAddress,
207
+ /** Async factory. Resolves key from: privateKey arg -> OS keychain -> PAYSKILL_KEY env -> error. */
208
+ static async create(options) {
209
+ if (options?.privateKey)
210
+ return new Wallet(options);
211
+ const keychainKey = await readFromKeychain();
212
+ if (keychainKey) {
213
+ return new Wallet({ ...options, privateKey: keychainKey });
214
+ }
215
+ return new Wallet(options);
216
+ }
217
+ /** Sync factory. Reads key from PAYSKILL_KEY env var only. */
218
+ static fromEnv(options) {
219
+ const key = process.env.PAYSKILL_KEY;
220
+ if (!key)
221
+ throw new PayError("PAYSKILL_KEY env var not set");
222
+ return new Wallet({ privateKey: key, testnet: options?.testnet });
223
+ }
224
+ /** Async factory. Creates a wallet backed by an OWS (Open Wallet Standard) wallet. */
225
+ static async fromOws(options) {
226
+ let owsModule;
227
+ if (options._owsModule) {
228
+ owsModule = options._owsModule;
229
+ }
230
+ else {
231
+ try {
232
+ const moduleName = "@open-wallet-standard/core";
233
+ owsModule = (await import(moduleName));
234
+ }
235
+ catch {
236
+ throw new PayError("@open-wallet-standard/core is not installed. " +
237
+ "Install it with: npm install @open-wallet-standard/core");
238
+ }
239
+ }
240
+ const walletInfo = owsModule.getWallet(options.walletId);
241
+ const evmAccount = walletInfo.accounts.find((a) => a.chainId === "evm" || a.chainId.startsWith("eip155:"));
242
+ if (!evmAccount) {
243
+ throw new PayError(`No EVM account found in OWS wallet '${options.walletId}'. ` +
244
+ `Available chains: ${walletInfo.accounts.map((a) => a.chainId).join(", ") || "none"}.`);
245
+ }
246
+ const signFn = createOwsSignTypedData(owsModule, options.walletId, options.owsApiKey);
247
+ // Use the internal init path through the constructor
248
+ return new Wallet({
249
+ [_OWS_INIT]: true,
250
+ _address: evmAccount.address,
251
+ _signTypedData: signFn,
252
+ _testnet: options.testnet ?? !!process.env.PAYSKILL_TESTNET,
253
+ _timeout: options.timeout ?? DEFAULT_TIMEOUT,
254
+ });
255
+ }
256
+ // ── Internal: contracts ──────────────────────────────────────────
257
+ async ensureContracts() {
258
+ if (this.#contracts)
259
+ return this.#contracts;
260
+ let resp;
261
+ try {
262
+ resp = await fetch(`${this.#apiUrl}/contracts`, {
263
+ signal: AbortSignal.timeout(this.#timeout),
264
+ });
265
+ }
266
+ catch (e) {
267
+ throw new PayNetworkError(`Failed to reach server: ${e}`);
268
+ }
269
+ if (!resp.ok) {
270
+ throw new PayNetworkError(`Failed to fetch contracts: ${resp.status}`);
271
+ }
272
+ const data = (await resp.json());
273
+ this.#contracts = {
274
+ router: String(data.router ?? ""),
275
+ tab: String(data.tab ?? ""),
276
+ direct: String(data.direct ?? ""),
277
+ fee: String(data.fee ?? ""),
278
+ usdc: String(data.usdc ?? ""),
279
+ chainId: Number(data.chain_id ?? 0),
280
+ relayer: String(data.relayer ?? ""),
46
281
  };
282
+ return this.#contracts;
47
283
  }
48
- /** Build authenticated fetch headers for an API request. */
49
- async _authFetch(path, init = {}) {
284
+ // ── Internal: HTTP ───────────────────────────────────────────────
285
+ async authFetch(path, init = {}) {
286
+ const contracts = await this.ensureContracts();
50
287
  const method = (init.method ?? "GET").toUpperCase();
51
- // Sign only the path portion (no query string) — server verifies against uri.path().
52
288
  const pathOnly = path.split("?")[0];
53
- const signPath = this._basePath + pathOnly;
54
- const authHeaders = await buildAuthHeaders(this._privateKey, method, signPath, this._authConfig);
55
- const resp = await fetch(`${this._apiUrl}${path}`, {
289
+ const signPath = this.#basePath + pathOnly;
290
+ const config = {
291
+ chainId: contracts.chainId,
292
+ routerAddress: contracts.router,
293
+ };
294
+ const headers = this.#rawKey
295
+ ? await buildAuthHeaders(this.#rawKey, method, signPath, config)
296
+ : await buildAuthHeadersSigned(this.address, this.#signTypedData, method, signPath, config);
297
+ return fetch(`${this.#apiUrl}${path}`, {
56
298
  ...init,
299
+ signal: init.signal ?? AbortSignal.timeout(this.#timeout),
57
300
  headers: {
58
301
  "Content-Type": "application/json",
59
- ...authHeaders,
302
+ ...headers,
60
303
  ...init.headers,
61
304
  },
62
305
  });
63
- return resp;
64
306
  }
65
- /** Get USDC balance in human-readable units (e.g., 142.50). */
66
- async balance() {
67
- const resp = await this._authFetch("/status");
68
- if (!resp.ok)
69
- throw new Error(`balance fetch failed: ${resp.status}`);
70
- const data = (await resp.json());
71
- if (!data.balance_usdc)
72
- return 0;
73
- const raw = parseFloat(data.balance_usdc);
74
- // Server returns raw micro-units (USDC 6 decimals). Convert to dollars.
75
- return raw / 1_000_000;
307
+ async get(path) {
308
+ let resp;
309
+ try {
310
+ resp = await this.authFetch(path);
311
+ }
312
+ catch (e) {
313
+ if (e instanceof PayError)
314
+ throw e;
315
+ throw new PayNetworkError(String(e));
316
+ }
317
+ return this.handleResponse(resp);
76
318
  }
77
- /**
78
- * Sign an EIP-2612 permit for a given spender and amount.
79
- * Uses the server's /permit/prepare endpoint to get the nonce and hash,
80
- * then signs the hash locally.
81
- * @param flow — "direct" or "tab" (used to look up the spender contract address)
82
- * @param amount — micro-USDC amount
83
- */
84
- async signPermit(flow, amount) {
85
- const contracts = await this.getContracts();
86
- const spender = flow === "tab" ? contracts.tab : contracts.direct;
87
- // Server prepares the permit: reads nonce via its own RPC, computes EIP-712 hash
88
- const prepResp = await this._authFetch("/permit/prepare", {
89
- method: "POST",
90
- body: JSON.stringify({ amount, spender }),
91
- });
92
- if (!prepResp.ok) {
93
- const err = (await prepResp.json().catch(() => ({})));
94
- throw new Error(err.error ?? `permit/prepare failed: ${prepResp.status}`);
319
+ async post(path, body) {
320
+ let resp;
321
+ try {
322
+ resp = await this.authFetch(path, {
323
+ method: "POST",
324
+ body: JSON.stringify(body),
325
+ });
95
326
  }
96
- const prep = (await prepResp.json());
97
- // Sign the pre-computed EIP-712 hash locally (raw ECDSA, no EIP-191 prefix)
98
- const signature = await this._account.sign({
99
- hash: prep.hash,
100
- });
101
- // Parse 65-byte signature into v, r, s
102
- const sigClean = signature.startsWith("0x") ? signature.slice(2) : signature;
103
- const r = "0x" + sigClean.slice(0, 64);
104
- const s = "0x" + sigClean.slice(64, 128);
105
- const v = parseInt(sigClean.slice(128, 130), 16);
106
- return { nonce: prep.nonce, deadline: prep.deadline, v, r, s };
107
- }
108
- /** Send a direct payment. Auto-signs permit if not provided. */
109
- async payDirect(to, amount, memo, options) {
110
- const microAmount = Math.round(amount * 1_000_000);
111
- // Auto-sign permit if not provided
112
- let permit = options?.permit;
113
- if (!permit) {
114
- permit = await this.signPermit("direct", microAmount);
327
+ catch (e) {
328
+ if (e instanceof PayError)
329
+ throw e;
330
+ throw new PayNetworkError(String(e));
115
331
  }
116
- const resp = await this._authFetch("/direct", {
117
- method: "POST",
118
- body: JSON.stringify({
119
- to,
120
- amount: microAmount,
121
- memo,
122
- permit,
123
- }),
124
- });
125
- if (!resp.ok) {
126
- const err = (await resp.json().catch(() => ({})));
127
- throw new Error(err.error ?? `payDirect failed: ${resp.status}`);
332
+ return this.handleResponse(resp);
333
+ }
334
+ async del(path) {
335
+ let resp;
336
+ try {
337
+ resp = await this.authFetch(path, { method: "DELETE" });
338
+ }
339
+ catch (e) {
340
+ if (e instanceof PayError)
341
+ throw e;
342
+ throw new PayNetworkError(String(e));
343
+ }
344
+ if (resp.status >= 400) {
345
+ const text = await resp.text();
346
+ throw new PayServerError(text, resp.status);
347
+ }
348
+ }
349
+ async handleResponse(resp) {
350
+ if (resp.status >= 400) {
351
+ let msg;
352
+ try {
353
+ const body = (await resp.json());
354
+ msg = body.error ?? `Server error: ${resp.status}`;
355
+ if (body.code === "insufficient_funds" ||
356
+ msg.toLowerCase().includes("insufficient")) {
357
+ throw new PayInsufficientFundsError(msg);
358
+ }
359
+ }
360
+ catch (e) {
361
+ if (e instanceof PayInsufficientFundsError)
362
+ throw e;
363
+ msg = `Server error: ${resp.status}`;
364
+ }
365
+ throw new PayServerError(msg, resp.status);
128
366
  }
367
+ if (resp.status === 204)
368
+ return undefined;
129
369
  return (await resp.json());
130
370
  }
131
- /** Create a one-time fund link via the server. Returns the dashboard URL. */
132
- async createFundLink(options) {
133
- const resp = await this._authFetch("/links/fund", {
134
- method: "POST",
135
- body: JSON.stringify({
136
- messages: options?.messages ?? [],
137
- agent_name: options?.agentName,
138
- }),
371
+ // ── Internal: permits ────────────────────────────────────────────
372
+ async signPermit(flow, microAmount) {
373
+ const contracts = await this.ensureContracts();
374
+ const spender = flow === "tab"
375
+ ? contracts.tab
376
+ : flow === "withdraw"
377
+ ? contracts.relayer
378
+ : contracts.direct;
379
+ const prep = await this.post("/permit/prepare", { amount: microAmount, spender });
380
+ if (this.#rawKey) {
381
+ // Private key path: sign the pre-computed hash directly
382
+ const account = privateKeyToAccount(this.#rawKey);
383
+ const signature = await account.sign({
384
+ hash: prep.hash,
385
+ });
386
+ return { nonce: prep.nonce, deadline: prep.deadline, ...parseSig(signature) };
387
+ }
388
+ // OWS path: sign full EIP-2612 permit typed data
389
+ const signature = await this.#signTypedData({
390
+ domain: {
391
+ name: "USD Coin",
392
+ version: "2",
393
+ chainId: contracts.chainId,
394
+ verifyingContract: contracts.usdc,
395
+ },
396
+ types: {
397
+ Permit: [
398
+ { name: "owner", type: "address" },
399
+ { name: "spender", type: "address" },
400
+ { name: "value", type: "uint256" },
401
+ { name: "nonce", type: "uint256" },
402
+ { name: "deadline", type: "uint256" },
403
+ ],
404
+ },
405
+ primaryType: "Permit",
406
+ message: {
407
+ owner: this.address,
408
+ spender: spender,
409
+ value: BigInt(microAmount),
410
+ nonce: BigInt(prep.nonce),
411
+ deadline: BigInt(prep.deadline),
412
+ },
139
413
  });
140
- if (!resp.ok) {
141
- const err = (await resp.json().catch(() => ({})));
142
- throw new Error(err.error ?? `createFundLink failed: ${resp.status}`);
414
+ return { nonce: prep.nonce, deadline: prep.deadline, ...parseSig(signature) };
415
+ }
416
+ // ── Internal: x402 ───────────────────────────────────────────────
417
+ async parse402(resp) {
418
+ const prHeader = resp.headers.get("payment-required");
419
+ if (prHeader) {
420
+ try {
421
+ const decoded = JSON.parse(atob(prHeader));
422
+ return extract402(decoded);
423
+ }
424
+ catch {
425
+ /* fall through to body */
426
+ }
143
427
  }
144
- const data = (await resp.json());
145
- return data.url;
428
+ const body = (await resp.json());
429
+ return extract402((body.requirements ?? body));
146
430
  }
147
- /** Create a one-time withdraw link via the server. Returns the dashboard URL. */
148
- async createWithdrawLink(options) {
149
- const resp = await this._authFetch("/links/withdraw", {
150
- method: "POST",
151
- body: JSON.stringify({
152
- messages: options?.messages ?? [],
153
- agent_name: options?.agentName,
154
- }),
155
- });
156
- if (!resp.ok) {
157
- const err = (await resp.json().catch(() => ({})));
158
- throw new Error(err.error ?? `createWithdrawLink failed: ${resp.status}`);
431
+ async handle402(resp, url, method, body, headers) {
432
+ const reqs = await this.parse402(resp);
433
+ if (reqs.settlement === "tab") {
434
+ return this.settleViaTab(url, method, body, headers, reqs);
159
435
  }
160
- const data = (await resp.json());
161
- return data.url;
436
+ return this.settleViaDirect(url, method, body, headers, reqs);
162
437
  }
163
- /** Register a webhook for this wallet. */
164
- async registerWebhook(url, events, secret) {
165
- const payload = { url, events };
166
- if (secret)
167
- payload.secret = secret;
168
- const resp = await this._authFetch("/webhooks", {
169
- method: "POST",
170
- body: JSON.stringify(payload),
438
+ async settleViaDirect(url, method, body, headers, reqs) {
439
+ if (!this.#rawKey) {
440
+ throw new PayError("x402 direct settlement requires a private key. " +
441
+ "OWS wallets only support tab settlement. " +
442
+ "Ask the provider to enable tab settlement, or use a private key wallet.");
443
+ }
444
+ const contracts = await this.ensureContracts();
445
+ const auth = await signTransferAuthorization(this.#rawKey, reqs.to, reqs.amount, contracts.chainId, contracts.usdc);
446
+ const paymentPayload = {
447
+ x402Version: 2,
448
+ accepted: reqs.accepted ?? {
449
+ scheme: "exact",
450
+ network: `eip155:${contracts.chainId}`,
451
+ amount: String(reqs.amount),
452
+ payTo: reqs.to,
453
+ },
454
+ payload: {
455
+ signature: combinedSignature(auth),
456
+ authorization: {
457
+ from: auth.from,
458
+ to: auth.to,
459
+ value: String(reqs.amount),
460
+ validAfter: "0",
461
+ validBefore: "0",
462
+ nonce: auth.nonce,
463
+ },
464
+ },
465
+ extensions: {},
466
+ };
467
+ return fetch(url, {
468
+ method,
469
+ body,
470
+ signal: AbortSignal.timeout(this.#timeout),
471
+ headers: {
472
+ ...headers,
473
+ "Content-Type": "application/json",
474
+ "PAYMENT-SIGNATURE": btoa(JSON.stringify(paymentPayload)),
475
+ },
171
476
  });
172
- if (!resp.ok)
173
- throw new Error(`registerWebhook failed: ${resp.status}`);
174
- return (await resp.json());
175
477
  }
176
- /** Open a tab with a provider (positional or object form). */
177
- async openTab(providerOrOpts, amount, maxChargePerCall, options) {
178
- let provider;
179
- let amt;
180
- let maxCharge;
181
- let permit;
182
- if (typeof providerOrOpts === "string") {
183
- if (amount === undefined || maxChargePerCall === undefined) {
184
- throw new Error("amount and maxChargePerCall are required when provider is a string");
478
+ async settleViaTab(url, method, body, headers, reqs) {
479
+ const contracts = await this.ensureContracts();
480
+ const rawTabs = await this.get("/tabs");
481
+ let tab = rawTabs.find((t) => t.provider === reqs.to && t.status === "open");
482
+ if (!tab) {
483
+ const tabMicro = Math.max(reqs.amount * TAB_MULTIPLIER, TAB_MIN_MICRO);
484
+ const bal = await this.balance();
485
+ const tabDollars = toDollars(tabMicro);
486
+ if (bal.available < tabDollars) {
487
+ throw new PayInsufficientFundsError(`Insufficient balance for tab: have $${bal.available.toFixed(2)}, need $${tabDollars.toFixed(2)}`, bal.available, tabDollars);
185
488
  }
186
- provider = providerOrOpts;
187
- amt = amount;
188
- maxCharge = maxChargePerCall;
189
- permit = options?.permit;
489
+ const permit = await this.signPermit("tab", tabMicro);
490
+ tab = await this.post("/tabs", {
491
+ provider: reqs.to,
492
+ amount: tabMicro,
493
+ max_charge_per_call: reqs.amount,
494
+ permit,
495
+ });
190
496
  }
191
- else {
192
- provider = providerOrOpts.to;
193
- amt = providerOrOpts.limit;
194
- maxCharge = providerOrOpts.perUnit;
195
- permit = providerOrOpts.permit;
497
+ const charge = await this.post(`/tabs/${tab.tab_id}/charge`, { amount: reqs.amount });
498
+ const paymentPayload = {
499
+ x402Version: 2,
500
+ accepted: reqs.accepted ?? {
501
+ scheme: "exact",
502
+ network: `eip155:${contracts.chainId}`,
503
+ amount: String(reqs.amount),
504
+ payTo: reqs.to,
505
+ },
506
+ payload: {
507
+ authorization: { from: this.address },
508
+ },
509
+ extensions: {
510
+ pay: {
511
+ settlement: "tab",
512
+ tabId: tab.tab_id,
513
+ chargeId: charge.charge_id ?? "",
514
+ },
515
+ },
516
+ };
517
+ return fetch(url, {
518
+ method,
519
+ body,
520
+ signal: AbortSignal.timeout(this.#timeout),
521
+ headers: {
522
+ ...headers,
523
+ "Content-Type": "application/json",
524
+ "PAYMENT-SIGNATURE": btoa(JSON.stringify(paymentPayload)),
525
+ },
526
+ });
527
+ }
528
+ // ── Public: Direct Payment ───────────────────────────────────────
529
+ async send(to, amount, memo) {
530
+ validateAddress(to);
531
+ const micro = toMicro(amount);
532
+ if (micro < DIRECT_MIN_MICRO) {
533
+ throw new PayValidationError("Amount below minimum ($1.00)", "amount");
196
534
  }
197
- const microAmount = Math.round(amt * 1_000_000);
198
- // Auto-sign permit if not provided
199
- if (!permit) {
200
- permit = await this.signPermit("tab", microAmount);
535
+ const permit = await this.signPermit("direct", micro);
536
+ const raw = await this.post("/direct", {
537
+ to,
538
+ amount: micro,
539
+ memo: memo ?? "",
540
+ permit,
541
+ });
542
+ return {
543
+ txHash: raw.tx_hash,
544
+ status: raw.status,
545
+ amount: toDollars(raw.amount),
546
+ fee: toDollars(raw.fee),
547
+ };
548
+ }
549
+ // ── Public: Tabs ─────────────────────────────────────────────────
550
+ async openTab(provider, amount, maxChargePerCall) {
551
+ validateAddress(provider);
552
+ const microAmount = toMicro(amount);
553
+ const microMax = toMicro(maxChargePerCall);
554
+ if (microAmount < TAB_MIN_MICRO) {
555
+ throw new PayValidationError("Tab amount below minimum ($5.00)", "amount");
201
556
  }
202
- const resp = await this._authFetch("/tabs", {
203
- method: "POST",
204
- body: JSON.stringify({
205
- provider,
206
- amount: microAmount,
207
- max_charge_per_call: Math.round(maxCharge * 1_000_000),
208
- permit,
209
- }),
557
+ if (microMax <= 0) {
558
+ throw new PayValidationError("maxChargePerCall must be positive", "maxChargePerCall");
559
+ }
560
+ const permit = await this.signPermit("tab", microAmount);
561
+ const raw = await this.post("/tabs", {
562
+ provider,
563
+ amount: microAmount,
564
+ max_charge_per_call: microMax,
565
+ permit,
210
566
  });
211
- if (!resp.ok)
212
- throw new Error(`openTab failed: ${resp.status}`);
213
- const data = (await resp.json());
214
- return { id: data.tab_id, tab_id: data.tab_id };
215
- }
216
- /** Charge a tab (provider-side). */
217
- async chargeTab(tabId, amountOrOpts) {
218
- const body = typeof amountOrOpts === "number"
219
- ? { amount: Math.round(amountOrOpts * 1_000_000) }
220
- : {
221
- amount: Math.round(amountOrOpts.amount * 1_000_000),
222
- cumulative: Math.round(amountOrOpts.cumulative * 1_000_000),
223
- call_count: amountOrOpts.callCount,
224
- provider_sig: amountOrOpts.providerSig,
225
- };
226
- const resp = await this._authFetch(`/tabs/${tabId}/charge`, {
227
- method: "POST",
228
- body: JSON.stringify(body),
567
+ return parseTab(raw);
568
+ }
569
+ async closeTab(tabId) {
570
+ const raw = await this.post(`/tabs/${tabId}/close`, {});
571
+ return parseTab(raw);
572
+ }
573
+ async topUpTab(tabId, amount) {
574
+ const micro = toMicro(amount);
575
+ if (micro <= 0) {
576
+ throw new PayValidationError("Amount must be positive", "amount");
577
+ }
578
+ const permit = await this.signPermit("tab", micro);
579
+ const raw = await this.post(`/tabs/${tabId}/topup`, {
580
+ amount: micro,
581
+ permit,
229
582
  });
230
- if (!resp.ok)
231
- throw new Error(`chargeTab failed: ${resp.status}`);
232
- return (await resp.json());
583
+ return parseTab(raw);
584
+ }
585
+ async listTabs() {
586
+ const raw = await this.get("/tabs");
587
+ return raw.map(parseTab);
233
588
  }
234
- /** Close a tab. */
235
- async closeTab(tabId, options) {
236
- const body = {};
237
- if (options?.finalAmount !== undefined)
238
- body.final_amount = Math.round(options.finalAmount * 1_000_000);
239
- if (options?.providerSig)
240
- body.provider_sig = options.providerSig;
241
- const resp = await this._authFetch(`/tabs/${tabId}/close`, {
242
- method: "POST",
243
- body: JSON.stringify(body),
589
+ async getTab(tabId) {
590
+ const raw = await this.get(`/tabs/${tabId}`);
591
+ return parseTab(raw);
592
+ }
593
+ async chargeTab(tabId, amount) {
594
+ const micro = toMicro(amount);
595
+ const raw = await this.post(`/tabs/${tabId}/charge`, { amount: micro });
596
+ return { chargeId: raw.charge_id ?? "", status: raw.status };
597
+ }
598
+ // ── Public: x402 ─────────────────────────────────────────────────
599
+ async request(url, options) {
600
+ const method = options?.method ?? "GET";
601
+ const headers = options?.headers ?? {};
602
+ const bodyStr = options?.body
603
+ ? JSON.stringify(options.body)
604
+ : undefined;
605
+ const resp = await fetch(url, {
606
+ method,
607
+ body: bodyStr,
608
+ headers,
609
+ signal: AbortSignal.timeout(this.#timeout),
244
610
  });
245
- if (!resp.ok)
246
- throw new Error(`closeTab failed: ${resp.status}`);
247
- return (await resp.json());
611
+ if (resp.status !== 402)
612
+ return resp;
613
+ return this.handle402(resp, url, method, bodyStr, headers);
248
614
  }
249
- /** Fetch contract addresses from the API (public, no auth). */
250
- async getContracts() {
251
- if (this._contractsCache)
252
- return this._contractsCache;
253
- const resp = await fetch(`${this._apiUrl}/contracts`);
254
- if (!resp.ok)
255
- throw new Error(`getContracts failed: ${resp.status}`);
256
- const data = (await resp.json());
257
- this._contractsCache = {
258
- router: data.router ?? "",
259
- tab: data.tab ?? "",
260
- direct: data.direct ?? "",
261
- fee: data.fee ?? "",
262
- usdc: data.usdc ?? "",
263
- chainId: data.chain_id ?? 0,
615
+ // ── Public: Wallet ───────────────────────────────────────────────
616
+ async balance() {
617
+ const raw = await this.get("/status");
618
+ const total = raw.balance_usdc
619
+ ? Number(raw.balance_usdc) / 1_000_000
620
+ : 0;
621
+ const locked = (raw.total_locked ?? 0) / 1_000_000;
622
+ return { total, locked, available: total - locked };
623
+ }
624
+ async status() {
625
+ const raw = await this.get("/status");
626
+ const total = raw.balance_usdc
627
+ ? Number(raw.balance_usdc) / 1_000_000
628
+ : 0;
629
+ const locked = (raw.total_locked ?? 0) / 1_000_000;
630
+ return {
631
+ address: raw.wallet,
632
+ balance: { total, locked, available: total - locked },
633
+ openTabs: raw.open_tabs,
264
634
  };
265
- return this._contractsCache;
266
635
  }
267
- /** Sign a tab charge (provider-side EIP-712 signature). */
268
- async signTabCharge(contractAddr, tabId, cumulativeUnits, callCount) {
269
- return this._account.signTypedData({
270
- domain: {
271
- name: "pay",
272
- version: "0.1",
273
- chainId: this._chainId,
274
- verifyingContract: contractAddr,
275
- },
276
- types: {
277
- TabCharge: [
278
- { name: "tabId", type: "string" },
279
- { name: "cumulativeUnits", type: "uint256" },
280
- { name: "callCount", type: "uint256" },
281
- ],
282
- },
283
- primaryType: "TabCharge",
284
- message: {
285
- tabId,
286
- cumulativeUnits: BigInt(cumulativeUnits),
287
- callCount: BigInt(callCount),
288
- },
636
+ // ── Public: Discovery ────────────────────────────────────────────
637
+ async discover(query, options) {
638
+ return discoverImpl(this.#apiUrl, this.#timeout, query, options);
639
+ }
640
+ // ── Public: Funding ──────────────────────────────────────────────
641
+ async ensureWithdrawApproved() {
642
+ const maxValue = Number.MAX_SAFE_INTEGER;
643
+ const permit = await this.signPermit("direct", maxValue);
644
+ await this.post("/relayer-approval", {
645
+ value: maxValue,
646
+ deadline: permit.deadline,
647
+ v: permit.v,
648
+ r: permit.r,
649
+ s: permit.s,
289
650
  });
290
651
  }
291
- /** Sign a raw hash with the wallet's private key. */
292
- async _signHash(hash) {
293
- const signature = await this._account.signMessage({
294
- message: { raw: hash },
652
+ async createFundLink(options) {
653
+ await this.ensureWithdrawApproved();
654
+ const data = await this.post("/links/fund", {
655
+ messages: options?.message ? [{ text: options.message }] : [],
656
+ agent_name: options?.agentName,
295
657
  });
296
- // Parse 65-byte signature into v, r, s
297
- const sigClean = signature.startsWith("0x")
298
- ? signature.slice(2)
299
- : signature;
300
- const r = "0x" + sigClean.slice(0, 64);
301
- const s = "0x" + sigClean.slice(64, 128);
302
- const v = parseInt(sigClean.slice(128, 130), 16);
303
- return { v, r, s };
658
+ return data.url;
304
659
  }
305
- }
306
- /** PrivateKeySigner — for manual EIP-712 signing in the playground. */
307
- export class PrivateKeySigner {
308
- _account;
309
- address;
310
- constructor(privateKey) {
311
- const key = normalizeKey(privateKey);
312
- this._account = privateKeyToAccount(key);
313
- this.address = this._account.address;
314
- }
315
- /** Sign EIP-712 typed data. Returns hex signature. */
316
- async signTypedData(domain, types, message) {
317
- return this._account.signTypedData({
318
- domain: domain,
319
- types: types,
320
- primaryType: Object.keys(types)[0],
321
- message,
660
+ async createWithdrawLink(options) {
661
+ await this.ensureWithdrawApproved();
662
+ const data = await this.post("/links/withdraw", {
663
+ messages: options?.message ? [{ text: options.message }] : [],
664
+ agent_name: options?.agentName,
322
665
  });
666
+ return data.url;
667
+ }
668
+ // ── Public: Webhooks ─────────────────────────────────────────────
669
+ async registerWebhook(url, events, secret) {
670
+ const payload = { url };
671
+ if (events)
672
+ payload.events = events;
673
+ if (secret)
674
+ payload.secret = secret;
675
+ const raw = await this.post("/webhooks", payload);
676
+ return { id: raw.id, url: raw.url, events: raw.events };
677
+ }
678
+ async listWebhooks() {
679
+ const raw = await this.get("/webhooks");
680
+ return raw.map((w) => ({ id: w.id, url: w.url, events: w.events }));
681
+ }
682
+ async deleteWebhook(webhookId) {
683
+ await this.del(`/webhooks/${webhookId}`);
684
+ }
685
+ // ── Public: Testnet ──────────────────────────────────────────────
686
+ async mint(amount) {
687
+ if (!this.#testnet) {
688
+ throw new PayError("mint is only available on testnet");
689
+ }
690
+ const micro = toMicro(amount);
691
+ const raw = await this.post("/mint", { amount: micro });
692
+ return { txHash: raw.tx_hash, amount: toDollars(raw.amount) };
323
693
  }
324
694
  }
325
- /** Normalize a private key to 0x-prefixed Hex. */
326
- function normalizeKey(key) {
327
- if (key.startsWith("0x"))
328
- return key;
329
- return ("0x" + key);
695
+ // ── x402 helpers ─────────────────────────────────────────────────────
696
+ function extract402(obj) {
697
+ const accepts = obj.accepts;
698
+ if (Array.isArray(accepts) && accepts.length > 0) {
699
+ const offer = accepts[0];
700
+ const extra = (offer.extra ?? {});
701
+ return {
702
+ settlement: String(extra.settlement ?? "direct"),
703
+ amount: Number(offer.amount ?? 0),
704
+ to: String(offer.payTo ?? ""),
705
+ accepted: offer,
706
+ };
707
+ }
708
+ return {
709
+ settlement: String(obj.settlement ?? "direct"),
710
+ amount: Number(obj.amount ?? 0),
711
+ to: String(obj.to ?? ""),
712
+ };
330
713
  }
331
714
  //# sourceMappingURL=wallet.js.map