@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,100 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { BTCPayClient, loadCryptoConfig } from "./client.js";
|
|
3
|
+
describe("BTCPayClient", () => {
|
|
4
|
+
it("createInvoice sends correct request and returns id + checkoutLink", async () => {
|
|
5
|
+
const mockResponse = { id: "inv-001", checkoutLink: "https://btcpay.example.com/i/inv-001" };
|
|
6
|
+
const fetchSpy = vi
|
|
7
|
+
.spyOn(globalThis, "fetch")
|
|
8
|
+
.mockResolvedValue(new Response(JSON.stringify(mockResponse), { status: 200 }));
|
|
9
|
+
const client = new BTCPayClient({
|
|
10
|
+
apiKey: "test-key",
|
|
11
|
+
baseUrl: "https://btcpay.example.com",
|
|
12
|
+
storeId: "store-abc",
|
|
13
|
+
});
|
|
14
|
+
const result = await client.createInvoice({
|
|
15
|
+
amountUsd: 25,
|
|
16
|
+
orderId: "order-123",
|
|
17
|
+
buyerEmail: "test@example.com",
|
|
18
|
+
});
|
|
19
|
+
expect(result.id).toBe("inv-001");
|
|
20
|
+
expect(result.checkoutLink).toBe("https://btcpay.example.com/i/inv-001");
|
|
21
|
+
expect(fetchSpy).toHaveBeenCalledOnce();
|
|
22
|
+
const [url, opts] = fetchSpy.mock.calls[0];
|
|
23
|
+
expect(url).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices");
|
|
24
|
+
expect(opts?.method).toBe("POST");
|
|
25
|
+
const headers = opts?.headers;
|
|
26
|
+
expect(headers.Authorization).toBe("token test-key");
|
|
27
|
+
expect(headers["Content-Type"]).toBe("application/json");
|
|
28
|
+
const body = JSON.parse(opts?.body);
|
|
29
|
+
expect(body.amount).toBe("25");
|
|
30
|
+
expect(body.currency).toBe("USD");
|
|
31
|
+
expect(body.metadata.orderId).toBe("order-123");
|
|
32
|
+
expect(body.metadata.buyerEmail).toBe("test@example.com");
|
|
33
|
+
expect(body.checkout.speedPolicy).toBe("MediumSpeed");
|
|
34
|
+
fetchSpy.mockRestore();
|
|
35
|
+
});
|
|
36
|
+
it("createInvoice includes redirectURL when provided", async () => {
|
|
37
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-002", checkoutLink: "https://btcpay.example.com/i/inv-002" }), {
|
|
38
|
+
status: 200,
|
|
39
|
+
}));
|
|
40
|
+
const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "s" });
|
|
41
|
+
await client.createInvoice({ amountUsd: 10, orderId: "o", redirectURL: "https://app.example.com/success" });
|
|
42
|
+
const body = JSON.parse(fetchSpy.mock.calls[0][1]?.body);
|
|
43
|
+
expect(body.checkout.redirectURL).toBe("https://app.example.com/success");
|
|
44
|
+
fetchSpy.mockRestore();
|
|
45
|
+
});
|
|
46
|
+
it("createInvoice throws on non-ok response", async () => {
|
|
47
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("Unauthorized", { status: 401 }));
|
|
48
|
+
const client = new BTCPayClient({ apiKey: "bad-key", baseUrl: "https://btcpay.example.com", storeId: "s" });
|
|
49
|
+
await expect(client.createInvoice({ amountUsd: 10, orderId: "o" })).rejects.toThrow("BTCPay createInvoice failed (401)");
|
|
50
|
+
fetchSpy.mockRestore();
|
|
51
|
+
});
|
|
52
|
+
it("getInvoice sends correct request", async () => {
|
|
53
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({ id: "inv-001", status: "Settled", amount: "25", currency: "USD" }), {
|
|
54
|
+
status: 200,
|
|
55
|
+
}));
|
|
56
|
+
const client = new BTCPayClient({ apiKey: "k", baseUrl: "https://btcpay.example.com", storeId: "store-abc" });
|
|
57
|
+
const result = await client.getInvoice("inv-001");
|
|
58
|
+
expect(result.status).toBe("Settled");
|
|
59
|
+
expect(fetchSpy.mock.calls[0][0]).toBe("https://btcpay.example.com/api/v1/stores/store-abc/invoices/inv-001");
|
|
60
|
+
fetchSpy.mockRestore();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
describe("loadCryptoConfig", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
delete process.env.BTCPAY_API_KEY;
|
|
66
|
+
delete process.env.BTCPAY_BASE_URL;
|
|
67
|
+
delete process.env.BTCPAY_STORE_ID;
|
|
68
|
+
});
|
|
69
|
+
afterEach(() => {
|
|
70
|
+
vi.unstubAllEnvs();
|
|
71
|
+
});
|
|
72
|
+
it("returns null when BTCPAY_API_KEY is missing", () => {
|
|
73
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
74
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
75
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
it("returns null when BTCPAY_BASE_URL is missing", () => {
|
|
78
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
79
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
80
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
81
|
+
});
|
|
82
|
+
it("returns null when BTCPAY_STORE_ID is missing", () => {
|
|
83
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
84
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
85
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
it("returns config when all env vars are set", () => {
|
|
88
|
+
vi.stubEnv("BTCPAY_API_KEY", "test-key");
|
|
89
|
+
vi.stubEnv("BTCPAY_BASE_URL", "https://btcpay.test");
|
|
90
|
+
vi.stubEnv("BTCPAY_STORE_ID", "store-1");
|
|
91
|
+
expect(loadCryptoConfig()).toEqual({
|
|
92
|
+
apiKey: "test-key",
|
|
93
|
+
baseUrl: "https://btcpay.test",
|
|
94
|
+
storeId: "store-1",
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
it("returns null when all env vars are missing", () => {
|
|
98
|
+
expect(loadCryptoConfig()).toBeNull();
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { deriveDepositAddress, isValidXpub } from "../address-gen.js";
|
|
4
|
+
// Generate a test xpub deterministically
|
|
5
|
+
function makeTestXpub() {
|
|
6
|
+
const seed = new Uint8Array(32);
|
|
7
|
+
seed[0] = 1; // deterministic seed
|
|
8
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
9
|
+
// Derive to m/44'/60'/0' (Ethereum BIP-44 path)
|
|
10
|
+
const account = master.derive("m/44'/60'/0'");
|
|
11
|
+
return account.publicExtendedKey;
|
|
12
|
+
}
|
|
13
|
+
const TEST_XPUB = makeTestXpub();
|
|
14
|
+
describe("deriveDepositAddress", () => {
|
|
15
|
+
it("derives a valid Ethereum address", () => {
|
|
16
|
+
const addr = deriveDepositAddress(TEST_XPUB, 0);
|
|
17
|
+
expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
18
|
+
});
|
|
19
|
+
it("derives different addresses for different indices", () => {
|
|
20
|
+
const addr0 = deriveDepositAddress(TEST_XPUB, 0);
|
|
21
|
+
const addr1 = deriveDepositAddress(TEST_XPUB, 1);
|
|
22
|
+
expect(addr0).not.toBe(addr1);
|
|
23
|
+
});
|
|
24
|
+
it("is deterministic — same xpub + index = same address", () => {
|
|
25
|
+
const a = deriveDepositAddress(TEST_XPUB, 42);
|
|
26
|
+
const b = deriveDepositAddress(TEST_XPUB, 42);
|
|
27
|
+
expect(a).toBe(b);
|
|
28
|
+
});
|
|
29
|
+
it("returns checksummed address", () => {
|
|
30
|
+
const addr = deriveDepositAddress(TEST_XPUB, 0);
|
|
31
|
+
// Must be a valid 0x-prefixed address
|
|
32
|
+
expect(addr).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
33
|
+
// viem's publicKeyToAddress always returns EIP-55 checksummed
|
|
34
|
+
// Verify it's not all-lowercase (checksummed addresses have mixed case)
|
|
35
|
+
const hexPart = addr.slice(2);
|
|
36
|
+
const hasUpperCase = hexPart !== hexPart.toLowerCase();
|
|
37
|
+
const hasLowerCase = hexPart !== hexPart.toUpperCase();
|
|
38
|
+
// At least one of these should be true for a checksummed address
|
|
39
|
+
// (unless the address happens to be all digits, which is extremely rare)
|
|
40
|
+
expect(hasUpperCase || !hexPart.match(/[a-f]/i)).toBe(true);
|
|
41
|
+
expect(hasLowerCase || !hexPart.match(/[a-f]/i)).toBe(true);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
describe("isValidXpub", () => {
|
|
45
|
+
it("accepts valid xpub", () => {
|
|
46
|
+
expect(isValidXpub(TEST_XPUB)).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
it("rejects garbage", () => {
|
|
49
|
+
expect(isValidXpub("not-an-xpub")).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
it("rejects empty string", () => {
|
|
52
|
+
expect(isValidXpub("")).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { createStablecoinCheckout } from "../checkout.js";
|
|
4
|
+
function makeTestXpub() {
|
|
5
|
+
const seed = new Uint8Array(32);
|
|
6
|
+
seed[0] = 1;
|
|
7
|
+
const master = HDKey.fromMasterSeed(seed);
|
|
8
|
+
return master.derive("m/44'/60'/0'").publicExtendedKey;
|
|
9
|
+
}
|
|
10
|
+
const TEST_XPUB = makeTestXpub();
|
|
11
|
+
describe("createStablecoinCheckout", () => {
|
|
12
|
+
it("derives address and creates charge", async () => {
|
|
13
|
+
const mockChargeStore = {
|
|
14
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(42),
|
|
15
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
16
|
+
};
|
|
17
|
+
const result = await createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" });
|
|
18
|
+
expect(result.depositAddress).toMatch(/^0x[0-9a-fA-F]{40}$/);
|
|
19
|
+
expect(result.amountRaw).toBe("10000000"); // 10 USDC = 10 * 10^6
|
|
20
|
+
expect(result.chain).toBe("base");
|
|
21
|
+
expect(result.token).toBe("USDC");
|
|
22
|
+
expect(mockChargeStore.createStablecoinCharge).toHaveBeenCalledOnce();
|
|
23
|
+
// Verify charge was created with integer cents, not floating point
|
|
24
|
+
const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
|
|
25
|
+
expect(chargeInput.amountUsdCents).toBe(1000); // $10 = 1000 cents
|
|
26
|
+
expect(Number.isInteger(chargeInput.amountUsdCents)).toBe(true);
|
|
27
|
+
});
|
|
28
|
+
it("rejects below minimum", async () => {
|
|
29
|
+
const mockChargeStore = {
|
|
30
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(0),
|
|
31
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
};
|
|
33
|
+
await expect(createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 5, chain: "base", token: "USDC" })).rejects.toThrow("Minimum");
|
|
34
|
+
});
|
|
35
|
+
it("stores deposit address in lowercase", async () => {
|
|
36
|
+
const mockChargeStore = {
|
|
37
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(0),
|
|
38
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
39
|
+
};
|
|
40
|
+
await createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 10, chain: "base", token: "USDC" });
|
|
41
|
+
const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
|
|
42
|
+
expect(chargeInput.depositAddress).toBe(chargeInput.depositAddress.toLowerCase());
|
|
43
|
+
});
|
|
44
|
+
it("converts $25 correctly to raw USDC amount", async () => {
|
|
45
|
+
const mockChargeStore = {
|
|
46
|
+
getNextDerivationIndex: vi.fn().mockResolvedValue(0),
|
|
47
|
+
createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
|
|
48
|
+
};
|
|
49
|
+
const result = await createStablecoinCheckout({ chargeStore: mockChargeStore, xpub: TEST_XPUB }, { tenant: "t1", amountUsd: 25, chain: "base", token: "USDC" });
|
|
50
|
+
expect(result.amountRaw).toBe("25000000"); // 25 * 10^6
|
|
51
|
+
const chargeInput = mockChargeStore.createStablecoinCharge.mock.calls[0][0];
|
|
52
|
+
expect(chargeInput.amountUsdCents).toBe(2500);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "../config.js";
|
|
3
|
+
describe("getChainConfig", () => {
|
|
4
|
+
it("returns Base config", () => {
|
|
5
|
+
const cfg = getChainConfig("base");
|
|
6
|
+
expect(cfg.chainId).toBe(8453);
|
|
7
|
+
expect(cfg.confirmations).toBe(1);
|
|
8
|
+
expect(cfg.blockTimeMs).toBe(2000);
|
|
9
|
+
});
|
|
10
|
+
it("throws on unknown chain", () => {
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
12
|
+
expect(() => getChainConfig("solana")).toThrow("Unsupported chain");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
15
|
+
describe("getTokenConfig", () => {
|
|
16
|
+
it("returns USDC on Base", () => {
|
|
17
|
+
const cfg = getTokenConfig("USDC", "base");
|
|
18
|
+
expect(cfg.decimals).toBe(6);
|
|
19
|
+
expect(cfg.contractAddress).toMatch(/^0x/);
|
|
20
|
+
expect(cfg.contractAddress).toBe("0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913");
|
|
21
|
+
});
|
|
22
|
+
it("throws on unsupported token/chain combo", () => {
|
|
23
|
+
// biome-ignore lint/suspicious/noExplicitAny: testing invalid input
|
|
24
|
+
expect(() => getTokenConfig("USDC", "ethereum")).toThrow("Unsupported token");
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
describe("tokenAmountFromCents", () => {
|
|
28
|
+
it("converts 1000 cents ($10) to USDC raw amount (6 decimals)", () => {
|
|
29
|
+
expect(tokenAmountFromCents(1000, 6)).toBe(10000000n);
|
|
30
|
+
});
|
|
31
|
+
it("converts 100 cents ($1) to DAI raw amount (18 decimals)", () => {
|
|
32
|
+
expect(tokenAmountFromCents(100, 18)).toBe(1000000000000000000n);
|
|
33
|
+
});
|
|
34
|
+
it("converts 1 cent to USDC", () => {
|
|
35
|
+
expect(tokenAmountFromCents(1, 6)).toBe(10000n);
|
|
36
|
+
});
|
|
37
|
+
it("rejects non-integer cents", () => {
|
|
38
|
+
expect(() => tokenAmountFromCents(10.5, 6)).toThrow("integer");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
describe("centsFromTokenAmount", () => {
|
|
42
|
+
it("converts 10 USDC raw to 1000 cents", () => {
|
|
43
|
+
expect(centsFromTokenAmount(10000000n, 6)).toBe(1000);
|
|
44
|
+
});
|
|
45
|
+
it("converts 1 DAI raw to 100 cents", () => {
|
|
46
|
+
expect(centsFromTokenAmount(1000000000000000000n, 18)).toBe(100);
|
|
47
|
+
});
|
|
48
|
+
it("truncates fractional cents", () => {
|
|
49
|
+
// 0.005 USDC = 5000 raw units = 0.5 cents -> truncates to 0
|
|
50
|
+
expect(centsFromTokenAmount(5000n, 6)).toBe(0);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { settleEvmPayment } from "../settler.js";
|
|
3
|
+
const mockEvent = {
|
|
4
|
+
chain: "base",
|
|
5
|
+
token: "USDC",
|
|
6
|
+
from: "0xsender",
|
|
7
|
+
to: "0xdeposit",
|
|
8
|
+
rawAmount: "10000000", // 10 USDC
|
|
9
|
+
amountUsdCents: 1000,
|
|
10
|
+
txHash: "0xtx123",
|
|
11
|
+
blockNumber: 100,
|
|
12
|
+
logIndex: 0,
|
|
13
|
+
};
|
|
14
|
+
describe("settleEvmPayment", () => {
|
|
15
|
+
it("credits ledger when charge found and not yet credited", async () => {
|
|
16
|
+
const deps = {
|
|
17
|
+
chargeStore: {
|
|
18
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
19
|
+
referenceId: "sc:base:usdc:abc",
|
|
20
|
+
tenantId: "tenant-1",
|
|
21
|
+
amountUsdCents: 1000,
|
|
22
|
+
status: "New",
|
|
23
|
+
creditedAt: null,
|
|
24
|
+
}),
|
|
25
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
26
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
27
|
+
},
|
|
28
|
+
creditLedger: {
|
|
29
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
30
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
31
|
+
},
|
|
32
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
33
|
+
};
|
|
34
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
35
|
+
expect(result.handled).toBe(true);
|
|
36
|
+
expect(result.creditedCents).toBe(1000);
|
|
37
|
+
expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
|
|
38
|
+
expect(deps.chargeStore.markCredited).toHaveBeenCalledOnce();
|
|
39
|
+
// Verify Credit.fromCents was used (credit is called with a Credit object, not raw cents)
|
|
40
|
+
const creditArg = deps.creditLedger.credit.mock.calls[0][1];
|
|
41
|
+
expect(creditArg.toCentsRounded()).toBe(1000);
|
|
42
|
+
});
|
|
43
|
+
it("skips crediting when already credited (idempotent)", async () => {
|
|
44
|
+
const deps = {
|
|
45
|
+
chargeStore: {
|
|
46
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
47
|
+
referenceId: "sc:base:usdc:abc",
|
|
48
|
+
tenantId: "tenant-1",
|
|
49
|
+
amountUsdCents: 1000,
|
|
50
|
+
status: "Settled",
|
|
51
|
+
creditedAt: "2026-01-01",
|
|
52
|
+
}),
|
|
53
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
54
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
55
|
+
},
|
|
56
|
+
creditLedger: {
|
|
57
|
+
hasReferenceId: vi.fn().mockResolvedValue(true),
|
|
58
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
62
|
+
expect(result.handled).toBe(true);
|
|
63
|
+
expect(result.creditedCents).toBe(0);
|
|
64
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
65
|
+
});
|
|
66
|
+
it("returns handled:false when no charge found for deposit address", async () => {
|
|
67
|
+
const deps = {
|
|
68
|
+
chargeStore: {
|
|
69
|
+
getByDepositAddress: vi.fn().mockResolvedValue(null),
|
|
70
|
+
updateStatus: vi.fn(),
|
|
71
|
+
markCredited: vi.fn(),
|
|
72
|
+
},
|
|
73
|
+
creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
|
|
74
|
+
};
|
|
75
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
76
|
+
expect(result.handled).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
it("credits the charge amount, not the transfer amount (overpayment safe)", async () => {
|
|
79
|
+
const overpaidEvent = { ...mockEvent, amountUsdCents: 2000 }; // sent $20
|
|
80
|
+
const deps = {
|
|
81
|
+
chargeStore: {
|
|
82
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
83
|
+
referenceId: "sc:x",
|
|
84
|
+
tenantId: "t",
|
|
85
|
+
amountUsdCents: 1000, // charge was for $10
|
|
86
|
+
status: "New",
|
|
87
|
+
creditedAt: null,
|
|
88
|
+
}),
|
|
89
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
90
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
91
|
+
},
|
|
92
|
+
creditLedger: {
|
|
93
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
94
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
95
|
+
},
|
|
96
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
97
|
+
};
|
|
98
|
+
const result = await settleEvmPayment(deps, overpaidEvent);
|
|
99
|
+
expect(result.creditedCents).toBe(1000); // charge amount, NOT transfer amount
|
|
100
|
+
});
|
|
101
|
+
it("rejects underpayment — does not credit if transfer < charge", async () => {
|
|
102
|
+
const underpaidEvent = { ...mockEvent, amountUsdCents: 500 }; // sent $5
|
|
103
|
+
const deps = {
|
|
104
|
+
chargeStore: {
|
|
105
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
106
|
+
referenceId: "sc:x",
|
|
107
|
+
tenantId: "t",
|
|
108
|
+
amountUsdCents: 1000, // charge was for $10
|
|
109
|
+
status: "New",
|
|
110
|
+
creditedAt: null,
|
|
111
|
+
}),
|
|
112
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
113
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
114
|
+
},
|
|
115
|
+
creditLedger: {
|
|
116
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
117
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const result = await settleEvmPayment(deps, underpaidEvent);
|
|
121
|
+
expect(result.creditedCents).toBe(0);
|
|
122
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
it("uses correct ledger referenceId format", async () => {
|
|
125
|
+
const deps = {
|
|
126
|
+
chargeStore: {
|
|
127
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
128
|
+
referenceId: "sc:ref",
|
|
129
|
+
tenantId: "t",
|
|
130
|
+
amountUsdCents: 500,
|
|
131
|
+
status: "New",
|
|
132
|
+
creditedAt: null,
|
|
133
|
+
}),
|
|
134
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
135
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
136
|
+
},
|
|
137
|
+
creditLedger: {
|
|
138
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
139
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
140
|
+
},
|
|
141
|
+
onCreditsPurchased: vi.fn().mockResolvedValue([]),
|
|
142
|
+
};
|
|
143
|
+
await settleEvmPayment(deps, mockEvent);
|
|
144
|
+
const creditOpts = deps.creditLedger.credit.mock.calls[0][3];
|
|
145
|
+
expect(creditOpts.referenceId).toBe("evm:base:0xtx123:0");
|
|
146
|
+
expect(creditOpts.fundingSource).toBe("crypto");
|
|
147
|
+
});
|
|
148
|
+
it("calls onCreditsPurchased when provided", async () => {
|
|
149
|
+
const onPurchased = vi.fn().mockResolvedValue(["bot-1", "bot-2"]);
|
|
150
|
+
const deps = {
|
|
151
|
+
chargeStore: {
|
|
152
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
153
|
+
referenceId: "sc:ref",
|
|
154
|
+
tenantId: "t",
|
|
155
|
+
amountUsdCents: 500,
|
|
156
|
+
status: "New",
|
|
157
|
+
creditedAt: null,
|
|
158
|
+
}),
|
|
159
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
160
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
161
|
+
},
|
|
162
|
+
creditLedger: {
|
|
163
|
+
hasReferenceId: vi.fn().mockResolvedValue(false),
|
|
164
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
165
|
+
},
|
|
166
|
+
onCreditsPurchased: onPurchased,
|
|
167
|
+
};
|
|
168
|
+
const result = await settleEvmPayment(deps, mockEvent);
|
|
169
|
+
expect(onPurchased).toHaveBeenCalledOnce();
|
|
170
|
+
expect(result.reactivatedBots).toEqual(["bot-1", "bot-2"]);
|
|
171
|
+
});
|
|
172
|
+
it("rejects second transfer to already-credited charge (no double-credit)", async () => {
|
|
173
|
+
const secondTxEvent = { ...mockEvent, txHash: "0xsecondtx", logIndex: 0 };
|
|
174
|
+
const deps = {
|
|
175
|
+
chargeStore: {
|
|
176
|
+
getByDepositAddress: vi.fn().mockResolvedValue({
|
|
177
|
+
referenceId: "sc:base:usdc:abc",
|
|
178
|
+
tenantId: "tenant-1",
|
|
179
|
+
amountUsdCents: 1000,
|
|
180
|
+
status: "Settled",
|
|
181
|
+
creditedAt: "2026-01-01T00:00:00Z", // already credited by first tx
|
|
182
|
+
}),
|
|
183
|
+
updateStatus: vi.fn().mockResolvedValue(undefined),
|
|
184
|
+
markCredited: vi.fn().mockResolvedValue(undefined),
|
|
185
|
+
},
|
|
186
|
+
creditLedger: {
|
|
187
|
+
hasReferenceId: vi.fn().mockResolvedValue(false), // new txHash, so this returns false
|
|
188
|
+
credit: vi.fn().mockResolvedValue({}),
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
const result = await settleEvmPayment(deps, secondTxEvent);
|
|
192
|
+
expect(result.handled).toBe(true);
|
|
193
|
+
expect(result.creditedCents).toBe(0);
|
|
194
|
+
expect(deps.creditLedger.credit).not.toHaveBeenCalled(); // must NOT double-credit
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { EvmWatcher } from "../watcher.js";
|
|
3
|
+
const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
|
|
4
|
+
function mockTransferLog(to, amount, blockNumber) {
|
|
5
|
+
return {
|
|
6
|
+
address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
|
|
7
|
+
topics: [
|
|
8
|
+
TRANSFER_TOPIC,
|
|
9
|
+
`0x${"00".repeat(12)}${"ab".repeat(20)}`, // from (padded)
|
|
10
|
+
`0x${"00".repeat(12)}${to.slice(2).toLowerCase()}`, // to (padded)
|
|
11
|
+
],
|
|
12
|
+
data: `0x${amount.toString(16).padStart(64, "0")}`,
|
|
13
|
+
blockNumber: `0x${blockNumber.toString(16)}`,
|
|
14
|
+
transactionHash: `0x${"ff".repeat(32)}`,
|
|
15
|
+
logIndex: "0x0",
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
describe("EvmWatcher", () => {
|
|
19
|
+
it("parses Transfer log into EvmPaymentEvent", async () => {
|
|
20
|
+
const events = [];
|
|
21
|
+
const mockRpc = vi
|
|
22
|
+
.fn()
|
|
23
|
+
.mockResolvedValueOnce(`0x${(102).toString(16)}`) // eth_blockNumber: block 102
|
|
24
|
+
.mockResolvedValueOnce([mockTransferLog(`0x${"cc".repeat(20)}`, 10000000n, 99)]); // eth_getLogs
|
|
25
|
+
const watcher = new EvmWatcher({
|
|
26
|
+
chain: "base",
|
|
27
|
+
token: "USDC",
|
|
28
|
+
rpcCall: mockRpc,
|
|
29
|
+
fromBlock: 99,
|
|
30
|
+
onPayment: (evt) => {
|
|
31
|
+
events.push(evt);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
await watcher.poll();
|
|
35
|
+
expect(events).toHaveLength(1);
|
|
36
|
+
expect(events[0].amountUsdCents).toBe(1000); // 10 USDC = $10 = 1000 cents
|
|
37
|
+
expect(events[0].to).toMatch(/^0x/);
|
|
38
|
+
});
|
|
39
|
+
it("advances cursor after processing", async () => {
|
|
40
|
+
const mockRpc = vi
|
|
41
|
+
.fn()
|
|
42
|
+
.mockResolvedValueOnce(`0x${(200).toString(16)}`) // block 200
|
|
43
|
+
.mockResolvedValueOnce([]); // no logs
|
|
44
|
+
const watcher = new EvmWatcher({
|
|
45
|
+
chain: "base",
|
|
46
|
+
token: "USDC",
|
|
47
|
+
rpcCall: mockRpc,
|
|
48
|
+
fromBlock: 100,
|
|
49
|
+
onPayment: vi.fn(),
|
|
50
|
+
});
|
|
51
|
+
await watcher.poll();
|
|
52
|
+
expect(watcher.cursor).toBeGreaterThan(100);
|
|
53
|
+
});
|
|
54
|
+
it("skips blocks not yet confirmed", async () => {
|
|
55
|
+
const events = [];
|
|
56
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`); // current block: 50
|
|
57
|
+
// Base needs 1 confirmation, so confirmed = 50 - 1 = 49
|
|
58
|
+
// cursor starts at 50, so confirmed (49) < cursor (50) → no poll
|
|
59
|
+
const watcher = new EvmWatcher({
|
|
60
|
+
chain: "base",
|
|
61
|
+
token: "USDC",
|
|
62
|
+
rpcCall: mockRpc,
|
|
63
|
+
fromBlock: 50,
|
|
64
|
+
onPayment: (evt) => {
|
|
65
|
+
events.push(evt);
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
await watcher.poll();
|
|
69
|
+
expect(events).toHaveLength(0);
|
|
70
|
+
// eth_getLogs should not even be called
|
|
71
|
+
expect(mockRpc).toHaveBeenCalledTimes(1);
|
|
72
|
+
});
|
|
73
|
+
it("processes multiple logs in one poll", async () => {
|
|
74
|
+
const events = [];
|
|
75
|
+
const mockRpc = vi
|
|
76
|
+
.fn()
|
|
77
|
+
.mockResolvedValueOnce(`0x${(110).toString(16)}`) // block 110
|
|
78
|
+
.mockResolvedValueOnce([
|
|
79
|
+
mockTransferLog(`0x${"aa".repeat(20)}`, 5000000n, 105), // $5
|
|
80
|
+
mockTransferLog(`0x${"bb".repeat(20)}`, 20000000n, 107), // $20
|
|
81
|
+
]);
|
|
82
|
+
const watcher = new EvmWatcher({
|
|
83
|
+
chain: "base",
|
|
84
|
+
token: "USDC",
|
|
85
|
+
rpcCall: mockRpc,
|
|
86
|
+
fromBlock: 100,
|
|
87
|
+
onPayment: (evt) => {
|
|
88
|
+
events.push(evt);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
await watcher.poll();
|
|
92
|
+
expect(events).toHaveLength(2);
|
|
93
|
+
expect(events[0].amountUsdCents).toBe(500);
|
|
94
|
+
expect(events[1].amountUsdCents).toBe(2000);
|
|
95
|
+
});
|
|
96
|
+
it("does nothing when no new blocks", async () => {
|
|
97
|
+
const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`); // block 99, confirmed = 98
|
|
98
|
+
const watcher = new EvmWatcher({
|
|
99
|
+
chain: "base",
|
|
100
|
+
token: "USDC",
|
|
101
|
+
rpcCall: mockRpc,
|
|
102
|
+
fromBlock: 100,
|
|
103
|
+
onPayment: vi.fn(),
|
|
104
|
+
});
|
|
105
|
+
await watcher.poll();
|
|
106
|
+
expect(watcher.cursor).toBe(100); // unchanged
|
|
107
|
+
expect(mockRpc).toHaveBeenCalledTimes(1); // only eth_blockNumber
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Derive a deposit address from an xpub at a given BIP-44 index.
|
|
3
|
+
* Path: xpub / 0 / index (external chain / address index).
|
|
4
|
+
* Returns a checksummed Ethereum address. No private keys involved.
|
|
5
|
+
*/
|
|
6
|
+
export declare function deriveDepositAddress(xpub: string, index: number): `0x${string}`;
|
|
7
|
+
/** Validate that a string is an xpub (not xprv). */
|
|
8
|
+
export declare function isValidXpub(key: string): boolean;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { HDKey } from "@scure/bip32";
|
|
2
|
+
import { publicKeyToAddress } from "viem/accounts";
|
|
3
|
+
/**
|
|
4
|
+
* Derive a deposit address from an xpub at a given BIP-44 index.
|
|
5
|
+
* Path: xpub / 0 / index (external chain / address index).
|
|
6
|
+
* Returns a checksummed Ethereum address. No private keys involved.
|
|
7
|
+
*/
|
|
8
|
+
export function deriveDepositAddress(xpub, index) {
|
|
9
|
+
if (!Number.isInteger(index) || index < 0)
|
|
10
|
+
throw new Error(`Invalid derivation index: ${index}`);
|
|
11
|
+
const master = HDKey.fromExtendedKey(xpub);
|
|
12
|
+
const child = master.deriveChild(0).deriveChild(index);
|
|
13
|
+
if (!child.publicKey)
|
|
14
|
+
throw new Error("Failed to derive public key");
|
|
15
|
+
const hexPubKey = `0x${Array.from(child.publicKey, (b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
16
|
+
return publicKeyToAddress(hexPubKey);
|
|
17
|
+
}
|
|
18
|
+
/** Validate that a string is an xpub (not xprv). */
|
|
19
|
+
export function isValidXpub(key) {
|
|
20
|
+
if (!key.startsWith("xpub"))
|
|
21
|
+
return false;
|
|
22
|
+
try {
|
|
23
|
+
HDKey.fromExtendedKey(key);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ICryptoChargeRepository } from "../charge-store.js";
|
|
2
|
+
import type { StablecoinCheckoutOpts } from "./types.js";
|
|
3
|
+
export declare const MIN_STABLECOIN_USD = 10;
|
|
4
|
+
export interface StablecoinCheckoutDeps {
|
|
5
|
+
chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
|
|
6
|
+
xpub: string;
|
|
7
|
+
}
|
|
8
|
+
export interface StablecoinCheckoutResult {
|
|
9
|
+
depositAddress: string;
|
|
10
|
+
amountRaw: string;
|
|
11
|
+
amountUsd: number;
|
|
12
|
+
chain: string;
|
|
13
|
+
token: string;
|
|
14
|
+
referenceId: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Create a stablecoin checkout — derive a unique deposit address, store the charge.
|
|
18
|
+
*
|
|
19
|
+
* Race safety: the unique constraint on derivation_index prevents two concurrent
|
|
20
|
+
* checkouts from claiming the same index. On conflict, we retry with the next index.
|
|
21
|
+
*
|
|
22
|
+
* CRITICAL: amountUsd is converted to integer cents via Credit.fromDollars().toCentsRounded().
|
|
23
|
+
* The charge store holds USD cents (integer). Credit.fromCents() handles the
|
|
24
|
+
* cents → nanodollars conversion when crediting the ledger in the settler.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createStablecoinCheckout(deps: StablecoinCheckoutDeps, opts: StablecoinCheckoutOpts): Promise<StablecoinCheckoutResult>;
|