@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.
Files changed (53) hide show
  1. package/README.md +154 -0
  2. package/dist/auth.d.ts +47 -0
  3. package/dist/auth.d.ts.map +1 -0
  4. package/dist/auth.js +121 -0
  5. package/dist/auth.js.map +1 -0
  6. package/dist/client.d.ts +93 -0
  7. package/dist/client.d.ts.map +1 -0
  8. package/dist/client.js +391 -0
  9. package/dist/client.js.map +1 -0
  10. package/dist/errors.d.ts +24 -0
  11. package/dist/errors.d.ts.map +1 -0
  12. package/dist/errors.js +42 -0
  13. package/dist/errors.js.map +1 -0
  14. package/dist/index.d.ts +13 -0
  15. package/dist/index.d.ts.map +1 -0
  16. package/dist/index.js +7 -0
  17. package/dist/index.js.map +1 -0
  18. package/dist/models.d.ts +69 -0
  19. package/dist/models.d.ts.map +1 -0
  20. package/dist/models.js +2 -0
  21. package/dist/models.js.map +1 -0
  22. package/dist/ows-signer.d.ts +75 -0
  23. package/dist/ows-signer.d.ts.map +1 -0
  24. package/dist/ows-signer.js +130 -0
  25. package/dist/ows-signer.js.map +1 -0
  26. package/dist/signer.d.ts +46 -0
  27. package/dist/signer.d.ts.map +1 -0
  28. package/dist/signer.js +112 -0
  29. package/dist/signer.js.map +1 -0
  30. package/dist/wallet.d.ts +121 -0
  31. package/dist/wallet.d.ts.map +1 -0
  32. package/dist/wallet.js +328 -0
  33. package/dist/wallet.js.map +1 -0
  34. package/eslint.config.js +22 -0
  35. package/package.json +44 -0
  36. package/src/auth.ts +200 -0
  37. package/src/client.ts +644 -0
  38. package/src/eip3009.ts +79 -0
  39. package/src/errors.ts +48 -0
  40. package/src/index.ts +51 -0
  41. package/src/models.ts +77 -0
  42. package/src/ows-signer.ts +223 -0
  43. package/src/signer.ts +147 -0
  44. package/src/wallet.ts +445 -0
  45. package/tests/test_auth_rejection.ts +154 -0
  46. package/tests/test_crypto.ts +251 -0
  47. package/tests/test_e2e.ts +158 -0
  48. package/tests/test_errors.ts +36 -0
  49. package/tests/test_ows_integration.ts +92 -0
  50. package/tests/test_ows_signer.ts +365 -0
  51. package/tests/test_signer.ts +47 -0
  52. package/tests/test_validation.ts +66 -0
  53. package/tsconfig.json +19 -0
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Crypto round-trip tests — proves that:
3
+ * 1. Address derivation uses real secp256k1 (not FNV hash)
4
+ * 2. EIP-712 signing produces valid signatures that the server can recover
5
+ * 3. buildAuthHeaders produces valid auth headers
6
+ */
7
+
8
+ import { describe, it } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { privateKeyToAccount } from "viem/accounts";
11
+ import { recoverAddress, type Hex, type Address } from "viem";
12
+
13
+ import { PrivateKeySigner, Wallet } from "../src/wallet.js";
14
+ import { RawKeySigner } from "../src/signer.js";
15
+ import { buildAuthHeaders, computeEip712Hash } from "../src/auth.js";
16
+
17
+ // Anvil account #0 — well-known test key
18
+ const ANVIL_PK =
19
+ "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" as Hex;
20
+ const ANVIL_ADDRESS = "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" as Address;
21
+
22
+ const TEST_ROUTER = "0x5FbDB2315678afecb367f032d93F642f64180aa3" as Address;
23
+ const TEST_CHAIN_ID = 8453;
24
+
25
+ describe("Address derivation", () => {
26
+ it("derives correct address from Anvil #0 private key via Wallet", () => {
27
+ const wallet = new Wallet({
28
+ privateKey: ANVIL_PK,
29
+ chain: "8453",
30
+ apiUrl: "http://localhost:3000/api/v1",
31
+ routerAddress: TEST_ROUTER,
32
+ });
33
+ assert.equal(wallet.address, ANVIL_ADDRESS);
34
+ });
35
+
36
+ it("derives correct address via PrivateKeySigner", () => {
37
+ const signer = new PrivateKeySigner(ANVIL_PK);
38
+ assert.equal(signer.address, ANVIL_ADDRESS);
39
+ });
40
+
41
+ it("derives correct address via RawKeySigner", () => {
42
+ const signer = new RawKeySigner(ANVIL_PK);
43
+ assert.equal(signer.address, ANVIL_ADDRESS);
44
+ });
45
+
46
+ it("works without 0x prefix", () => {
47
+ const signer = new PrivateKeySigner(ANVIL_PK.slice(2));
48
+ assert.equal(signer.address, ANVIL_ADDRESS);
49
+ });
50
+ });
51
+
52
+ describe("EIP-712 signing round-trip", () => {
53
+ it("PrivateKeySigner produces recoverable EIP-712 signature", async () => {
54
+ const signer = new PrivateKeySigner(ANVIL_PK);
55
+
56
+ const domain = {
57
+ name: "pay",
58
+ version: "0.1",
59
+ chainId: BigInt(TEST_CHAIN_ID),
60
+ verifyingContract: TEST_ROUTER,
61
+ };
62
+ const types = {
63
+ APIRequest: [
64
+ { name: "method", type: "string" },
65
+ { name: "path", type: "string" },
66
+ { name: "timestamp", type: "uint256" },
67
+ { name: "nonce", type: "bytes32" },
68
+ ],
69
+ };
70
+ const nonce =
71
+ "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef";
72
+ const message = {
73
+ method: "POST",
74
+ path: "/api/v1/direct",
75
+ timestamp: BigInt(1741400000),
76
+ nonce,
77
+ };
78
+
79
+ const signature = await signer.signTypedData(domain, types, message);
80
+
81
+ assert.ok(signature.startsWith("0x"), "signature should be hex");
82
+ assert.equal(signature.length, 132, "signature should be 65 bytes hex");
83
+ assert.notEqual(
84
+ signature,
85
+ "0x" + "0".repeat(130),
86
+ "signature must not be zeros (stub)"
87
+ );
88
+
89
+ // Recover the signer address from the signature
90
+ const recovered = await recoverAddress({
91
+ hash: (await import("viem")).hashTypedData({
92
+ domain,
93
+ types,
94
+ primaryType: "APIRequest",
95
+ message,
96
+ }),
97
+ signature: signature as Hex,
98
+ });
99
+
100
+ assert.equal(
101
+ recovered.toLowerCase(),
102
+ ANVIL_ADDRESS.toLowerCase(),
103
+ "recovered address must match signer"
104
+ );
105
+ });
106
+ });
107
+
108
+ describe("buildAuthHeaders", () => {
109
+ it("produces valid auth headers with correct address", async () => {
110
+ const headers = await buildAuthHeaders(ANVIL_PK, "POST", "/api/v1/direct", {
111
+ chainId: TEST_CHAIN_ID,
112
+ routerAddress: TEST_ROUTER,
113
+ });
114
+
115
+ assert.equal(headers["X-Pay-Agent"], ANVIL_ADDRESS);
116
+ assert.ok(
117
+ headers["X-Pay-Signature"].startsWith("0x"),
118
+ "signature should be hex"
119
+ );
120
+ assert.equal(
121
+ headers["X-Pay-Signature"].length,
122
+ 132,
123
+ "signature should be 65 bytes"
124
+ );
125
+ assert.ok(
126
+ Number(headers["X-Pay-Timestamp"]) > 0,
127
+ "timestamp should be positive"
128
+ );
129
+ assert.ok(
130
+ headers["X-Pay-Nonce"].startsWith("0x"),
131
+ "nonce should be hex"
132
+ );
133
+ assert.equal(
134
+ headers["X-Pay-Nonce"].length,
135
+ 66,
136
+ "nonce should be 32 bytes hex"
137
+ );
138
+ });
139
+
140
+ it("signature recovers to the correct address", async () => {
141
+ const headers = await buildAuthHeaders(ANVIL_PK, "POST", "/api/v1/direct", {
142
+ chainId: TEST_CHAIN_ID,
143
+ routerAddress: TEST_ROUTER,
144
+ });
145
+
146
+ // Recompute the hash and recover
147
+ const { hashTypedData } = await import("viem");
148
+ const hash = hashTypedData({
149
+ domain: {
150
+ name: "pay",
151
+ version: "0.1",
152
+ chainId: TEST_CHAIN_ID,
153
+ verifyingContract: TEST_ROUTER,
154
+ },
155
+ types: {
156
+ APIRequest: [
157
+ { name: "method", type: "string" },
158
+ { name: "path", type: "string" },
159
+ { name: "timestamp", type: "uint256" },
160
+ { name: "nonce", type: "bytes32" },
161
+ ],
162
+ },
163
+ primaryType: "APIRequest",
164
+ message: {
165
+ method: "POST",
166
+ path: "/api/v1/direct",
167
+ timestamp: BigInt(headers["X-Pay-Timestamp"]),
168
+ nonce: headers["X-Pay-Nonce"] as Hex,
169
+ },
170
+ });
171
+
172
+ const recovered = await recoverAddress({
173
+ hash,
174
+ signature: headers["X-Pay-Signature"] as Hex,
175
+ });
176
+
177
+ assert.equal(
178
+ recovered.toLowerCase(),
179
+ ANVIL_ADDRESS.toLowerCase(),
180
+ "recovered address must match signer"
181
+ );
182
+ });
183
+ });
184
+
185
+ describe("computeEip712Hash", () => {
186
+ it("produces deterministic output", () => {
187
+ const nonce =
188
+ "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as Hex;
189
+ const h1 = computeEip712Hash(
190
+ "POST",
191
+ "/api/v1/direct",
192
+ BigInt(1741400000),
193
+ nonce,
194
+ TEST_CHAIN_ID,
195
+ TEST_ROUTER
196
+ );
197
+ const h2 = computeEip712Hash(
198
+ "POST",
199
+ "/api/v1/direct",
200
+ BigInt(1741400000),
201
+ nonce,
202
+ TEST_CHAIN_ID,
203
+ TEST_ROUTER
204
+ );
205
+ assert.deepEqual(h1, h2);
206
+ });
207
+
208
+ it("different methods produce different hashes", () => {
209
+ const nonce =
210
+ "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as Hex;
211
+ const h1 = computeEip712Hash(
212
+ "POST",
213
+ "/api/v1/direct",
214
+ BigInt(1741400000),
215
+ nonce,
216
+ TEST_CHAIN_ID,
217
+ TEST_ROUTER
218
+ );
219
+ const h2 = computeEip712Hash(
220
+ "GET",
221
+ "/api/v1/direct",
222
+ BigInt(1741400000),
223
+ nonce,
224
+ TEST_CHAIN_ID,
225
+ TEST_ROUTER
226
+ );
227
+ assert.notDeepEqual(h1, h2);
228
+ });
229
+
230
+ it("different chain IDs produce different hashes", () => {
231
+ const nonce =
232
+ "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef" as Hex;
233
+ const h1 = computeEip712Hash(
234
+ "POST",
235
+ "/api/v1/direct",
236
+ BigInt(1741400000),
237
+ nonce,
238
+ 8453,
239
+ TEST_ROUTER
240
+ );
241
+ const h2 = computeEip712Hash(
242
+ "POST",
243
+ "/api/v1/direct",
244
+ BigInt(1741400000),
245
+ nonce,
246
+ 84531,
247
+ TEST_ROUTER
248
+ );
249
+ assert.notDeepEqual(h1, h2);
250
+ });
251
+ });
@@ -0,0 +1,158 @@
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
+ });
@@ -0,0 +1,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() 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
+ });
@@ -0,0 +1,92 @@
1
+ /**
2
+ * OWS Signer integration test.
3
+ *
4
+ * Requires real @open-wallet-standard/core installed with native binaries.
5
+ * Auto-skips if OWS is not available — safe to run in CI without OWS.
6
+ *
7
+ * Tests:
8
+ * - Creating a real OWS wallet
9
+ * - Constructing OwsSigner from it
10
+ * - Signing EIP-712 Permit typed data
11
+ * - Verifying signature recovers to correct address (ecrecover round-trip)
12
+ */
13
+
14
+ import { describe, it } from "node:test";
15
+ import assert from "node:assert/strict";
16
+
17
+ // Check if OWS is available before running tests
18
+ let owsAvailable = false;
19
+ try {
20
+ const ows = await import("@open-wallet-standard/core");
21
+ ows.listWallets();
22
+ owsAvailable = true;
23
+ } catch {
24
+ // OWS not installed — tests will be skipped
25
+ }
26
+
27
+ describe("OwsSigner integration (real OWS)", { skip: !owsAvailable }, () => {
28
+ it("creates signer from real OWS wallet and signs EIP-712 data", async () => {
29
+ const { OwsSigner } = await import("../src/ows-signer.js");
30
+ const { verifyTypedData } = await import("viem");
31
+
32
+ // Import OWS module
33
+ const ows = await import("@open-wallet-standard/core");
34
+
35
+ // Create a test wallet (unique name to avoid collisions)
36
+ const walletName = `pay-test-${Date.now()}`;
37
+ const walletInfo = ows.createWallet(walletName);
38
+ const evmAccount = walletInfo.accounts.find(
39
+ (a: { chainId: string }) =>
40
+ a.chainId === "evm" || a.chainId.startsWith("eip155:"),
41
+ );
42
+ assert.ok(evmAccount, "OWS wallet must have an EVM account");
43
+
44
+ // Create OwsSigner from the real wallet
45
+ const signer = await OwsSigner.create({ walletId: walletName });
46
+ assert.equal(
47
+ signer.address.toLowerCase(),
48
+ evmAccount.address.toLowerCase(),
49
+ );
50
+
51
+ // Sign an EIP-712 Permit (the most common signing operation in Pay)
52
+ const domain = {
53
+ name: "USD Coin",
54
+ version: "2",
55
+ chainId: 84532,
56
+ verifyingContract:
57
+ "0x036CbD53842c5426634e7929541eC2318f3dCF7e" as `0x${string}`,
58
+ };
59
+ const types = {
60
+ Permit: [
61
+ { name: "owner", type: "address" },
62
+ { name: "spender", type: "address" },
63
+ { name: "value", type: "uint256" },
64
+ { name: "nonce", type: "uint256" },
65
+ { name: "deadline", type: "uint256" },
66
+ ],
67
+ };
68
+ const message = {
69
+ owner: signer.address,
70
+ spender: "0x0000000000000000000000000000000000000001",
71
+ value: "5000000",
72
+ nonce: "0",
73
+ deadline: "999999999999",
74
+ };
75
+
76
+ const signature = await signer.signTypedData(domain, types, message);
77
+
78
+ // Verify the signature recovers to the signer's address
79
+ assert.ok(signature.startsWith("0x"), "signature must be 0x-prefixed");
80
+ assert.equal(signature.length, 132, "signature must be 65 bytes (132 hex)");
81
+
82
+ const valid = await verifyTypedData({
83
+ address: signer.address as `0x${string}`,
84
+ domain,
85
+ types,
86
+ primaryType: "Permit",
87
+ message: message as Record<string, unknown>,
88
+ signature: signature as `0x${string}`,
89
+ });
90
+ assert.ok(valid, "ecrecover must recover signer address");
91
+ });
92
+ });