@wopr-network/platform-core 1.42.3 → 1.44.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 (63) hide show
  1. package/.github/workflows/key-server-image.yml +35 -0
  2. package/Dockerfile.key-server +20 -0
  3. package/GATEWAY_BILLING_RESEARCH.md +430 -0
  4. package/biome.json +2 -9
  5. package/dist/billing/crypto/__tests__/key-server.test.js +240 -0
  6. package/dist/billing/crypto/btc/watcher.d.ts +2 -0
  7. package/dist/billing/crypto/btc/watcher.js +1 -1
  8. package/dist/billing/crypto/charge-store.d.ts +7 -1
  9. package/dist/billing/crypto/charge-store.js +7 -1
  10. package/dist/billing/crypto/client.d.ts +68 -30
  11. package/dist/billing/crypto/client.js +63 -46
  12. package/dist/billing/crypto/client.test.js +66 -83
  13. package/dist/billing/crypto/index.d.ts +8 -8
  14. package/dist/billing/crypto/index.js +4 -5
  15. package/dist/billing/crypto/key-server-entry.js +84 -0
  16. package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
  17. package/dist/billing/crypto/key-server-webhook.js +73 -0
  18. package/dist/billing/crypto/key-server.d.ts +20 -0
  19. package/dist/billing/crypto/key-server.js +263 -0
  20. package/dist/billing/crypto/watcher-service.d.ts +33 -0
  21. package/dist/billing/crypto/watcher-service.js +295 -0
  22. package/dist/billing/index.js +1 -1
  23. package/dist/db/schema/crypto.d.ts +464 -2
  24. package/dist/db/schema/crypto.js +60 -6
  25. package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
  26. package/dist/monetization/crypto/index.d.ts +4 -4
  27. package/dist/monetization/crypto/index.js +2 -2
  28. package/dist/monetization/crypto/webhook.d.ts +13 -14
  29. package/dist/monetization/crypto/webhook.js +12 -83
  30. package/dist/monetization/index.d.ts +2 -2
  31. package/dist/monetization/index.js +1 -1
  32. package/drizzle/migrations/0014_crypto_key_server.sql +60 -0
  33. package/drizzle/migrations/0015_callback_url.sql +32 -0
  34. package/drizzle/migrations/meta/_journal.json +28 -0
  35. package/package.json +2 -1
  36. package/src/billing/crypto/__tests__/key-server.test.ts +262 -0
  37. package/src/billing/crypto/btc/watcher.ts +3 -1
  38. package/src/billing/crypto/charge-store.ts +13 -1
  39. package/src/billing/crypto/client.test.ts +70 -98
  40. package/src/billing/crypto/client.ts +118 -59
  41. package/src/billing/crypto/index.ts +19 -14
  42. package/src/billing/crypto/key-server-entry.ts +96 -0
  43. package/src/billing/crypto/key-server-webhook.ts +119 -0
  44. package/src/billing/crypto/key-server.ts +343 -0
  45. package/src/billing/crypto/watcher-service.ts +381 -0
  46. package/src/billing/index.ts +1 -1
  47. package/src/db/schema/crypto.ts +75 -6
  48. package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
  49. package/src/monetization/crypto/index.ts +9 -11
  50. package/src/monetization/crypto/webhook.ts +25 -99
  51. package/src/monetization/index.ts +3 -7
  52. package/dist/billing/crypto/checkout.d.ts +0 -18
  53. package/dist/billing/crypto/checkout.js +0 -35
  54. package/dist/billing/crypto/checkout.test.js +0 -71
  55. package/dist/billing/crypto/webhook.d.ts +0 -34
  56. package/dist/billing/crypto/webhook.js +0 -107
  57. package/dist/billing/crypto/webhook.test.js +0 -266
  58. package/src/billing/crypto/checkout.test.ts +0 -93
  59. package/src/billing/crypto/checkout.ts +0 -48
  60. package/src/billing/crypto/webhook.test.ts +0 -340
  61. package/src/billing/crypto/webhook.ts +0 -136
  62. /package/dist/billing/crypto/{checkout.test.d.ts → __tests__/key-server.test.d.ts} +0 -0
  63. /package/dist/billing/crypto/{webhook.test.d.ts → key-server-entry.d.ts} +0 -0
