@wopr-network/platform-core 1.14.8 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/account/deletion-executor-repository.d.ts +2 -2
  2. package/dist/account/deletion-executor-repository.js +5 -5
  3. package/dist/{monetization/payram → billing/crypto}/cents-credits-boundary.test.js +14 -17
  4. package/dist/billing/crypto/charge-store.d.ts +68 -0
  5. package/dist/billing/crypto/charge-store.js +109 -0
  6. package/dist/billing/crypto/charge-store.test.js +120 -0
  7. package/dist/billing/crypto/checkout.d.ts +18 -0
  8. package/dist/billing/crypto/checkout.js +35 -0
  9. package/dist/billing/crypto/checkout.test.js +71 -0
  10. package/dist/billing/crypto/client.d.ts +39 -0
  11. package/dist/billing/crypto/client.js +72 -0
  12. package/dist/billing/crypto/client.test.js +100 -0
  13. package/dist/billing/crypto/evm/__tests__/address-gen.test.js +54 -0
  14. package/dist/billing/crypto/evm/__tests__/checkout.test.js +54 -0
  15. package/dist/billing/crypto/evm/__tests__/config.test.js +52 -0
  16. package/dist/billing/crypto/evm/__tests__/settler.test.js +196 -0
  17. package/dist/billing/crypto/evm/__tests__/watcher.test.js +109 -0
  18. package/dist/billing/crypto/evm/address-gen.d.ts +8 -0
  19. package/dist/billing/crypto/evm/address-gen.js +29 -0
  20. package/dist/billing/crypto/evm/checkout.d.ts +26 -0
  21. package/dist/billing/crypto/evm/checkout.js +57 -0
  22. package/dist/billing/crypto/evm/config.d.ts +13 -0
  23. package/dist/billing/crypto/evm/config.js +46 -0
  24. package/dist/billing/crypto/evm/index.d.ts +9 -0
  25. package/dist/billing/crypto/evm/index.js +5 -0
  26. package/dist/billing/crypto/evm/settler.d.ts +23 -0
  27. package/dist/billing/crypto/evm/settler.js +60 -0
  28. package/dist/billing/crypto/evm/types.d.ts +40 -0
  29. package/dist/billing/crypto/evm/types.js +1 -0
  30. package/dist/billing/crypto/evm/watcher.d.ts +31 -0
  31. package/dist/billing/crypto/evm/watcher.js +91 -0
  32. package/dist/billing/crypto/index.d.ts +10 -0
  33. package/dist/billing/crypto/index.js +6 -0
  34. package/dist/billing/crypto/types.d.ts +61 -0
  35. package/dist/billing/crypto/types.js +24 -0
  36. package/dist/billing/crypto/webhook.d.ts +34 -0
  37. package/dist/billing/crypto/webhook.js +107 -0
  38. package/dist/billing/crypto/webhook.test.d.ts +1 -0
  39. package/dist/billing/crypto/webhook.test.js +266 -0
  40. package/dist/billing/index.d.ts +1 -1
  41. package/dist/billing/index.js +2 -2
  42. package/dist/billing/payment-processor.d.ts +3 -3
  43. package/dist/credits/credit-ledger.d.ts +3 -3
  44. package/dist/credits/credit-ledger.js +3 -3
  45. package/dist/db/schema/credits.js +1 -1
  46. package/dist/db/schema/{payram.d.ts → crypto.d.ts} +85 -13
  47. package/dist/db/schema/crypto.js +32 -0
  48. package/dist/db/schema/index.d.ts +1 -1
  49. package/dist/db/schema/index.js +1 -1
  50. package/dist/monetization/crypto/__tests__/webhook.test.d.ts +1 -0
  51. package/dist/monetization/crypto/__tests__/webhook.test.js +249 -0
  52. package/dist/monetization/crypto/index.d.ts +4 -0
  53. package/dist/monetization/crypto/index.js +2 -0
  54. package/dist/monetization/crypto/webhook.d.ts +24 -0
  55. package/dist/monetization/crypto/webhook.js +88 -0
  56. package/dist/monetization/index.d.ts +3 -3
  57. package/dist/monetization/index.js +1 -1
  58. package/dist/monetization/repository-types.d.ts +1 -1
  59. package/dist/observability/pagerduty.test.js +1 -0
  60. package/docs/superpowers/plans/2026-03-14-stablecoin-phase1.md +1413 -0
  61. package/drizzle/migrations/0004_crypto_charges.sql +25 -0
  62. package/drizzle/migrations/0005_stablecoin_columns.sql +7 -0
  63. package/drizzle/migrations/meta/_journal.json +14 -0
  64. package/package.json +4 -3
  65. package/src/account/deletion-executor-repository.ts +6 -6
  66. package/src/billing/{payram → crypto}/cents-credits-boundary.test.ts +14 -17
  67. package/src/billing/crypto/charge-store.test.ts +142 -0
  68. package/src/billing/crypto/charge-store.ts +166 -0
  69. package/src/billing/crypto/checkout.test.ts +93 -0
  70. package/src/billing/crypto/checkout.ts +48 -0
  71. package/src/billing/crypto/client.test.ts +132 -0
  72. package/src/billing/crypto/client.ts +86 -0
  73. package/src/billing/crypto/evm/__tests__/address-gen.test.ts +63 -0
  74. package/src/billing/crypto/evm/__tests__/checkout.test.ts +83 -0
  75. package/src/billing/crypto/evm/__tests__/config.test.ts +63 -0
  76. package/src/billing/crypto/evm/__tests__/settler.test.ts +218 -0
  77. package/src/billing/crypto/evm/__tests__/watcher.test.ts +128 -0
  78. package/src/billing/crypto/evm/address-gen.ts +29 -0
  79. package/src/billing/crypto/evm/checkout.ts +82 -0
  80. package/src/billing/crypto/evm/config.ts +50 -0
  81. package/src/billing/crypto/evm/index.ts +16 -0
  82. package/src/billing/crypto/evm/settler.ts +79 -0
  83. package/src/billing/crypto/evm/types.ts +45 -0
  84. package/src/billing/crypto/evm/watcher.ts +126 -0
  85. package/src/billing/crypto/index.ts +16 -0
  86. package/src/billing/crypto/types.ts +83 -0
  87. package/src/billing/crypto/webhook.test.ts +340 -0
  88. package/src/billing/crypto/webhook.ts +136 -0
  89. package/src/billing/index.ts +2 -2
  90. package/src/billing/payment-processor.ts +3 -3
  91. package/src/credits/credit-ledger.ts +3 -3
  92. package/src/db/schema/credits.ts +1 -1
  93. package/src/db/schema/crypto.ts +37 -0
  94. package/src/db/schema/index.ts +1 -1
  95. package/src/monetization/crypto/__tests__/webhook.test.ts +327 -0
  96. package/src/monetization/crypto/index.ts +23 -0
  97. package/src/monetization/crypto/webhook.ts +115 -0
  98. package/src/monetization/index.ts +23 -21
  99. package/src/monetization/repository-types.ts +2 -2
  100. package/src/observability/pagerduty.test.ts +1 -0
  101. package/dist/billing/payram/cents-credits-boundary.test.js +0 -75
  102. package/dist/billing/payram/charge-store.d.ts +0 -41
  103. package/dist/billing/payram/charge-store.js +0 -72
  104. package/dist/billing/payram/charge-store.test.js +0 -64
  105. package/dist/billing/payram/checkout.d.ts +0 -15
  106. package/dist/billing/payram/checkout.js +0 -24
  107. package/dist/billing/payram/checkout.test.js +0 -74
  108. package/dist/billing/payram/client.d.ts +0 -7
  109. package/dist/billing/payram/client.js +0 -15
  110. package/dist/billing/payram/client.test.js +0 -52
  111. package/dist/billing/payram/index.d.ts +0 -8
  112. package/dist/billing/payram/index.js +0 -4
  113. package/dist/billing/payram/types.d.ts +0 -40
  114. package/dist/billing/payram/webhook.d.ts +0 -19
  115. package/dist/billing/payram/webhook.js +0 -71
  116. package/dist/billing/payram/webhook.test.d.ts +0 -7
  117. package/dist/billing/payram/webhook.test.js +0 -249
  118. package/dist/db/schema/payram.js +0 -21
  119. package/dist/monetization/payram/charge-store.test.js +0 -64
  120. package/dist/monetization/payram/checkout.test.js +0 -73
  121. package/dist/monetization/payram/client.test.js +0 -52
  122. package/dist/monetization/payram/index.d.ts +0 -4
  123. package/dist/monetization/payram/index.js +0 -2
  124. package/dist/monetization/payram/webhook.d.ts +0 -17
  125. package/dist/monetization/payram/webhook.js +0 -71
  126. package/dist/monetization/payram/webhook.test.d.ts +0 -7
  127. package/dist/monetization/payram/webhook.test.js +0 -247
  128. package/src/billing/payram/charge-store.test.ts +0 -84
  129. package/src/billing/payram/charge-store.ts +0 -109
  130. package/src/billing/payram/checkout.test.ts +0 -99
  131. package/src/billing/payram/checkout.ts +0 -40
  132. package/src/billing/payram/client.test.ts +0 -62
  133. package/src/billing/payram/client.ts +0 -21
  134. package/src/billing/payram/index.ts +0 -14
  135. package/src/billing/payram/types.ts +0 -44
  136. package/src/billing/payram/webhook.test.ts +0 -320
  137. package/src/billing/payram/webhook.ts +0 -94
  138. package/src/db/schema/payram.ts +0 -26
  139. package/src/monetization/payram/cents-credits-boundary.test.ts +0 -84
  140. package/src/monetization/payram/charge-store.test.ts +0 -84
  141. package/src/monetization/payram/checkout.test.ts +0 -98
  142. package/src/monetization/payram/client.test.ts +0 -62
  143. package/src/monetization/payram/index.ts +0 -20
  144. package/src/monetization/payram/webhook.test.ts +0 -327
  145. package/src/monetization/payram/webhook.ts +0 -97
  146. /package/dist/billing/{payram → crypto}/cents-credits-boundary.test.d.ts +0 -0
  147. /package/dist/billing/{payram → crypto}/charge-store.test.d.ts +0 -0
  148. /package/dist/billing/{payram → crypto}/checkout.test.d.ts +0 -0
  149. /package/dist/billing/{payram → crypto}/client.test.d.ts +0 -0
  150. /package/dist/billing/{payram/types.js → crypto/evm/__tests__/address-gen.test.d.ts} +0 -0
  151. /package/dist/{monetization/payram → billing/crypto/evm/__tests__}/checkout.test.d.ts +0 -0
  152. /package/dist/{monetization/payram/cents-credits-boundary.test.d.ts → billing/crypto/evm/__tests__/config.test.d.ts} +0 -0
  153. /package/dist/{monetization/payram/charge-store.test.d.ts → billing/crypto/evm/__tests__/settler.test.d.ts} +0 -0
  154. /package/dist/{monetization/payram/client.test.d.ts → billing/crypto/evm/__tests__/watcher.test.d.ts} +0 -0
