@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
@@ -0,0 +1,74 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ import type { ILedger } from "../../../credits/ledger.js";
3
+ import type { ICryptoChargeRepository } from "../charge-store.js";
4
+ import type { CryptoWebhookResult } from "../types.js";
5
+ import type { BtcPaymentEvent } from "./types.js";
6
+
7
+ export interface BtcSettlerDeps {
8
+ chargeStore: Pick<ICryptoChargeRepository, "getByDepositAddress" | "updateStatus" | "markCredited">;
9
+ creditLedger: Pick<ILedger, "credit" | "hasReferenceId">;
10
+ onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
11
+ }
12
+
13
+ /**
14
+ * Settle a BTC payment — look up charge by deposit address, credit ledger.
15
+ *
16
+ * Same idempotency pattern as EVM settler and BTCPay webhook handler:
17
+ * 1. Charge-level: check creditedAt (prevents second tx double-credit)
18
+ * 2. Transfer-level: creditLedger.hasReferenceId (atomic)
19
+ * 3. Advisory: chargeStore.markCredited
20
+ *
21
+ * Credits the CHARGE amount (not the BTC amount) for consistency.
22
+ *
23
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
24
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
25
+ */
26
+ export async function settleBtcPayment(deps: BtcSettlerDeps, event: BtcPaymentEvent): Promise<CryptoWebhookResult> {
27
+ const { chargeStore, creditLedger } = deps;
28
+
29
+ const charge = await chargeStore.getByDepositAddress(event.address);
30
+ if (!charge) {
31
+ return { handled: false, status: "Settled" };
32
+ }
33
+
34
+ await chargeStore.updateStatus(charge.referenceId, "Settled");
35
+
36
+ // Charge-level idempotency
37
+ if (charge.creditedAt != null) {
38
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
39
+ }
40
+
41
+ // Transfer-level idempotency
42
+ const creditRef = `btc:${event.txid}`;
43
+ if (await creditLedger.hasReferenceId(creditRef)) {
44
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
45
+ }
46
+
47
+ // Underpayment check
48
+ if (event.amountUsdCents < charge.amountUsdCents) {
49
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
50
+ }
51
+
52
+ const creditCents = charge.amountUsdCents;
53
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
54
+ description: `BTC credit purchase (txid: ${event.txid})`,
55
+ referenceId: creditRef,
56
+ fundingSource: "crypto",
57
+ });
58
+
59
+ await chargeStore.markCredited(charge.referenceId);
60
+
61
+ let reactivatedBots: string[] | undefined;
62
+ if (deps.onCreditsPurchased) {
63
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger as ILedger);
64
+ if (reactivatedBots.length === 0) reactivatedBots = undefined;
65
+ }
66
+
67
+ return {
68
+ handled: true,
69
+ status: "Settled",
70
+ tenant: charge.tenantId,
71
+ creditedCents: creditCents,
72
+ reactivatedBots,
73
+ };
74
+ }
@@ -0,0 +1,25 @@
1
+ /** BTC payment event emitted when a deposit is confirmed. */
2
+ export interface BtcPaymentEvent {
3
+ readonly address: string;
4
+ readonly txid: string;
5
+ /** Amount in satoshis (integer). */
6
+ readonly amountSats: number;
7
+ /** USD cents equivalent (integer). */
8
+ readonly amountUsdCents: number;
9
+ readonly confirmations: number;
10
+ }
11
+
12
+ /** Options for creating a BTC checkout. */
13
+ export interface BtcCheckoutOpts {
14
+ tenant: string;
15
+ amountUsd: number;
16
+ }
17
+
18
+ /** Bitcoind RPC configuration. */
19
+ export interface BitcoindConfig {
20
+ readonly rpcUrl: string;
21
+ readonly rpcUser: string;
22
+ readonly rpcPassword: string;
23
+ readonly network: "mainnet" | "testnet" | "regtest";
24
+ readonly confirmations: number;
25
+ }
@@ -0,0 +1,115 @@
1
+ import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
2
+
3
+ type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
4
+
5
+ export interface BtcWatcherOpts {
6
+ config: BitcoindConfig;
7
+ rpcCall: RpcCall;
8
+ /** Addresses to watch (must be imported into bitcoind wallet first). */
9
+ watchedAddresses: string[];
10
+ onPayment: (event: BtcPaymentEvent) => void | Promise<void>;
11
+ /** Current BTC/USD price for conversion. */
12
+ getBtcPrice: () => Promise<number>;
13
+ }
14
+
15
+ interface ReceivedByAddress {
16
+ address: string;
17
+ amount: number;
18
+ confirmations: number;
19
+ txids: string[];
20
+ }
21
+
22
+ /** Track which txids we've already processed to avoid double-crediting. */
23
+ const processedTxids = new Set<string>();
24
+
25
+ export class BtcWatcher {
26
+ private readonly rpc: RpcCall;
27
+ private readonly addresses: Set<string>;
28
+ private readonly onPayment: BtcWatcherOpts["onPayment"];
29
+ private readonly minConfirmations: number;
30
+ private readonly getBtcPrice: () => Promise<number>;
31
+
32
+ constructor(opts: BtcWatcherOpts) {
33
+ this.rpc = opts.rpcCall;
34
+ this.addresses = new Set(opts.watchedAddresses);
35
+ this.onPayment = opts.onPayment;
36
+ this.minConfirmations = opts.config.confirmations;
37
+ this.getBtcPrice = opts.getBtcPrice;
38
+ }
39
+
40
+ /** Update the set of watched addresses. */
41
+ setWatchedAddresses(addresses: string[]): void {
42
+ this.addresses.clear();
43
+ for (const a of addresses) this.addresses.add(a);
44
+ }
45
+
46
+ /** Import an address into bitcoind's wallet (watch-only, no rescan). */
47
+ async importAddress(address: string): Promise<void> {
48
+ await this.rpc("importaddress", [address, "", false]);
49
+ this.addresses.add(address);
50
+ }
51
+
52
+ /** Poll for confirmed payments to watched addresses. */
53
+ async poll(): Promise<void> {
54
+ if (this.addresses.size === 0) return;
55
+
56
+ const received = (await this.rpc("listreceivedbyaddress", [
57
+ this.minConfirmations,
58
+ false, // include_empty
59
+ true, // include_watchonly
60
+ ])) as ReceivedByAddress[];
61
+
62
+ const btcPrice = await this.getBtcPrice();
63
+
64
+ for (const entry of received) {
65
+ if (!this.addresses.has(entry.address)) continue;
66
+
67
+ for (const txid of entry.txids) {
68
+ if (processedTxids.has(txid)) continue;
69
+ processedTxids.add(txid);
70
+
71
+ // Get transaction details for the exact amount sent to this address
72
+ const tx = (await this.rpc("gettransaction", [txid, true])) as {
73
+ details: Array<{ address: string; amount: number; category: string }>;
74
+ confirmations: number;
75
+ };
76
+
77
+ const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
78
+ if (!detail) continue;
79
+
80
+ const amountSats = Math.round(detail.amount * 100_000_000);
81
+ const amountUsdCents = Math.round(detail.amount * btcPrice * 100);
82
+
83
+ const event: BtcPaymentEvent = {
84
+ address: entry.address,
85
+ txid,
86
+ amountSats,
87
+ amountUsdCents,
88
+ confirmations: tx.confirmations,
89
+ };
90
+
91
+ await this.onPayment(event);
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ /** Create a bitcoind JSON-RPC caller with basic auth. */
98
+ export function createBitcoindRpc(config: BitcoindConfig): RpcCall {
99
+ let id = 0;
100
+ const auth = btoa(`${config.rpcUser}:${config.rpcPassword}`);
101
+ return async (method: string, params: unknown[]): Promise<unknown> => {
102
+ const res = await fetch(config.rpcUrl, {
103
+ method: "POST",
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ Authorization: `Basic ${auth}`,
107
+ },
108
+ body: JSON.stringify({ jsonrpc: "1.0", id: ++id, method, params }),
109
+ });
110
+ if (!res.ok) throw new Error(`bitcoind ${method} failed: ${res.status}`);
111
+ const data = (await res.json()) as { result?: unknown; error?: { message: string } };
112
+ if (data.error) throw new Error(`bitcoind ${method}: ${data.error.message}`);
113
+ return data.result;
114
+ };
115
+ }
@@ -19,7 +19,7 @@ export interface CryptoChargeRecord {
19
19
  derivationIndex: number | null;
20
20
  }
21
21
 
22
- export interface StablecoinChargeInput {
22
+ export interface CryptoDepositChargeInput {
23
23
  referenceId: string;
24
24
  tenantId: string;
25
25
  amountUsdCents: number;
@@ -40,7 +40,7 @@ export interface ICryptoChargeRepository {
40
40
  ): Promise<void>;
41
41
  markCredited(referenceId: string): Promise<void>;
42
42
  isCredited(referenceId: string): Promise<boolean>;
43
- createStablecoinCharge(input: StablecoinChargeInput): Promise<void>;
43
+ createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void>;
44
44
  getByDepositAddress(address: string): Promise<CryptoChargeRecord | null>;
45
45
  getNextDerivationIndex(): Promise<number>;
46
46
  }
@@ -134,7 +134,7 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
134
134
  }
135
135
 
136
136
  /** Create a stablecoin charge with chain/token/deposit address. */
137
- async createStablecoinCharge(input: StablecoinChargeInput): Promise<void> {
137
+ async createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void> {
138
138
  await this.db.insert(cryptoCharges).values({
139
139
  referenceId: input.referenceId,
140
140
  tenantId: input.tenantId,
@@ -9,6 +9,24 @@ describe("getChainConfig", () => {
9
9
  expect(cfg.blockTimeMs).toBe(2000);
10
10
  });
11
11
 
12
+ it("returns Ethereum config", () => {
13
+ const cfg = getChainConfig("ethereum");
14
+ expect(cfg.chainId).toBe(1);
15
+ expect(cfg.confirmations).toBe(12);
16
+ });
17
+
18
+ it("returns Arbitrum config", () => {
19
+ const cfg = getChainConfig("arbitrum");
20
+ expect(cfg.chainId).toBe(42161);
21
+ expect(cfg.confirmations).toBe(1);
22
+ });
23
+
24
+ it("returns Polygon config", () => {
25
+ const cfg = getChainConfig("polygon");
26
+ expect(cfg.chainId).toBe(137);
27
+ expect(cfg.confirmations).toBe(32);
28
+ });
29
+
12
30
  it("throws on unknown chain", () => {
13
31
  // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
14
32
  expect(() => getChainConfig("solana" as any)).toThrow("Unsupported chain");
@@ -35,9 +53,40 @@ describe("getTokenConfig", () => {
35
53
  expect(cfg.contractAddress).toBe("0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb");
36
54
  });
37
55
 
38
- it("throws on unsupported token/chain combo", () => {
56
+ it("returns USDC on Ethereum", () => {
57
+ const cfg = getTokenConfig("USDC", "ethereum");
58
+ expect(cfg.decimals).toBe(6);
59
+ expect(cfg.contractAddress).toBe("0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48");
60
+ });
61
+
62
+ it("returns USDT on Ethereum", () => {
63
+ const cfg = getTokenConfig("USDT", "ethereum");
64
+ expect(cfg.decimals).toBe(6);
65
+ });
66
+
67
+ it("returns DAI on Ethereum", () => {
68
+ const cfg = getTokenConfig("DAI", "ethereum");
69
+ expect(cfg.decimals).toBe(18);
70
+ });
71
+
72
+ it("returns USDC on Arbitrum", () => {
73
+ const cfg = getTokenConfig("USDC", "arbitrum");
74
+ expect(cfg.chain).toBe("arbitrum");
75
+ expect(cfg.decimals).toBe(6);
76
+ });
77
+
78
+ it("returns USDT on Polygon", () => {
79
+ const cfg = getTokenConfig("USDT", "polygon");
80
+ expect(cfg.decimals).toBe(6);
81
+ });
82
+
83
+ it("throws on DAI:polygon (not supported)", () => {
84
+ expect(() => getTokenConfig("DAI", "polygon")).toThrow("Unsupported token");
85
+ });
86
+
87
+ it("throws on unsupported chain", () => {
39
88
  // biome-ignore lint/suspicious/noExplicitAny: testing invalid input
40
- expect(() => getTokenConfig("USDC" as any, "ethereum" as any)).toThrow("Unsupported token");
89
+ expect(() => getTokenConfig("USDC" as any, "solana" as any)).toThrow("Unsupported token");
41
90
  });
42
91
  });
43
92
 
@@ -20,17 +20,19 @@ function mockTransferLog(to: string, amount: bigint, blockNumber: number) {
20
20
 
21
21
  describe("EvmWatcher", () => {
22
22
  it("parses Transfer log into EvmPaymentEvent", async () => {
23
+ const toAddr = `0x${"cc".repeat(20)}`;
23
24
  const events: { amountUsdCents: number; to: string }[] = [];
24
25
  const mockRpc = vi
25
26
  .fn()
26
- .mockResolvedValueOnce(`0x${(102).toString(16)}`) // eth_blockNumber: block 102
27
- .mockResolvedValueOnce([mockTransferLog(`0x${"cc".repeat(20)}`, 10_000_000n, 99)]); // eth_getLogs
27
+ .mockResolvedValueOnce(`0x${(102).toString(16)}`)
28
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 99)]);
28
29
 
29
30
  const watcher = new EvmWatcher({
30
31
  chain: "base",
31
32
  token: "USDC",
32
33
  rpcCall: mockRpc,
33
34
  fromBlock: 99,
35
+ watchedAddresses: [toAddr],
34
36
  onPayment: (evt) => {
35
37
  events.push(evt);
36
38
  },
@@ -39,21 +41,22 @@ describe("EvmWatcher", () => {
39
41
  await watcher.poll();
40
42
 
41
43
  expect(events).toHaveLength(1);
42
- expect(events[0].amountUsdCents).toBe(1000); // 10 USDC = $10 = 1000 cents
44
+ expect(events[0].amountUsdCents).toBe(1000);
43
45
  expect(events[0].to).toMatch(/^0x/);
44
46
  });
45
47
 
46
48
  it("advances cursor after processing", async () => {
47
49
  const mockRpc = vi
48
50
  .fn()
49
- .mockResolvedValueOnce(`0x${(200).toString(16)}`) // block 200
50
- .mockResolvedValueOnce([]); // no logs
51
+ .mockResolvedValueOnce(`0x${(200).toString(16)}`)
52
+ .mockResolvedValueOnce([]);
51
53
 
52
54
  const watcher = new EvmWatcher({
53
55
  chain: "base",
54
56
  token: "USDC",
55
57
  rpcCall: mockRpc,
56
58
  fromBlock: 100,
59
+ watchedAddresses: ["0xdeadbeef"],
57
60
  onPayment: vi.fn(),
58
61
  });
59
62
 
@@ -63,15 +66,14 @@ describe("EvmWatcher", () => {
63
66
 
64
67
  it("skips blocks not yet confirmed", async () => {
65
68
  const events: unknown[] = [];
66
- const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`); // current block: 50
69
+ const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`);
67
70
 
68
- // Base needs 1 confirmation, so confirmed = 50 - 1 = 49
69
- // cursor starts at 50, so confirmed (49) < cursor (50) → no poll
70
71
  const watcher = new EvmWatcher({
71
72
  chain: "base",
72
73
  token: "USDC",
73
74
  rpcCall: mockRpc,
74
75
  fromBlock: 50,
76
+ watchedAddresses: ["0xdeadbeef"],
75
77
  onPayment: (evt) => {
76
78
  events.push(evt);
77
79
  },
@@ -79,25 +81,24 @@ describe("EvmWatcher", () => {
79
81
 
80
82
  await watcher.poll();
81
83
  expect(events).toHaveLength(0);
82
- // eth_getLogs should not even be called
83
84
  expect(mockRpc).toHaveBeenCalledTimes(1);
84
85
  });
85
86
 
86
87
  it("processes multiple logs in one poll", async () => {
88
+ const addr1 = `0x${"aa".repeat(20)}`;
89
+ const addr2 = `0x${"bb".repeat(20)}`;
87
90
  const events: { amountUsdCents: number }[] = [];
88
91
  const mockRpc = vi
89
92
  .fn()
90
- .mockResolvedValueOnce(`0x${(110).toString(16)}`) // block 110
91
- .mockResolvedValueOnce([
92
- mockTransferLog(`0x${"aa".repeat(20)}`, 5_000_000n, 105), // $5
93
- mockTransferLog(`0x${"bb".repeat(20)}`, 20_000_000n, 107), // $20
94
- ]);
93
+ .mockResolvedValueOnce(`0x${(110).toString(16)}`)
94
+ .mockResolvedValueOnce([mockTransferLog(addr1, 5_000_000n, 105), mockTransferLog(addr2, 20_000_000n, 107)]);
95
95
 
96
96
  const watcher = new EvmWatcher({
97
97
  chain: "base",
98
98
  token: "USDC",
99
99
  rpcCall: mockRpc,
100
100
  fromBlock: 100,
101
+ watchedAddresses: [addr1, addr2],
101
102
  onPayment: (evt) => {
102
103
  events.push(evt);
103
104
  },
@@ -111,18 +112,34 @@ describe("EvmWatcher", () => {
111
112
  });
112
113
 
113
114
  it("does nothing when no new blocks", async () => {
114
- const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`); // block 99, confirmed = 98
115
+ const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`);
115
116
 
116
117
  const watcher = new EvmWatcher({
117
118
  chain: "base",
118
119
  token: "USDC",
119
120
  rpcCall: mockRpc,
120
121
  fromBlock: 100,
122
+ watchedAddresses: ["0xdeadbeef"],
121
123
  onPayment: vi.fn(),
122
124
  });
123
125
 
124
126
  await watcher.poll();
125
- expect(watcher.cursor).toBe(100); // unchanged
126
- expect(mockRpc).toHaveBeenCalledTimes(1); // only eth_blockNumber
127
+ expect(watcher.cursor).toBe(100);
128
+ expect(mockRpc).toHaveBeenCalledTimes(1);
129
+ });
130
+
131
+ it("early-returns when no watched addresses are set", async () => {
132
+ const mockRpc = vi.fn();
133
+
134
+ const watcher = new EvmWatcher({
135
+ chain: "base",
136
+ token: "USDC",
137
+ rpcCall: mockRpc,
138
+ fromBlock: 0,
139
+ onPayment: vi.fn(),
140
+ });
141
+
142
+ await watcher.poll();
143
+ expect(mockRpc).not.toHaveBeenCalled(); // no RPC calls at all
127
144
  });
128
145
  });
@@ -72,8 +72,10 @@ export async function createStablecoinCheckout(
72
72
  } catch (err: unknown) {
73
73
  // Unique constraint violation = another checkout claimed this index concurrently.
74
74
  // Retry with the next available index.
75
- const msg = err instanceof Error ? err.message : "";
76
- const isConflict = msg.includes("unique") || msg.includes("duplicate") || msg.includes("23505");
75
+ // PostgreSQL error code 23505 = unique_violation.
76
+ // Check structured code first, fall back to message for other drivers.
77
+ const code = (err as { code?: string }).code;
78
+ const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
77
79
  if (!isConflict || attempt === maxRetries) throw err;
78
80
  }
79
81
  }
@@ -8,9 +8,31 @@ const CHAINS: Record<EvmChain, ChainConfig> = {
8
8
  blockTimeMs: 2000,
9
9
  chainId: 8453,
10
10
  },
11
+ ethereum: {
12
+ chain: "ethereum",
13
+ rpcUrl: process.env.EVM_RPC_ETHEREUM ?? "http://geth:8545",
14
+ confirmations: 12,
15
+ blockTimeMs: 12000,
16
+ chainId: 1,
17
+ },
18
+ arbitrum: {
19
+ chain: "arbitrum",
20
+ rpcUrl: process.env.EVM_RPC_ARBITRUM ?? "http://nitro:8547",
21
+ confirmations: 1,
22
+ blockTimeMs: 250,
23
+ chainId: 42161,
24
+ },
25
+ polygon: {
26
+ chain: "polygon",
27
+ rpcUrl: process.env.EVM_RPC_POLYGON ?? "http://bor:8545",
28
+ confirmations: 32,
29
+ blockTimeMs: 2000,
30
+ chainId: 137,
31
+ },
11
32
  };
12
33
 
13
34
  const TOKENS: Partial<Record<`${StablecoinToken}:${EvmChain}`, TokenConfig>> = {
35
+ // --- Base ---
14
36
  "USDC:base": {
15
37
  token: "USDC",
16
38
  chain: "base",
@@ -29,6 +51,57 @@ const TOKENS: Partial<Record<`${StablecoinToken}:${EvmChain}`, TokenConfig>> = {
29
51
  contractAddress: "0x50c5725949A6F0c72E6C4a641F24049A917DB0Cb",
30
52
  decimals: 18,
31
53
  },
54
+ // --- Ethereum ---
55
+ "USDC:ethereum": {
56
+ token: "USDC",
57
+ chain: "ethereum",
58
+ contractAddress: "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48",
59
+ decimals: 6,
60
+ },
61
+ "USDT:ethereum": {
62
+ token: "USDT",
63
+ chain: "ethereum",
64
+ contractAddress: "0xdAC17F958D2ee523a2206206994597C13D831ec7",
65
+ decimals: 6,
66
+ },
67
+ "DAI:ethereum": {
68
+ token: "DAI",
69
+ chain: "ethereum",
70
+ contractAddress: "0x6B175474E89094C44Da98b954EedeAC495271d0F",
71
+ decimals: 18,
72
+ },
73
+ // --- Arbitrum ---
74
+ "USDC:arbitrum": {
75
+ token: "USDC",
76
+ chain: "arbitrum",
77
+ contractAddress: "0xaf88d065e77c8cC2239327C5EDb3A432268e5831",
78
+ decimals: 6,
79
+ },
80
+ "USDT:arbitrum": {
81
+ token: "USDT",
82
+ chain: "arbitrum",
83
+ contractAddress: "0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9",
84
+ decimals: 6,
85
+ },
86
+ "DAI:arbitrum": {
87
+ token: "DAI",
88
+ chain: "arbitrum",
89
+ contractAddress: "0xDA10009cBd5D07dd0CeCc66161FC93D7c9000da1",
90
+ decimals: 18,
91
+ },
92
+ // --- Polygon ---
93
+ "USDC:polygon": {
94
+ token: "USDC",
95
+ chain: "polygon",
96
+ contractAddress: "0x3c499c542cEF5E3811e1192ce70d8cC03d5c3359",
97
+ decimals: 6,
98
+ },
99
+ "USDT:polygon": {
100
+ token: "USDT",
101
+ chain: "polygon",
102
+ contractAddress: "0xc2132D05D31c914a87C6611C10748AEb04B58e8F",
103
+ decimals: 6,
104
+ },
32
105
  };
33
106
 
34
107
  export function getChainConfig(chain: EvmChain): ChainConfig {
@@ -1,5 +1,5 @@
1
1
  /** Supported EVM chains. */
2
- export type EvmChain = "base";
2
+ export type EvmChain = "base" | "ethereum" | "arbitrum" | "polygon";
3
3
 
4
4
  /** Supported stablecoin tokens. */
5
5
  export type StablecoinToken = "USDC" | "USDT" | "DAI";
@@ -61,6 +61,8 @@ export class EvmWatcher {
61
61
 
62
62
  /** Poll for new Transfer events. Call on an interval. */
63
63
  async poll(): Promise<void> {
64
+ if (this._watchedAddresses.length === 0) return; // nothing to watch
65
+
64
66
  const latestHex = (await this.rpc("eth_blockNumber", [])) as string;
65
67
  const latest = Number.parseInt(latestHex, 16);
66
68
  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";
@@ -31,7 +31,7 @@ export const cryptoCharges = pgTable(
31
31
  index("idx_crypto_charges_status").on(table.status),
32
32
  index("idx_crypto_charges_created").on(table.createdAt),
33
33
  index("idx_crypto_charges_deposit_address").on(table.depositAddress),
34
- // uniqueIndex would be ideal but drizzle pgTable helper doesn't support it inline.
34
+ // Unique indexes use WHERE IS NOT NULL partial indexes (declared in migration SQL).
35
35
  // Enforced via migration: CREATE UNIQUE INDEX.
36
36
  ],
37
37
  );