@wopr-network/platform-core 1.19.0 → 1.20.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 (50) hide show
  1. package/dist/billing/crypto/btc/__tests__/address-gen.test.d.ts +1 -0
  2. package/dist/billing/crypto/btc/__tests__/address-gen.test.js +44 -0
  3. package/dist/billing/crypto/btc/__tests__/config.test.d.ts +1 -0
  4. package/dist/billing/crypto/btc/__tests__/config.test.js +24 -0
  5. package/dist/billing/crypto/btc/__tests__/settler.test.d.ts +1 -0
  6. package/dist/billing/crypto/btc/__tests__/settler.test.js +92 -0
  7. package/dist/billing/crypto/btc/address-gen.d.ts +8 -0
  8. package/dist/billing/crypto/btc/address-gen.js +34 -0
  9. package/dist/billing/crypto/btc/checkout.d.ts +21 -0
  10. package/dist/billing/crypto/btc/checkout.js +42 -0
  11. package/dist/billing/crypto/btc/config.d.ts +12 -0
  12. package/dist/billing/crypto/btc/config.js +28 -0
  13. package/dist/billing/crypto/btc/index.d.ts +9 -0
  14. package/dist/billing/crypto/btc/index.js +5 -0
  15. package/dist/billing/crypto/btc/settler.d.ts +23 -0
  16. package/dist/billing/crypto/btc/settler.js +55 -0
  17. package/dist/billing/crypto/btc/types.d.ts +23 -0
  18. package/dist/billing/crypto/btc/types.js +1 -0
  19. package/dist/billing/crypto/btc/watcher.d.ts +28 -0
  20. package/dist/billing/crypto/btc/watcher.js +83 -0
  21. package/dist/billing/crypto/charge-store.d.ts +3 -3
  22. package/dist/billing/crypto/evm/__tests__/config.test.js +42 -2
  23. package/dist/billing/crypto/evm/__tests__/watcher.test.js +31 -17
  24. package/dist/billing/crypto/evm/checkout.js +4 -2
  25. package/dist/billing/crypto/evm/config.js +73 -0
  26. package/dist/billing/crypto/evm/types.d.ts +1 -1
  27. package/dist/billing/crypto/evm/watcher.js +2 -0
  28. package/dist/billing/crypto/index.d.ts +2 -1
  29. package/dist/billing/crypto/index.js +1 -0
  30. package/dist/db/schema/crypto.js +1 -1
  31. package/package.json +3 -1
  32. package/src/billing/crypto/btc/__tests__/address-gen.test.ts +53 -0
  33. package/src/billing/crypto/btc/__tests__/config.test.ts +28 -0
  34. package/src/billing/crypto/btc/__tests__/settler.test.ts +103 -0
  35. package/src/billing/crypto/btc/address-gen.ts +41 -0
  36. package/src/billing/crypto/btc/checkout.ts +61 -0
  37. package/src/billing/crypto/btc/config.ts +33 -0
  38. package/src/billing/crypto/btc/index.ts +9 -0
  39. package/src/billing/crypto/btc/settler.ts +74 -0
  40. package/src/billing/crypto/btc/types.ts +25 -0
  41. package/src/billing/crypto/btc/watcher.ts +115 -0
  42. package/src/billing/crypto/charge-store.ts +3 -3
  43. package/src/billing/crypto/evm/__tests__/config.test.ts +51 -2
  44. package/src/billing/crypto/evm/__tests__/watcher.test.ts +34 -17
  45. package/src/billing/crypto/evm/checkout.ts +4 -2
  46. package/src/billing/crypto/evm/config.ts +73 -0
  47. package/src/billing/crypto/evm/types.ts +1 -1
  48. package/src/billing/crypto/evm/watcher.ts +2 -0
  49. package/src/billing/crypto/index.ts +2 -1
  50. package/src/db/schema/crypto.ts +1 -1
@@ -17,35 +17,38 @@ function mockTransferLog(to, amount, blockNumber) {
17
17
  }