@@ -0,0 +1,128 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { EvmWatcher } from "../watcher.js";
3
+
4
+ const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
5
+
6
+ function mockTransferLog(to: string, amount: bigint, blockNumber: number) {
7
+ return {
8
+ address: "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913",
9
+ topics: [
10
+ TRANSFER_TOPIC,
11
+ `0x${"00".repeat(12)}${"ab".repeat(20)}`, // from (padded)
12
+ `0x${"00".repeat(12)}${to.slice(2).toLowerCase()}`, // to (padded)
13
+ ],
14
+ data: `0x${amount.toString(16).padStart(64, "0")}`,
15
+ blockNumber: `0x${blockNumber.toString(16)}`,
16
+ transactionHash: `0x${"ff".repeat(32)}`,
17
+ logIndex: "0x0",
18
+ };
19
+ }
20
+
21
+ describe("EvmWatcher", () => {
22
+ it("parses Transfer log into EvmPaymentEvent", async () => {
23
+ const events: { amountUsdCents: number; to: string }[] = [];
24
+ const mockRpc = vi
25
+ .fn()
26
+ .mockResolvedValueOnce(`0x${(102).toString(16)}`) // eth_blockNumber: block 102
27
+ .mockResolvedValueOnce([mockTransferLog(`0x${"cc".repeat(20)}`, 10_000_000n, 99)]); // eth_getLogs
28
+
29
+ const watcher = new EvmWatcher({
30
+ chain: "base",
31
+ token: "USDC",
32
+ rpcCall: mockRpc,
33
+ fromBlock: 99,
34
+ onPayment: (evt) => {
35
+ events.push(evt);
36
+ },
37
+ });
38
+
39
+ await watcher.poll();
40
+
41
+ expect(events).toHaveLength(1);
42
+ expect(events[0].amountUsdCents).toBe(1000); // 10 USDC = $10 = 1000 cents
43
+ expect(events[0].to).toMatch(/^0x/);
44
+ });
45
+
46
+ it("advances cursor after processing", async () => {
47
+ const mockRpc = vi
48
+ .fn()
49
+ .mockResolvedValueOnce(`0x${(200).toString(16)}`) // block 200
50
+ .mockResolvedValueOnce([]); // no logs
51
+
52
+ const watcher = new EvmWatcher({
53
+ chain: "base",
54
+ token: "USDC",
55
+ rpcCall: mockRpc,
56
+ fromBlock: 100,
57
+ onPayment: vi.fn(),
58
+ });
59
+
60
+ await watcher.poll();
61
+ expect(watcher.cursor).toBeGreaterThan(100);
62
+ });
63
+
64
+ it("skips blocks not yet confirmed", async () => {
65
+ const events: unknown[] = [];
66
+ const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(50).toString(16)}`); // current block: 50
67
+
68
+ // Base needs 1 confirmation, so confirmed = 50 - 1 = 49
69
+ // cursor starts at 50, so confirmed (49) < cursor (50) → no poll
70
+ const watcher = new EvmWatcher({
71
+ chain: "base",
72
+ token: "USDC",
73
+ rpcCall: mockRpc,
74
+ fromBlock: 50,
75
+ onPayment: (evt) => {
76
+ events.push(evt);
77
+ },
78
+ });
79
+
80
+ await watcher.poll();
81
+ expect(events).toHaveLength(0);
82
+ // eth_getLogs should not even be called
83
+ expect(mockRpc).toHaveBeenCalledTimes(1);
84
+ });
85
+
86
+ it("processes multiple logs in one poll", async () => {
87
+ const events: { amountUsdCents: number }[] = [];
88
+ const mockRpc = vi
89
+ .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
+ ]);
95
+
96
+ const watcher = new EvmWatcher({
97
+ chain: "base",
98
+ token: "USDC",
99
+ rpcCall: mockRpc,
100
+ fromBlock: 100,
101
+ onPayment: (evt) => {
102
+ events.push(evt);
103
+ },
104
+ });
105
+
106
+ await watcher.poll();
107
+
108
+ expect(events).toHaveLength(2);
109
+ expect(events[0].amountUsdCents).toBe(500);
110
+ expect(events[1].amountUsdCents).toBe(2000);
111
+ });
112
+
113
+ it("does nothing when no new blocks", async () => {
114
+ const mockRpc = vi.fn().mockResolvedValueOnce(`0x${(99).toString(16)}`); // block 99, confirmed = 98
115
+
116
+ const watcher = new EvmWatcher({
117
+ chain: "base",
118
+ token: "USDC",
119
+ rpcCall: mockRpc,
120
+ fromBlock: 100,
121
+ onPayment: vi.fn(),
122
+ });
123
+
124
+ await watcher.poll();
125
+ expect(watcher.cursor).toBe(100); // unchanged
126
+ expect(mockRpc).toHaveBeenCalledTimes(1); // only eth_blockNumber
127
+ });
128
+ });
@@ -0,0 +1,29 @@
1
+ import { HDKey } from "@scure/bip32";
2
+ import { publicKeyToAddress } from "viem/accounts";
3
+
4
+ /**
5
+ * Derive a deposit address from an xpub at a given BIP-44 index.
6
+ * Path: xpub / 0 / index (external chain / address index).
7
+ * Returns a checksummed Ethereum address. No private keys involved.
8
+ */
9
+ export function deriveDepositAddress(xpub: string, index: number): `0x${string}` {
10
+ if (!Number.isInteger(index) || index < 0) 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) throw new Error("Failed to derive public key");
14
+
15
+ const hexPubKey =
16
+ `0x${Array.from(child.publicKey, (b) => b.toString(16).padStart(2, "0")).join("")}` as `0x${string}`;
17
+ return publicKeyToAddress(hexPubKey);
18
+ }
19
+
20
+ /** Validate that a string is an xpub (not xprv). */
21
+ export function isValidXpub(key: string): boolean {
22
+ if (!key.startsWith("xpub")) return false;
23
+ try {
24
+ HDKey.fromExtendedKey(key);
25
+ return true;
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
@@ -0,0 +1,82 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ import type { ICryptoChargeRepository } from "../charge-store.js";
3
+ import { deriveDepositAddress } from "./address-gen.js";
4
+ import { getTokenConfig, tokenAmountFromCents } from "./config.js";
5
+ import type { StablecoinCheckoutOpts } from "./types.js";
6
+
7
+ export const MIN_STABLECOIN_USD = 10;
8
+
9
+ export interface StablecoinCheckoutDeps {
10
+ chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
11
+ xpub: string;
12
+ }
13
+
14
+ export interface StablecoinCheckoutResult {
15
+ depositAddress: string;
16
+ amountRaw: string;
17
+ amountUsd: number;
18
+ chain: string;
19
+ token: string;
20
+ referenceId: string;
21
+ }
22
+
23
+ /**
24
+ * Create a stablecoin checkout — derive a unique deposit address, store the charge.
25
+ *
26
+ * Race safety: the unique constraint on derivation_index prevents two concurrent
27
+ * checkouts from claiming the same index. On conflict, we retry with the next index.
28
+ *
29
+ * CRITICAL: amountUsd is converted to integer cents via Credit.fromDollars().toCentsRounded().
30
+ * The charge store holds USD cents (integer). Credit.fromCents() handles the
31
+ * cents → nanodollars conversion when crediting the ledger in the settler.
32
+ */
33
+ export async function createStablecoinCheckout(
34
+ deps: StablecoinCheckoutDeps,
35
+ opts: StablecoinCheckoutOpts,
36
+ ): Promise<StablecoinCheckoutResult> {
37
+ if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_STABLECOIN_USD) {
38
+ throw new Error(`Minimum payment amount is $${MIN_STABLECOIN_USD}`);
39
+ }
40
+
41
+ const tokenCfg = getTokenConfig(opts.token, opts.chain);
42
+
43
+ // Convert dollars to integer cents via Credit (no floating point in billing path).
44
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
45
+ const rawAmount = tokenAmountFromCents(amountUsdCents, tokenCfg.decimals);
46
+
47
+ const maxRetries = 3;
48
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
49
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
50
+ const depositAddress = deriveDepositAddress(deps.xpub, derivationIndex);
51
+ const referenceId = `sc:${opts.chain}:${opts.token.toLowerCase()}:${depositAddress.toLowerCase()}`;
52
+
53
+ try {
54
+ await deps.chargeStore.createStablecoinCharge({
55
+ referenceId,
56
+ tenantId: opts.tenant,
57
+ amountUsdCents,
58
+ chain: opts.chain,
59
+ token: opts.token,
60
+ depositAddress: depositAddress.toLowerCase(),
61
+ derivationIndex,
62
+ });
63
+
64
+ return {
65
+ depositAddress,
66
+ amountRaw: rawAmount.toString(),
67
+ amountUsd: opts.amountUsd,
68
+ chain: opts.chain,
69
+ token: opts.token,
70
+ referenceId,
71
+ };
72
+ } catch (err: unknown) {
73
+ // Unique constraint violation = another checkout claimed this index concurrently.
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");
77
+ if (!isConflict || attempt === maxRetries) throw err;
78
+ }
79
+ }
80
+
81
+ throw new Error("Failed to claim derivation index after retries");
82
+ }
@@ -0,0 +1,50 @@
1
+ import type { ChainConfig, EvmChain, StablecoinToken, TokenConfig } from "./types.js";
2
+
3
+ const CHAINS: Record<EvmChain, ChainConfig> = {
4
+ base: {
5
+ chain: "base",
6
+ rpcUrl: process.env.EVM_RPC_BASE ?? "http://op-geth:8545",
7
+ confirmations: 1,
8
+ blockTimeMs: 2000,
9
+ chainId: 8453,
10
+ },
11
+ };
12
+
13
+ const TOKENS: Record<`${StablecoinToken}:${EvmChain}`, TokenConfig> = {
14
+ "USDC:base": {
15
+ token: "USDC",
16
+ chain: "base",
17
+ contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
18
+ decimals: 6,
19
+ },
20
+ };
21
+
22
+ export function getChainConfig(chain: EvmChain): ChainConfig {
23
+ const cfg = CHAINS[chain];
24
+ if (!cfg) throw new Error(`Unsupported chain: ${chain}`);
25
+ return cfg;
26
+ }
27
+
28
+ export function getTokenConfig(token: StablecoinToken, chain: EvmChain): TokenConfig {
29
+ const key = `${token}:${chain}` as const;
30
+ const cfg = TOKENS[key];
31
+ if (!cfg) throw new Error(`Unsupported token ${token} on ${chain}`);
32
+ return cfg;
33
+ }
34
+
35
+ /**
36
+ * Convert USD cents (integer) to token raw amount (BigInt).
37
+ * Stablecoins are 1:1 USD. Integer math only.
38
+ */
39
+ export function tokenAmountFromCents(cents: number, decimals: number): bigint {
40
+ if (!Number.isInteger(cents)) throw new Error("cents must be an integer");
41
+ return (BigInt(cents) * 10n ** BigInt(decimals)) / 100n;
42
+ }
43
+
44
+ /**
45
+ * Convert token raw amount (BigInt) to USD cents (integer).
46
+ * Truncates fractional cents.
47
+ */
48
+ export function centsFromTokenAmount(rawAmount: bigint, decimals: number): number {
49
+ return Number((rawAmount * 100n) / 10n ** BigInt(decimals));
50
+ }
@@ -0,0 +1,16 @@
1
+ export { deriveDepositAddress, isValidXpub } from "./address-gen.js";
2
+ export type { StablecoinCheckoutDeps, StablecoinCheckoutResult } from "./checkout.js";
3
+ export { createStablecoinCheckout, MIN_STABLECOIN_USD } from "./checkout.js";
4
+ export { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "./config.js";
5
+ export type { EvmSettlerDeps } from "./settler.js";
6
+ export { settleEvmPayment } from "./settler.js";
7
+ export type {
8
+ ChainConfig,
9
+ EvmChain,
10
+ EvmPaymentEvent,
11
+ StablecoinCheckoutOpts,
12
+ StablecoinToken,
13
+ TokenConfig,
14
+ } from "./types.js";
15
+ export type { EvmWatcherOpts } from "./watcher.js";
16
+ export { createRpcCaller, EvmWatcher } from "./watcher.js";
@@ -0,0 +1,79 @@
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 { EvmPaymentEvent } from "./types.js";
6
+
7
+ export interface EvmSettlerDeps {
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 an EVM payment event — look up charge by deposit address, credit ledger.
15
+ *
16
+ * Same idempotency pattern as handleCryptoWebhook():
17
+ * Primary: creditLedger.hasReferenceId() — atomic in ledger transaction
18
+ * Secondary: chargeStore.markCredited() — advisory
19
+ *
20
+ * Credits the CHARGE amount (not the transfer amount) for overpayment safety.
21
+ *
22
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
23
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
24
+ * Never pass raw cents to the ledger — always go through Credit.fromCents().
25
+ */
26
+ export async function settleEvmPayment(deps: EvmSettlerDeps, event: EvmPaymentEvent): Promise<CryptoWebhookResult> {
27
+ const { chargeStore, creditLedger } = deps;
28
+
29
+ const charge = await chargeStore.getByDepositAddress(event.to.toLowerCase());
30
+ if (!charge) {
31
+ return { handled: false, status: "Settled" };
32
+ }
33
+
34
+ // Update charge status to Settled.
35
+ await chargeStore.updateStatus(charge.referenceId, "Settled");
36
+
37
+ // Charge-level idempotency: if this charge was already credited (by any transfer),
38
+ // reject. Prevents double-credit when a user sends two transactions to the same address.
39
+ if (charge.creditedAt != null) {
40
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
41
+ }
42
+
43
+ // Transfer-level idempotency: if this specific tx was already processed, skip.
44
+ const creditRef = `evm:${event.chain}:${event.txHash}:${event.logIndex}`;
45
+ if (await creditLedger.hasReferenceId(creditRef)) {
46
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
47
+ }
48
+
49
+ // Reject underpayment — transfer must cover the charge amount.
50
+ if (event.amountUsdCents < charge.amountUsdCents) {
51
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
52
+ }
53
+
54
+ // Credit the CHARGE amount (NOT the transfer amount — overpayment stays in wallet).
55
+ // charge.amountUsdCents is in USD cents (integer).
56
+ // Credit.fromCents() converts to nanodollars for the ledger.
57
+ const creditCents = charge.amountUsdCents;
58
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
59
+ description: `Stablecoin credit purchase (${event.token} on ${event.chain}, tx: ${event.txHash})`,
60
+ referenceId: creditRef,
61
+ fundingSource: "crypto",
62
+ });
63
+
64
+ await chargeStore.markCredited(charge.referenceId);
65
+
66
+ let reactivatedBots: string[] | undefined;
67
+ if (deps.onCreditsPurchased) {
68
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger as ILedger);
69
+ if (reactivatedBots.length === 0) reactivatedBots = undefined;
70
+ }
71
+
72
+ return {
73
+ handled: true,
74
+ status: "Settled",
75
+ tenant: charge.tenantId,
76
+ creditedCents: creditCents,
77
+ reactivatedBots,
78
+ };
79
+ }
@@ -0,0 +1,45 @@
1
+ /** Supported EVM chains. */
2
+ export type EvmChain = "base";
3
+
4
+ /** Supported stablecoin tokens. */
5
+ export type StablecoinToken = "USDC";
6
+
7
+ /** Chain configuration. */
8
+ export interface ChainConfig {
9
+ readonly chain: EvmChain;
10
+ readonly rpcUrl: string;
11
+ readonly confirmations: number;
12
+ readonly blockTimeMs: number;
13
+ readonly chainId: number;
14
+ }
15
+
16
+ /** Token configuration on a specific chain. */
17
+ export interface TokenConfig {
18
+ readonly token: StablecoinToken;
19
+ readonly chain: EvmChain;
20
+ readonly contractAddress: `0x${string}`;
21
+ readonly decimals: number;
22
+ }
23
+
24
+ /** Event emitted when a Transfer is detected and confirmed. */
25
+ export interface EvmPaymentEvent {
26
+ readonly chain: EvmChain;
27
+ readonly token: StablecoinToken;
28
+ readonly from: string;
29
+ readonly to: string;
30
+ /** Raw token amount (BigInt as string for serialization). */
31
+ readonly rawAmount: string;
32
+ /** USD cents equivalent (integer). */
33
+ readonly amountUsdCents: number;
34
+ readonly txHash: string;
35
+ readonly blockNumber: number;
36
+ readonly logIndex: number;
37
+ }
38
+
39
+ /** Options for creating a stablecoin checkout. */
40
+ export interface StablecoinCheckoutOpts {
41
+ tenant: string;
42
+ amountUsd: number;
43
+ chain: EvmChain;
44
+ token: StablecoinToken;
45
+ }
@@ -0,0 +1,126 @@
1
+ import { centsFromTokenAmount, getChainConfig, getTokenConfig } from "./config.js";
2
+ import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./types.js";
3
+
4
+ const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
5
+
6
+ type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
7
+
8
+ export interface EvmWatcherOpts {
9
+ chain: EvmChain;
10
+ token: StablecoinToken;
11
+ rpcCall: RpcCall;
12
+ fromBlock: number;
13
+ onPayment: (event: EvmPaymentEvent) => void | Promise<void>;
14
+ /** Active deposit addresses to watch. Filters eth_getLogs by topic[2] (to address). */
15
+ watchedAddresses?: string[];
16
+ }
17
+
18
+ interface RpcLog {
19
+ address: string;
20
+ topics: string[];
21
+ data: string;
22
+ blockNumber: string;
23
+ transactionHash: string;
24
+ logIndex: string;
25
+ }
26
+
27
+ export class EvmWatcher {
28
+ private _cursor: number;
29
+ private readonly chain: EvmChain;
30
+ private readonly token: StablecoinToken;
31
+ private readonly rpc: RpcCall;
32
+ private readonly onPayment: EvmWatcherOpts["onPayment"];
33
+ private readonly confirmations: number;
34
+ private readonly contractAddress: string;
35
+ private readonly decimals: number;
36
+ private _watchedAddresses: string[];
37
+
38
+ constructor(opts: EvmWatcherOpts) {
39
+ this.chain = opts.chain;
40
+ this.token = opts.token;
41
+ this.rpc = opts.rpcCall;
42
+ this._cursor = opts.fromBlock;
43
+ this.onPayment = opts.onPayment;
44
+ this._watchedAddresses = (opts.watchedAddresses ?? []).map((a) => a.toLowerCase());
45
+
46
+ const chainCfg = getChainConfig(opts.chain);
47
+ const tokenCfg = getTokenConfig(opts.token, opts.chain);
48
+ this.confirmations = chainCfg.confirmations;
49
+ this.contractAddress = tokenCfg.contractAddress.toLowerCase();
50
+ this.decimals = tokenCfg.decimals;
51
+ }
52
+
53
+ /** Update the set of watched deposit addresses (e.g. after a new checkout). */
54
+ setWatchedAddresses(addresses: string[]): void {
55
+ this._watchedAddresses = addresses.map((a) => a.toLowerCase());
56
+ }
57
+
58
+ get cursor(): number {
59
+ return this._cursor;
60
+ }
61
+
62
+ /** Poll for new Transfer events. Call on an interval. */
63
+ async poll(): Promise<void> {
64
+ const latestHex = (await this.rpc("eth_blockNumber", [])) as string;
65
+ const latest = Number.parseInt(latestHex, 16);
66
+ const confirmed = latest - this.confirmations;
67
+
68
+ if (confirmed < this._cursor) return;
69
+
70
+ // Filter by topic[2] (to address) when watched addresses are set.
71
+ // This avoids fetching ALL USDC transfers on the chain (millions/day on Base).
72
+ // topic[2] values are 32-byte zero-padded: 0x000000000000000000000000<address>
73
+ const toFilter =
74
+ this._watchedAddresses.length > 0
75
+ ? this._watchedAddresses.map((a) => `0x000000000000000000000000${a.slice(2)}`)
76
+ : null;
77
+
78
+ const logs = (await this.rpc("eth_getLogs", [
79
+ {
80
+ address: this.contractAddress,
81
+ topics: [TRANSFER_TOPIC, null, toFilter],
82
+ fromBlock: `0x${this._cursor.toString(16)}`,
83
+ toBlock: `0x${confirmed.toString(16)}`,
84
+ },
85
+ ])) as RpcLog[];
86
+
87
+ for (const log of logs) {
88
+ const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
89
+ const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
90
+ const rawAmount = BigInt(log.data);
91
+ const amountUsdCents = centsFromTokenAmount(rawAmount, this.decimals);
92
+
93
+ const event: EvmPaymentEvent = {
94
+ chain: this.chain,
95
+ token: this.token,
96
+ from,
97
+ to,
98
+ rawAmount: rawAmount.toString(),
99
+ amountUsdCents,
100
+ txHash: log.transactionHash,
101
+ blockNumber: Number.parseInt(log.blockNumber, 16),
102
+ logIndex: Number.parseInt(log.logIndex, 16),
103
+ };
104
+
105
+ await this.onPayment(event);
106
+ }
107
+
108
+ this._cursor = confirmed + 1;
109
+ }
110
+ }
111
+
112
+ /** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
113
+ export function createRpcCaller(rpcUrl: string): RpcCall {
114
+ let id = 0;
115
+ return async (method: string, params: unknown[]): Promise<unknown> => {
116
+ const res = await fetch(rpcUrl, {
117
+ method: "POST",
118
+ headers: { "Content-Type": "application/json" },
119
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
120
+ });
121
+ if (!res.ok) throw new Error(`RPC ${method} failed: ${res.status}`);
122
+ const data = (await res.json()) as { result?: unknown; error?: { message: string } };
123
+ if (data.error) throw new Error(`RPC ${method} error: ${data.error.message}`);
124
+ return data.result;
125
+ };
126
+ }
@@ -0,0 +1,16 @@
1
+ export type { CryptoChargeRecord, ICryptoChargeRepository, StablecoinChargeInput } from "./charge-store.js";
2
+ export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
3
+ export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
4
+ export type { CryptoConfig } from "./client.js";
5
+ export { BTCPayClient, loadCryptoConfig } from "./client.js";
6
+ export * from "./evm/index.js";
7
+ export type {
8
+ CryptoBillingConfig,
9
+ CryptoCheckoutOpts,
10
+ CryptoPaymentState,
11
+ CryptoWebhookPayload,
12
+ CryptoWebhookResult,
13
+ } from "./types.js";
14
+ export { mapBtcPayEventToStatus } from "./types.js";
15
+ export type { CryptoWebhookDeps } from "./webhook.js";
16
+ export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
@@ -0,0 +1,83 @@
1
+ /** BTCPay Server invoice states (Greenfield API v1). */
2
+ export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
3
+
4
+ /** Options for creating a crypto payment session. */
5
+ export interface CryptoCheckoutOpts {
6
+ /** Internal tenant ID. */
7
+ tenant: string;
8
+ /** Amount in USD (minimum $10). */
9
+ amountUsd: number;
10
+ }
11
+
12
+ /** Webhook payload received from BTCPay Server (InvoiceSettled event). */
13
+ export interface CryptoWebhookPayload {
14
+ /** BTCPay delivery ID (for deduplication). */
15
+ deliveryId: string;
16
+ /** Webhook ID. */
17
+ webhookId: string;
18
+ /** Original delivery ID (same as deliveryId on first delivery). */
19
+ originalDeliveryId: string;
20
+ /** Whether this is a redelivery. */
21
+ isRedelivery: boolean;
22
+ /** Event type (e.g. "InvoiceSettled", "InvoiceProcessing", "InvoiceExpired"). */
23
+ type: string;
24
+ /** Unix timestamp. */
25
+ timestamp: number;
26
+ /** BTCPay store ID. */
27
+ storeId: string;
28
+ /** BTCPay invoice ID. */
29
+ invoiceId: string;
30
+ /** Invoice metadata (echoed from creation). */
31
+ metadata: Record<string, unknown>;
32
+ /** Whether admin manually marked as settled (InvoiceSettled only). */
33
+ manuallyMarked?: boolean;
34
+ /** Whether customer overpaid (InvoiceSettled only). */
35
+ overPaid?: boolean;
36
+ /** Whether invoice was partially paid (InvoiceExpired only). */
37
+ partiallyPaid?: boolean;
38
+ }
39
+
40
+ /** Configuration for BTCPay Server integration. */
41
+ export interface CryptoBillingConfig {
42
+ /** BTCPay API key (from Account > API keys). */
43
+ apiKey: string;
44
+ /** BTCPay Server base URL. */
45
+ baseUrl: string;
46
+ /** BTCPay store ID. */
47
+ storeId: string;
48
+ }
49
+
50
+ /** Result of processing a crypto webhook event. */
51
+ export interface CryptoWebhookResult {
52
+ handled: boolean;
53
+ status: string;
54
+ tenant?: string;
55
+ creditedCents?: number;
56
+ reactivatedBots?: string[];
57
+ duplicate?: boolean;
58
+ }
59
+
60
+ /**
61
+ * Map BTCPay webhook event type string to a CryptoPaymentState.
62
+ *
63
+ * Shared between the core (billing) and consumer (monetization) webhook handlers.
64
+ * Throws on unrecognized event types to surface integration errors early.
65
+ */
66
+ export function mapBtcPayEventToStatus(eventType: string): CryptoPaymentState {
67
+ switch (eventType) {
68
+ case "InvoiceCreated":
69
+ return "New";
70
+ case "InvoiceReceivedPayment":
71
+ case "InvoiceProcessing":
72
+ return "Processing";
73
+ case "InvoiceSettled":
74
+ case "InvoicePaymentSettled":
75
+ return "Settled";
76
+ case "InvoiceExpired":
77
+ return "Expired";
78
+ case "InvoiceInvalid":
79
+ return "Invalid";
80
+ default:
81
+ throw new Error(`Unknown BTCPay event type: ${eventType}`);
82
+ }
83
+ }