@wopr-network/platform-core 1.14.8 → 1.16.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 (154) hide show
  1. package/dist/account/deletion-executor-repository.d.ts +2 -2
  2. package/dist/account/deletion-executor-repository.js +5 -5
  3. package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
  4. package/dist/billing/crypto/charge-store.d.ts +68 -0
  5. package/dist/billing/crypto/charge-store.js +109 -0
  6. package/dist/billing/crypto/charge-store.test.js +120 -0
  7. package/dist/billing/crypto/checkout.d.ts +18 -0
  8. package/dist/billing/crypto/checkout.js +35 -0
  9. package/dist/billing/crypto/checkout.test.js +71 -0
  10. package/dist/billing/crypto/client.d.ts +39 -0
  11. package/dist/billing/crypto/client.js +72 -0
  12. package/dist/billing/crypto/client.test.js +100 -0
  13. package/dist/billing/crypto/evm/__tests__/address-gen.test.js +54 -0
  14. package/dist/billing/crypto/evm/__tests__/checkout.test.js +54 -0
  15. package/dist/billing/crypto/evm/__tests__/config.test.js +52 -0
  16. package/dist/billing/crypto/evm/__tests__/settler.test.js +196 -0
  17. package/dist/billing/crypto/evm/__tests__/watcher.test.js +109 -0
  18. package/dist/billing/crypto/evm/address-gen.d.ts +8 -0
  19. package/dist/billing/crypto/evm/address-gen.js +29 -0
  20. package/dist/billing/crypto/evm/checkout.d.ts +26 -0
  21. package/dist/billing/crypto/evm/checkout.js +57 -0
  22. package/dist/billing/crypto/evm/config.d.ts +13 -0
  23. package/dist/billing/crypto/evm/config.js +46 -0
  24. package/dist/billing/crypto/evm/index.d.ts +9 -0
  25. package/dist/billing/crypto/evm/index.js +5 -0
  26. package/dist/billing/crypto/evm/settler.d.ts +23 -0
  27. package/dist/billing/crypto/evm/settler.js +60 -0
  28. package/dist/billing/crypto/evm/types.d.ts +40 -0
  29. package/dist/billing/crypto/evm/types.js +1 -0
  30. package/dist/billing/crypto/evm/watcher.d.ts +31 -0
  31. package/dist/billing/crypto/evm/watcher.js +91 -0
  32. package/dist/billing/crypto/index.d.ts +10 -0
  33. package/dist/billing/crypto/index.js +6 -0
  34. package/dist/billing/crypto/types.d.ts +61 -0
  35. package/dist/billing/crypto/types.js +24 -0
  36. package/dist/billing/crypto/webhook.d.ts +34 -0
  37. package/dist/billing/crypto/webhook.js +107 -0
  38. package/dist/billing/crypto/webhook.test.d.ts +1 -0
  39. package/dist/billing/crypto/webhook.test.js +266 -0
  40. package/dist/billing/index.d.ts +1 -1
  41. package/dist/billing/index.js +2 -2
  42. package/dist/billing/payment-processor.d.ts +3 -3
  43. package/dist/credits/credit-ledger.d.ts +3 -3
  44. package/dist/credits/credit-ledger.js +3 -3
  45. package/dist/db/schema/credits.js +1 -1
  46. package/dist/db/schema/{payram.d.ts → crypto.d.ts} +85 -13
  47. package/dist/db/schema/crypto.js +32 -0
  48. package/dist/db/schema/index.d.ts +1 -1
  49. package/dist/db/schema/index.js +1 -1
  50. package/dist/monetization/crypto/__tests__/webhook.test.d.ts +1 -0
  51. package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
  52. package/dist/monetization/crypto/index.d.ts +4 -0
  53. package/dist/monetization/crypto/index.js +2 -0
  54. package/dist/monetization/crypto/webhook.d.ts +24 -0
  55. package/dist/monetization/crypto/webhook.js +88 -0
  56. package/dist/monetization/index.d.ts +3 -3
  57. package/dist/monetization/index.js +1 -1
  58. package/dist/monetization/repository-types.d.ts +1 -1
  59. package/dist/observability/pagerduty.test.js +1 -0
  60. package/docs/superpowers/plans/2026-03-14-stablecoin-phase1.md +1413 -0
  61. package/drizzle/migrations/0004_crypto_charges.sql +25 -0
  62. package/drizzle/migrations/0005_stablecoin_columns.sql +7 -0
  63. package/drizzle/migrations/meta/_journal.json +14 -0
  64. package/package.json +4 -3
  65. package/src/account/deletion-executor-repository.ts +6 -6
  66. package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
  67. package/src/billing/crypto/charge-store.test.ts +142 -0
  68. package/src/billing/crypto/charge-store.ts +166 -0
  69. package/src/billing/crypto/checkout.test.ts +93 -0
  70. package/src/billing/crypto/checkout.ts +48 -0
  71. package/src/billing/crypto/client.test.ts +132 -0
  72. package/src/billing/crypto/client.ts +86 -0
  73. package/src/billing/crypto/evm/__tests__/address-gen.test.ts +63 -0
  74. package/src/billing/crypto/evm/__tests__/checkout.test.ts +83 -0
  75. package/src/billing/crypto/evm/__tests__/config.test.ts +63 -0
  76. package/src/billing/crypto/evm/__tests__/settler.test.ts +218 -0
  77. package/src/billing/crypto/evm/__tests__/watcher.test.ts +128 -0
  78. package/src/billing/crypto/evm/address-gen.ts +29 -0
  79. package/src/billing/crypto/evm/checkout.ts +82 -0
  80. package/src/billing/crypto/evm/config.ts +50 -0
  81. package/src/billing/crypto/evm/index.ts +16 -0
  82. package/src/billing/crypto/evm/settler.ts +79 -0
  83. package/src/billing/crypto/evm/types.ts +45 -0
  84. package/src/billing/crypto/evm/watcher.ts +126 -0
  85. package/src/billing/crypto/index.ts +16 -0
  86. package/src/billing/crypto/types.ts +83 -0
  87. package/src/billing/crypto/webhook.test.ts +340 -0
  88. package/src/billing/crypto/webhook.ts +136 -0
  89. package/src/billing/index.ts +2 -2
  90. package/src/billing/payment-processor.ts +3 -3
  91. package/src/credits/credit-ledger.ts +3 -3
  92. package/src/db/schema/credits.ts +1 -1
  93. package/src/db/schema/crypto.ts +37 -0
  94. package/src/db/schema/index.ts +1 -1
  95. package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
  96. package/src/monetization/crypto/index.ts +23 -0
  97. package/src/monetization/crypto/webhook.ts +115 -0
  98. package/src/monetization/index.ts +23 -21
  99. package/src/monetization/repository-types.ts +2 -2
  100. package/src/observability/pagerduty.test.ts +1 -0
  101. package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
  102. package/dist/billing/payram/charge-store.d.ts +0 -41
  103. package/dist/billing/payram/charge-store.js +0 -72
  104. package/dist/billing/payram/charge-store.test.js +0 -64
  105. package/dist/billing/payram/checkout.d.ts +0 -15
  106. package/dist/billing/payram/checkout.js +0 -24
  107. package/dist/billing/payram/checkout.test.js +0 -74
  108. package/dist/billing/payram/client.d.ts +0 -7
  109. package/dist/billing/payram/client.js +0 -15
  110. package/dist/billing/payram/client.test.js +0 -52
  111. package/dist/billing/payram/index.d.ts +0 -8
  112. package/dist/billing/payram/index.js +0 -4
  113. package/dist/billing/payram/types.d.ts +0 -40
  114. package/dist/billing/payram/webhook.d.ts +0 -19
  115. package/dist/billing/payram/webhook.js +0 -71
  116. package/dist/billing/payram/webhook.test.d.ts +0 -7
  117. package/dist/billing/payram/webhook.test.js +0 -249
  118. package/dist/db/schema/payram.js +0 -21
  119. package/dist/monetization/payram/charge-store.test.js +0 -64
  120. package/dist/monetization/payram/checkout.test.js +0 -73
  121. package/dist/monetization/payram/client.test.js +0 -52
  122. package/dist/monetization/payram/index.d.ts +0 -4
  123. package/dist/monetization/payram/index.js +0 -2
  124. package/dist/monetization/payram/webhook.d.ts +0 -17
  125. package/dist/monetization/payram/webhook.js +0 -71
  126. package/dist/monetization/payram/webhook.test.d.ts +0 -7
  127. package/dist/monetization/payram/webhook.test.js +0 -247
  128. package/src/billing/payram/charge-store.test.ts +0 -84
  129. package/src/billing/payram/charge-store.ts +0 -109
  130. package/src/billing/payram/checkout.test.ts +0 -99
  131. package/src/billing/payram/checkout.ts +0 -40
  132. package/src/billing/payram/client.test.ts +0 -62
  133. package/src/billing/payram/client.ts +0 -21
  134. package/src/billing/payram/index.ts +0 -14
  135. package/src/billing/payram/types.ts +0 -44
  136. package/src/billing/payram/webhook.test.ts +0 -320
  137. package/src/billing/payram/webhook.ts +0 -94
  138. package/src/db/schema/payram.ts +0 -26
  139. package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
  140. package/src/monetization/payram/charge-store.test.ts +0 -84
  141. package/src/monetization/payram/checkout.test.ts +0 -98
  142. package/src/monetization/payram/client.test.ts +0 -62
  143. package/src/monetization/payram/index.ts +0 -20
  144. package/src/monetization/payram/webhook.test.ts +0 -327
  145. package/src/monetization/payram/webhook.ts +0 -97
  146. /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
  147. /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
  148. /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
  149. /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
  150. /package/dist/billing/{payram/types.js → crypto/evm/__tests__/address-gen.test.d.ts} +0 -0
  151. /package/dist/{monetization/payram → billing/crypto/evm/__tests__}/checkout.test.d.ts +0 -0
  152. /package/dist/{monetization/payram/cents-credits-boundary.test.d.ts → billing/crypto/evm/__tests__/config.test.d.ts} +0 -0
  153. /package/dist/{monetization/payram/charge-store.test.d.ts → billing/crypto/evm/__tests__/settler.test.d.ts} +0 -0
  154. /package/dist/{monetization/payram/client.test.d.ts → billing/crypto/evm/__tests__/watcher.test.d.ts} +0 -0