18
18
  describe("EvmWatcher", () => {
19
19
  it("parses Transfer log into EvmPaymentEvent", async () => {
20
+ const toAddr = `0x${"cc".repeat(20)}`;
20
21
  const events = [];
21
22
  const mockRpc = vi
22
23
  .fn()
23
- .mockResolvedValueOnce(`0x${(102).toString(16)}`) // eth_blockNumber: block 102
24
- .mockResolvedValueOnce([mockTransferLog(`0x${"cc".repeat(20)}`, 10000000n, 99)]); // eth_getLogs
24
+ .mockResolvedValueOnce(`0x${(102).toString(16)}`)
25
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10000000n, 99)]);
25
26
  const watcher = new EvmWatcher({
26
27
  chain: "base",
27
28
  token: "USDC",
28
29
  rpcCall: mockRpc,
29
30
  fromBlock: 99,
31
+ watchedAddresses: [toAddr],
30
32
  onPayment: (evt) => {
31
33
  events.push(evt);
32
34
  },
33
35
  });
34
36
  await watcher.poll();
35
37
  expect(events).toHaveLength(1);
36
- expect(events[0].amountUsdCents).toBe(1000); // 10 USDC = $10 = 1000 cents
38
+ expect(events[0].amountUsdCents).toBe(1000);
37
39
  expect(events[0].to).toMatch(/^0x/);
38
40
  });