@@ -0,0 +1,262 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { ICryptoChargeRepository } from "../charge-store.js";
3
+ import type { KeyServerDeps } from "../key-server.js";
4
+ import { createKeyServerApp } from "../key-server.js";
5
+ import type { IPaymentMethodStore } from "../payment-method-store.js";
6
+
7
+ /** Create a mock db that supports transaction() by passing itself to the callback. */
8
+ function createMockDb() {
9
+ const mockMethod = {
10
+ id: "btc",
11
+ type: "native",
12
+ token: "BTC",
13
+ chain: "bitcoin",
14
+ xpub: "xpub6CUGRUonZSQ4TWtTMmzXdrXDtypWKiKrhko4egpiMZbpiaQL2jkwSB1icqYh2cfDfVxdx4df189oLKnC5fSwqPfgyP3hooxujYzAu3fDVmz",
15
+ nextIndex: 1,
16
+ decimals: 8,
17
+ confirmations: 6,
18
+ };
19
+
20
+ const db = {
21
+ update: vi.fn().mockReturnValue({
22
+ set: vi.fn().mockReturnValue({
23
+ where: vi.fn().mockReturnValue({
24
+ returning: vi.fn().mockResolvedValue([mockMethod]),
25
+ }),
26
+ }),
27
+ }),
28
+ insert: vi.fn().mockReturnValue({
29
+ values: vi.fn().mockReturnValue({
30
+ onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }),
31
+ }),
32
+ }),
33
+ select: vi.fn().mockReturnValue({
34
+ from: vi.fn().mockReturnValue({
35
+ where: vi.fn().mockResolvedValue([]),
36
+ }),
37
+ }),
38
+ // transaction() passes itself as tx — mocks work the same way
39
+ transaction: vi.fn().mockImplementation(async (fn: (tx: unknown) => unknown) => fn(db)),
40
+ };
41
+ return db;
42
+ }
43
+
44
+ /** Minimal mock deps for key server tests. */
45
+ function mockDeps(): KeyServerDeps & {
46
+ chargeStore: { [K in keyof ICryptoChargeRepository]: ReturnType<typeof vi.fn> };
47
+ methodStore: { [K in keyof IPaymentMethodStore]: ReturnType<typeof vi.fn> };
48
+ } {
49
+ const chargeStore = {
50
+ getByReferenceId: vi.fn().mockResolvedValue({
51
+ referenceId: "btc:bc1q...",
52
+ status: "New",
53
+ depositAddress: "bc1q...",
54
+ chain: "bitcoin",
55
+ token: "BTC",
56
+ amountUsdCents: 5000,
57
+ creditedAt: null,
58
+ }),
59
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
60
+ create: vi.fn(),
61
+ updateStatus: vi.fn(),
62
+ markCredited: vi.fn(),
63
+ isCredited: vi.fn(),
64
+ getByDepositAddress: vi.fn(),
65
+ getNextDerivationIndex: vi.fn(),
66
+ listActiveDepositAddresses: vi.fn(),
67
+ };
68
+ const methodStore = {
69
+ listEnabled: vi.fn().mockResolvedValue([
70
+ {
71
+ id: "btc",
72
+ token: "BTC",
73
+ chain: "bitcoin",
74
+ decimals: 8,
75
+ displayName: "Bitcoin",
76
+ contractAddress: null,
77
+ confirmations: 6,
78
+ },
79
+ {
80
+ id: "base-usdc",
81
+ token: "USDC",
82
+ chain: "base",
83
+ decimals: 6,
84
+ displayName: "USDC on Base",
85
+ contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
86
+ confirmations: 12,
87
+ },
88
+ ]),
89
+ listAll: vi.fn(),
90
+ getById: vi.fn().mockResolvedValue({
91
+ id: "btc",
92
+ type: "native",
93
+ token: "BTC",
94
+ chain: "bitcoin",
95
+ decimals: 8,
96
+ displayName: "Bitcoin",
97
+ contractAddress: null,
98
+ confirmations: 6,
99
+ oracleAddress: "0x64c911996D3c6aC71f9b455B1E8E7266BcbD848F",
100
+ xpub: null,
101
+ displayOrder: 0,
102
+ enabled: true,
103
+ rpcUrl: null,
104
+ }),
105
+ listByType: vi.fn(),
106
+ upsert: vi.fn().mockResolvedValue(undefined),
107
+ setEnabled: vi.fn().mockResolvedValue(undefined),
108
+ };
109
+ return {
110
+ db: createMockDb() as never,
111
+ chargeStore: chargeStore as never,
112
+ methodStore: methodStore as never,
113
+ oracle: { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000, updatedAt: new Date() }) } as never,
114
+ };
115
+ }
116
+
117
+ describe("key-server routes", () => {
118
+ it("GET /chains returns enabled payment methods", async () => {
119
+ const app = createKeyServerApp(mockDeps());
120
+ const res = await app.request("/chains");
121
+ expect(res.status).toBe(200);
122
+ const body = await res.json();
123
+ expect(body).toHaveLength(2);
124
+ expect(body[0].token).toBe("BTC");
125
+ expect(body[1].token).toBe("USDC");
126
+ });
127
+
128
+ it("POST /address requires chain", async () => {
129
+ const app = createKeyServerApp(mockDeps());
130
+ const res = await app.request("/address", {
131
+ method: "POST",
132
+ headers: { "Content-Type": "application/json" },
133
+ body: JSON.stringify({}),
134
+ });
135
+ expect(res.status).toBe(400);
136
+ });
137
+
138
+ it("POST /address derives BTC address", async () => {
139
+ const app = createKeyServerApp(mockDeps());
140
+ const res = await app.request("/address", {
141
+ method: "POST",
142
+ headers: { "Content-Type": "application/json" },
143
+ body: JSON.stringify({ chain: "btc" }),
144
+ });
145
+ expect(res.status).toBe(201);
146
+ const body = await res.json();
147
+ expect(body.address).toMatch(/^bc1q/);
148
+ expect(body.index).toBe(0);
149
+ expect(body.chain).toBe("bitcoin");
150
+ expect(body.token).toBe("BTC");
151
+ });
152
+
153
+ it("GET /charges/:id returns charge status", async () => {
154
+ const app = createKeyServerApp(mockDeps());
155
+ const res = await app.request("/charges/btc:bc1q...");
156
+ expect(res.status).toBe(200);
157
+ const body = await res.json();
158
+ expect(body.chargeId).toBe("btc:bc1q...");
159
+ expect(body.status).toBe("New");
160
+ });
161
+
162
+ it("GET /charges/:id returns 404 for missing charge", async () => {
163
+ const deps = mockDeps();
164
+ (deps.chargeStore.getByReferenceId as ReturnType<typeof vi.fn>).mockResolvedValue(null);
165
+ const app = createKeyServerApp(deps);
166
+ const res = await app.request("/charges/nonexistent");
167
+ expect(res.status).toBe(404);
168
+ });
169
+
170
+ it("POST /charges validates amountUsd", async () => {
171
+ const app = createKeyServerApp(mockDeps());
172
+ const res = await app.request("/charges", {
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" },
175
+ body: JSON.stringify({ chain: "btc", amountUsd: -10 }),
176
+ });
177
+ expect(res.status).toBe(400);
178
+ });
179
+
180
+ it("POST /charges creates a charge", async () => {
181
+ const app = createKeyServerApp(mockDeps());
182
+ const res = await app.request("/charges", {
183
+ method: "POST",
184
+ headers: { "Content-Type": "application/json" },
185
+ body: JSON.stringify({ chain: "btc", amountUsd: 50 }),
186
+ });
187
+ expect(res.status).toBe(201);
188
+ const body = await res.json();
189
+ expect(body.address).toMatch(/^bc1q/);
190
+ expect(body.amountUsd).toBe(50);
191
+ expect(body.expiresAt).toBeTruthy();
192
+ });
193
+
194
+ it("GET /admin/next-path returns available path", async () => {
195
+ const deps = mockDeps();
196
+ deps.adminToken = "test-admin";
197
+ const app = createKeyServerApp(deps);
198
+ const res = await app.request("/admin/next-path?coin_type=0", {
199
+ headers: { Authorization: "Bearer test-admin" },
200
+ });
201
+ expect(res.status).toBe(200);
202
+ const body = await res.json();
203
+ expect(body.path).toBe("m/44'/0'/0'");
204
+ expect(body.status).toBe("available");
205
+ });
206
+
207
+ it("DELETE /admin/chains/:id disables chain", async () => {
208
+ const deps = mockDeps();
209
+ deps.adminToken = "test-admin";
210
+ const app = createKeyServerApp(deps);
211
+ const res = await app.request("/admin/chains/doge", {
212
+ method: "DELETE",
213
+ headers: { Authorization: "Bearer test-admin" },
214
+ });
215
+ expect(res.status).toBe(204);
216
+ expect(deps.methodStore.setEnabled).toHaveBeenCalledWith("doge", false);
217
+ });
218
+ });
219
+
220
+ describe("key-server auth", () => {
221
+ it("rejects unauthenticated request when serviceKey is set", async () => {
222
+ const deps = mockDeps();
223
+ deps.serviceKey = "sk-test-secret";
224
+ const app = createKeyServerApp(deps);
225
+ const res = await app.request("/address", {
226
+ method: "POST",
227
+ headers: { "Content-Type": "application/json" },
228
+ body: JSON.stringify({ chain: "btc" }),
229
+ });
230
+ expect(res.status).toBe(401);
231
+ });
232
+
233
+ it("allows authenticated request with correct serviceKey", async () => {
234
+ const deps = mockDeps();
235
+ deps.serviceKey = "sk-test-secret";
236
+ const app = createKeyServerApp(deps);
237
+ const res = await app.request("/address", {
238
+ method: "POST",
239
+ headers: { "Content-Type": "application/json", Authorization: "Bearer sk-test-secret" },
240
+ body: JSON.stringify({ chain: "btc" }),
241
+ });
242
+ expect(res.status).toBe(201);
243
+ });
244
+
245
+ it("rejects admin route without adminToken", async () => {
246
+ const deps = mockDeps();
247
+ // no adminToken set — admin routes disabled
248
+ const app = createKeyServerApp(deps);
249
+ const res = await app.request("/admin/next-path?coin_type=0");
250
+ expect(res.status).toBe(403);
251
+ });
252
+
253
+ it("allows admin route with correct adminToken", async () => {
254
+ const deps = mockDeps();
255
+ deps.adminToken = "admin-secret";
256
+ const app = createKeyServerApp(deps);
257
+ const res = await app.request("/admin/next-path?coin_type=0", {
258
+ headers: { Authorization: "Bearer admin-secret" },
259
+ });
260
+ expect(res.status).toBe(200);
261
+ });
262
+ });
@@ -14,6 +14,8 @@ export interface BtcWatcherOpts {
14
14
  oracle: IPriceOracle;
15
15
  /** Required — BTC has no block cursor, so txid dedup must be persisted. */
16
16
  cursorStore: IWatcherCursorStore;
17
+ /** Override chain identity for cursor namespace (default: config.network). Prevents txid collisions across BTC/LTC/DOGE. */
18
+ chainId?: string;
17
19
  }
