@pay-skill/sdk 0.1.1
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 +154 -0
- package/dist/auth.d.ts +47 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +121 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +93 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +391 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +42 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/models.d.ts +69 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +2 -0
- package/dist/models.js.map +1 -0
- package/dist/ows-signer.d.ts +75 -0
- package/dist/ows-signer.d.ts.map +1 -0
- package/dist/ows-signer.js +130 -0
- package/dist/ows-signer.js.map +1 -0
- package/dist/signer.d.ts +46 -0
- package/dist/signer.d.ts.map +1 -0
- package/dist/signer.js +112 -0
- package/dist/signer.js.map +1 -0
- package/dist/wallet.d.ts +121 -0
- package/dist/wallet.d.ts.map +1 -0
- package/dist/wallet.js +328 -0
- package/dist/wallet.js.map +1 -0
- package/eslint.config.js +22 -0
- package/package.json +44 -0
- package/src/auth.ts +200 -0
- package/src/client.ts +644 -0
- package/src/eip3009.ts +79 -0
- package/src/errors.ts +48 -0
- package/src/index.ts +51 -0
- package/src/models.ts +77 -0
- package/src/ows-signer.ts +223 -0
- package/src/signer.ts +147 -0
- package/src/wallet.ts +445 -0
- package/tests/test_auth_rejection.ts +154 -0
- package/tests/test_crypto.ts +251 -0
- package/tests/test_e2e.ts +158 -0
- package/tests/test_errors.ts +36 -0
- package/tests/test_ows_integration.ts +92 -0
- package/tests/test_ows_signer.ts +365 -0
- package/tests/test_signer.ts +47 -0
- package/tests/test_validation.ts +66 -0
- package/tsconfig.json +19 -0
package/src/wallet.ts
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wallet — high-level write client for agents.
|
|
3
|
+
* Wraps PayClient with private key signing and balance tracking.
|
|
4
|
+
*
|
|
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.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { type Hex, type Address } from "viem";
|
|
10
|
+
import { privateKeyToAccount, type PrivateKeyAccount } from "viem/accounts";
|
|
11
|
+
import { buildAuthHeaders, type AuthConfig } from "./auth.js";
|
|
12
|
+
|
|
13
|
+
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;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface FundLinkOptions {
|
|
23
|
+
messages?: unknown[];
|
|
24
|
+
agentName?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PermitResult {
|
|
28
|
+
nonce: string;
|
|
29
|
+
deadline: number;
|
|
30
|
+
v: number;
|
|
31
|
+
r: string;
|
|
32
|
+
s: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Map well-known chain names to numeric IDs. */
|
|
36
|
+
const CHAIN_IDS: Record<string, number> = {
|
|
37
|
+
"base": 8453,
|
|
38
|
+
"base-sepolia": 84532,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export class Wallet {
|
|
42
|
+
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;
|
|
64
|
+
try {
|
|
65
|
+
this._basePath = new URL(options.apiUrl).pathname.replace(/\/+$/, "");
|
|
66
|
+
} catch {
|
|
67
|
+
this._basePath = "";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private get _authConfig(): AuthConfig {
|
|
72
|
+
return {
|
|
73
|
+
chainId: this._chainId,
|
|
74
|
+
routerAddress: this._routerAddress,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Build authenticated fetch headers for an API request. */
|
|
79
|
+
private async _authFetch(
|
|
80
|
+
path: string,
|
|
81
|
+
init: RequestInit = {}
|
|
82
|
+
): Promise<Response> {
|
|
83
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
84
|
+
// Sign only the path portion (no query string) — server verifies against uri.path().
|
|
85
|
+
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}`, {
|
|
94
|
+
...init,
|
|
95
|
+
headers: {
|
|
96
|
+
"Content-Type": "application/json",
|
|
97
|
+
...authHeaders,
|
|
98
|
+
...(init.headers as Record<string, string> | undefined),
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
return resp;
|
|
102
|
+
}
|
|
103
|
+
|
|
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;
|
|
113
|
+
}
|
|
114
|
+
|
|
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}`);
|
|
134
|
+
}
|
|
135
|
+
const prep = (await prepResp.json()) as {
|
|
136
|
+
hash: string;
|
|
137
|
+
nonce: string;
|
|
138
|
+
deadline: number;
|
|
139
|
+
spender: string;
|
|
140
|
+
amount: number;
|
|
141
|
+
};
|
|
142
|
+
|
|
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}`,
|
|
146
|
+
});
|
|
147
|
+
|
|
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);
|
|
153
|
+
|
|
154
|
+
return { nonce: prep.nonce, deadline: prep.deadline, v, r, s };
|
|
155
|
+
}
|
|
156
|
+
|
|
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);
|
|
170
|
+
}
|
|
171
|
+
|
|
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}`);
|
|
184
|
+
}
|
|
185
|
+
return (await resp.json()) as { tx_hash: string; status: string };
|
|
186
|
+
}
|
|
187
|
+
|
|
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}`);
|
|
200
|
+
}
|
|
201
|
+
const data = (await resp.json()) as { url: string };
|
|
202
|
+
return data.url;
|
|
203
|
+
}
|
|
204
|
+
|
|
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
|
+
}),
|
|
213
|
+
});
|
|
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
|
+
}
|
|
221
|
+
|
|
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),
|
|
233
|
+
});
|
|
234
|
+
if (!resp.ok) throw new Error(`registerWebhook failed: ${resp.status}`);
|
|
235
|
+
return (await resp.json()) as { id: string };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Open a tab with a provider (positional or object form). */
|
|
239
|
+
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;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const microAmount = Math.round(amt * 1_000_000);
|
|
273
|
+
|
|
274
|
+
// Auto-sign permit if not provided
|
|
275
|
+
if (!permit) {
|
|
276
|
+
permit = await this.signPermit("tab", microAmount);
|
|
277
|
+
}
|
|
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),
|
|
317
|
+
});
|
|
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),
|
|
334
|
+
});
|
|
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,
|
|
359
|
+
};
|
|
360
|
+
return this._contractsCache;
|
|
361
|
+
}
|
|
362
|
+
|
|
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),
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
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 };
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** PrivateKeySigner — for manual EIP-712 signing in the playground. */
|
|
412
|
+
export class PrivateKeySigner {
|
|
413
|
+
private readonly _account: PrivateKeyAccount;
|
|
414
|
+
readonly address: string;
|
|
415
|
+
|
|
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
|
+
});
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
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;
|
|
445
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth rejection tests — proves that:
|
|
3
|
+
* 1. Requests without auth headers are rejected with 401
|
|
4
|
+
* 2. Requests with invalid/wrong signatures are rejected with 401
|
|
5
|
+
* 3. The SDK surfaces auth errors as PayServerError with correct statusCode
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "node:http";
|
|
11
|
+
import { once } from "node:events";
|
|
12
|
+
|
|
13
|
+
import { PayClient, PayServerError, CallbackSigner, RawKeySigner } from "../src/index.js";
|
|
14
|
+
import type { Hex, Address } from "viem";
|
|
15
|
+
|
|
16
|
+
const ANVIL_PK =
|
|
17
|
+
"0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex;
|
|
18
|
+
const WRONG_PK =
|
|
19
|
+
"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d" as Hex;
|
|
20
|
+
|
|
21
|
+
const TEST_ROUTER = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as Address;
|
|
22
|
+
const TEST_CHAIN_ID = 8453;
|
|
23
|
+
const VALID_ADDR = "0x" + "a1".repeat(20);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal HTTP server that enforces X-Pay-* auth headers.
|
|
27
|
+
* Returns 401 if any required header is missing, 200 otherwise.
|
|
28
|
+
*/
|
|
29
|
+
function createAuthServer(): Server {
|
|
30
|
+
return createServer((req: IncomingMessage, res: ServerResponse) => {
|
|
31
|
+
const agent = req.headers["x-pay-agent"];
|
|
32
|
+
const sig = req.headers["x-pay-signature"];
|
|
33
|
+
const ts = req.headers["x-pay-timestamp"];
|
|
34
|
+
const nonce = req.headers["x-pay-nonce"];
|
|
35
|
+
|
|
36
|
+
if (!agent || !sig || !ts || !nonce) {
|
|
37
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
38
|
+
res.end(JSON.stringify({ error: "Missing auth headers" }));
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Check that sig looks like a real 65-byte hex signature
|
|
43
|
+
const sigStr = Array.isArray(sig) ? sig[0] : sig;
|
|
44
|
+
if (!sigStr.startsWith("0x") || sigStr.length !== 132) {
|
|
45
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
46
|
+
res.end(JSON.stringify({ error: "Invalid signature format" }));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check that sig is not all zeros (stub detection)
|
|
51
|
+
if (sigStr === "0x" + "0".repeat(130)) {
|
|
52
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
53
|
+
res.end(JSON.stringify({ error: "Stub signature rejected" }));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Auth passed — return mock data
|
|
58
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
59
|
+
res.end(
|
|
60
|
+
JSON.stringify({
|
|
61
|
+
address: agent,
|
|
62
|
+
balance: 100_000_000,
|
|
63
|
+
open_tabs: [],
|
|
64
|
+
})
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let server: Server;
|
|
70
|
+
let baseUrl: string;
|
|
71
|
+
|
|
72
|
+
describe("Auth rejection", () => {
|
|
73
|
+
beforeEach(async () => {
|
|
74
|
+
server = createAuthServer();
|
|
75
|
+
server.listen(0); // random port
|
|
76
|
+
await once(server, "listening");
|
|
77
|
+
const addr = server.address();
|
|
78
|
+
if (typeof addr === "object" && addr) {
|
|
79
|
+
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
afterEach(async () => {
|
|
84
|
+
server.close();
|
|
85
|
+
await once(server, "close");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rejects request without auth headers (no private key configured)", async () => {
|
|
89
|
+
// Client with no auth config — sends no X-Pay-* headers
|
|
90
|
+
const client = new PayClient({
|
|
91
|
+
apiUrl: baseUrl,
|
|
92
|
+
signer: new CallbackSigner((_h: Uint8Array) => new Uint8Array(65)),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
await assert.rejects(
|
|
96
|
+
() => client.getStatus(),
|
|
97
|
+
(err: unknown) => {
|
|
98
|
+
assert.ok(err instanceof PayServerError);
|
|
99
|
+
assert.equal(err.statusCode, 401);
|
|
100
|
+
assert.ok(err.message.includes("Missing auth headers"));
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("rejects request with stub signer (all-zero signature)", async () => {
|
|
107
|
+
// Client with a stub signer that returns zeros — server should reject
|
|
108
|
+
const client = new PayClient({
|
|
109
|
+
apiUrl: baseUrl,
|
|
110
|
+
signer: new CallbackSigner((_h: Uint8Array) => new Uint8Array(65)),
|
|
111
|
+
chainId: TEST_CHAIN_ID,
|
|
112
|
+
routerAddress: TEST_ROUTER,
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await assert.rejects(
|
|
116
|
+
() => client.getStatus(),
|
|
117
|
+
(err: unknown) => {
|
|
118
|
+
assert.ok(err instanceof PayServerError);
|
|
119
|
+
assert.equal(err.statusCode, 401);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("accepts request with valid auth headers (real signing)", async () => {
|
|
126
|
+
const client = new PayClient({
|
|
127
|
+
apiUrl: baseUrl,
|
|
128
|
+
privateKey: ANVIL_PK,
|
|
129
|
+
chainId: TEST_CHAIN_ID,
|
|
130
|
+
routerAddress: TEST_ROUTER,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Should NOT throw — server accepts valid auth
|
|
134
|
+
const status = await client.getStatus();
|
|
135
|
+
assert.ok(status.balance >= 0);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("PayServerError has statusCode 401 for auth failures", async () => {
|
|
139
|
+
// Directly verify error structure
|
|
140
|
+
const client = new PayClient({
|
|
141
|
+
apiUrl: baseUrl,
|
|
142
|
+
signer: new CallbackSigner((_h: Uint8Array) => new Uint8Array(65)),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
await client.getStatus();
|
|
147
|
+
assert.fail("Should have thrown PayServerError");
|
|
148
|
+
} catch (err) {
|
|
149
|
+
assert.ok(err instanceof PayServerError, "must be PayServerError");
|
|
150
|
+
assert.equal(err.statusCode, 401, "statusCode must be 401");
|
|
151
|
+
assert.equal(err.code, "server_error");
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
});
|