39
41
  it("advances cursor after processing", async () => {
40
42
  const mockRpc = vi
41
43
  .fn()
42
- .mockResolvedValueOnce(`0x${(200).toString(16)}`) // block 200
43
- .mockResolvedValueOnce([]); // no logs
44
+ .mockResolvedValueOnce(`0x${(200).toString(16)}`)
45
+ .mockResolvedValueOnce([]);
44
46
  const watcher = new EvmWatcher({
45
47
  chain: "base",
46
48
  token: "USDC",
47
49
  rpcCall: mockRpc,
48
50
  fromBlock: 100,
51
+ watchedAddresses: ["0xdeadbeef"],
49
52
  onPayment: vi.fn(),
50
53
  });
51
54
  await watcher.poll();
@@ -53,37 +56,35 @@ describe("EvmWatcher", () => {
53
56
  });
54
57
  it("skips blocks not yet confirmed", async () => {
55
58
  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 mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
59
60
  const watcher = new EvmWatcher({
60
61
  chain: "base",
61
62
  token: "USDC",
62
63
  rpcCall: mockRpc,
63
64
  fromBlock: 50,
65
+ watchedAddresses: ["0xdeadbeef"],
64
66
  onPayment: (evt) => {
65
67
  events.push(evt);
66
68
  },
67
69
  });
68
70
  await watcher.poll();
69
71
  expect(events).toHaveLength(0);
70
- // eth_getLogs should not even be called
71
72
  expect(mockRpc).toHaveBeenCalledTimes(1);
72
73
  });
73
74
  it("processes multiple logs in one poll", async () => {
75
+ const addr1 = `0x${"aa".repeat(20)}`;
76
+ const addr2 = `0x${"bb".repeat(20)}`;
74
77
  const events = [];
75
78
  const mockRpc = vi
76
79
  .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
- ]);
80
+ .mockResolvedValueOnce(`0x${(110).toString(16)}`)
81
+ .mockResolvedValueOnce([mockTransferLog(addr1, 5000000n, 105), mockTransferLog(addr2, 20000000n, 107)]);
82
82
  const watcher = new EvmWatcher({
83
83
  chain: "base",
84
84
  token: "USDC",
85
85
  rpcCall: mockRpc,
86
86
  fromBlock: 100,
87
+ watchedAddresses: [addr1, addr2],
87
88
  onPayment: (evt) => {
88
89
  events.push(evt);
89
90
  },
@@ -94,16 +95,29 @@ describe("EvmWatcher", () => {
94
95
  expect(events[1].amountUsdCents).toBe(2000);
95
96
  });
96
97
  it("does nothing when no new blocks", async () => {
97
- const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`); // block 99, confirmed = 98
98
+ const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`);
98
99
  const watcher = new EvmWatcher({
99
100
  chain: "base",
100
101
  token: "USDC",
101
102
  rpcCall: mockRpc,
102
103
  fromBlock: 100,
104
+ watchedAddresses: ["0xdeadbeef"],
103
105
  onPayment: vi.fn(),
104
106
  });
105
107
  await watcher.poll();
106
- expect(watcher.cursor).toBe(100); // unchanged
107
- expect(mockRpc).toHaveBeenCalledTimes(1); // only eth_blockNumber
108
+ expect(watcher.cursor).toBe(100);
109
+ expect(mockRpc).toHaveBeenCalledTimes(1);
110
+ });
111
+ it("early-returns when no watched addresses are set", async () => {
112
+ const mockRpc = vi.fn();
113
+ const watcher = new EvmWatcher({
114
+ chain: "base",
115
+ token: "USDC",
116
+ rpcCall: mockRpc,
117
+ fromBlock: 0,
118
+ onPayment: vi.fn(),
119
+ });
120
+ await watcher.poll();
121
+ expect(mockRpc).not.toHaveBeenCalled(); // no RPC calls at all
108
122
  });
109
123
  });
@@ -47,8 +47,10 @@ export async function createStablecoinCheckout(deps, opts) {
47
47
  catch (err) {
48
48
  // Unique constraint violation = another checkout claimed this index concurrently.
49
49
  // Retry with the next available index.
50
- const msg = err instanceof Error ? err.message : "";
51
- const isConflict = msg.includes("unique") || msg.includes("duplicate") || msg.includes("23505");
50
+ // PostgreSQL error code 23505 = unique_violation.
51
+ // Check structured code first, fall back to message for other drivers.
52
+ const code = err.code;
53
+ const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
52
54
  if (!isConflict || attempt === maxRetries)
53
55
  throw err;
54
56
  }
@@ -6,8 +6,30 @@ const CHAINS = {
6
6
  blockTimeMs: 2000,
7
7
  chainId: 8453,
8
8
  },
9
+ ethereum: {
10
+ chain: "ethereum",
11
+ rpcUrl: process.env.EVM_RPC_ETHEREUM ?? "http://geth:8545",
12
+ confirmations: 12,
13
+ blockTimeMs: 12000,
14
+ chainId: 1,
15
+ },
16
+ arbitrum: {
17
+ chain: "arbitrum",
18
+ rpcUrl: process.env.EVM_RPC_ARBITRUM ?? "http://nitro:8547",
19
+ confirmations: 1,
20
+ blockTimeMs: 250,
21
+ chainId: 42161,
22
+ },
23
+ polygon: {
24
+ chain: "polygon",
25
+ rpcUrl: process.env.EVM_RPC_POLYGON ?? "http://bor:8545",
26
+ confirmations: 32,
27
+ blockTimeMs: 2000,
28
+ chainId: 137,
29
+ },
9
30
  };
10
31
  const TOKENS = {
32
+ // --- Base ---
11
33
  "USDC:base": {
12
34
  token: "USDC",
13
35
  chain: "base",
@@ -26,6 +48,57 @@ const TOKENS = {
26
48
  contractAddress: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
27
49
  decimals: 18,
28
50
  },
51
+ // --- Ethereum ---
52
+ "USDC:ethereum": {
53
+ token: "USDC",
54
+ chain: "ethereum",
55
+ contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
56
+ decimals: 6,
57
+ },
58
+ "USDT:ethereum": {
59
+ token: "USDT",
60
+ chain: "ethereum",
61
+ contractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
62
+ decimals: 6,
63
+ },
64
+ "DAI:ethereum": {
65
+ token: "DAI",
66
+ chain: "ethereum",
67
+ contractAddress: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
68
+ decimals: 18,
69
+ },
70
+ // --- Arbitrum ---
71
+ "USDC:arbitrum": {
72
+ token: "USDC",
73
+ chain: "arbitrum",
74
+ contractAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
75
+ decimals: 6,
76
+ },
77
+ "USDT:arbitrum": {
78
+ token: "USDT",
79
+ chain: "arbitrum",
80
+ contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
81
+ decimals: 6,
82
+ },
83
+ "DAI:arbitrum": {
84
+ token: "DAI",
85
+ chain: "arbitrum",
86
+ contractAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1",
87
+ decimals: 18,
88
+ },
89
+ // --- Polygon ---
90
+ "USDC:polygon": {
91
+ token: "USDC",
92
+ chain: "polygon",
93
+ contractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
94
+ decimals: 6,
95
+ },
96
+ "USDT:polygon": {
97
+ token: "USDT",
98
+ chain: "polygon",
99
+ contractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
100
+ decimals: 6,
101
+ },
29
102
  };
30
103
  export function getChainConfig(chain) {
31
104
  const cfg = CHAINS[chain];
@@ -1,5 +1,5 @@
1
1
  /** Supported EVM chains. */
2
- export type EvmChain = "base";
2
+ export type EvmChain = "base" | "ethereum" | "arbitrum" | "polygon";
3
3
  /** Supported stablecoin tokens. */
4
4
  export type StablecoinToken = "USDC" | "USDT" | "DAI";
5
5
  /** Chain configuration. */
@@ -32,6 +32,8 @@ export class EvmWatcher {
32
32
  }
33
33
  /** Poll for new Transfer events. Call on an interval. */
34
34
  async poll() {
35
+ if (this._watchedAddresses.length === 0)
36
+ return; // nothing to watch
35
37
  const latestHex = (await this.rpc("eth_blockNumber", []));
36
38
  const latest = Number.parseInt(latestHex, 16);
37
39
  const confirmed = latest - this.confirmations;
@@ -1,4 +1,5 @@
1
- export type { CryptoChargeRecord, ICryptoChargeRepository, StablecoinChargeInput } from "./charge-store.js";
1
+ export * from "./btc/index.js";
2
+ export type { CryptoChargeRecord, CryptoDepositChargeInput, ICryptoChargeRepository } from "./charge-store.js";
2
3
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
3
4
  export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
4
5
  export type { CryptoConfig } from "./client.js";
@@ -1,3 +1,4 @@
1
+ export * from "./btc/index.js";
1
2
  export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
2
3
  export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
3
4
  export { BTCPayClient, loadCryptoConfig } from "./client.js";
@@ -27,6 +27,6 @@ export const cryptoCharges = pgTable("crypto_charges", {
27
27
  index("idx_crypto_charges_status").on(table.status),
28
28
  index("idx_crypto_charges_created").on(table.createdAt),
29
29
  index("idx_crypto_charges_deposit_address").on(table.depositAddress),
30
- // uniqueIndex would be ideal but drizzle pgTable helper doesn't support it inline.
30
+ // Unique indexes use WHERE IS NOT NULL partial indexes (declared in migration SQL).
31
31
  // Enforced via migration: CREATE UNIQUE INDEX.
32
32
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -128,8 +128,10 @@
128
128
  },
129
129
  "packageManager": "pnpm@10.31.0",
130
130
  "dependencies": {
131
+ "@noble/hashes": "^2.0.1",
131
132
  "@scure/base": "^2.0.0",
132
133
  "@scure/bip32": "^2.0.1",
134
+ "@scure/bip39": "^2.0.1",
133
135
  "js-yaml": "^4.1.1",
134
136
  "viem": "^2.47.4",
135
137
  "yaml": "^2.8.2"
@@ -0,0 +1,53 @@
1
+ import { HDKey } from "@scure/bip32";
2
+ import { describe, expect, it } from "vitest";
3
+ import { deriveBtcAddress, deriveBtcTreasury } from "../address-gen.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'/0'/0'").publicExtendedKey;
10
+ }
11
+
12
+ const TEST_XPUB = makeTestXpub();
13
+
14
+ describe("deriveBtcAddress", () => {
15
+ it("derives a valid bech32 address", () => {
16
+ const addr = deriveBtcAddress(TEST_XPUB, 0);
17
+ expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
18
+ });
19
+
20
+ it("derives different addresses for different indices", () => {
21
+ const a = deriveBtcAddress(TEST_XPUB, 0);
22
+ const b = deriveBtcAddress(TEST_XPUB, 1);
23
+ expect(a).not.toBe(b);
24
+ });
25
+
26
+ it("is deterministic", () => {
27
+ const a = deriveBtcAddress(TEST_XPUB, 42);
28
+ const b = deriveBtcAddress(TEST_XPUB, 42);
29
+ expect(a).toBe(b);
30
+ });
31
+
32
+ it("uses tb prefix for testnet/regtest", () => {
33
+ const addr = deriveBtcAddress(TEST_XPUB, 0, "testnet");
34
+ expect(addr).toMatch(/^tb1q[a-z0-9]+$/);
35
+ });
36
+
37
+ it("rejects negative index", () => {
38
+ expect(() => deriveBtcAddress(TEST_XPUB, -1)).toThrow("Invalid");
39
+ });
40
+ });
41
+
42
+ describe("deriveBtcTreasury", () => {
43
+ it("derives a valid bech32 address", () => {
44
+ const addr = deriveBtcTreasury(TEST_XPUB);
45
+ expect(addr).toMatch(/^bc1q[a-z0-9]+$/);
46
+ });
47
+
48
+ it("differs from deposit address at index 0", () => {
49
+ const deposit = deriveBtcAddress(TEST_XPUB, 0);
50
+ const treasury = deriveBtcTreasury(TEST_XPUB);
51
+ expect(deposit).not.toBe(treasury);
52
+ });
53
+ });
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { centsToSats, satsToCents } from "../config.js";
3
+
4
+ describe("centsToSats", () => {
5
+ it("converts $10 at $100k BTC", () => {
6
+ // $10 = 1000 cents, BTC at $100,000
7
+ // 10 / 100000 = 0.0001 BTC = 10000 sats
8
+ expect(centsToSats(1000, 100_000)).toBe(10000);
9
+ });
10
+
11
+ it("converts $100 at $50k BTC", () => {
12
+ // $100 = 10000 cents, BTC at $50,000
13
+ // 100 / 50000 = 0.002 BTC = 200000 sats
14
+ expect(centsToSats(10000, 50_000)).toBe(200000);
15
+ });
16
+ });
17
+
18
+ describe("satsToCents", () => {
19
+ it("converts 10000 sats at $100k BTC", () => {
20
+ // 10000 sats = 0.0001 BTC, at $100k = $10 = 1000 cents
21
+ expect(satsToCents(10000, 100_000)).toBe(1000);
22
+ });
23
+
24
+ it("rounds to nearest cent", () => {
25
+ // 15000 sats at $100k = 0.00015 BTC = $15 = 1500 cents
26
+ expect(satsToCents(15000, 100_000)).toBe(1500);
27
+ });
28
+ });
@@ -0,0 +1,103 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { settleBtcPayment } from "../settler.js";
3
+ import type { BtcPaymentEvent } from "../types.js";
4
+
5
+ const mockEvent: BtcPaymentEvent = {
6
+ address: "bc1qtest",
7
+ txid: "abc123",
8
+ amountSats: 15000,
9
+ amountUsdCents: 1000,
10
+ confirmations: 6,
11
+ };
12
+
13
+ describe("settleBtcPayment", () => {
14
+ it("credits ledger when charge found", async () => {
15
+ const deps = {
16
+ chargeStore: {
17
+ getByDepositAddress: vi.fn().mockResolvedValue({
18
+ referenceId: "btc:test",
19
+ tenantId: "t1",
20
+ amountUsdCents: 1000,
21
+ creditedAt: null,
22
+ }),
23
+ updateStatus: vi.fn().mockResolvedValue(undefined),
24
+ markCredited: vi.fn().mockResolvedValue(undefined),
25
+ },
26
+ creditLedger: {
27
+ hasReferenceId: vi.fn().mockResolvedValue(false),
28
+ credit: vi.fn().mockResolvedValue({}),
29
+ },
30
+ onCreditsPurchased: vi.fn().mockResolvedValue([]),
31
+ };
32
+
33
+ const result = await settleBtcPayment(deps as never, mockEvent);
34
+ expect(result.handled).toBe(true);
35
+ expect(result.creditedCents).toBe(1000);
36
+ expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
37
+
38
+ // Verify Credit.fromCents was used
39
+ const creditArg = deps.creditLedger.credit.mock.calls[0][1];
40
+ expect(creditArg.toCentsRounded()).toBe(1000);
41
+ });
42
+
43
+ it("rejects double-credit on already-credited charge", async () => {
44
+ const deps = {
45
+ chargeStore: {
46
+ getByDepositAddress: vi.fn().mockResolvedValue({
47
+ referenceId: "btc:test",
48
+ tenantId: "t1",
49
+ amountUsdCents: 1000,
50
+ creditedAt: "2026-01-01",
51
+ }),
52
+ updateStatus: vi.fn().mockResolvedValue(undefined),
53
+ markCredited: vi.fn(),
54
+ },
55
+ creditLedger: {
56
+ hasReferenceId: vi.fn().mockResolvedValue(false),
57
+ credit: vi.fn(),
58
+ },
59
+ };
60
+
61
+ const result = await settleBtcPayment(deps as never, mockEvent);
62
+ expect(result.creditedCents).toBe(0);
63
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
64
+ });
65
+
66
+ it("rejects underpayment", async () => {
67
+ const underpaid = { ...mockEvent, amountUsdCents: 500 };
68
+ const deps = {
69
+ chargeStore: {
70
+ getByDepositAddress: vi.fn().mockResolvedValue({
71
+ referenceId: "btc:test",
72
+ tenantId: "t1",
73
+ amountUsdCents: 1000,
74
+ creditedAt: null,
75
+ }),
76
+ updateStatus: vi.fn().mockResolvedValue(undefined),
77
+ markCredited: vi.fn(),
78
+ },
79
+ creditLedger: {
80
+ hasReferenceId: vi.fn().mockResolvedValue(false),
81
+ credit: vi.fn(),
82
+ },
83
+ };
84
+
85
+ const result = await settleBtcPayment(deps as never, underpaid);
86
+ expect(result.creditedCents).toBe(0);
87
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
88
+ });
89
+
90
+ it("returns handled:false when no charge found", async () => {
91
+ const deps = {
92
+ chargeStore: {
93
+ getByDepositAddress: vi.fn().mockResolvedValue(null),
94
+ updateStatus: vi.fn(),
95
+ markCredited: vi.fn(),
96
+ },
97
+ creditLedger: { hasReferenceId: vi.fn(), credit: vi.fn() },
98
+ };
99
+
100
+ const result = await settleBtcPayment(deps as never, mockEvent);
101
+ expect(result.handled).toBe(false);
102
+ });
103
+ });
@@ -0,0 +1,41 @@
1
+ import { ripemd160 } from "@noble/hashes/legacy.js";
2
+ import { sha256 } from "@noble/hashes/sha2.js";
3
+ import { bech32 } from "@scure/base";
4
+ import { HDKey } from "@scure/bip32";
5
+
6
+ /**
7
+ * Derive a native segwit (bech32, bc1q...) BTC address from an xpub at a given index.
8
+ * Path: xpub / 0 / index (external chain).
9
+ * No private keys involved.
10
+ */
11
+ export function deriveBtcAddress(
12
+ xpub: string,
13
+ index: number,
14
+ network: "mainnet" | "testnet" | "regtest" = "mainnet",
15
+ ): string {
16
+ if (!Number.isInteger(index) || index < 0) throw new Error(`Invalid derivation index: ${index}`);
17
+
18
+ const master = HDKey.fromExtendedKey(xpub);
19
+ const child = master.deriveChild(0).deriveChild(index);
20
+ if (!child.publicKey) throw new Error("Failed to derive public key");
21
+
22
+ // HASH160 = RIPEMD160(SHA256(compressedPubKey))
23
+ const hash160 = ripemd160(sha256(child.publicKey));
24
+
25
+ // Bech32 encode: witness version 0 + 20-byte hash
26
+ const prefix = network === "mainnet" ? "bc" : "tb";
27
+ const words = bech32.toWords(hash160);
28
+ return bech32.encode(prefix, [0, ...words]);
29
+ }
30
+
31
+ /** Derive the BTC treasury address (internal chain, index 0). */
32
+ export function deriveBtcTreasury(xpub: string, network: "mainnet" | "testnet" | "regtest" = "mainnet"): string {
33
+ const master = HDKey.fromExtendedKey(xpub);
34
+ const child = master.deriveChild(1).deriveChild(0); // internal chain
35
+ if (!child.publicKey) throw new Error("Failed to derive public key");
36
+
37
+ const hash160 = ripemd160(sha256(child.publicKey));
38
+ const prefix = network === "mainnet" ? "bc" : "tb";
39
+ const words = bech32.toWords(hash160);
40
+ return bech32.encode(prefix, [0, ...words]);
41
+ }
@@ -0,0 +1,61 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ import type { ICryptoChargeRepository } from "../charge-store.js";
3
+ import { deriveBtcAddress } from "./address-gen.js";
4
+ import type { BtcCheckoutOpts } from "./types.js";
5
+
6
+ export const MIN_BTC_USD = 10;
7
+
8
+ export interface BtcCheckoutDeps {
9
+ chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
10
+ xpub: string;
11
+ network?: "mainnet" | "testnet" | "regtest";
12
+ }
13
+
14
+ export interface BtcCheckoutResult {
15
+ depositAddress: string;
16
+ amountUsd: number;
17
+ referenceId: string;
18
+ }
19
+
20
+ /**
21
+ * Create a BTC checkout — derive a unique deposit address, store the charge.
22
+ *
23
+ * Same pattern as stablecoin checkout: HD derivation + charge store + retry on conflict.
24
+ *
25
+ * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
26
+ */
27
+ export async function createBtcCheckout(deps: BtcCheckoutDeps, opts: BtcCheckoutOpts): Promise<BtcCheckoutResult> {
28
+ if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_BTC_USD) {
29
+ throw new Error(`Minimum payment amount is $${MIN_BTC_USD}`);
30
+ }
31
+
32
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
33
+ const network = deps.network ?? "mainnet";
34
+ const maxRetries = 3;
35
+
36
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
37
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
38
+ const depositAddress = deriveBtcAddress(deps.xpub, derivationIndex, network);
39
+ const referenceId = `btc:${depositAddress}`;
40
+
41
+ try {
42
+ await deps.chargeStore.createStablecoinCharge({
43
+ referenceId,
44
+ tenantId: opts.tenant,
45
+ amountUsdCents,
46
+ chain: "bitcoin",
47
+ token: "BTC",
48
+ depositAddress,
49
+ derivationIndex,
50
+ });
51
+
52
+ return { depositAddress, amountUsd: opts.amountUsd, referenceId };
53
+ } catch (err: unknown) {
54
+ const code = (err as { code?: string }).code;
55
+ const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
56
+ if (!isConflict || attempt === maxRetries) throw err;
57
+ }
58
+ }
59
+
60
+ throw new Error("Failed to claim derivation index after retries");
61
+ }
@@ -0,0 +1,33 @@
1
+ import type { BitcoindConfig } from "./types.js";
2
+
3
+ export function loadBitcoindConfig(): BitcoindConfig | null {
4
+ const rpcUrl = process.env.BITCOIND_RPC_URL;
5
+ const rpcUser = process.env.BITCOIND_RPC_USER;
6
+ const rpcPassword = process.env.BITCOIND_RPC_PASSWORD;
7
+ if (!rpcUrl || !rpcUser || !rpcPassword) return null;
8
+
9
+ const network = (process.env.BITCOIND_NETWORK ?? "mainnet") as BitcoindConfig["network"];
10
+ const confirmations = network === "regtest" ? 1 : 6;
11
+
12
+ return { rpcUrl, rpcUser, rpcPassword, network, confirmations };
13
+ }
14
+
15
+ /**
16
+ * Convert USD cents to satoshis given a BTC/USD price.
17
+ * Integer math only.
18
+ */
19
+ export function centsToSats(cents: number, btcPriceUsd: number): number {
20
+ // 1 BTC = 100_000_000 sats, price is in dollars
21
+ // cents / 100 = dollars, dollars / btcPrice = BTC, BTC * 1e8 = sats
22
+ // To avoid float: (cents * 1e8) / (btcPrice * 100)
23
+ return Math.round((cents * 100_000_000) / (btcPriceUsd * 100));
24
+ }
25
+
26
+ /**
27
+ * Convert satoshis to USD cents given a BTC/USD price.
28
+ * Integer math only (rounds to nearest cent).
29
+ */
30
+ export function satsToCents(sats: number, btcPriceUsd: number): number {
31
+ // sats / 1e8 = BTC, BTC * btcPrice = dollars, dollars * 100 = cents
32
+ return Math.round((sats * btcPriceUsd * 100) / 100_000_000);
33
+ }
@@ -0,0 +1,9 @@
1
+ export { deriveBtcAddress, deriveBtcTreasury } from "./address-gen.js";
2
+ export type { BtcCheckoutDeps, BtcCheckoutResult } from "./checkout.js";
3
+ export { createBtcCheckout, MIN_BTC_USD } from "./checkout.js";
4
+ export { centsToSats, loadBitcoindConfig, satsToCents } from "./config.js";
5
+ export type { BtcSettlerDeps } from "./settler.js";
6
+ export { settleBtcPayment } from "./settler.js";
7
+ export type { BitcoindConfig, BtcCheckoutOpts, BtcPaymentEvent } from "./types.js";
8
+ export type { BtcWatcherOpts } from "./watcher.js";
9
+ export { BtcWatcher, createBitcoindRpc } from "./watcher.js";