@wopr-network/platform-core 1.21.0 → 1.22.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 (29) hide show
  1. package/dist/billing/crypto/btc/settler.js +1 -1
  2. package/dist/billing/crypto/btc/watcher.d.ts +5 -3
  3. package/dist/billing/crypto/btc/watcher.js +9 -8
  4. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.d.ts +1 -0
  5. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +49 -0
  6. package/dist/billing/crypto/evm/__tests__/eth-settler.test.d.ts +1 -0
  7. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +80 -0
  8. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.d.ts +1 -0
  9. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +134 -0
  10. package/dist/billing/crypto/evm/eth-checkout.d.ts +34 -0
  11. package/dist/billing/crypto/evm/eth-checkout.js +53 -0
  12. package/dist/billing/crypto/evm/eth-settler.d.ts +23 -0
  13. package/dist/billing/crypto/evm/eth-settler.js +52 -0
  14. package/dist/billing/crypto/evm/eth-watcher.d.ts +53 -0
  15. package/dist/billing/crypto/evm/eth-watcher.js +83 -0
  16. package/dist/billing/crypto/evm/index.d.ts +6 -0
  17. package/dist/billing/crypto/evm/index.js +3 -0
  18. package/dist/billing/crypto/evm/settler.js +1 -1
  19. package/package.json +1 -1
  20. package/src/billing/crypto/btc/settler.ts +1 -1
  21. package/src/billing/crypto/btc/watcher.ts +12 -11
  22. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +60 -0
  23. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +98 -0
  24. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +157 -0
  25. package/src/billing/crypto/evm/eth-checkout.ts +84 -0
  26. package/src/billing/crypto/evm/eth-settler.ts +71 -0
  27. package/src/billing/crypto/evm/eth-watcher.ts +129 -0
  28. package/src/billing/crypto/evm/index.ts +6 -0
  29. package/src/billing/crypto/evm/settler.ts +1 -1
