@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.
- package/dist/account/deletion-executor-repository.d.ts +2 -2
- package/dist/account/deletion-executor-repository.js +5 -5
- package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
- package/dist/billing/crypto/charge-store.d.ts +68 -0
- package/dist/billing/crypto/charge-store.js +109 -0
- package/dist/billing/crypto/charge-store.test.js +120 -0
- package/dist/billing/crypto/checkout.d.ts +18 -0
- package/dist/billing/crypto/checkout.js +35 -0
- package/dist/billing/crypto/checkout.test.js +71 -0
- package/dist/billing/crypto/client.d.ts +39 -0
- package/dist/billing/crypto/client.js +72 -0
- package/dist/billing/crypto/client.test.js +100 -0
- package/dist/billing/crypto/evm/__tests__/address-gen.test.js +54 -0
- package/dist/billing/crypto/evm/__tests__/checkout.test.js +54 -0
- package/dist/billing/crypto/evm/__tests__/config.test.js +52 -0
- package/dist/billing/crypto/evm/__tests__/settler.test.js +196 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +109 -0
- package/dist/billing/crypto/evm/address-gen.d.ts +8 -0
- package/dist/billing/crypto/evm/address-gen.js +29 -0
- package/dist/billing/crypto/evm/checkout.d.ts +26 -0
- package/dist/billing/crypto/evm/checkout.js +57 -0
- package/dist/billing/crypto/evm/config.d.ts +13 -0
- package/dist/billing/crypto/evm/config.js +46 -0
- package/dist/billing/crypto/evm/index.d.ts +9 -0
- package/dist/billing/crypto/evm/index.js +5 -0
- package/dist/billing/crypto/evm/settler.d.ts +23 -0
- package/dist/billing/crypto/evm/settler.js +60 -0
- package/dist/billing/crypto/evm/types.d.ts +40 -0
- package/dist/billing/crypto/evm/types.js +1 -0
- package/dist/billing/crypto/evm/watcher.d.ts +31 -0
- package/dist/billing/crypto/evm/watcher.js +91 -0
- package/dist/billing/crypto/index.d.ts +10 -0
- package/dist/billing/crypto/index.js +6 -0
- package/dist/billing/crypto/types.d.ts +61 -0
- package/dist/billing/crypto/types.js +24 -0
- package/dist/billing/crypto/webhook.d.ts +34 -0
- package/dist/billing/crypto/webhook.js +107 -0
- package/dist/billing/crypto/webhook.test.d.ts +1 -0
- package/dist/billing/crypto/webhook.test.js +266 -0
- package/dist/billing/index.d.ts +1 -1
- package/dist/billing/index.js +2 -2
- package/dist/billing/payment-processor.d.ts +3 -3
- package/dist/credits/credit-ledger.d.ts +3 -3
- package/dist/credits/credit-ledger.js +3 -3
- package/dist/db/schema/credits.js +1 -1
- package/dist/db/schema/{payram.d.ts → crypto.d.ts} +85 -13
- package/dist/db/schema/crypto.js +32 -0
- package/dist/db/schema/index.d.ts +1 -1
- package/dist/db/schema/index.js +1 -1
- package/dist/monetization/crypto/__tests__/webhook.test.d.ts +1 -0
- package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
- package/dist/monetization/crypto/index.d.ts +4 -0
- package/dist/monetization/crypto/index.js +2 -0
- package/dist/monetization/crypto/webhook.d.ts +24 -0
- package/dist/monetization/crypto/webhook.js +88 -0
- package/dist/monetization/index.d.ts +3 -3
- package/dist/monetization/index.js +1 -1
- package/dist/monetization/repository-types.d.ts +1 -1
- package/dist/observability/pagerduty.test.js +1 -0
- package/docs/superpowers/plans/2026-03-14-stablecoin-phase1.md +1413 -0
- package/drizzle/migrations/0004_crypto_charges.sql +25 -0
- package/drizzle/migrations/0005_stablecoin_columns.sql +7 -0
- package/drizzle/migrations/meta/_journal.json +14 -0
- package/package.json +4 -3
- package/src/account/deletion-executor-repository.ts +6 -6
- package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
- package/src/billing/crypto/charge-store.test.ts +142 -0
- package/src/billing/crypto/charge-store.ts +166 -0
- package/src/billing/crypto/checkout.test.ts +93 -0
- package/src/billing/crypto/checkout.ts +48 -0
- package/src/billing/crypto/client.test.ts +132 -0
- package/src/billing/crypto/client.ts +86 -0
- package/src/billing/crypto/evm/__tests__/address-gen.test.ts +63 -0
- package/src/billing/crypto/evm/__tests__/checkout.test.ts +83 -0
- package/src/billing/crypto/evm/__tests__/config.test.ts +63 -0
- package/src/billing/crypto/evm/__tests__/settler.test.ts +218 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +128 -0
- package/src/billing/crypto/evm/address-gen.ts +29 -0
- package/src/billing/crypto/evm/checkout.ts +82 -0
- package/src/billing/crypto/evm/config.ts +50 -0
- package/src/billing/crypto/evm/index.ts +16 -0
- package/src/billing/crypto/evm/settler.ts +79 -0
- package/src/billing/crypto/evm/types.ts +45 -0
- package/src/billing/crypto/evm/watcher.ts +126 -0
- package/src/billing/crypto/index.ts +16 -0
- package/src/billing/crypto/types.ts +83 -0
- package/src/billing/crypto/webhook.test.ts +340 -0
- package/src/billing/crypto/webhook.ts +136 -0
- package/src/billing/index.ts +2 -2
- package/src/billing/payment-processor.ts +3 -3
- package/src/credits/credit-ledger.ts +3 -3
- package/src/db/schema/credits.ts +1 -1
- package/src/db/schema/crypto.ts +37 -0
- package/src/db/schema/index.ts +1 -1
- package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
- package/src/monetization/crypto/index.ts +23 -0
- package/src/monetization/crypto/webhook.ts +115 -0
- package/src/monetization/index.ts +23 -21
- package/src/monetization/repository-types.ts +2 -2
- package/src/observability/pagerduty.test.ts +1 -0
- package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
- package/dist/billing/payram/charge-store.d.ts +0 -41
- package/dist/billing/payram/charge-store.js +0 -72
- package/dist/billing/payram/charge-store.test.js +0 -64
- package/dist/billing/payram/checkout.d.ts +0 -15
- package/dist/billing/payram/checkout.js +0 -24
- package/dist/billing/payram/checkout.test.js +0 -74
- package/dist/billing/payram/client.d.ts +0 -7
- package/dist/billing/payram/client.js +0 -15
- package/dist/billing/payram/client.test.js +0 -52
- package/dist/billing/payram/index.d.ts +0 -8
- package/dist/billing/payram/index.js +0 -4
- package/dist/billing/payram/types.d.ts +0 -40
- package/dist/billing/payram/webhook.d.ts +0 -19
- package/dist/billing/payram/webhook.js +0 -71
- package/dist/billing/payram/webhook.test.d.ts +0 -7
- package/dist/billing/payram/webhook.test.js +0 -249
- package/dist/db/schema/payram.js +0 -21
- package/dist/monetization/payram/charge-store.test.js +0 -64
- package/dist/monetization/payram/checkout.test.js +0 -73
- package/dist/monetization/payram/client.test.js +0 -52
- package/dist/monetization/payram/index.d.ts +0 -4
- package/dist/monetization/payram/index.js +0 -2
- package/dist/monetization/payram/webhook.d.ts +0 -17
- package/dist/monetization/payram/webhook.js +0 -71
- package/dist/monetization/payram/webhook.test.d.ts +0 -7
- package/dist/monetization/payram/webhook.test.js +0 -247
- package/src/billing/payram/charge-store.test.ts +0 -84
- package/src/billing/payram/charge-store.ts +0 -109
- package/src/billing/payram/checkout.test.ts +0 -99
- package/src/billing/payram/checkout.ts +0 -40
- package/src/billing/payram/client.test.ts +0 -62
- package/src/billing/payram/client.ts +0 -21
- package/src/billing/payram/index.ts +0 -14
- package/src/billing/payram/types.ts +0 -44
- package/src/billing/payram/webhook.test.ts +0 -320
- package/src/billing/payram/webhook.ts +0 -94
- package/src/db/schema/payram.ts +0 -26
- package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
- package/src/monetization/payram/charge-store.test.ts +0 -84
- package/src/monetization/payram/checkout.test.ts +0 -98
- package/src/monetization/payram/client.test.ts +0 -62
- package/src/monetization/payram/index.ts +0 -20
- package/src/monetization/payram/webhook.test.ts +0 -327
- package/src/monetization/payram/webhook.ts +0 -97
- /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
- /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
- /package/dist/billing/{payram/types.js → crypto/evm/__tests__/address-gen.test.d.ts} +0 -0
- /package/dist/{monetization/payram → billing/crypto/evm/__tests__}/checkout.test.d.ts +0 -0
- /package/dist/{monetization/payram/cents-credits-boundary.test.d.ts → billing/crypto/evm/__tests__/config.test.d.ts} +0 -0
- /package/dist/{monetization/payram/charge-store.test.d.ts → billing/crypto/evm/__tests__/settler.test.d.ts} +0 -0
- /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
|
+
});
|