@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.
Files changed (60) hide show
  1. package/README.md +143 -154
  2. package/dist/auth.d.ts +11 -6
  3. package/dist/auth.d.ts.map +1 -1
  4. package/dist/auth.js +19 -7
  5. package/dist/auth.js.map +1 -1
  6. package/dist/errors.d.ts +4 -2
  7. package/dist/errors.d.ts.map +1 -1
  8. package/dist/errors.js +8 -3
  9. package/dist/errors.js.map +1 -1
  10. package/dist/index.d.ts +2 -13
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -6
  13. package/dist/index.js.map +1 -1
  14. package/dist/keychain.d.ts +8 -0
  15. package/dist/keychain.d.ts.map +1 -0
  16. package/dist/keychain.js +17 -0
  17. package/dist/keychain.js.map +1 -0
  18. package/dist/wallet.d.ts +135 -104
  19. package/dist/wallet.d.ts.map +1 -1
  20. package/dist/wallet.js +631 -276
  21. package/dist/wallet.js.map +1 -1
  22. package/jsr.json +13 -13
  23. package/knip.json +5 -5
  24. package/package.json +51 -48
  25. package/src/auth.ts +210 -200
  26. package/src/eip3009.ts +79 -79
  27. package/src/errors.ts +55 -48
  28. package/src/index.ts +24 -51
  29. package/src/keychain.ts +18 -0
  30. package/src/wallet.ts +1111 -445
  31. package/tests/test_auth_rejection.ts +102 -154
  32. package/tests/test_crypto.ts +138 -251
  33. package/tests/test_e2e.ts +99 -158
  34. package/tests/test_errors.ts +44 -36
  35. package/tests/test_ows.ts +153 -0
  36. package/tests/test_wallet.ts +194 -0
  37. package/dist/client.d.ts +0 -94
  38. package/dist/client.d.ts.map +0 -1
  39. package/dist/client.js +0 -443
  40. package/dist/client.js.map +0 -1
  41. package/dist/models.d.ts +0 -78
  42. package/dist/models.d.ts.map +0 -1
  43. package/dist/models.js +0 -2
  44. package/dist/models.js.map +0 -1
  45. package/dist/ows-signer.d.ts +0 -75
  46. package/dist/ows-signer.d.ts.map +0 -1
  47. package/dist/ows-signer.js +0 -130
  48. package/dist/ows-signer.js.map +0 -1
  49. package/dist/signer.d.ts +0 -46
  50. package/dist/signer.d.ts.map +0 -1
  51. package/dist/signer.js +0 -111
  52. package/dist/signer.js.map +0 -1
  53. package/src/client.ts +0 -644
  54. package/src/models.ts +0 -77
  55. package/src/ows-signer.ts +0 -223
  56. package/src/signer.ts +0 -147
  57. package/tests/test_ows_integration.ts +0 -92
  58. package/tests/test_ows_signer.ts +0 -365
  59. package/tests/test_signer.ts +0 -47
  60. package/tests/test_validation.ts +0 -66