@@ -0,0 +1,132 @@
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
+ });
22
+
23
+ expect(result.id).toBe("inv-001");
24
+ expect(result.checkoutLink).toBe("https://btcpay.example.com/i/inv-001");
25
+
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");
30
+
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");
34
+
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");
41
+
42
+ fetchSpy.mockRestore();
43
+ });
44
+
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" });
54
+
55
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body as string);
56
+ expect(body.checkout.redirectURL).toBe("https://app.example.com/success");
57
+
58
+ fetchSpy.mockRestore();
59
+ });
60
+
61
+ it("createInvoice throws on non-ok response", async () => {
62
+ const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401 }));
63
+
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
+ );
68
+
69
+ fetchSpy.mockRestore();
70
+ });
71
+
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
+ );
78
+
79
+ const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "store-abc" });
80
+ const result = await client.getInvoice("inv-001");
81
+
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");
84
+
85
+ fetchSpy.mockRestore();
86
+ });
87
+ });
88
+
89
+ describe("loadCryptoConfig", () => {
90
+ beforeEach(() => {
91
+ delete process.env.BTCPAY_API_KEY;
92
+ delete process.env.BTCPAY_BASE_URL;
93
+ delete process.env.BTCPAY_STORE_ID;
94
+ });
95
+
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
+ });
117
+
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");
122
+ expect(loadCryptoConfig()).toEqual({
123
+ apiKey: "test-key",
124
+ baseUrl: "https://btcpay.test",
125
+ storeId: "store-1",
126
+ });
127
+ });
128
+
129
+ it("returns null when all env vars are missing", () => {
130
+ expect(loadCryptoConfig()).toBeNull();
131
+ });
132
+ });
@@ -0,0 +1,86 @@
1
+ import type { CryptoBillingConfig } from "./types.js";
2
+
3
+ export type { CryptoBillingConfig as CryptoConfig };
4
+
5
+ /**
6
+ * Lightweight BTCPay Server Greenfield API client.
7
+ *
8
+ * Uses plain fetch — zero vendor dependencies.
9
+ * Auth header format: "token <apiKey>" (NOT "Bearer").
10
+ */
11
+ export class BTCPayClient {
12
+ constructor(private readonly config: CryptoBillingConfig) {}
13
+
14
+ private headers(): Record<string, string> {
15
+ return {
16
+ "Content-Type": "application/json",
17
+ Authorization: `token ${this.config.apiKey}`,
18
+ };
19
+ }
20
+
21
+ /**
22
+ * Create an invoice on the BTCPay store.
23
+ *
24
+ * Returns the invoice ID and checkout link (URL to redirect the user).
25
+ */
26
+ async createInvoice(opts: {
27
+ amountUsd: number;
28
+ orderId: string;
29
+ buyerEmail?: string;
30
+ redirectURL?: string;
31
+ }): Promise<{ id: string; checkoutLink: string }> {
32
+ const url = `${this.config.baseUrl}/api/v1/stores/${this.config.storeId}/invoices`;
33
+ const body = {
34
+ amount: String(opts.amountUsd),
35
+ currency: "USD",
36
+ metadata: {
37
+ orderId: opts.orderId,
38
+ buyerEmail: opts.buyerEmail,
39
+ },
40
+ checkout: {
41
+ speedPolicy: "MediumSpeed",
42
+ expirationMinutes: 30,
43
+ ...(opts.redirectURL ? { redirectURL: opts.redirectURL } : {}),
44
+ },
45
+ };
46
+
47
+ const res = await fetch(url, {
48
+ method: "POST",
49
+ headers: this.headers(),
50
+ body: JSON.stringify(body),
51
+ });
52
+
53
+ if (!res.ok) {
54
+ const text = await res.text().catch(() => "");
55
+ throw new Error(`BTCPay createInvoice failed (${res.status}): ${text}`);
56
+ }
57
+
58
+ const data = (await res.json()) as { id: string; checkoutLink: string };
59
+ return { id: data.id, checkoutLink: data.checkoutLink };
60
+ }
61
+
62
+ /** Get invoice status by ID. */
63
+ async getInvoice(invoiceId: string): Promise<{ id: string; status: string; amount: string; currency: string }> {
64
+ const url = `${this.config.baseUrl}/api/v1/stores/${this.config.storeId}/invoices/${invoiceId}`;
65
+ const res = await fetch(url, { headers: this.headers() });
66
+
67
+ if (!res.ok) {
68
+ const text = await res.text().catch(() => "");
69
+ throw new Error(`BTCPay getInvoice failed (${res.status}): ${text}`);
70
+ }
71
+
72
+ return (await res.json()) as { id: string; status: string; amount: string; currency: string };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Load BTCPay config from environment variables.
78
+ * Returns null if any required var is missing.
79
+ */
80
+ export function loadCryptoConfig(): CryptoBillingConfig | null {
81
+ const apiKey = process.env.BTCPAY_API_KEY;
82
+ const baseUrl = process.env.BTCPAY_BASE_URL;
83
+ const storeId = process.env.BTCPAY_STORE_ID;
84
+ if (!apiKey || !baseUrl || !storeId) return null;
85
+ return { apiKey, baseUrl, storeId };
86
+ }
@@ -0,0 +1,63 @@
1
+ import { HDKey } from "@scure/bip32";
2
+ import { describe, expect, it } from "vitest";
3
+ import { deriveDepositAddress, isValidXpub } from "../address-gen.js";
4
+
5
+ // Generate a test xpub deterministically
6
+ function makeTestXpub(): string {
7
+ const seed = new Uint8Array(32);
8
+ seed[0] = 1; // deterministic seed
9
+ const master = HDKey.fromMasterSeed(seed);
10
+ // Derive to m/44'/60'/0' (Ethereum BIP-44 path)
11
+ const account = master.derive("m/44'/60'/0'");
12
+ return account.publicExtendedKey;
13
+ }
14
+
15
+ const TEST_XPUB = makeTestXpub();
16
+
17
+ describe("deriveDepositAddress", () => {
18
+ it("derives a valid Ethereum address", () => {
19
+ const addr = deriveDepositAddress(TEST_XPUB, 0);
20
+ expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
21
+ });
22
+
23
+ it("derives different addresses for different indices", () => {
24
+ const addr0 = deriveDepositAddress(TEST_XPUB, 0);
25
+ const addr1 = deriveDepositAddress(TEST_XPUB, 1);
26
+ expect(addr0).not.toBe(addr1);
27
+ });
28
+
29
+ it("is deterministic — same xpub + index = same address", () => {
30
+ const a = deriveDepositAddress(TEST_XPUB, 42);
31
+ const b = deriveDepositAddress(TEST_XPUB, 42);
32
+ expect(a).toBe(b);
33
+ });
34
+
35
+ it("returns checksummed address", () => {
36
+ const addr = deriveDepositAddress(TEST_XPUB, 0);
37
+ // Must be a valid 0x-prefixed address
38
+ expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
39
+ // viem's publicKeyToAddress always returns EIP-55 checksummed
40
+ // Verify it's not all-lowercase (checksummed addresses have mixed case)
41
+ const hexPart = addr.slice(2);
42
+ const hasUpperCase = hexPart !== hexPart.toLowerCase();
43
+ const hasLowerCase = hexPart !== hexPart.toUpperCase();
44
+ // At least one of these should be true for a checksummed address
45
+ // (unless the address happens to be all digits, which is extremely rare)
46
+ expect(hasUpperCase || !hexPart.match(/[a-f]/i)).toBe(true);
47
+ expect(hasLowerCase || !hexPart.match(/[a-f]/i)).toBe(true);
48
+ });
49
+ });
50
+
51
+ describe("isValidXpub", () => {
52
+ it("accepts valid xpub", () => {
53
+ expect(isValidXpub(TEST_XPUB)).toBe(true);
54
+ });
55
+
56
+ it("rejects garbage", () => {
57
+ expect(isValidXpub("not-an-xpub")).toBe(false);
58
+ });
59
+
60
+ it("rejects empty string", () => {
61
+ expect(isValidXpub("")).toBe(false);
62
+ });
63
+ });
@@ -0,0 +1,83 @@
1
+ import { HDKey } from "@scure/bip32";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { createStablecoinCheckout } from "../checkout.js";
4
+
5
+ function makeTestXpub(): string {
6
+ const seed = new Uint8Array(32);
7
+ seed[0] = 1;
8
+ const master = HDKey.fromMasterSeed(seed);
9
+ return master.derive("m/44'/60'/0'").publicExtendedKey;
10
+ }
11
+
12
+ const TEST_XPUB = makeTestXpub();
13
+
14
+ describe("createStablecoinCheckout", () => {
15
+ it("derives address and creates charge", async () => {
16
+ const mockChargeStore = {
17
+ getNextDerivationIndex: vi.fn().mockResolvedValue(42),
18
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
19
+ };
20
+
21
+ const result = await createStablecoinCheckout(
22
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
23
+ { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" },
24
+ );
25
+
26
+ expect(result.depositAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
27
+ expect(result.amountRaw).toBe("10000000"); // 10 USDC = 10 * 10^6
28
+ expect(result.chain).toBe("base");
29
+ expect(result.token).toBe("USDC");
30
+ expect(mockChargeStore.createStablecoinCharge).toHaveBeenCalledOnce();
31
+
32
+ // Verify charge was created with integer cents, not floating point
33
+ const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
34
+ expect(chargeInput.amountUsdCents).toBe(1000); // $10 = 1000 cents
35
+ expect(Number.isInteger(chargeInput.amountUsdCents)).toBe(true);
36
+ });
37
+
38
+ it("rejects below minimum", async () => {
39
+ const mockChargeStore = {
40
+ getNextDerivationIndex: vi.fn().mockResolvedValue(0),
41
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
42
+ };
43
+
44
+ await expect(
45
+ createStablecoinCheckout(
46
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
47
+ { tenant: "t1", amountUsd: 5, chain: "base", token: "USDC" },
48
+ ),
49
+ ).rejects.toThrow("Minimum");
50
+ });
51
+
52
+ it("stores deposit address in lowercase", async () => {
53
+ const mockChargeStore = {
54
+ getNextDerivationIndex: vi.fn().mockResolvedValue(0),
55
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
56
+ };
57
+
58
+ await createStablecoinCheckout(
59
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
60
+ { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" },
61
+ );
62
+
63
+ const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
64
+ expect(chargeInput.depositAddress).toBe(chargeInput.depositAddress.toLowerCase());
65
+ });
66
+
67
+ it("converts $25 correctly to raw USDC amount", async () => {
68
+ const mockChargeStore = {
69
+ getNextDerivationIndex: vi.fn().mockResolvedValue(0),
70
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
71
+ };
72
+
73
+ const result = await createStablecoinCheckout(
74
+ { chargeStore: mockChargeStore as never, xpub: TEST_XPUB },
75
+ { tenant: "t1", amountUsd: 25, chain: "base", token: "USDC" },
76
+ );
77
+
78
+ expect(result.amountRaw).toBe("25000000"); // 25 * 10^6
79
+
80
+ const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
81
+ expect(chargeInput.amountUsdCents).toBe(2500);
82
+ });
83
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "../config.js";
3
+
4
+ describe("getChainConfig", () => {
5
+ it("returns Base config", () => {
6
+ const cfg = getChainConfig("base");
7
+ expect(cfg.chainId).toBe(8453);
8
+ expect(cfg.confirmations).toBe(1);
9
+ expect(cfg.blockTimeMs).toBe(2000);
10
+ });
11
+
12
+ it("throws on unknown chain", () => {
13
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
14
+ expect(() => getChainConfig("solana" as any)).toThrow("Unsupported chain");
15
+ });
16
+ });
17
+
18
+ describe("getTokenConfig", () => {
19
+ it("returns USDC on Base", () => {
20
+ const cfg = getTokenConfig("USDC", "base");
21
+ expect(cfg.decimals).toBe(6);
22
+ expect(cfg.contractAddress).toMatch(/^0x/);
23
+ expect(cfg.contractAddress).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913");
24
+ });
25
+
26
+ it("throws on unsupported token/chain combo", () => {
27
+ // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
28
+ expect(() => getTokenConfig("USDC" as any, "ethereum" as any)).toThrow("Unsupported token");
29
+ });
30
+ });
31
+
32
+ describe("tokenAmountFromCents", () => {
33
+ it("converts 1000 cents ($10) to USDC raw amount (6 decimals)", () => {
34
+ expect(tokenAmountFromCents(1000, 6)).toBe(10_000_000n);
35
+ });
36
+
37
+ it("converts 100 cents ($1) to DAI raw amount (18 decimals)", () => {
38
+ expect(tokenAmountFromCents(100, 18)).toBe(1_000_000_000_000_000_000n);
39
+ });
40
+
41
+ it("converts 1 cent to USDC", () => {
42
+ expect(tokenAmountFromCents(1, 6)).toBe(10_000n);
43
+ });
44
+
45
+ it("rejects non-integer cents", () => {
46
+ expect(() => tokenAmountFromCents(10.5, 6)).toThrow("integer");
47
+ });
48
+ });
49
+
50
+ describe("centsFromTokenAmount", () => {
51
+ it("converts 10 USDC raw to 1000 cents", () => {
52
+ expect(centsFromTokenAmount(10_000_000n, 6)).toBe(1000);
53
+ });
54
+
55
+ it("converts 1 DAI raw to 100 cents", () => {
56
+ expect(centsFromTokenAmount(1_000_000_000_000_000_000n, 18)).toBe(100);
57
+ });
58
+
59
+ it("truncates fractional cents", () => {
60
+ // 0.005 USDC = 5000 raw units = 0.5 cents -> truncates to 0
61
+ expect(centsFromTokenAmount(5000n, 6)).toBe(0);
62
+ });
63
+ });
@@ -0,0 +1,218 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { settleEvmPayment } from "../settler.js";
3
+ import type { EvmPaymentEvent } from "../types.js";
4
+
5
+ const mockEvent: EvmPaymentEvent = {
6
+ chain: "base",
7
+ token: "USDC",
8
+ from: "0xsender",
9
+ to: "0xdeposit",
10
+ rawAmount: "10000000", // 10 USDC
11
+ amountUsdCents: 1000,
12
+ txHash: "0xtx123",
13
+ blockNumber: 100,
14
+ logIndex: 0,
15
+ };
16
+
17
+ describe("settleEvmPayment", () => {
18
+ it("credits ledger when charge found and not yet credited", async () => {
19
+ const deps = {
20
+ chargeStore: {
21
+ getByDepositAddress: vi.fn().mockResolvedValue({
22
+ referenceId: "sc:base:usdc:abc",
23
+ tenantId: "tenant-1",
24
+ amountUsdCents: 1000,
25
+ status: "New",
26
+ creditedAt: null,
27
+ }),
28
+ updateStatus: vi.fn().mockResolvedValue(undefined),
29
+ markCredited: vi.fn().mockResolvedValue(undefined),
30
+ },
31
+ creditLedger: {
32
+ hasReferenceId: vi.fn().mockResolvedValue(false),
33
+ credit: vi.fn().mockResolvedValue({}),
34
+ },
35
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
36
+ };
37
+
38
+ const result = await settleEvmPayment(deps as never, mockEvent);
39
+
40
+ expect(result.handled).toBe(true);
41
+ expect(result.creditedCents).toBe(1000);
42
+ expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
43
+ expect(deps.chargeStore.markCredited).toHaveBeenCalledOnce();
44
+
45
+ // Verify Credit.fromCents was used (credit is called with a Credit object, not raw cents)
46
+ const creditArg = deps.creditLedger.credit.mock.calls[0][1];
47
+ expect(creditArg.toCentsRounded()).toBe(1000);
48
+ });
49
+
50
+ it("skips crediting when already credited (idempotent)", async () => {
51
+ const deps = {
52
+ chargeStore: {
53
+ getByDepositAddress: vi.fn().mockResolvedValue({
54
+ referenceId: "sc:base:usdc:abc",
55
+ tenantId: "tenant-1",
56
+ amountUsdCents: 1000,
57
+ status: "Settled",
58
+ creditedAt: "2026-01-01",
59
+ }),
60
+ updateStatus: vi.fn().mockResolvedValue(undefined),
61
+ markCredited: vi.fn().mockResolvedValue(undefined),
62
+ },
63
+ creditLedger: {
64
+ hasReferenceId: vi.fn().mockResolvedValue(true),
65
+ credit: vi.fn().mockResolvedValue({}),
66
+ },
67
+ };
68
+
69
+ const result = await settleEvmPayment(deps as never, mockEvent);
70
+
71
+ expect(result.handled).toBe(true);
72
+ expect(result.creditedCents).toBe(0);
73
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
74
+ });
75
+
76
+ it("returns handled:false when no charge found for deposit address", async () => {
77
+ const deps = {
78
+ chargeStore: {
79
+ getByDepositAddress: vi.fn().mockResolvedValue(null),
80
+ updateStatus: vi.fn(),
81
+ markCredited: vi.fn(),
82
+ },
83
+ creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
84
+ };
85
+
86
+ const result = await settleEvmPayment(deps as never, mockEvent);
87
+ expect(result.handled).toBe(false);
88
+ });
89
+
90
+ it("credits the charge amount, not the transfer amount (overpayment safe)", async () => {
91
+ const overpaidEvent = { ...mockEvent, amountUsdCents: 2000 }; // sent $20
92
+ const deps = {
93
+ chargeStore: {
94
+ getByDepositAddress: vi.fn().mockResolvedValue({
95
+ referenceId: "sc:x",
96
+ tenantId: "t",
97
+ amountUsdCents: 1000, // charge was for $10
98
+ status: "New",
99
+ creditedAt: null,
100
+ }),
101
+ updateStatus: vi.fn().mockResolvedValue(undefined),
102
+ markCredited: vi.fn().mockResolvedValue(undefined),
103
+ },
104
+ creditLedger: {
105
+ hasReferenceId: vi.fn().mockResolvedValue(false),
106
+ credit: vi.fn().mockResolvedValue({}),
107
+ },
108
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
109
+ };
110
+
111
+ const result = await settleEvmPayment(deps as never, overpaidEvent);
112
+ expect(result.creditedCents).toBe(1000); // charge amount, NOT transfer amount
113
+ });
114
+
115
+ it("rejects underpayment — does not credit if transfer < charge", async () => {
116
+ const underpaidEvent = { ...mockEvent, amountUsdCents: 500 }; // sent $5
117
+ const deps = {
118
+ chargeStore: {
119
+ getByDepositAddress: vi.fn().mockResolvedValue({
120
+ referenceId: "sc:x",
121
+ tenantId: "t",
122
+ amountUsdCents: 1000, // charge was for $10
123
+ status: "New",
124
+ creditedAt: null,
125
+ }),
126
+ updateStatus: vi.fn().mockResolvedValue(undefined),
127
+ markCredited: vi.fn().mockResolvedValue(undefined),
128
+ },
129
+ creditLedger: {
130
+ hasReferenceId: vi.fn().mockResolvedValue(false),
131
+ credit: vi.fn().mockResolvedValue({}),
132
+ },
133
+ };
134
+
135
+ const result = await settleEvmPayment(deps as never, underpaidEvent);
136
+ expect(result.creditedCents).toBe(0);
137
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
138
+ });
139
+
140
+ it("uses correct ledger referenceId format", async () => {
141
+ const deps = {
142
+ chargeStore: {
143
+ getByDepositAddress: vi.fn().mockResolvedValue({
144
+ referenceId: "sc:ref",
145
+ tenantId: "t",
146
+ amountUsdCents: 500,
147
+ status: "New",
148
+ creditedAt: null,
149
+ }),
150
+ updateStatus: vi.fn().mockResolvedValue(undefined),
151
+ markCredited: vi.fn().mockResolvedValue(undefined),
152
+ },
153
+ creditLedger: {
154
+ hasReferenceId: vi.fn().mockResolvedValue(false),
155
+ credit: vi.fn().mockResolvedValue({}),
156
+ },
157
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
158
+ };
159
+
160
+ await settleEvmPayment(deps as never, mockEvent);
161
+
162
+ const creditOpts = deps.creditLedger.credit.mock.calls[0][3];
163
+ expect(creditOpts.referenceId).toBe("evm:base:0xtx123:0");
164
+ expect(creditOpts.fundingSource).toBe("crypto");
165
+ });
166
+
167
+ it("calls onCreditsPurchased when provided", async () => {
168
+ const onPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
169
+ const deps = {
170
+ chargeStore: {
171
+ getByDepositAddress: vi.fn().mockResolvedValue({
172
+ referenceId: "sc:ref",
173
+ tenantId: "t",
174
+ amountUsdCents: 500,
175
+ status: "New",
176
+ creditedAt: null,
177
+ }),
178
+ updateStatus: vi.fn().mockResolvedValue(undefined),
179
+ markCredited: vi.fn().mockResolvedValue(undefined),
180
+ },
181
+ creditLedger: {
182
+ hasReferenceId: vi.fn().mockResolvedValue(false),
183
+ credit: vi.fn().mockResolvedValue({}),
184
+ },
185
+ onCreditsPurchased: onPurchased,
186
+ };
187
+
188
+ const result = await settleEvmPayment(deps as never, mockEvent);
189
+ expect(onPurchased).toHaveBeenCalledOnce();
190
+ expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
191
+ });
192
+
193
+ it("rejects second transfer to already-credited charge (no double-credit)", async () => {
194
+ const secondTxEvent = { ...mockEvent, txHash: "0xsecondtx", logIndex: 0 };
195
+ const deps = {
196
+ chargeStore: {
197
+ getByDepositAddress: vi.fn().mockResolvedValue({
198
+ referenceId: "sc:base:usdc:abc",
199
+ tenantId: "tenant-1",
200
+ amountUsdCents: 1000,
201
+ status: "Settled",
202
+ creditedAt: "2026-01-01T00:00:00Z", // already credited by first tx
203
+ }),
204
+ updateStatus: vi.fn().mockResolvedValue(undefined),
205
+ markCredited: vi.fn().mockResolvedValue(undefined),
206
+ },
207
+ creditLedger: {
208
+ hasReferenceId: vi.fn().mockResolvedValue(false), // new txHash, so this returns false
209
+ credit: vi.fn().mockResolvedValue({}),
210
+ },
211
+ };
212
+
213
+ const result = await settleEvmPayment(deps as never, secondTxEvent);
214
+ expect(result.handled).toBe(true);
215
+ expect(result.creditedCents).toBe(0);
216
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled(); // must NOT double-credit
217
+ });
218
+ });