@@ -16,7 +16,7 @@ export async function settleBtcPayment(deps, event) {
16
16
  const { chargeStore, creditLedger } = deps;
17
17
  const charge = await chargeStore.getByDepositAddress(event.address);
18
18
  if (!charge) {
19
- return { handled: false, status: "Settled" };
19
+ return { handled: false, status: "Invalid" };
20
20
  }
21
21
  await chargeStore.updateStatus(charge.referenceId, "Settled");
22
22
  // Charge-level idempotency
@@ -1,3 +1,4 @@
1
+ import type { IPriceOracle } from "../oracle/types.js";
1
2
  import type { BitcoindConfig, BtcPaymentEvent } from "./types.js";
2
3
  type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
3
4
  export interface BtcWatcherOpts {
@@ -6,15 +7,16 @@ export interface BtcWatcherOpts {
6
7
  /** Addresses to watch (must be imported into bitcoind wallet first). */
7
8
  watchedAddresses: string[];
8
9
  onPayment: (event: BtcPaymentEvent) => void | Promise<void>;
9
- /** Current BTC/USD price for conversion. */
10
- getBtcPrice: () => Promise<number>;
10
+ /** Price oracle for BTC/USD conversion. */
11
+ oracle: IPriceOracle;
11
12
  }
12
13
  export declare class BtcWatcher {
13
14
  private readonly rpc;
14
15
  private readonly addresses;
15
16
  private readonly onPayment;
16
17
  private readonly minConfirmations;
17
- private readonly getBtcPrice;
18
+ private readonly oracle;
19
+ private readonly processedTxids;
18
20
  constructor(opts: BtcWatcherOpts);
19
21
  /** Update the set of watched addresses. */
20
22
  setWatchedAddresses(addresses: string[]): void;
@@ -1,17 +1,16 @@
1
- /** Track which txids we've already processed to avoid double-crediting. */
2
- const processedTxids = new Set();
3
1
  export class BtcWatcher {
4
2
  rpc;
5
3
  addresses;
6
4
  onPayment;
7
5
  minConfirmations;
8
- getBtcPrice;
6
+ oracle;
7
+ processedTxids = new Set();
9
8
  constructor(opts) {
10
9
  this.rpc = opts.rpcCall;
11
10
  this.addresses = new Set(opts.watchedAddresses);
12
11
  this.onPayment = opts.onPayment;
13
12
  this.minConfirmations = opts.config.confirmations;
14
- this.getBtcPrice = opts.getBtcPrice;
13
+ this.oracle = opts.oracle;
15
14
  }
16
15
  /** Update the set of watched addresses. */
17
16
  setWatchedAddresses(addresses) {
@@ -33,21 +32,21 @@ export class BtcWatcher {
33
32
  false, // include_empty
34
33
  true, // include_watchonly
35
34
  ]));
36
- const btcPrice = await this.getBtcPrice();
35
+ const { priceCents } = await this.oracle.getPrice("BTC");
37
36
  for (const entry of received) {
38
37
  if (!this.addresses.has(entry.address))
39
38
  continue;
40
39
  for (const txid of entry.txids) {
41
- if (processedTxids.has(txid))
40
+ if (this.processedTxids.has(txid))
42
41
  continue;
43
- processedTxids.add(txid);
44
42
  // Get transaction details for the exact amount sent to this address
45
43
  const tx = (await this.rpc("gettransaction", [txid, true]));
46
44
  const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
47
45
  if (!detail)
48
46
  continue;
49
47
  const amountSats = Math.round(detail.amount * 100_000_000);
50
- const amountUsdCents = Math.round(detail.amount * btcPrice * 100);
48
+ // priceCents is cents per 1 BTC. detail.amount is in BTC.
49
+ const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
51
50
  const event = {
52
51
  address: entry.address,
53
52
  txid,
@@ -56,6 +55,8 @@ export class BtcWatcher {
56
55
  confirmations: tx.confirmations,
57
56
  };
58
57
  await this.onPayment(event);
58
+ // Add AFTER successful onPayment to avoid skipping on failure
59
+ this.processedTxids.add(txid);
59
60
  }
60
61
  }
61
62
  }
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { createEthCheckout, MIN_ETH_USD } from "../eth-checkout.js";
3
+ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000, updatedAt: new Date() }) };
4
+ function makeDeps(derivationIndex = 0) {
5
+ return {
6
+ chargeStore: {
7
+ getNextDerivationIndex: vi.fn().mockResolvedValue(derivationIndex),
8
+ createStablecoinCharge: vi.fn().mockResolvedValue(undefined),
9
+ },
10
+ oracle: mockOracle,
11
+ xpub: "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8",
12
+ };
13
+ }
14
+ describe("createEthCheckout", () => {
15
+ it("creates checkout with oracle-derived expected wei", async () => {
16
+ const deps = makeDeps();
17
+ const result = await createEthCheckout(deps, { tenant: "t1", amountUsd: 50, chain: "base" });
18
+ expect(result.amountUsd).toBe(50);
19
+ expect(result.priceCents).toBe(350_000);
20
+ expect(result.chain).toBe("base");
21
+ // $50 = 5000 cents. 5000 × 10^18 / 350000 = 14285714285714285n
22
+ expect(result.expectedWei).toBe("14285714285714285");
23
+ expect(result.depositAddress).toMatch(/^0x/);
24
+ expect(result.referenceId).toMatch(/^eth:base:0x/);
25
+ });
26
+ it("rejects amount below minimum", async () => {
27
+ const deps = makeDeps();
28
+ await expect(createEthCheckout(deps, { tenant: "t1", amountUsd: 5, chain: "base" })).rejects.toThrow(`Minimum payment amount is $${MIN_ETH_USD}`);
29
+ });
30
+ it("retries on unique constraint violation", async () => {
31
+ const deps = makeDeps();
32
+ deps.chargeStore.createStablecoinCharge
33
+ .mockRejectedValueOnce(Object.assign(new Error("unique_violation"), { code: "23505" }))
34
+ .mockResolvedValueOnce(undefined);
35
+ deps.chargeStore.getNextDerivationIndex.mockResolvedValueOnce(0).mockResolvedValueOnce(1);
36
+ const result = await createEthCheckout(deps, { tenant: "t1", amountUsd: 50, chain: "base" });
37
+ expect(result.depositAddress).toMatch(/^0x/);
38
+ expect(deps.chargeStore.createStablecoinCharge).toHaveBeenCalledTimes(2);
39
+ });
40
+ it("stores amountUsdCents as integer cents", async () => {
41
+ const deps = makeDeps();
42
+ await createEthCheckout(deps, { tenant: "t1", amountUsd: 50, chain: "base" });
43
+ const call = deps.chargeStore.createStablecoinCharge.mock.calls[0][0];
44
+ expect(call.amountUsdCents).toBe(5000);
45
+ expect(Number.isInteger(call.amountUsdCents)).toBe(true);
46
+ expect(call.token).toBe("ETH");
47
+ expect(call.chain).toBe("base");
48
+ });
49
+ });
@@ -0,0 +1,80 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { settleEthPayment } from "../eth-settler.js";
3
+ function makeEvent(overrides = {}) {
4
+ return {
5
+ chain: "base",
6
+ from: "0xsender",
7
+ to: "0xdeposit",
8
+ valueWei: "14285714285714285",
9
+ amountUsdCents: 5000,
10
+ txHash: "0xabc123",
11
+ blockNumber: 100,
12
+ ...overrides,
13
+ };
14
+ }
15
+ function makeDeps(charge = null) {
16
+ return {
17
+ chargeStore: {
18
+ getByDepositAddress: vi.fn().mockResolvedValue(charge
19
+ ? {
20
+ referenceId: "eth:base:0xdeposit",
21
+ tenantId: "t1",
22
+ amountUsdCents: charge.amountUsdCents,
23
+ creditedAt: charge.creditedAt,
24
+ }
25
+ : null),
26
+ updateStatus: vi.fn().mockResolvedValue(undefined),
27
+ markCredited: vi.fn().mockResolvedValue(undefined),
28
+ },
29
+ creditLedger: {
30
+ credit: vi.fn().mockResolvedValue(undefined),
31
+ hasReferenceId: vi.fn().mockResolvedValue(false),
32
+ },
33
+ };
34
+ }
35
+ describe("settleEthPayment", () => {
36
+ it("returns Invalid for unknown deposit address", async () => {
37
+ const deps = makeDeps(null);
38
+ const result = await settleEthPayment(deps, makeEvent());
39
+ expect(result.handled).toBe(false);
40
+ expect(result.status).toBe("Invalid");
41
+ });
42
+ it("credits ledger for valid payment", async () => {
43
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
44
+ const result = await settleEthPayment(deps, makeEvent());
45
+ expect(result.handled).toBe(true);
46
+ expect(result.creditedCents).toBe(5000);
47
+ expect(deps.creditLedger.credit).toHaveBeenCalledOnce();
48
+ expect(deps.chargeStore.markCredited).toHaveBeenCalledOnce();
49
+ });
50
+ it("skips already-credited charge (charge-level idempotency)", async () => {
51
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: "2026-01-01" });
52
+ const result = await settleEthPayment(deps, makeEvent());
53
+ expect(result.handled).toBe(true);
54
+ expect(result.creditedCents).toBe(0);
55
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
56
+ });
57
+ it("skips duplicate transfer (transfer-level idempotency)", async () => {
58
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
59
+ deps.creditLedger.hasReferenceId.mockResolvedValue(true);
60
+ const result = await settleEthPayment(deps, makeEvent());
61
+ expect(result.creditedCents).toBe(0);
62
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
63
+ });
64
+ it("rejects underpayment", async () => {
65
+ const deps = makeDeps({ amountUsdCents: 10000, creditedAt: null });
66
+ const result = await settleEthPayment(deps, makeEvent({ amountUsdCents: 5000 }));
67
+ expect(result.creditedCents).toBe(0);
68
+ expect(deps.creditLedger.credit).not.toHaveBeenCalled();
69
+ });
70
+ it("credits charge amount, not transfer amount (overpayment safe)", async () => {
71
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
72
+ const result = await settleEthPayment(deps, makeEvent({ amountUsdCents: 10000 }));
73
+ expect(result.creditedCents).toBe(5000);
74
+ });
75
+ it("uses correct creditRef format", async () => {
76
+ const deps = makeDeps({ amountUsdCents: 5000, creditedAt: null });
77
+ await settleEthPayment(deps, makeEvent({ chain: "base", txHash: "0xdef" }));
78
+ expect(deps.creditLedger.hasReferenceId).toHaveBeenCalledWith("eth:base:0xdef");
79
+ });
80
+ });
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { EthWatcher } from "../eth-watcher.js";
3
+ function makeRpc(responses) {
4
+ return vi.fn(async (method) => responses[method]);
5
+ }
6
+ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000, updatedAt: new Date() }) };
7
+ describe("EthWatcher", () => {
8
+ it("detects native ETH transfer to watched address", async () => {
9
+ const onPayment = vi.fn();
10
+ const rpc = makeRpc({
11
+ eth_blockNumber: "0xb",
12
+ eth_getBlockByNumber: {
13
+ transactions: [
14
+ {
15
+ hash: "0xabc",
16
+ from: "0xsender",
17
+ to: "0xdeposit",
18
+ value: "0xDE0B6B3A7640000", // 1 ETH = 10^18 wei
19
+ blockNumber: "0xa",
20
+ },
21
+ ],
22
+ },
23
+ });
24
+ const watcher = new EthWatcher({
25
+ chain: "base",
26
+ rpcCall: rpc,
27
+ oracle: mockOracle,
28
+ fromBlock: 10,
29
+ onPayment,
30
+ watchedAddresses: ["0xDeposit"],
31
+ });
32
+ await watcher.poll();
33
+ expect(onPayment).toHaveBeenCalledOnce();
34
+ const event = onPayment.mock.calls[0][0];
35
+ expect(event.to).toBe("0xdeposit");
36
+ expect(event.valueWei).toBe("1000000000000000000");
37
+ expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500
38
+ expect(event.txHash).toBe("0xabc");
39
+ });
40
+ it("skips transactions not to watched addresses", async () => {
41
+ const onPayment = vi.fn();
42
+ const rpc = makeRpc({
43
+ eth_blockNumber: "0xb",
44
+ eth_getBlockByNumber: {
45
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xother", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
46
+ },
47
+ });
48
+ const watcher = new EthWatcher({
49
+ chain: "base",
50
+ rpcCall: rpc,
51
+ oracle: mockOracle,
52
+ fromBlock: 10,
53
+ onPayment,
54
+ watchedAddresses: ["0xDeposit"],
55
+ });
56
+ await watcher.poll();
57
+ expect(onPayment).not.toHaveBeenCalled();
58
+ });
59
+ it("skips zero-value transactions", async () => {
60
+ const onPayment = vi.fn();
61
+ const rpc = makeRpc({
62
+ eth_blockNumber: "0xb",
63
+ eth_getBlockByNumber: {
64
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0x0", blockNumber: "0xa" }],
65
+ },
66
+ });
67
+ const watcher = new EthWatcher({
68
+ chain: "base",
69
+ rpcCall: rpc,
70
+ oracle: mockOracle,
71
+ fromBlock: 10,
72
+ onPayment,
73
+ watchedAddresses: ["0xDeposit"],
74
+ });
75
+ await watcher.poll();
76
+ expect(onPayment).not.toHaveBeenCalled();
77
+ });
78
+ it("does not double-process same txid", async () => {
79
+ const onPayment = vi.fn();
80
+ const rpc = makeRpc({
81
+ eth_blockNumber: "0xb",
82
+ eth_getBlockByNumber: {
83
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
84
+ },
85
+ });
86
+ const watcher = new EthWatcher({
87
+ chain: "base",
88
+ rpcCall: rpc,
89
+ oracle: mockOracle,
90
+ fromBlock: 10,
91
+ onPayment,
92
+ watchedAddresses: ["0xDeposit"],
93
+ });
94
+ await watcher.poll();
95
+ // Reset cursor to re-scan same block
96
+ await watcher.poll();
97
+ expect(onPayment).toHaveBeenCalledOnce();
98
+ });
99
+ it("skips poll when no watched addresses", async () => {
100
+ const onPayment = vi.fn();
101
+ const rpc = vi.fn();
102
+ const watcher = new EthWatcher({
103
+ chain: "base",
104
+ rpcCall: rpc,
105
+ oracle: mockOracle,
106
+ fromBlock: 10,
107
+ onPayment,
108
+ watchedAddresses: [],
109
+ });
110
+ await watcher.poll();
111
+ expect(rpc).not.toHaveBeenCalled();
112
+ });
113
+ it("does not mark txid as processed if onPayment throws", async () => {
114
+ const onPayment = vi.fn().mockRejectedValueOnce(new Error("db fail")).mockResolvedValueOnce(undefined);
115
+ const rpc = makeRpc({
116
+ eth_blockNumber: "0xb",
117
+ eth_getBlockByNumber: {
118
+ transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
119
+ },
120
+ });
121
+ const watcher = new EthWatcher({
122
+ chain: "base",
123
+ rpcCall: rpc,
124
+ oracle: mockOracle,
125
+ fromBlock: 10,
126
+ onPayment,
127
+ watchedAddresses: ["0xDeposit"],
128
+ });
129
+ await expect(watcher.poll()).rejects.toThrow("db fail");
130
+ // Retry — should process the same tx again since it wasn't marked
131
+ await watcher.poll();
132
+ expect(onPayment).toHaveBeenCalledTimes(2);
133
+ });
134
+ });
@@ -0,0 +1,34 @@
1
+ import type { ICryptoChargeRepository } from "../charge-store.js";
2
+ import type { IPriceOracle } from "../oracle/types.js";
3
+ import type { EvmChain } from "./types.js";
4
+ export declare const MIN_ETH_USD = 10;
5
+ export interface EthCheckoutDeps {
6
+ chargeStore: Pick<ICryptoChargeRepository, "getNextDerivationIndex" | "createStablecoinCharge">;
7
+ oracle: IPriceOracle;
8
+ xpub: string;
9
+ }
10
+ export interface EthCheckoutOpts {
11
+ tenant: string;
12
+ amountUsd: number;
13
+ chain: EvmChain;
14
+ }
15
+ export interface EthCheckoutResult {
16
+ depositAddress: `0x${string}`;
17
+ amountUsd: number;
18
+ /** Expected ETH amount in wei (BigInt as string). */
19
+ expectedWei: string;
20
+ /** ETH price in USD cents at checkout time. */
21
+ priceCents: number;
22
+ chain: EvmChain;
23
+ referenceId: string;
24
+ }
25
+ /**
26
+ * Create an ETH checkout — derive deposit address, lock price, store charge.
27
+ *
28
+ * Uses the oracle to get live ETH/USD price and compute the expected
29
+ * deposit amount in wei. The charge stores amountUsdCents (not wei) —
30
+ * settlement always credits the USD amount, not the ETH value.
31
+ *
32
+ * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
33
+ */
34
+ export declare function createEthCheckout(deps: EthCheckoutDeps, opts: EthCheckoutOpts): Promise<EthCheckoutResult>;
@@ -0,0 +1,53 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ import { centsToNative } from "../oracle/convert.js";
3
+ import { deriveDepositAddress } from "./address-gen.js";
4
+ export const MIN_ETH_USD = 10;
5
+ /**
6
+ * Create an ETH checkout — derive deposit address, lock price, store charge.
7
+ *
8
+ * Uses the oracle to get live ETH/USD price and compute the expected
9
+ * deposit amount in wei. The charge stores amountUsdCents (not wei) —
10
+ * settlement always credits the USD amount, not the ETH value.
11
+ *
12
+ * CRITICAL: amountUsd → integer cents via Credit.fromDollars().toCentsRounded().
13
+ */
14
+ export async function createEthCheckout(deps, opts) {
15
+ if (!Number.isFinite(opts.amountUsd) || opts.amountUsd < MIN_ETH_USD) {
16
+ throw new Error(`Minimum payment amount is $${MIN_ETH_USD}`);
17
+ }
18
+ const amountUsdCents = Credit.fromDollars(opts.amountUsd).toCentsRounded();
19
+ const { priceCents } = await deps.oracle.getPrice("ETH");
20
+ const expectedWei = centsToNative(amountUsdCents, priceCents, 18);
21
+ const maxRetries = 3;
22
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
23
+ const derivationIndex = await deps.chargeStore.getNextDerivationIndex();
24
+ const depositAddress = deriveDepositAddress(deps.xpub, derivationIndex);
25
+ const referenceId = `eth:${opts.chain}:${depositAddress}`;
26
+ try {
27
+ await deps.chargeStore.createStablecoinCharge({
28
+ referenceId,
29
+ tenantId: opts.tenant,
30
+ amountUsdCents,
31
+ chain: opts.chain,
32
+ token: "ETH",
33
+ depositAddress,
34
+ derivationIndex,
35
+ });
36
+ return {
37
+ depositAddress,
38
+ amountUsd: opts.amountUsd,
39
+ expectedWei: expectedWei.toString(),
40
+ priceCents,
41
+ chain: opts.chain,
42
+ referenceId,
43
+ };
44
+ }
45
+ catch (err) {
46
+ const code = err.code;
47
+ const isConflict = code === "23505" || (err instanceof Error && err.message.includes("unique_violation"));
48
+ if (!isConflict || attempt === maxRetries)
49
+ throw err;
50
+ }
51
+ }
52
+ throw new Error("Failed to claim derivation index after retries");
53
+ }
@@ -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 { EthPaymentEvent } from "./eth-watcher.js";
5
+ export interface EthSettlerDeps {
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 a native ETH payment — look up charge by deposit address, credit ledger.
12
+ *
13
+ * Same idempotency pattern as EVM stablecoin and BTC settlers:
14
+ * 1. Charge-level: charge.creditedAt != null → skip
15
+ * 2. Transfer-level: creditLedger.hasReferenceId → skip (atomic)
16
+ * 3. Advisory: chargeStore.markCredited
17
+ *
18
+ * Credits the CHARGE amount (not the ETH value) for overpayment safety.
19
+ *
20
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
21
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
22
+ */
23
+ export declare function settleEthPayment(deps: EthSettlerDeps, event: EthPaymentEvent): Promise<CryptoWebhookResult>;
@@ -0,0 +1,52 @@
1
+ import { Credit } from "../../../credits/credit.js";
2
+ /**
3
+ * Settle a native ETH payment — look up charge by deposit address, credit ledger.
4
+ *
5
+ * Same idempotency pattern as EVM stablecoin and BTC settlers:
6
+ * 1. Charge-level: charge.creditedAt != null → skip
7
+ * 2. Transfer-level: creditLedger.hasReferenceId → skip (atomic)
8
+ * 3. Advisory: chargeStore.markCredited
9
+ *
10
+ * Credits the CHARGE amount (not the ETH value) for overpayment safety.
11
+ *
12
+ * CRITICAL: charge.amountUsdCents is in USD cents (integer).
13
+ * Credit.fromCents() converts cents → nanodollars for the ledger.
14
+ */
15
+ export async function settleEthPayment(deps, event) {
16
+ const { chargeStore, creditLedger } = deps;
17
+ const charge = await chargeStore.getByDepositAddress(event.to.toLowerCase());
18
+ if (!charge) {
19
+ return { handled: false, status: "Invalid" };
20
+ }
21
+ await chargeStore.updateStatus(charge.referenceId, "Settled");
22
+ if (charge.creditedAt != null) {
23
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
24
+ }
25
+ const creditRef = `eth:${event.chain}:${event.txHash}`;
26
+ if (await creditLedger.hasReferenceId(creditRef)) {
27
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
28
+ }
29
+ if (event.amountUsdCents < charge.amountUsdCents) {
30
+ return { handled: true, status: "Settled", tenant: charge.tenantId, creditedCents: 0 };
31
+ }
32
+ const creditCents = charge.amountUsdCents;
33
+ await creditLedger.credit(charge.tenantId, Credit.fromCents(creditCents), "purchase", {
34
+ description: `ETH credit purchase (${event.chain}, tx: ${event.txHash})`,
35
+ referenceId: creditRef,
36
+ fundingSource: "crypto",
37
+ });
38
+ await chargeStore.markCredited(charge.referenceId);
39
+ let reactivatedBots;
40
+ if (deps.onCreditsPurchased) {
41
+ reactivatedBots = await deps.onCreditsPurchased(charge.tenantId, creditLedger);
42
+ if (reactivatedBots.length === 0)
43
+ reactivatedBots = undefined;
44
+ }
45
+ return {
46
+ handled: true,
47
+ status: "Settled",
48
+ tenant: charge.tenantId,
49
+ creditedCents: creditCents,
50
+ reactivatedBots,
51
+ };
52
+ }
@@ -0,0 +1,53 @@
1
+ import type { IPriceOracle } from "../oracle/types.js";
2
+ import type { EvmChain } from "./types.js";
3
+ type RpcCall = (method: string, params: unknown[]) => Promise<unknown>;
4
+ /** Event emitted when a native ETH deposit is detected and confirmed. */
5
+ export interface EthPaymentEvent {
6
+ readonly chain: EvmChain;
7
+ readonly from: string;
8
+ readonly to: string;
9
+ /** Raw value in wei (BigInt as string for serialization). */
10
+ readonly valueWei: string;
11
+ /** USD cents equivalent at detection time (integer). */
12
+ readonly amountUsdCents: number;
13
+ readonly txHash: string;
14
+ readonly blockNumber: number;
15
+ }
16
+ export interface EthWatcherOpts {
17
+ chain: EvmChain;
18
+ rpcCall: RpcCall;
19
+ oracle: IPriceOracle;
20
+ fromBlock: number;
21
+ onPayment: (event: EthPaymentEvent) => void | Promise<void>;
22
+ watchedAddresses?: string[];
23
+ }
24
+ /**
25
+ * Native ETH transfer watcher.
26
+ *
27
+ * Unlike the ERC-20 EvmWatcher which uses eth_getLogs for Transfer events,
28
+ * this scans blocks for transactions where `to` matches a watched deposit
29
+ * address and `value > 0`.
30
+ *
31
+ * Uses the price oracle to convert wei → USD cents at detection time.
32
+ */
33
+ export declare class EthWatcher {
34
+ private _cursor;
35
+ private readonly chain;
36
+ private readonly rpc;
37
+ private readonly oracle;
38
+ private readonly onPayment;
39
+ private readonly confirmations;
40
+ private _watchedAddresses;
41
+ private readonly processedTxids;
42
+ constructor(opts: EthWatcherOpts);
43
+ setWatchedAddresses(addresses: string[]): void;
44
+ get cursor(): number;
45
+ /**
46
+ * Poll for new native ETH transfers to watched addresses.
47
+ *
48
+ * Scans each confirmed block's transactions. Only processes txs
49
+ * where `to` is in the watched set and `value > 0`.
50
+ */
51
+ poll(): Promise<void>;
52
+ }
53
+ export {};
@@ -0,0 +1,83 @@
1
+ import { nativeToCents } from "../oracle/convert.js";
2
+ import { getChainConfig } from "./config.js";
3
+ /**
4
+ * Native ETH transfer watcher.
5
+ *
6
+ * Unlike the ERC-20 EvmWatcher which uses eth_getLogs for Transfer events,
7
+ * this scans blocks for transactions where `to` matches a watched deposit
8
+ * address and `value > 0`.
9
+ *
10
+ * Uses the price oracle to convert wei → USD cents at detection time.
11
+ */
12
+ export class EthWatcher {
13
+ _cursor;
14
+ chain;
15
+ rpc;
16
+ oracle;
17
+ onPayment;
18
+ confirmations;
19
+ _watchedAddresses;
20
+ processedTxids = new Set();
21
+ constructor(opts) {
22
+ this.chain = opts.chain;
23
+ this.rpc = opts.rpcCall;
24
+ this.oracle = opts.oracle;
25
+ this._cursor = opts.fromBlock;
26
+ this.onPayment = opts.onPayment;
27
+ this.confirmations = getChainConfig(opts.chain).confirmations;
28
+ this._watchedAddresses = new Set((opts.watchedAddresses ?? []).map((a) => a.toLowerCase()));
29
+ }
30
+ setWatchedAddresses(addresses) {
31
+ this._watchedAddresses = new Set(addresses.map((a) => a.toLowerCase()));
32
+ }
33
+ get cursor() {
34
+ return this._cursor;
35
+ }
36
+ /**
37
+ * Poll for new native ETH transfers to watched addresses.
38
+ *
39
+ * Scans each confirmed block's transactions. Only processes txs
40
+ * where `to` is in the watched set and `value > 0`.
41
+ */
42
+ async poll() {
43
+ if (this._watchedAddresses.size === 0)
44
+ return;
45
+ const latestHex = (await this.rpc("eth_blockNumber", []));
46
+ const latest = Number.parseInt(latestHex, 16);
47
+ const confirmed = latest - this.confirmations;
48
+ if (confirmed < this._cursor)
49
+ return;
50
+ const { priceCents } = await this.oracle.getPrice("ETH");
51
+ for (let blockNum = this._cursor; blockNum <= confirmed; blockNum++) {
52
+ const block = (await this.rpc("eth_getBlockByNumber", [`0x${blockNum.toString(16)}`, true]));
53
+ if (!block)
54
+ continue;
55
+ for (const tx of block.transactions) {
56
+ if (!tx.to)
57
+ continue;
58
+ const to = tx.to.toLowerCase();
59
+ if (!this._watchedAddresses.has(to))
60
+ continue;
61
+ const valueWei = BigInt(tx.value);
62
+ if (valueWei === 0n)
63
+ continue;
64
+ if (this.processedTxids.has(tx.hash))
65
+ continue;
66
+ const amountUsdCents = nativeToCents(valueWei, priceCents, 18);
67
+ const event = {
68
+ chain: this.chain,
69
+ from: tx.from.toLowerCase(),
70
+ to,
71
+ valueWei: valueWei.toString(),
72
+ amountUsdCents,
73
+ txHash: tx.hash,
74
+ blockNumber: blockNum,
75
+ };
76
+ await this.onPayment(event);
77
+ // Add to processed AFTER successful onPayment to avoid skipping on failure
78
+ this.processedTxids.add(tx.hash);
79
+ }
80
+ }
81
+ this._cursor = confirmed + 1;
82
+ }
83
+ }