package/tests/test_e2e.ts CHANGED
@@ -1,158 +1,99 @@
1
- /**
2
- * E2E acceptance tests — run against live testnet.
3
- *
4
- * Skip unless PAYSKILL_TESTNET_KEY is set. These hit the real testnet server
5
- * and exercise the full SDK → server round-trip with REAL authentication.
6
- *
7
- * Usage:
8
- * PAYSKILL_TESTNET_KEY=0xdead... \
9
- * PAYSKILL_TESTNET_URL=http://204.168.133.111:3001/api/v1 \
10
- * node --import tsx --test tests/test_e2e.ts
11
- */
12
-
13
- import { describe, it, before } from "node:test";
14
- import assert from "node:assert/strict";
15
- import { randomUUID } from "node:crypto";
16
-
17
- import { PayClient, PayValidationError, PayServerError, buildAuthHeaders } from "../src/index.js";
18
- import type { WebhookRegistration, AuthHeaders } from "../src/index.js";
19
- import type { Hex } from "viem";
20
-
21
- const TESTNET_URL =
22
- process.env.PAYSKILL_TESTNET_URL ?? "http://204.168.133.111:3001/api/v1";
23
- const TESTNET_KEY = process.env.PAYSKILL_TESTNET_KEY ?? "";
24
-
25
- // Testnet contract addresses (Base Sepolia)
26
- const CHAIN_ID = 84532;
27
- const ROUTER_ADDRESS = "0xE0Aa45e6937F3b9Fc0BEe457361885Cb9bfC067F";
28
-
29
- const skip = !TESTNET_KEY;
30
-
31
- function makeClient(): PayClient {
32
- return new PayClient({
33
- apiUrl: TESTNET_URL,
34
- privateKey: TESTNET_KEY,
35
- chainId: CHAIN_ID,
36
- routerAddress: ROUTER_ADDRESS,
37
- });
38
- }
39
-
40
- // ── Auth Verification ──────────────────────────────────────────────
41
-
42
- describe("E2E: Auth works with real signing", { skip }, () => {
43
- let client: PayClient;
44
-
45
- before(() => {
46
- client = makeClient();
47
- });
48
-
49
- it("status endpoint returns valid response with real auth", async () => {
50
- const status = await client.getStatus();
51
- assert.ok(typeof status.address === "string");
52
- assert.ok(status.address.startsWith("0x"));
53
- assert.ok(typeof status.balance === "number");
54
- assert.ok(status.balance >= 0);
55
- assert.ok(Array.isArray(status.openTabs));
56
- });
57
-
58
- it("rejects request without auth headers (raw fetch)", async () => {
59
- const resp = await fetch(`${TESTNET_URL}/status`);
60
- assert.equal(resp.status, 400, "should reject unauthenticated request");
61
- const body = (await resp.json()) as { error: string };
62
- assert.equal(body.error, "auth_missing");
63
- });
64
- });
65
-
66
- // ── Mint (Testnet Faucet) ──────────────────────────────────────────
67
-
68
- describe("E2E: Mint testnet USDC", { skip }, () => {
69
- let client: PayClient;
70
-
71
- before(() => {
72
- client = makeClient();
73
- });
74
-
75
- it("mints $10 USDC to the authenticated wallet", async () => {
76
- const headers = await buildAuthHeaders(
77
- TESTNET_KEY as Hex,
78
- "POST",
79
- "/api/v1/mint",
80
- { chainId: CHAIN_ID, routerAddress: ROUTER_ADDRESS }
81
- );
82
- const resp = await fetch(`${TESTNET_URL}/mint`, {
83
- method: "POST",
84
- headers: { "Content-Type": "application/json", ...headers },
85
- body: JSON.stringify({ amount: 10_000_000 }),
86
- });
87
- const bodyText = await resp.text();
88
- assert.equal(resp.status, 200, `mint failed: ${bodyText}`);
89
- const body = JSON.parse(bodyText) as { tx_hash: string; amount: number; to: string };
90
- assert.ok(body.tx_hash, "should return a tx_hash");
91
- assert.equal(body.amount, 10_000_000);
92
- assert.ok(body.to.startsWith("0x"));
93
- });
94
- });
95
-
96
- // ── Webhook CRUD ───────────────────────────────────────────────────
97
-
98
- describe("E2E: Webhook CRUD with real auth", { skip }, () => {
99
- let client: PayClient;
100
- let whId = "";
101
-
102
- before(() => {
103
- client = makeClient();
104
- });
105
-
106
- it("registers a webhook", async () => {
107
- const slug = randomUUID().slice(0, 8);
108
- const wh = await client.registerWebhook(
109
- `https://example.com/hook/${slug}`,
110
- {
111
- events: ["payment.completed"],
112
- secret: `whsec_test_${slug}`,
113
- }
114
- );
115
- assert.ok(wh.webhookId);
116
- assert.ok(wh.url.startsWith("https://"));
117
- assert.ok(wh.events.includes("payment.completed"));
118
- whId = wh.webhookId;
119
- });
120
-
121
- it("lists webhooks including the new one", async () => {
122
- const webhooks = await client.listWebhooks();
123
- assert.ok(Array.isArray(webhooks));
124
- const ids = webhooks.map((w: WebhookRegistration) => w.webhookId);
125
- assert.ok(ids.includes(whId));
126
- });
127
-
128
- it("deletes the webhook", async () => {
129
- await client.deleteWebhook(whId);
130
- const webhooks = await client.listWebhooks();
131
- const ids = webhooks.map((w: WebhookRegistration) => w.webhookId);
132
- assert.ok(!ids.includes(whId));
133
- });
134
- });
135
-
136
- // ── Client-side validation still works ─────────────────────────────
137
-
138
- describe("E2E: Client validation", { skip }, () => {
139
- let client: PayClient;
140
-
141
- before(() => {
142
- client = makeClient();
143
- });
144
-
145
- it("rejects invalid address", async () => {
146
- await assert.rejects(
147
- () => client.payDirect("not-an-address", 1_000_000),
148
- (err: unknown) => err instanceof PayValidationError
149
- );
150
- });
151
-
152
- it("rejects amount below minimum", async () => {
153
- await assert.rejects(
154
- () => client.payDirect("0x" + "a1".repeat(20), 500_000),
155
- (err: unknown) => err instanceof PayValidationError
156
- );
157
- });
158
- });
1
+ /**
2
+ * E2E acceptance tests — run against live testnet.
3
+ *
4
+ * Skip unless PAYSKILL_TESTNET_KEY is set.
5
+ *
6
+ * Usage:
7
+ * PAYSKILL_TESTNET_KEY=0xdead... node --import tsx --test tests/test_e2e.ts
8
+ */
9
+
10
+ import { describe, it, before } from "node:test";
11
+ import assert from "node:assert/strict";
12
+ import { randomUUID } from "node:crypto";
13
+ import { Wallet, PayValidationError } from "../src/index.js";
14
+
15
+ const TESTNET_KEY = process.env.PAYSKILL_TESTNET_KEY ?? "";
16
+ const skip = !TESTNET_KEY;
17
+
18
+ function makeWallet(): Wallet {
19
+ return new Wallet({ privateKey: TESTNET_KEY, testnet: true });
20
+ }
21
+
22
+ describe("E2E: Status + Balance", { skip }, () => {
23
+ let wallet: Wallet;
24
+ before(() => { wallet = makeWallet(); });
25
+
26
+ it("status returns valid response", async () => {
27
+ const status = await wallet.status();
28
+ assert.ok(status.address.startsWith("0x"));
29
+ assert.ok(status.balance.total >= 0);
30
+ assert.ok(typeof status.openTabs === "number");
31
+ });
32
+
33
+ it("balance returns total/locked/available", async () => {
34
+ const bal = await wallet.balance();
35
+ assert.ok(typeof bal.total === "number");
36
+ assert.ok(typeof bal.locked === "number");
37
+ assert.ok(typeof bal.available === "number");
38
+ assert.ok(bal.available <= bal.total);
39
+ });
40
+ });
41
+
42
+ describe("E2E: Mint testnet USDC", { skip }, () => {
43
+ let wallet: Wallet;
44
+ before(() => { wallet = makeWallet(); });
45
+
46
+ it("mints $10 USDC", async () => {
47
+ const result = await wallet.mint(10);
48
+ assert.ok(result.txHash);
49
+ assert.equal(result.amount, 10);
50
+ });
51
+ });
52
+
53
+ describe("E2E: Webhook CRUD", { skip }, () => {
54
+ let wallet: Wallet;
55
+ let hookId = "";
56
+ before(() => { wallet = makeWallet(); });
57
+
58
+ it("registers a webhook", async () => {
59
+ const slug = randomUUID().slice(0, 8);
60
+ const wh = await wallet.registerWebhook(
61
+ `https://example.com/hook/${slug}`,
62
+ ["payment.completed"],
63
+ `whsec_test_${slug}`,
64
+ );
65
+ assert.ok(wh.id);
66
+ assert.ok(wh.url.startsWith("https://"));
67
+ hookId = wh.id;
68
+ });
69
+
70
+ it("lists webhooks", async () => {
71
+ const hooks = await wallet.listWebhooks();
72
+ assert.ok(hooks.some((h) => h.id === hookId));
73
+ });
74
+
75
+ it("deletes the webhook", async () => {
76
+ await wallet.deleteWebhook(hookId);
77
+ const hooks = await wallet.listWebhooks();
78
+ assert.ok(!hooks.some((h) => h.id === hookId));
79
+ });
80
+ });
81
+
82
+ describe("E2E: Validation still works", { skip }, () => {
83
+ let wallet: Wallet;
84
+ before(() => { wallet = makeWallet(); });
85
+
86
+ it("rejects invalid address", async () => {
87
+ await assert.rejects(
88
+ () => wallet.send("not-an-address", 5),
89
+ PayValidationError,
90
+ );
91
+ });
92
+
93
+ it("rejects amount below minimum", async () => {
94
+ await assert.rejects(
95
+ () => wallet.send("0x" + "a1".repeat(20), 0.5),
96
+ PayValidationError,
97
+ );
98
+ });
99
+ });
@@ -1,36 +1,44 @@
1
- import { describe, it } from "node:test";
2
- import assert from "node:assert/strict";
3
- import {
4
- PayError,
5
- PayValidationError,
6
- PayNetworkError,
7
- PayServerError,
8
- PayInsufficientFundsError,
9
- } from "../src/errors.js";
10
-
11
- describe("error hierarchy", () => {
12
- it("all errors extend PayError", () => {
13
- assert.ok(new PayValidationError("x") instanceof PayError);
14
- assert.ok(new PayNetworkError("x") instanceof PayError);
15
- assert.ok(new PayServerError("x", 400) instanceof PayError);
16
- assert.ok(new PayInsufficientFundsError() instanceof PayError);
17
- });
18
-
19
- it("PayValidationError has field and code", () => {
20
- const err = new PayValidationError("bad input", "amount");
21
- assert.equal(err.code, "validation_error");
22
- assert.equal(err.field, "amount");
23
- assert.equal(err.message, "bad input");
24
- });
25
-
26
- it("PayServerError has statusCode", () => {
27
- const err = new PayServerError("not found", 404);
28
- assert.equal(err.statusCode, 404);
29
- assert.equal(err.code, "server_error");
30
- });
31
-
32
- it("PayNetworkError has code", () => {
33
- const err = new PayNetworkError("connection refused");
34
- assert.equal(err.code, "network_error");
35
- });
36
- });
1
+ import { describe, it } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ PayError,
5
+ PayValidationError,
6
+ PayNetworkError,
7
+ PayServerError,
8
+ PayInsufficientFundsError,
9
+ } from "../src/errors.js";
10
+
11
+ describe("error hierarchy", () => {
12
+ it("all errors extend PayError", () => {
13
+ assert.ok(new PayValidationError("x") instanceof PayError);
14
+ assert.ok(new PayNetworkError("x") instanceof PayError);
15
+ assert.ok(new PayServerError("x", 400) instanceof PayError);
16
+ assert.ok(new PayInsufficientFundsError("x") instanceof PayError);
17
+ });
18
+
19
+ it("PayValidationError has field and code", () => {
20
+ const err = new PayValidationError("bad input", "amount");
21
+ assert.equal(err.code, "validation_error");
22
+ assert.equal(err.field, "amount");
23
+ assert.equal(err.message, "bad input");
24
+ });
25
+
26
+ it("PayServerError has statusCode", () => {
27
+ const err = new PayServerError("not found", 404);
28
+ assert.equal(err.statusCode, 404);
29
+ assert.equal(err.code, "server_error");
30
+ });
31
+
32
+ it("PayNetworkError has code", () => {
33
+ const err = new PayNetworkError("connection refused");
34
+ assert.equal(err.code, "network_error");
35
+ });
36
+
37
+ it("PayInsufficientFundsError includes fund link hint", () => {
38
+ const err = new PayInsufficientFundsError("low balance", 5, 10);
39
+ assert.ok(err.message.includes("createFundLink"));
40
+ assert.equal(err.balance, 5);
41
+ assert.equal(err.required, 10);
42
+ assert.equal(err.code, "insufficient_funds");
43
+ });
44
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * OWS (Open Wallet Standard) integration tests.
3
+ * Uses a mock OWS module — no real @open-wallet-standard/core needed.
4
+ */
5
+
6
+ import { describe, it } from "node:test";
7
+ import assert from "node:assert/strict";
8
+ import { Wallet, PayError } from "../src/index.js";
9
+
10
+ // ── Mock OWS module ──────────────────────────────────────────────────
11
+
12
+ interface SignCall {
13
+ wallet: string;
14
+ chain: string;
15
+ json: string;
16
+ passphrase?: string;
17
+ }
18
+
19
+ function createMockOws(options?: {
20
+ accounts?: Array<{
21
+ chainId: string;
22
+ address: string;
23
+ derivationPath: string;
24
+ }>;
25
+ signature?: string;
26
+ recoveryId?: number;
27
+ }) {
28
+ const calls: SignCall[] = [];
29
+ const accounts = options?.accounts ?? [
30
+ {
31
+ chainId: "eip155:8453",
32
+ address: "0x1234567890abcdef1234567890abcdef12345678",
33
+ derivationPath: "m/44'/60'/0'/0/0",
34
+ },
35
+ ];
36
+
37
+ // Default: 65-byte hex sig (r+s+v)
38
+ const sig = options?.signature ?? "ab".repeat(64) + "1b";
39
+
40
+ return {
41
+ calls,
42
+ getWallet(nameOrId: string) {
43
+ return {
44
+ id: `id-${nameOrId}`,
45
+ name: nameOrId,
46
+ accounts,
47
+ createdAt: "2026-04-01T00:00:00Z",
48
+ };
49
+ },
50
+ signTypedData(
51
+ wallet: string,
52
+ chain: string,
53
+ typedDataJson: string,
54
+ passphrase?: string,
55
+ ) {
56
+ calls.push({ wallet, chain, json: typedDataJson, passphrase });
57
+ return {
58
+ signature: sig,
59
+ recoveryId: options?.recoveryId,
60
+ };
61
+ },
62
+ };
63
+ }
64
+
65
+ // ── Construction ──────────────────────────────────────────────────────
66
+
67
+ describe("Wallet.fromOws construction", () => {
68
+ it("creates wallet with correct address from mock OWS", async () => {
69
+ const ows = createMockOws();
70
+ const wallet = await Wallet.fromOws({
71
+ walletId: "test-agent",
72
+ _owsModule: ows,
73
+ });
74
+ assert.equal(
75
+ wallet.address,
76
+ "0x1234567890abcdef1234567890abcdef12345678",
77
+ );
78
+ });
79
+
80
+ it("finds evm chain account", async () => {
81
+ const ows = createMockOws({
82
+ accounts: [
83
+ {
84
+ chainId: "evm",
85
+ address: "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
86
+ derivationPath: "m/44'/60'/0'/0/0",
87
+ },
88
+ ],
89
+ });
90
+ const wallet = await Wallet.fromOws({
91
+ walletId: "test",
92
+ _owsModule: ows,
93
+ });
94
+ assert.equal(
95
+ wallet.address,
96
+ "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
97
+ );
98
+ });
99
+
100
+ it("throws when no EVM account found", async () => {
101
+ const ows = createMockOws({
102
+ accounts: [
103
+ {
104
+ chainId: "solana",
105
+ address: "SoLaNa...",
106
+ derivationPath: "m/44'/501'/0'",
107
+ },
108
+ ],
109
+ });
110
+ await assert.rejects(
111
+ () => Wallet.fromOws({ walletId: "test", _owsModule: ows }),
112
+ PayError,
113
+ );
114
+ });
115
+
116
+ it("throws when OWS module not installed (no mock)", async () => {
117
+ await assert.rejects(
118
+ () =>
119
+ Wallet.fromOws({
120
+ walletId: "test",
121
+ // _owsModule not provided -> tries dynamic import, fails
122
+ }),
123
+ PayError,
124
+ );
125
+ });
126
+
127
+ it("passes owsApiKey to signTypedData", async () => {
128
+ const ows = createMockOws();
129
+ const wallet = await Wallet.fromOws({
130
+ walletId: "agent-1",
131
+ owsApiKey: "secret-key",
132
+ _owsModule: ows,
133
+ });
134
+ // Trigger a signing call (will fail on network but the mock captures the call)
135
+ // We can't easily trigger signing without a server, so just verify construction
136
+ assert.equal(wallet.address, "0x1234567890abcdef1234567890abcdef12345678");
137
+ });
138
+ });
139
+
140
+ // ── Serialization safety ──────────────────────────────────────────────
141
+
142
+ describe("Wallet.fromOws does not leak keys", () => {
143
+ it("JSON.stringify does not expose private fields", async () => {
144
+ const ows = createMockOws();
145
+ const wallet = await Wallet.fromOws({
146
+ walletId: "secret-agent",
147
+ _owsModule: ows,
148
+ });
149
+ const json = JSON.stringify(wallet);
150
+ assert.ok(!json.includes("secret-agent"));
151
+ assert.ok(!json.includes("signTypedData"));
152
+ });
153
+ });
@@ -0,0 +1,194 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import type { Hex } from "viem";
4
+ import { Wallet, PayError, PayValidationError } from "../src/index.js";
5
+
6
+ // Anvil account #0 — well-known test key
7
+ const ANVIL_PK =
8
+ "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
9
+ const ANVIL_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266";
10
+ const VALID_ADDR = "0x" + "a1".repeat(20);
11
+
12
+ // Save/restore env for tests that modify it
13
+ let savedEnv: Record<string, string | undefined>;
14
+
15
+ function saveEnv() {
16
+ savedEnv = {
17
+ PAYSKILL_KEY: process.env.PAYSKILL_KEY,
18
+ PAYSKILL_TESTNET: process.env.PAYSKILL_TESTNET,
19
+ PAYSKILL_API_URL: process.env.PAYSKILL_API_URL,
20
+ };
21
+ }
22
+
23
+ function restoreEnv() {
24
+ for (const [k, v] of Object.entries(savedEnv)) {
25
+ if (v === undefined) delete process.env[k];
26
+ else process.env[k] = v;
27
+ }
28
+ }
29
+
30
+ // ── Construction ──────────────────────────────────────────────────────
31
+
32
+ describe("Wallet construction", () => {
33
+ beforeEach(saveEnv);
34
+ afterEach(restoreEnv);
35
+
36
+ it("constructs with explicit private key", () => {
37
+ const wallet = new Wallet({ privateKey: ANVIL_PK });
38
+ assert.equal(wallet.address, ANVIL_ADDRESS);
39
+ });
40
+
41
+ it("constructs without 0x prefix", () => {
42
+ const wallet = new Wallet({ privateKey: ANVIL_PK.slice(2) });
43
+ assert.equal(wallet.address, ANVIL_ADDRESS);
44
+ });
45
+
46
+ it("constructs from PAYSKILL_KEY env var", () => {
47
+ process.env.PAYSKILL_KEY = ANVIL_PK;
48
+ const wallet = new Wallet();
49
+ assert.equal(wallet.address, ANVIL_ADDRESS);
50
+ });
51
+
52
+ it("throws without key", () => {
53
+ delete process.env.PAYSKILL_KEY;
54
+ assert.throws(() => new Wallet(), PayError);
55
+ });
56
+
57
+ it("throws on invalid key (too short)", () => {
58
+ assert.throws(
59
+ () => new Wallet({ privateKey: "0xdead" }),
60
+ PayValidationError,
61
+ );
62
+ });
63
+
64
+ it("throws on invalid key (non-hex)", () => {
65
+ assert.throws(
66
+ () => new Wallet({ privateKey: "0x" + "zz".repeat(32) }),
67
+ PayValidationError,
68
+ );
69
+ });
70
+
71
+ it("Wallet.fromEnv reads PAYSKILL_KEY", () => {
72
+ process.env.PAYSKILL_KEY = ANVIL_PK;
73
+ const wallet = Wallet.fromEnv();
74
+ assert.equal(wallet.address, ANVIL_ADDRESS);
75
+ });
76
+
77
+ it("Wallet.fromEnv throws without env var", () => {
78
+ delete process.env.PAYSKILL_KEY;
79
+ assert.throws(() => Wallet.fromEnv(), PayError);
80
+ });
81
+
82
+ it("Wallet.create falls back to env var when keychain unavailable", async () => {
83
+ process.env.PAYSKILL_KEY = ANVIL_PK;
84
+ const wallet = await Wallet.create();
85
+ assert.equal(wallet.address, ANVIL_ADDRESS);
86
+ });
87
+
88
+ it("respects testnet option", () => {
89
+ process.env.PAYSKILL_KEY = ANVIL_PK;
90
+ // Just verify it doesn't throw — testnet flag affects API URL only
91
+ const wallet = new Wallet({ testnet: true });
92
+ assert.equal(wallet.address, ANVIL_ADDRESS);
93
+ });
94
+
95
+ it("respects PAYSKILL_TESTNET env var", () => {
96
+ process.env.PAYSKILL_KEY = ANVIL_PK;
97
+ process.env.PAYSKILL_TESTNET = "1";
98
+ const wallet = new Wallet();
99
+ assert.equal(wallet.address, ANVIL_ADDRESS);
100
+ });
101
+ });
102
+
103
+ // ── Validation ──────────────────────────────────────────────────────
104
+
105
+ describe("Wallet input validation", () => {
106
+ let wallet: Wallet;
107
+
108
+ beforeEach(() => {
109
+ wallet = new Wallet({ privateKey: ANVIL_PK });
110
+ });
111
+
112
+ it("send rejects invalid address", async () => {
113
+ await assert.rejects(
114
+ () => wallet.send("not-an-address", 5),
115
+ PayValidationError,
116
+ );
117
+ });
118
+
119
+ it("send rejects amount below $1 minimum", async () => {
120
+ await assert.rejects(
121
+ () => wallet.send(VALID_ADDR, 0.5),
122
+ PayValidationError,
123
+ );
124
+ });
125
+
126
+ it("send rejects negative amount", async () => {
127
+ await assert.rejects(
128
+ () => wallet.send(VALID_ADDR, -5),
129
+ PayValidationError,
130
+ );
131
+ });
132
+
133
+ it("send rejects NaN", async () => {
134
+ await assert.rejects(
135
+ () => wallet.send(VALID_ADDR, NaN),
136
+ PayValidationError,
137
+ );
138
+ });
139
+
140
+ it("send rejects Infinity", async () => {
141
+ await assert.rejects(
142
+ () => wallet.send(VALID_ADDR, Infinity),
143
+ PayValidationError,
144
+ );
145
+ });
146
+
147
+ it("openTab rejects amount below $5 minimum", async () => {
148
+ await assert.rejects(
149
+ () => wallet.openTab(VALID_ADDR, 3, 1),
150
+ PayValidationError,
151
+ );
152
+ });
153
+
154
+ it("openTab rejects zero maxChargePerCall", async () => {
155
+ await assert.rejects(
156
+ () => wallet.openTab(VALID_ADDR, 10, 0),
157
+ PayValidationError,
158
+ );
159
+ });
160
+
161
+ it("openTab rejects invalid provider address", async () => {
162
+ await assert.rejects(
163
+ () => wallet.openTab("bad-addr", 10, 1),
164
+ PayValidationError,
165
+ );
166
+ });
167
+
168
+ it("mint rejects on mainnet", async () => {
169
+ await assert.rejects(() => wallet.mint(10), PayError);
170
+ });
171
+
172
+ it("micro amount conversion works", async () => {
173
+ // $5 micro amount should not fail validation for tab (need network for full test)
174
+ await assert.rejects(
175
+ () => wallet.openTab(VALID_ADDR, { micro: 5_000_000 }, { micro: 100_000 }),
176
+ // Will fail on network (can't reach server), not validation
177
+ (err: Error) => !(err instanceof PayValidationError),
178
+ );
179
+ });
180
+
181
+ it("micro amount rejects negative", async () => {
182
+ await assert.rejects(
183
+ () => wallet.send(VALID_ADDR, { micro: -1 }),
184
+ PayValidationError,
185
+ );
186
+ });
187
+
188
+ it("micro amount rejects non-integer", async () => {
189
+ await assert.rejects(
190
+ () => wallet.send(VALID_ADDR, { micro: 1.5 }),
191
+ PayValidationError,
192
+ );
193
+ });
194
+ });