18
20
 
19
21
  interface ReceivedByAddress {
@@ -39,7 +41,7 @@ export class BtcWatcher {
39
41
  this.minConfirmations = opts.config.confirmations;
40
42
  this.oracle = opts.oracle;
41
43
  this.cursorStore = opts.cursorStore;
42
- this.watcherId = `btc:${opts.config.network}`;
44
+ this.watcherId = `btc:${opts.chainId ?? opts.config.network}`;
43
45
  }
44
46
 
45
47
  /** Update the set of watched addresses. */
@@ -17,6 +17,9 @@ export interface CryptoChargeRecord {
17
17
  token: string | null;
18
18
  depositAddress: string | null;
19
19
  derivationIndex: number | null;
20
+ callbackUrl: string | null;
21
+ expectedAmount: string | null;
22
+ receivedAmount: string | null;
20
23
  }
21
24
 
22
25
  export interface CryptoDepositChargeInput {
@@ -27,6 +30,9 @@ export interface CryptoDepositChargeInput {
27
30
  token: string;
28
31
  depositAddress: string;
29
32
  derivationIndex: number;
33
+ callbackUrl?: string;
34
+ /** Expected crypto amount in native base units (sats for BTC, base units for ERC20). */
35
+ expectedAmount?: string;
30
36
  }
31
37
 
32
38
  export interface ICryptoChargeRepository {
@@ -50,7 +56,7 @@ export interface ICryptoChargeRepository {
50
56
  /**
51
57
  * Manages crypto charge records in PostgreSQL.
52
58
  *
53
- * Each charge maps a BTCPay invoice ID to a tenant and tracks
59
+ * Each charge maps a deposit address to a tenant and tracks
54
60
  * the payment lifecycle (New → Processing → Settled/Expired/Invalid).
55
61
  *
56
62
  * amountUsdCents stores the requested amount in USD cents (integer).
@@ -92,6 +98,9 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
92
98
  token: row.token ?? null,
93
99
  depositAddress: row.depositAddress ?? null,
94
100
  derivationIndex: row.derivationIndex ?? null,
101
+ callbackUrl: row.callbackUrl ?? null,
102
+ expectedAmount: row.expectedAmount ?? null,
103
+ receivedAmount: row.receivedAmount ?? null,
95
104
  };
96
105
  }
97
106
 
@@ -146,6 +155,9 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
146
155
  token: input.token,
147
156
  depositAddress: input.depositAddress.toLowerCase(),
148
157
  derivationIndex: input.derivationIndex,
158
+ callbackUrl: input.callbackUrl,
159
+ expectedAmount: input.expectedAmount,
160
+ receivedAmount: "0",
149
161
  });
150
162
  }
151
163
 
@@ -1,132 +1,104 @@
1
1
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
- import { BTCPayClient, loadCryptoConfig } from "./client.js";
3
-
4
- describe("BTCPayClient", () => {
5
- it("createInvoice sends correct request and returns id + checkoutLink", async () => {
6
- const mockResponse = { id: "inv-001", checkoutLink: "https://btcpay.example.com/i/inv-001" };
7
- const fetchSpy = vi
8
- .spyOn(globalThis, "fetch")
9
- .mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
10
-
11
- const client = new BTCPayClient({
12
- apiKey: "test-key",
13
- baseUrl: "https://btcpay.example.com",
14
- storeId: "store-abc",
15
- });
16
-
17
- const result = await client.createInvoice({
18
- amountUsd: 25,
19
- orderId: "order-123",
20
- buyerEmail: "test@example.com",
21
- });
2
+ import { CryptoServiceClient, loadCryptoConfig } from "./client.js";
22
3
 
23
- expect(result.id).toBe("inv-001");
24
- expect(result.checkoutLink).toBe("https://btcpay.example.com/i/inv-001");
4
+ describe("CryptoServiceClient", () => {
5
+ afterEach(() => vi.restoreAllMocks());
25
6
 
26
- expect(fetchSpy).toHaveBeenCalledOnce();
27
- const [url, opts] = fetchSpy.mock.calls[0];
28
- expect(url).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices");
29
- expect(opts?.method).toBe("POST");
7
+ it("deriveAddress sends POST /address with chain", async () => {
8
+ const mockResponse = { address: "bc1q...", index: 42, chain: "bitcoin", token: "BTC" };
9
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 201 }));
30
10
 
31
- const headers = opts?.headers as Record<string, string>;
32
- expect(headers.Authorization).toBe("token test-key");
33
- expect(headers["Content-Type"]).toBe("application/json");
11
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
12
+ const result = await client.deriveAddress("btc");
34
13
 
35
- const body = JSON.parse(opts?.body as string);
36
- expect(body.amount).toBe("25");
37
- expect(body.currency).toBe("USD");
38
- expect(body.metadata.orderId).toBe("order-123");
39
- expect(body.metadata.buyerEmail).toBe("test@example.com");
40
- expect(body.checkout.speedPolicy).toBe("MediumSpeed");
14
+ expect(result.address).toBe("bc1q...");
15
+ expect(result.index).toBe(42);
41
16
 
42
- fetchSpy.mockRestore();
17
+ const [url, opts] = vi.mocked(fetch).mock.calls[0];
18
+ expect(url).toBe("http://localhost:3100/address");
19
+ expect(opts?.method).toBe("POST");
20
+ expect(JSON.parse(opts?.body as string)).toEqual({ chain: "btc" });
43
21
  });
44
22
 
45
- it("createInvoice includes redirectURL when provided", async () => {
46
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
47
- new Response(JSON.stringify({ id: "inv-002", checkoutLink: "https://btcpay.example.com/i/inv-002" }), {
48
- status: 200,
49
- }),
50
- );
51
-
52
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "s" });
53
- await client.createInvoice({ amountUsd: 10, orderId: "o", redirectURL: "https://app.example.com/success" });
23
+ it("createCharge sends POST /charges", async () => {
24
+ const mockResponse = {
25
+ chargeId: "btc:bc1q...",
26
+ address: "bc1q...",
27
+ chain: "btc",
28
+ token: "BTC",
29
+ amountUsd: 50,
30
+ derivationIndex: 42,
31
+ expiresAt: "2026-03-20T04:00:00Z",
32
+ };
33
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 201 }));
34
+
35
+ const client = new CryptoServiceClient({
36
+ baseUrl: "http://localhost:3100",
37
+ serviceKey: "sk-test",
38
+ tenantId: "tenant-1",
39
+ });
40
+ const result = await client.createCharge({ chain: "btc", amountUsd: 50 });
54
41
 
55
- const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
56
- expect(body.checkout.redirectURL).toBe("https://app.example.com/success");
42
+ expect(result.chargeId).toBe("btc:bc1q...");
43
+ expect(result.address).toBe("bc1q...");
57
44
 
58
- fetchSpy.mockRestore();
45
+ const [, opts] = vi.mocked(fetch).mock.calls[0];
46
+ const headers = opts?.headers as Record<string, string>;
47
+ expect(headers.Authorization).toBe("Bearer sk-test");
48
+ expect(headers["X-Tenant-Id"]).toBe("tenant-1");
59
49
  });
60
50
 
61
- it("createInvoice throws on non-ok response", async () => {
62
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401 }));
51
+ it("getCharge sends GET /charges/:id", async () => {
52
+ const mockResponse = { chargeId: "btc:bc1q...", status: "confirmed" };
53
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
63
54
 
64
- const client = new BTCPayClient({ apiKey: "bad-key", baseUrl: "https://btcpay.example.com", storeId: "s" });
65
- await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow(
66
- "BTCPay createInvoice failed (401)",
67
- );
55
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
56
+ const result = await client.getCharge("btc:bc1q...");
68
57
 
69
- fetchSpy.mockRestore();
58
+ expect(result.status).toBe("confirmed");
59
+ expect(vi.mocked(fetch).mock.calls[0][0]).toBe("http://localhost:3100/charges/btc%3Abc1q...");
70
60
  });
71
61
 
72
- it("getInvoice sends correct request", async () => {
73
- const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
74
- new Response(JSON.stringify({ id: "inv-001", status: "Settled", amount: "25", currency: "USD" }), {
75
- status: 200,
76
- }),
77
- );
62
+ it("listChains sends GET /chains", async () => {
63
+ const mockResponse = [{ id: "btc", token: "BTC", chain: "bitcoin", decimals: 8 }];
64
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
78
65
 
79
- const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "store-abc" });
80
- const result = await client.getInvoice("inv-001");
66
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
67
+ const result = await client.listChains();
68
+
69
+ expect(result).toHaveLength(1);
70
+ expect(result[0].token).toBe("BTC");
71
+ });
81
72
 
82
- expect(result.status).toBe("Settled");
83
- expect(fetchSpy.mock.calls[0][0]).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices/inv-001");
73
+ it("throws on non-ok response", async () => {
74
+ vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Not found", { status: 404 }));
84
75
 
85
- fetchSpy.mockRestore();
76
+ const client = new CryptoServiceClient({ baseUrl: "http://localhost:3100" });
77
+ await expect(client.getCharge("missing")).rejects.toThrow("CryptoService getCharge failed (404)");
86
78
  });
87
79
  });
88
80
 
89
81
  describe("loadCryptoConfig", () => {
90
82
  beforeEach(() => {
91
- delete process.env.BTCPAY_API_KEY;
92
- delete process.env.BTCPAY_BASE_URL;
93
- delete process.env.BTCPAY_STORE_ID;
83
+ delete process.env.CRYPTO_SERVICE_URL;
84
+ delete process.env.CRYPTO_SERVICE_KEY;
85
+ delete process.env.TENANT_ID;
94
86
  });
95
87
 
96
- afterEach(() => {
97
- vi.unstubAllEnvs();
98
- });
99
-
100
- it("returns null when BTCPAY_API_KEY is missing", () => {
101
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
102
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
103
- expect(loadCryptoConfig()).toBeNull();
104
- });
105
-
106
- it("returns null when BTCPAY_BASE_URL is missing", () => {
107
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
108
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
109
- expect(loadCryptoConfig()).toBeNull();
110
- });
111
-
112
- it("returns null when BTCPAY_STORE_ID is missing", () => {
113
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
114
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
115
- expect(loadCryptoConfig()).toBeNull();
116
- });
88
+ afterEach(() => vi.unstubAllEnvs());
117
89
 
118
- it("returns config when all env vars are set", () => {
119
- vi.stubEnv("BTCPAY_API_KEY", "test-key");
120
- vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
121
- vi.stubEnv("BTCPAY_STORE_ID", "store-1");
90
+ it("returns config when CRYPTO_SERVICE_URL is set", () => {
91
+ vi.stubEnv("CRYPTO_SERVICE_URL", "http://10.120.0.5:3100");
92
+ vi.stubEnv("CRYPTO_SERVICE_KEY", "sk-test");
93
+ vi.stubEnv("TENANT_ID", "tenant-1");
122
94
  expect(loadCryptoConfig()).toEqual({
123
- apiKey: "test-key",
124
- baseUrl: "https://btcpay.test",
125
- storeId: "store-1",
95
+ baseUrl: "http://10.120.0.5:3100",
96
+ serviceKey: "sk-test",
97
+ tenantId: "tenant-1",
126
98
  });
127
99
  });
128
100
 
129
- it("returns null when all env vars are missing", () => {
101
+ it("returns null when CRYPTO_SERVICE_URL is missing", () => {
130
102
  expect(loadCryptoConfig()).toBeNull();
131
103
  });
132
104
  });