@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,57 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ import { deriveDepositAddress } from "./address-gen.js";
3
+ import { getTokenConfig, tokenAmountFromCents } from "./config.js";
4
+ export const MIN_STABLECOIN_USD = 10;
5
+ /**
6
+ * Create a stablecoin checkout — derive a unique deposit address, store the charge.
7
+ *
8
+ * Race safety: the unique constraint on derivation_index prevents two concurrent
9
+ * checkouts from claiming the same index. On conflict, we retry with the next index.
10
+ *
11
+ * CRITICAL: amountUsd is converted to integer cents via Credit.fromDollars().toCentsRounded().
12
+ * The charge store holds USD cents (integer). Credit.fromCents() handles the
13
+ * cents → nanodollars conversion when crediting the ledger in the settler.
14
+ */
15
+ export async function createStablecoinCheckout(deps, opts) {
16
+ if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_STABLECOIN_USD) {
17
+ throw new Error(`Minimum payment amount is $${MIN_STABLECOIN_USD}`);
18
+ }
19
+ const tokenCfg = getTokenConfig(opts.token, opts.chain);
20
+ // Convert dollars to integer cents via Credit (no floating point in billing path).
21
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
22
+ const rawAmount = tokenAmountFromCents(amountUsdCents, tokenCfg.decimals);
23
+ const maxRetries = 3;
24
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
25
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
26
+ const depositAddress = deriveDepositAddress(deps.xpub, derivationIndex);
27
+ const referenceId = `sc:${opts.chain}:${opts.token.toLowerCase()}:${depositAddress.toLowerCase()}`;
28
+ try {
29
+ await deps.chargeStore.createStablecoinCharge({
30
+ referenceId,
31
+ tenantId: opts.tenant,
32
+ amountUsdCents,
33
+ chain: opts.chain,
34
+ token: opts.token,
35
+ depositAddress: depositAddress.toLowerCase(),
36
+ derivationIndex,
37
+ });
38
+ return {
39
+ depositAddress,
40
+ amountRaw: rawAmount.toString(),
41
+ amountUsd: opts.amountUsd,
42
+ chain: opts.chain,
43
+ token: opts.token,
44
+ referenceId,
45
+ };
46
+ }
47
+ catch (err) {
48
+ // Unique constraint violation = another checkout claimed this index concurrently.
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");
52
+ if (!isConflict || attempt === maxRetries)
53
+ throw err;
54
+ }
55
+ }
56
+ throw new Error("Failed to claim derivation index after retries");
57
+ }
@@ -0,0 +1,13 @@
1
+ import type { ChainConfig, EvmChain, StablecoinToken, TokenConfig } from "./types.js";
2
+ export declare function getChainConfig(chain: EvmChain): ChainConfig;
3
+ export declare function getTokenConfig(token: StablecoinToken, chain: EvmChain): TokenConfig;
4
+ /**
5
+ * Convert USD cents (integer) to token raw amount (BigInt).
6
+ * Stablecoins are 1:1 USD. Integer math only.
7
+ */
8
+ export declare function tokenAmountFromCents(cents: number, decimals: number): bigint;
9
+ /**
10
+ * Convert token raw amount (BigInt) to USD cents (integer).
11
+ * Truncates fractional cents.
12
+ */
13
+ export declare function centsFromTokenAmount(rawAmount: bigint, decimals: number): number;
@@ -0,0 +1,46 @@
1
+ const CHAINS = {
2
+ base: {
3
+ chain: "base",
4
+ rpcUrl: process.env.EVM_RPC_BASE ?? "http://op-geth:8545",
5
+ confirmations: 1,
6
+ blockTimeMs: 2000,
7
+ chainId: 8453,
8
+ },
9
+ };
10
+ const TOKENS = {
11
+ "USDC:base": {
12
+ token: "USDC",
13
+ chain: "base",
14
+ contractAddress: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
15
+ decimals: 6,
16
+ },
17
+ };
18
+ export function getChainConfig(chain) {
19
+ const cfg = CHAINS[chain];
20
+ if (!cfg)
21
+ throw new Error(`Unsupported chain: ${chain}`);
22
+ return cfg;
23
+ }
24
+ export function getTokenConfig(token, chain) {
25
+ const key = `${token}:${chain}`;
26
+ const cfg = TOKENS[key];
27
+ if (!cfg)
28
+ throw new Error(`Unsupported token ${token} on ${chain}`);
29
+ return cfg;
30
+ }
31
+ /**
32
+ * Convert USD cents (integer) to token raw amount (BigInt).
33
+ * Stablecoins are 1:1 USD. Integer math only.
34
+ */
35
+ export function tokenAmountFromCents(cents, decimals) {
36
+ if (!Number.isInteger(cents))
37
+ throw new Error("cents must be an integer");
38
+ return (BigInt(cents) * 10n ** BigInt(decimals)) / 100n;
39
+ }
40
+ /**
41
+ * Convert token raw amount (BigInt) to USD cents (integer).
42
+ * Truncates fractional cents.
43
+ */
44
+ export function centsFromTokenAmount(rawAmount, decimals) {
45
+ return Number((rawAmount * 100n) / 10n ** BigInt(decimals));
46
+ }
@@ -0,0 +1,9 @@
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 { ChainConfig, EvmChain, EvmPaymentEvent, StablecoinCheckoutOpts, StablecoinToken, TokenConfig, } from "./types.js";
8
+ export type { EvmWatcherOpts } from "./watcher.js";
9
+ export { createRpcCaller, EvmWatcher } from "./watcher.js";
@@ -0,0 +1,5 @@
1
+ export { deriveDepositAddress, isValidXpub } from "./address-gen.js";
2
+ export { createStablecoinCheckout, MIN_STABLECOIN_USD } from "./checkout.js";
3
+ export { centsFromTokenAmount, getChainConfig, getTokenConfig, tokenAmountFromCents } from "./config.js";
4
+ export { settleEvmPayment } from "./settler.js";
5
+ export { createRpcCaller, EvmWatcher } from "./watcher.js";
@@ -0,0 +1,23 @@
1
+ import type { ILedger } from "../../../credits/ledger.js";
2
+ import type { ICryptoChargeRepository } from "../charge-store.js";
3
+ import type { CryptoWebhookResult } from "../types.js";
4
+ import type { EvmPaymentEvent } from "./types.js";
5
+ export interface EvmSettlerDeps {
6
+ chargeStore: Pick<ICryptoChargeRepository, "getByDepositAddress" | "updateStatus" | "markCredited">;
7
+ creditLedger: Pick<ILedger, "credit" | "hasReferenceId">;
8
+ onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
9
+ }
10
+ /**
11
+ * Settle an EVM payment event — look up charge by deposit address, credit ledger.
12
+ *
13
+ * Same idempotency pattern as handleCryptoWebhook():
14
+ * Primary: creditLedger.hasReferenceId() — atomic in ledger transaction
15
+ * Secondary: chargeStore.markCredited() — advisory
16
+ *
17
+ * Credits the CHARGE amount (not the transfer amount) for overpayment safety.
18
+ *
19
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
20
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
21
+ * Never pass raw cents to the ledger — always go through Credit.fromCents().
22
+ */
23
+ export declare function settleEvmPayment(deps: EvmSettlerDeps, event: EvmPaymentEvent): Promise<CryptoWebhookResult>;
@@ -0,0 +1,60 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ /**
3
+ * Settle an EVM payment event — look up charge by deposit address, credit ledger.
4
+ *
5
+ * Same idempotency pattern as handleCryptoWebhook():
6
+ * Primary: creditLedger.hasReferenceId() — atomic in ledger transaction
7
+ * Secondary: chargeStore.markCredited() — advisory
8
+ *
9
+ * Credits the CHARGE amount (not the transfer amount) for overpayment safety.
10
+ *
11
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
12
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
13
+ * Never pass raw cents to the ledger — always go through Credit.fromCents().
14
+ */
15
+ export async function settleEvmPayment(deps, event) {
16
+ const { chargeStore, creditLedger } = deps;
17
+ const charge = await chargeStore.getByDepositAddress(event.to.toLowerCase());
18
+ if (!charge) {
19
+ return { handled: false, status: "Settled" };
20
+ }
21
+ // Update charge status to Settled.
22
+ await chargeStore.updateStatus(charge.referenceId, "Settled");
23
+ // Charge-level idempotency: if this charge was already credited (by any transfer),
24
+ // reject. Prevents double-credit when a user sends two transactions to the same address.
25
+ if (charge.creditedAt != null) {
26
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
27
+ }
28
+ // Transfer-level idempotency: if this specific tx was already processed, skip.
29
+ const creditRef = `evm:${event.chain}:${event.txHash}:${event.logIndex}`;
30
+ if (await creditLedger.hasReferenceId(creditRef)) {
31
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
32
+ }
33
+ // Reject underpayment — transfer must cover the charge amount.
34
+ if (event.amountUsdCents < charge.amountUsdCents) {
35
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
36
+ }
37
+ // Credit the CHARGE amount (NOT the transfer amount — overpayment stays in wallet).
38
+ // charge.amountUsdCents is in USD cents (integer).
39
+ // Credit.fromCents() converts to nanodollars for the ledger.
40
+ const creditCents = charge.amountUsdCents;
41
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
42
+ description: `Stablecoin credit purchase (${event.token} on ${event.chain}, tx: ${event.txHash})`,
43
+ referenceId: creditRef,
44
+ fundingSource: "crypto",
45
+ });
46
+ await chargeStore.markCredited(charge.referenceId);
47
+ let reactivatedBots;
48
+ if (deps.onCreditsPurchased) {
49
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
50
+ if (reactivatedBots.length === 0)
51
+ reactivatedBots = undefined;
52
+ }
53
+ return {
54
+ handled: true,
55
+ status: "Settled",
56
+ tenant: charge.tenantId,
57
+ creditedCents: creditCents,
58
+ reactivatedBots,
59
+ };
60
+ }
@@ -0,0 +1,40 @@
1
+ /** Supported EVM chains. */
2
+ export type EvmChain = "base";
3
+ /** Supported stablecoin tokens. */
4
+ export type StablecoinToken = "USDC";
5
+ /** Chain configuration. */
6
+ export interface ChainConfig {
7
+ readonly chain: EvmChain;
8
+ readonly rpcUrl: string;
9
+ readonly confirmations: number;
10
+ readonly blockTimeMs: number;
11
+ readonly chainId: number;
12
+ }
13
+ /** Token configuration on a specific chain. */
14
+ export interface TokenConfig {
15
+ readonly token: StablecoinToken;
16
+ readonly chain: EvmChain;
17
+ readonly contractAddress: `0x${string}`;
18
+ readonly decimals: number;
19
+ }
20
+ /** Event emitted when a Transfer is detected and confirmed. */
21
+ export interface EvmPaymentEvent {
22
+ readonly chain: EvmChain;
23
+ readonly token: StablecoinToken;
24
+ readonly from: string;
25
+ readonly to: string;
26
+ /** Raw token amount (BigInt as string for serialization). */
27
+ readonly rawAmount: string;
28
+ /** USD cents equivalent (integer). */
29
+ readonly amountUsdCents: number;
30
+ readonly txHash: string;
31
+ readonly blockNumber: number;
32
+ readonly logIndex: number;
33
+ }
34
+ /** Options for creating a stablecoin checkout. */
35
+ export interface StablecoinCheckoutOpts {
36
+ tenant: string;
37
+ amountUsd: number;
38
+ chain: EvmChain;
39
+ token: StablecoinToken;
40
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,31 @@
1
+ import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./types.js";
2
+ type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
3
+ export interface EvmWatcherOpts {
4
+ chain: EvmChain;
5
+ token: StablecoinToken;
6
+ rpcCall: RpcCall;
7
+ fromBlock: number;
8
+ onPayment: (event: EvmPaymentEvent) => void | Promise<void>;
9
+ /** Active deposit addresses to watch. Filters eth_getLogs by topic[2] (to address). */
10
+ watchedAddresses?: string[];
11
+ }
12
+ export declare class EvmWatcher {
13
+ private _cursor;
14
+ private readonly chain;
15
+ private readonly token;
16
+ private readonly rpc;
17
+ private readonly onPayment;
18
+ private readonly confirmations;
19
+ private readonly contractAddress;
20
+ private readonly decimals;
21
+ private _watchedAddresses;
22
+ constructor(opts: EvmWatcherOpts);
23
+ /** Update the set of watched deposit addresses (e.g. after a new checkout). */
24
+ setWatchedAddresses(addresses: string[]): void;
25
+ get cursor(): number;
26
+ /** Poll for new Transfer events. Call on an interval. */
27
+ poll(): Promise<void>;
28
+ }
29
+ /** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
30
+ export declare function createRpcCaller(rpcUrl: string): RpcCall;
31
+ export {};
@@ -0,0 +1,91 @@
1
+ import { centsFromTokenAmount, getChainConfig, getTokenConfig } from "./config.js";
2
+ const TRANSFER_TOPIC = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef";
3
+ export class EvmWatcher {
4
+ _cursor;
5
+ chain;
6
+ token;
7
+ rpc;
8
+ onPayment;
9
+ confirmations;
10
+ contractAddress;
11
+ decimals;
12
+ _watchedAddresses;
13
+ constructor(opts) {
14
+ this.chain = opts.chain;
15
+ this.token = opts.token;
16
+ this.rpc = opts.rpcCall;
17
+ this._cursor = opts.fromBlock;
18
+ this.onPayment = opts.onPayment;
19
+ this._watchedAddresses = (opts.watchedAddresses ?? []).map((a) => a.toLowerCase());
20
+ const chainCfg = getChainConfig(opts.chain);
21
+ const tokenCfg = getTokenConfig(opts.token, opts.chain);
22
+ this.confirmations = chainCfg.confirmations;
23
+ this.contractAddress = tokenCfg.contractAddress.toLowerCase();
24
+ this.decimals = tokenCfg.decimals;
25
+ }
26
+ /** Update the set of watched deposit addresses (e.g. after a new checkout). */
27
+ setWatchedAddresses(addresses) {
28
+ this._watchedAddresses = addresses.map((a) => a.toLowerCase());
29
+ }
30
+ get cursor() {
31
+ return this._cursor;
32
+ }
33
+ /** Poll for new Transfer events. Call on an interval. */
34
+ async poll() {
35
+ const latestHex = (await this.rpc("eth_blockNumber", []));
36
+ const latest = Number.parseInt(latestHex, 16);
37
+ const confirmed = latest - this.confirmations;
38
+ if (confirmed < this._cursor)
39
+ return;
40
+ // Filter by topic[2] (to address) when watched addresses are set.
41
+ // This avoids fetching ALL USDC transfers on the chain (millions/day on Base).
42
+ // topic[2] values are 32-byte zero-padded: 0x000000000000000000000000<address>
43
+ const toFilter = this._watchedAddresses.length > 0
44
+ ? this._watchedAddresses.map((a) => `0x000000000000000000000000${a.slice(2)}`)
45
+ : null;
46
+ const logs = (await this.rpc("eth_getLogs", [
47
+ {
48
+ address: this.contractAddress,
49
+ topics: [TRANSFER_TOPIC, null, toFilter],
50
+ fromBlock: `0x${this._cursor.toString(16)}`,
51
+ toBlock: `0x${confirmed.toString(16)}`,
52
+ },
53
+ ]));
54
+ for (const log of logs) {
55
+ const to = `0x${log.topics[2].slice(26)}`.toLowerCase();
56
+ const from = `0x${log.topics[1].slice(26)}`.toLowerCase();
57
+ const rawAmount = BigInt(log.data);
58
+ const amountUsdCents = centsFromTokenAmount(rawAmount, this.decimals);
59
+ const event = {
60
+ chain: this.chain,
61
+ token: this.token,
62
+ from,
63
+ to,
64
+ rawAmount: rawAmount.toString(),
65
+ amountUsdCents,
66
+ txHash: log.transactionHash,
67
+ blockNumber: Number.parseInt(log.blockNumber, 16),
68
+ logIndex: Number.parseInt(log.logIndex, 16),
69
+ };
70
+ await this.onPayment(event);
71
+ }
72
+ this._cursor = confirmed + 1;
73
+ }
74
+ }
75
+ /** Create an RPC caller for a given URL (plain JSON-RPC over fetch). */
76
+ export function createRpcCaller(rpcUrl) {
77
+ let id = 0;
78
+ return async (method, params) => {
79
+ const res = await fetch(rpcUrl, {
80
+ method: "POST",
81
+ headers: { "Content-Type": "application/json" },
82
+ body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
83
+ });
84
+ if (!res.ok)
85
+ throw new Error(`RPC ${method} failed: ${res.status}`);
86
+ const data = (await res.json());
87
+ if (data.error)
88
+ throw new Error(`RPC ${method} error: ${data.error.message}`);
89
+ return data.result;
90
+ };
91
+ }
@@ -0,0 +1,10 @@
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 { CryptoBillingConfig, CryptoCheckoutOpts, CryptoPaymentState, CryptoWebhookPayload, CryptoWebhookResult, } from "./types.js";
8
+ export { mapBtcPayEventToStatus } from "./types.js";
9
+ export type { CryptoWebhookDeps } from "./webhook.js";
10
+ export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
@@ -0,0 +1,6 @@
1
+ export { CryptoChargeRepository, DrizzleCryptoChargeRepository } from "./charge-store.js";
2
+ export { createCryptoCheckout, MIN_PAYMENT_USD } from "./checkout.js";
3
+ export { BTCPayClient, loadCryptoConfig } from "./client.js";
4
+ export * from "./evm/index.js";
5
+ export { mapBtcPayEventToStatus } from "./types.js";
6
+ export { handleCryptoWebhook, verifyCryptoWebhookSignature } from "./webhook.js";
@@ -0,0 +1,61 @@
1
+ /** BTCPay Server invoice states (Greenfield API v1). */
2
+ export type CryptoPaymentState = "New" | "Processing" | "Expired" | "Invalid" | "Settled";
3
+ /** Options for creating a crypto payment session. */
4
+ export interface CryptoCheckoutOpts {
5
+ /** Internal tenant ID. */
6
+ tenant: string;
7
+ /** Amount in USD (minimum $10). */
8
+ amountUsd: number;
9
+ }
10
+ /** Webhook payload received from BTCPay Server (InvoiceSettled event). */
11
+ export interface CryptoWebhookPayload {
12
+ /** BTCPay delivery ID (for deduplication). */
13
+ deliveryId: string;
14
+ /** Webhook ID. */
15
+ webhookId: string;
16
+ /** Original delivery ID (same as deliveryId on first delivery). */
17
+ originalDeliveryId: string;
18
+ /** Whether this is a redelivery. */
19
+ isRedelivery: boolean;
20
+ /** Event type (e.g. "InvoiceSettled", "InvoiceProcessing", "InvoiceExpired"). */
21
+ type: string;
22
+ /** Unix timestamp. */
23
+ timestamp: number;
24
+ /** BTCPay store ID. */
25
+ storeId: string;
26
+ /** BTCPay invoice ID. */
27
+ invoiceId: string;
28
+ /** Invoice metadata (echoed from creation). */
29
+ metadata: Record<string, unknown>;
30
+ /** Whether admin manually marked as settled (InvoiceSettled only). */
31
+ manuallyMarked?: boolean;
32
+ /** Whether customer overpaid (InvoiceSettled only). */
33
+ overPaid?: boolean;
34
+ /** Whether invoice was partially paid (InvoiceExpired only). */
35
+ partiallyPaid?: boolean;
36
+ }
37
+ /** Configuration for BTCPay Server integration. */
38
+ export interface CryptoBillingConfig {
39
+ /** BTCPay API key (from Account > API keys). */
40
+ apiKey: string;
41
+ /** BTCPay Server base URL. */
42
+ baseUrl: string;
43
+ /** BTCPay store ID. */
44
+ storeId: string;
45
+ }
46
+ /** Result of processing a crypto webhook event. */
47
+ export interface CryptoWebhookResult {
48
+ handled: boolean;
49
+ status: string;
50
+ tenant?: string;
51
+ creditedCents?: number;
52
+ reactivatedBots?: string[];
53
+ duplicate?: boolean;
54
+ }
55
+ /**
56
+ * Map BTCPay webhook event type string to a CryptoPaymentState.
57
+ *
58
+ * Shared between the core (billing) and consumer (monetization) webhook handlers.
59
+ * Throws on unrecognized event types to surface integration errors early.
60
+ */
61
+ export declare function mapBtcPayEventToStatus(eventType: string): CryptoPaymentState;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Map BTCPay webhook event type string to a CryptoPaymentState.
3
+ *
4
+ * Shared between the core (billing) and consumer (monetization) webhook handlers.
5
+ * Throws on unrecognized event types to surface integration errors early.
6
+ */
7
+ export function mapBtcPayEventToStatus(eventType) {
8
+ switch (eventType) {
9
+ case "InvoiceCreated":
10
+ return "New";
11
+ case "InvoiceReceivedPayment":
12
+ case "InvoiceProcessing":
13
+ return "Processing";
14
+ case "InvoiceSettled":
15
+ case "InvoicePaymentSettled":
16
+ return "Settled";
17
+ case "InvoiceExpired":
18
+ return "Expired";
19
+ case "InvoiceInvalid":
20
+ return "Invalid";
21
+ default:
22
+ throw new Error(`Unknown BTCPay event type: ${eventType}`);
23
+ }
24
+ }
@@ -0,0 +1,34 @@
1
+ import type { ILedger } from "../../credits/ledger.js";
2
+ import type { IWebhookSeenRepository } from "../webhook-seen-repository.js";
3
+ import type { ICryptoChargeRepository } from "./charge-store.js";
4
+ import type { CryptoWebhookPayload, CryptoWebhookResult } from "./types.js";
5
+ export interface CryptoWebhookDeps {
6
+ chargeStore: ICryptoChargeRepository;
7
+ creditLedger: ILedger;
8
+ replayGuard: IWebhookSeenRepository;
9
+ /** Called after credits are purchased — consumer can reactivate suspended resources. Returns reactivated resource IDs. */
10
+ onCreditsPurchased?: (tenantId: string, ledger: ILedger) => Promise<string[]>;
11
+ }
12
+ /**
13
+ * Verify BTCPay webhook signature (HMAC-SHA256).
14
+ *
15
+ * BTCPay sends the signature in the BTCPAY-SIG header as "sha256=<hex>".
16
+ */
17
+ export declare function verifyCryptoWebhookSignature(rawBody: Buffer | string, sigHeader: string | undefined, secret: string): boolean;
18
+ /**
19
+ * Process a BTCPay Server webhook event.
20
+ *
21
+ * Only credits the ledger on InvoiceSettled status.
22
+ * Uses the BTCPay invoice ID mapped to the stored charge record
23
+ * for tenant resolution and idempotency.
24
+ *
25
+ * Idempotency strategy (matches Stripe webhook pattern):
26
+ * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
27
+ * checked inside the ledger's serialized transaction.
28
+ * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
29
+ *
30
+ * CRITICAL: The charge store holds amountUsdCents (USD cents, integer).
31
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
32
+ * Never pass raw cents to the ledger — always go through Credit.fromCents().
33
+ */
34
+ export declare function handleCryptoWebhook(deps: CryptoWebhookDeps, payload: CryptoWebhookPayload): Promise<CryptoWebhookResult>;
@@ -0,0 +1,107 @@
1
+ import crypto from "node:crypto";
2
+ import { Credit } from "../../credits/credit.js";
3
+ import { mapBtcPayEventToStatus } from "./types.js";
4
+ /**
5
+ * Verify BTCPay webhook signature (HMAC-SHA256).
6
+ *
7
+ * BTCPay sends the signature in the BTCPAY-SIG header as "sha256=<hex>".
8
+ */
9
+ export function verifyCryptoWebhookSignature(rawBody, sigHeader, secret) {
10
+ if (!sigHeader)
11
+ return false;
12
+ const expectedSig = `sha256=${crypto.createHmac("sha256", secret).update(rawBody).digest("hex")}`;
13
+ const expected = Buffer.from(expectedSig, "utf8");
14
+ const received = Buffer.from(sigHeader, "utf8");
15
+ if (expected.length !== received.length)
16
+ return false;
17
+ return crypto.timingSafeEqual(expected, received);
18
+ }
19
+ /**
20
+ * Process a BTCPay Server webhook event.
21
+ *
22
+ * Only credits the ledger on InvoiceSettled status.
23
+ * Uses the BTCPay invoice ID mapped to the stored charge record
24
+ * for tenant resolution and idempotency.
25
+ *
26
+ * Idempotency strategy (matches Stripe webhook pattern):
27
+ * Primary: `creditLedger.hasReferenceId("crypto:<invoiceId>")` — atomic,
28
+ * checked inside the ledger's serialized transaction.
29
+ * Secondary: `chargeStore.markCredited()` — advisory flag for queries.
30
+ *
31
+ * CRITICAL: The charge store holds amountUsdCents (USD cents, integer).
32
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
33
+ * Never pass raw cents to the ledger — always go through Credit.fromCents().
34
+ */
35
+ export async function handleCryptoWebhook(deps, payload) {
36
+ const { chargeStore, creditLedger } = deps;
37
+ // Replay guard FIRST: deduplicate by invoiceId + event type.
38
+ // Must run before mapBtcPayEventToStatus() — unknown event types throw,
39
+ // and BTCPay retries webhooks on failure. Without this ordering, an unknown
40
+ // event type causes an infinite retry loop.
41
+ const dedupeKey = `${payload.invoiceId}:${payload.type}`;
42
+ if (await deps.replayGuard.isDuplicate(dedupeKey, "crypto")) {
43
+ return { handled: true, status: "New", duplicate: true };
44
+ }
45
+ // Map BTCPay event type to a CryptoPaymentState (throws on unknown types).
46
+ const status = mapBtcPayEventToStatus(payload.type);
47
+ // Look up the charge record to find the tenant.
48
+ const charge = await chargeStore.getByReferenceId(payload.invoiceId);
49
+ if (!charge) {
50
+ return { handled: false, status };
51
+ }
52
+ // Update charge status regardless of event type.
53
+ await chargeStore.updateStatus(payload.invoiceId, status);
54
+ let result;
55
+ if (payload.type === "InvoiceSettled") {
56
+ // Idempotency: use ledger referenceId check (same pattern as Stripe webhook).
57
+ // This is atomic — the referenceId is checked inside the ledger's serialized
58
+ // transaction, eliminating the TOCTOU race of isCredited() + creditLedger().
59
+ const creditRef = `crypto:${payload.invoiceId}`;
60
+ if (await creditLedger.hasReferenceId(creditRef)) {
61
+ result = {
62
+ handled: true,
63
+ status,
64
+ tenant: charge.tenantId,
65
+ creditedCents: 0,
66
+ };
67
+ }
68
+ else {
69
+ // Credit the original USD amount requested (not the crypto amount).
70
+ // For overpayments, we still credit the requested amount.
71
+ // charge.amountUsdCents is in USD cents (integer).
72
+ // Credit.fromCents() converts to nanodollars for the ledger.
73
+ const creditCents = charge.amountUsdCents;
74
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
75
+ description: `Crypto credit purchase via BTCPay (invoice: ${payload.invoiceId})`,
76
+ referenceId: creditRef,
77
+ fundingSource: "crypto",
78
+ });
79
+ // Mark credited (advisory — primary idempotency is the ledger referenceId above).
80
+ await chargeStore.markCredited(payload.invoiceId);
81
+ // Reactivate suspended resources after credit purchase.
82
+ let reactivatedBots;
83
+ if (deps.onCreditsPurchased) {
84
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
85
+ if (reactivatedBots.length === 0)
86
+ reactivatedBots = undefined;
87
+ }
88
+ result = {
89
+ handled: true,
90
+ status,
91
+ tenant: charge.tenantId,
92
+ creditedCents: creditCents,
93
+ reactivatedBots,
94
+ };
95
+ }
96
+ }
97
+ else {
98
+ // New, Processing, Expired, Invalid — just track status.
99
+ result = {
100
+ handled: true,
101
+ status,
102
+ tenant: charge.tenantId,
103
+ };
104
+ }
105
+ await deps.replayGuard.markSeen(dedupeKey, "crypto");
106
+ return result;
107
+ }
@@ -0,0 +1 @@
1
+ export {};