@wopr-network/platform-core 1.48.0 → 1.49.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 (70) hide show
  1. package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
  2. package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
  3. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
  4. package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
  5. package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
  6. package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
  7. package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
  8. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
  9. package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
  10. package/dist/billing/crypto/btc/types.d.ts +3 -1
  11. package/dist/billing/crypto/btc/watcher.d.ts +6 -1
  12. package/dist/billing/crypto/btc/watcher.js +20 -6
  13. package/dist/billing/crypto/charge-store.d.ts +27 -2
  14. package/dist/billing/crypto/charge-store.js +67 -1
  15. package/dist/billing/crypto/charge-store.test.js +180 -1
  16. package/dist/billing/crypto/client.d.ts +2 -0
  17. package/dist/billing/crypto/cursor-store.d.ts +10 -3
  18. package/dist/billing/crypto/cursor-store.js +21 -1
  19. package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
  20. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
  21. package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
  22. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
  23. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
  24. package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
  25. package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
  26. package/dist/billing/crypto/evm/eth-watcher.js +27 -13
  27. package/dist/billing/crypto/evm/types.d.ts +5 -1
  28. package/dist/billing/crypto/evm/watcher.d.ts +9 -1
  29. package/dist/billing/crypto/evm/watcher.js +36 -13
  30. package/dist/billing/crypto/index.d.ts +3 -3
  31. package/dist/billing/crypto/index.js +1 -1
  32. package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
  33. package/dist/billing/crypto/key-server-webhook.js +76 -15
  34. package/dist/billing/crypto/types.d.ts +16 -0
  35. package/dist/billing/crypto/unified-checkout.d.ts +8 -17
  36. package/dist/billing/crypto/unified-checkout.js +17 -131
  37. package/dist/billing/crypto/watcher-service.d.ts +22 -2
  38. package/dist/billing/crypto/watcher-service.js +71 -30
  39. package/dist/db/schema/crypto.d.ts +68 -0
  40. package/dist/db/schema/crypto.js +8 -0
  41. package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
  42. package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
  43. package/drizzle/migrations/meta/_journal.json +7 -0
  44. package/package.json +1 -1
  45. package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
  46. package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
  47. package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
  48. package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
  49. package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
  50. package/src/billing/crypto/btc/types.ts +3 -1
  51. package/src/billing/crypto/btc/watcher.ts +22 -6
  52. package/src/billing/crypto/charge-store.test.ts +204 -1
  53. package/src/billing/crypto/charge-store.ts +86 -2
  54. package/src/billing/crypto/client.ts +2 -0
  55. package/src/billing/crypto/cursor-store.ts +31 -3
  56. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
  57. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
  58. package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
  59. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
  60. package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
  61. package/src/billing/crypto/evm/eth-watcher.ts +34 -14
  62. package/src/billing/crypto/evm/types.ts +5 -1
  63. package/src/billing/crypto/evm/watcher.ts +39 -13
  64. package/src/billing/crypto/index.ts +12 -3
  65. package/src/billing/crypto/key-server-webhook.ts +92 -21
  66. package/src/billing/crypto/types.ts +18 -0
  67. package/src/billing/crypto/unified-checkout.ts +20 -179
  68. package/src/billing/crypto/watcher-service.ts +85 -32
  69. package/src/db/schema/crypto.ts +8 -0
  70. package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
@@ -34,6 +34,10 @@ describe("CryptoChargeRepository", () => {
34
34
  expect(charge?.amountUsdCents).toBe(2500);
35
35
  expect(charge?.status).toBe("New");
36
36
  expect(charge?.creditedAt).toBeNull();
37
+ expect(charge?.confirmations).toBe(0);
38
+ expect(charge?.confirmationsRequired).toBe(1);
39
+ expect(charge?.txHash).toBeNull();
40
+ expect(charge?.amountReceivedCents).toBe(0);
37
41
  });
38
42
 
39
43
  it("getByReferenceId() returns null when not found", async () => {
@@ -41,7 +45,7 @@ describe("CryptoChargeRepository", () => {
41
45
  expect(charge).toBeNull();
42
46
  });
43
47
 
44
- it("updateStatus() updates status, currency and filled_amount", async () => {
48
+ it("updateStatus() updates status, currency and filled_amount (deprecated compat)", async () => {
45
49
  await store.create("inv-002", "tenant-2", 5000);
46
50
  await store.updateStatus("inv-002", "Settled", "BTC", "0.00025");
47
51
 
@@ -79,6 +83,205 @@ describe("CryptoChargeRepository", () => {
79
83
  expect(await store.isCredited("inv-006")).toBe(true);
80
84
  });
81
85
 
86
+ describe("updateProgress", () => {
87
+ it("updates partial payment progress", async () => {
88
+ await store.createStablecoinCharge({
89
+ referenceId: "prog-001",
90
+ tenantId: "t-1",
91
+ amountUsdCents: 5000,
92
+ chain: "base",
93
+ token: "USDC",
94
+ depositAddress: "0xprog001",
95
+ derivationIndex: 0,
96
+ });
97
+
98
+ await store.updateProgress("prog-001", {
99
+ status: "partial",
100
+ amountReceivedCents: 2500,
101
+ confirmations: 2,
102
+ confirmationsRequired: 6,
103
+ txHash: "0xabc123",
104
+ });
105
+
106
+ const record = await store.getByReferenceId("prog-001");
107
+ expect(record?.status).toBe("Processing");
108
+ expect(record?.amountReceivedCents).toBe(2500);
109
+ expect(record?.confirmations).toBe(2);
110
+ expect(record?.confirmationsRequired).toBe(6);
111
+ expect(record?.txHash).toBe("0xabc123");
112
+ });
113
+
114
+ it("increments confirmations over multiple updates", async () => {
115
+ await store.createStablecoinCharge({
116
+ referenceId: "prog-002",
117
+ tenantId: "t-2",
118
+ amountUsdCents: 1000,
119
+ chain: "base",
120
+ token: "USDC",
121
+ depositAddress: "0xprog002",
122
+ derivationIndex: 1,
123
+ });
124
+
125
+ await store.updateProgress("prog-002", {
126
+ status: "partial",
127
+ amountReceivedCents: 1000,
128
+ confirmations: 1,
129
+ confirmationsRequired: 6,
130
+ txHash: "0xdef456",
131
+ });
132
+
133
+ await store.updateProgress("prog-002", {
134
+ status: "partial",
135
+ amountReceivedCents: 1000,
136
+ confirmations: 3,
137
+ confirmationsRequired: 6,
138
+ txHash: "0xdef456",
139
+ });
140
+
141
+ const record = await store.getByReferenceId("prog-002");
142
+ expect(record?.confirmations).toBe(3);
143
+ });
144
+
145
+ it("maps confirmed status to Settled in DB", async () => {
146
+ await store.createStablecoinCharge({
147
+ referenceId: "prog-003",
148
+ tenantId: "t-3",
149
+ amountUsdCents: 2000,
150
+ chain: "base",
151
+ token: "USDC",
152
+ depositAddress: "0xprog003",
153
+ derivationIndex: 2,
154
+ });
155
+
156
+ await store.updateProgress("prog-003", {
157
+ status: "confirmed",
158
+ amountReceivedCents: 2000,
159
+ confirmations: 6,
160
+ confirmationsRequired: 6,
161
+ txHash: "0xfinal",
162
+ });
163
+
164
+ const record = await store.getByReferenceId("prog-003");
165
+ expect(record?.status).toBe("Settled");
166
+ });
167
+ });
168
+
169
+ describe("get (UI-facing CryptoCharge)", () => {
170
+ it("returns null when not found", async () => {
171
+ const charge = await store.get("nonexistent");
172
+ expect(charge).toBeNull();
173
+ });
174
+
175
+ it("returns full CryptoCharge with all fields for a new charge", async () => {
176
+ await store.createStablecoinCharge({
177
+ referenceId: "get-001",
178
+ tenantId: "t-get",
179
+ amountUsdCents: 5000,
180
+ chain: "base",
181
+ token: "USDC",
182
+ depositAddress: "0xget001",
183
+ derivationIndex: 10,
184
+ });
185
+
186
+ const charge = await store.get("get-001");
187
+ expect(charge).not.toBeNull();
188
+ expect(charge?.id).toBe("get-001");
189
+ expect(charge?.tenantId).toBe("t-get");
190
+ expect(charge?.chain).toBe("base");
191
+ expect(charge?.status).toBe("pending");
192
+ expect(charge?.amountExpectedCents).toBe(5000);
193
+ expect(charge?.amountReceivedCents).toBe(0);
194
+ expect(charge?.confirmations).toBe(0);
195
+ expect(charge?.confirmationsRequired).toBe(1);
196
+ expect(charge?.txHash).toBeUndefined();
197
+ expect(charge?.credited).toBe(false);
198
+ expect(charge?.createdAt).toBeInstanceOf(Date);
199
+ });
200
+
201
+ it("reflects partial payment progress", async () => {
202
+ await store.createStablecoinCharge({
203
+ referenceId: "get-002",
204
+ tenantId: "t-get2",
205
+ amountUsdCents: 5000,
206
+ chain: "base",
207
+ token: "USDC",
208
+ depositAddress: "0xget002",
209
+ derivationIndex: 11,
210
+ });
211
+
212
+ await store.updateProgress("get-002", {
213
+ status: "partial",
214
+ amountReceivedCents: 2500,
215
+ confirmations: 3,
216
+ confirmationsRequired: 6,
217
+ txHash: "0xpartial",
218
+ });
219
+
220
+ const charge = await store.get("get-002");
221
+ expect(charge?.status).toBe("partial");
222
+ expect(charge?.amountReceivedCents).toBe(2500);
223
+ expect(charge?.confirmations).toBe(3);
224
+ expect(charge?.confirmationsRequired).toBe(6);
225
+ expect(charge?.txHash).toBe("0xpartial");
226
+ expect(charge?.credited).toBe(false);
227
+ });
228
+
229
+ it("shows confirmed+credited status after markCredited", async () => {
230
+ await store.createStablecoinCharge({
231
+ referenceId: "get-003",
232
+ tenantId: "t-get3",
233
+ amountUsdCents: 1000,
234
+ chain: "base",
235
+ token: "USDC",
236
+ depositAddress: "0xget003",
237
+ derivationIndex: 12,
238
+ });
239
+
240
+ await store.updateProgress("get-003", {
241
+ status: "confirmed",
242
+ amountReceivedCents: 1000,
243
+ confirmations: 6,
244
+ confirmationsRequired: 6,
245
+ txHash: "0xfull",
246
+ });
247
+ await store.markCredited("get-003");
248
+
249
+ const charge = await store.get("get-003");
250
+ expect(charge?.status).toBe("confirmed");
251
+ expect(charge?.credited).toBe(true);
252
+ expect(charge?.amountReceivedCents).toBe(1000);
253
+ });
254
+
255
+ it("maps expired status correctly", async () => {
256
+ await store.createStablecoinCharge({
257
+ referenceId: "get-004",
258
+ tenantId: "t-get4",
259
+ amountUsdCents: 3000,
260
+ chain: "base",
261
+ token: "USDC",
262
+ depositAddress: "0xget004",
263
+ derivationIndex: 13,
264
+ });
265
+
266
+ await store.updateProgress("get-004", {
267
+ status: "expired",
268
+ amountReceivedCents: 0,
269
+ confirmations: 0,
270
+ confirmationsRequired: 6,
271
+ });
272
+
273
+ const charge = await store.get("get-004");
274
+ expect(charge?.status).toBe("expired");
275
+ });
276
+
277
+ it("returns chain as 'unknown' for legacy charges without chain", async () => {
278
+ await store.create("get-005", "t-get5", 500);
279
+
280
+ const charge = await store.get("get-005");
281
+ expect(charge?.chain).toBe("unknown");
282
+ });
283
+ });
284
+
82
285
  describe("stablecoin charges", () => {
83
286
  it("creates a stablecoin charge with chain/token/address", async () => {
84
287
  await store.createStablecoinCharge({
@@ -1,7 +1,7 @@
1
1
  import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
2
2
  import type { PlatformDb } from "../../db/index.js";
3
3
  import { cryptoCharges } from "../../db/schema/crypto.js";
4
- import type { CryptoPaymentState } from "./types.js";
4
+ import type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
5
5
 
6
6
  export interface CryptoChargeRecord {
7
7
  referenceId: string;
@@ -20,6 +20,10 @@ export interface CryptoChargeRecord {
20
20
  callbackUrl: string | null;
21
21
  expectedAmount: string | null;
22
22
  receivedAmount: string | null;
23
+ confirmations: number;
24
+ confirmationsRequired: number;
25
+ txHash: string | null;
26
+ amountReceivedCents: number;
23
27
  }
24
28
 
25
29
  export interface CryptoDepositChargeInput {
@@ -35,15 +39,28 @@ export interface CryptoDepositChargeInput {
35
39
  expectedAmount?: string;
36
40
  }
37
41
 
42
+ export interface CryptoChargeProgressUpdate {
43
+ status: CryptoChargeStatus;
44
+ amountReceivedCents: number;
45
+ confirmations: number;
46
+ confirmationsRequired: number;
47
+ txHash?: string;
48
+ }
49
+
38
50
  export interface ICryptoChargeRepository {
39
51
  create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
40
52
  getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
53
+ /** @deprecated Use updateProgress() instead. Kept for one release cycle. */
41
54
  updateStatus(
42
55
  referenceId: string,
43
56
  status: CryptoPaymentState,
44
57
  currency?: string,
45
58
  filledAmount?: string,
46
59
  ): Promise<void>;
60
+ /** Update partial payment progress, confirmations, and tx hash. */
61
+ updateProgress(referenceId: string, update: CryptoChargeProgressUpdate): Promise<void>;
62
+ /** Get a charge as a UI-facing CryptoCharge with all progress fields. */
63
+ get(referenceId: string): Promise<CryptoCharge | null>;
47
64
  markCredited(referenceId: string): Promise<void>;
48
65
  isCredited(referenceId: string): Promise<boolean>;
49
66
  createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void>;
@@ -101,10 +118,77 @@ export class DrizzleCryptoChargeRepository implements ICryptoChargeRepository {
101
118
  callbackUrl: row.callbackUrl ?? null,
102
119
  expectedAmount: row.expectedAmount ?? null,
103
120
  receivedAmount: row.receivedAmount ?? null,
121
+ confirmations: row.confirmations,
122
+ confirmationsRequired: row.confirmationsRequired,
123
+ txHash: row.txHash ?? null,
124
+ amountReceivedCents: row.amountReceivedCents,
125
+ };
126
+ }
127
+
128
+ /** Map DB status strings to CryptoChargeStatus for UI consumption. */
129
+ private mapStatus(dbStatus: string, credited: boolean): CryptoChargeStatus {
130
+ if (credited) return "confirmed";
131
+ switch (dbStatus) {
132
+ case "New":
133
+ return "pending";
134
+ case "Processing":
135
+ return "partial";
136
+ case "Settled":
137
+ return "confirmed";
138
+ case "Expired":
139
+ return "expired";
140
+ case "Invalid":
141
+ return "failed";
142
+ default:
143
+ return "pending";
144
+ }
145
+ }
146
+
147
+ /** Get a charge as a UI-facing CryptoCharge with all progress fields. */
148
+ async get(referenceId: string): Promise<CryptoCharge | null> {
149
+ const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.referenceId, referenceId)))[0];
150
+ if (!row) return null;
151
+ return {
152
+ id: row.referenceId,
153
+ tenantId: row.tenantId,
154
+ chain: row.chain ?? "unknown",
155
+ status: this.mapStatus(row.status, row.creditedAt != null),
156
+ amountExpectedCents: row.amountUsdCents,
157
+ amountReceivedCents: row.amountReceivedCents,
158
+ confirmations: row.confirmations,
159
+ confirmationsRequired: row.confirmationsRequired,
160
+ txHash: row.txHash ?? undefined,
161
+ credited: row.creditedAt != null,
162
+ createdAt: new Date(row.createdAt),
104
163
  };
105
164
  }
106
165
 
107
- /** Update charge status and payment details from webhook. */
166
+ /** Update partial payment progress, confirmations, and tx hash. */
167
+ async updateProgress(referenceId: string, update: CryptoChargeProgressUpdate): Promise<void> {
168
+ const statusMap: Record<CryptoChargeStatus, string> = {
169
+ pending: "New",
170
+ partial: "Processing",
171
+ confirmed: "Settled",
172
+ expired: "Expired",
173
+ failed: "Invalid",
174
+ };
175
+ await this.db
176
+ .update(cryptoCharges)
177
+ .set({
178
+ status: statusMap[update.status],
179
+ amountReceivedCents: update.amountReceivedCents,
180
+ confirmations: update.confirmations,
181
+ confirmationsRequired: update.confirmationsRequired,
182
+ txHash: update.txHash,
183
+ updatedAt: sql`now()`,
184
+ })
185
+ .where(eq(cryptoCharges.referenceId, referenceId));
186
+ }
187
+
188
+ /**
189
+ * @deprecated Use updateProgress() instead. Kept for one release cycle.
190
+ * Update charge status and payment details from webhook.
191
+ */
108
192
  async updateStatus(
109
193
  referenceId: string,
110
194
  status: CryptoPaymentState,
@@ -29,6 +29,8 @@ export interface CreateChargeResult {
29
29
  amountUsd: number;
30
30
  derivationIndex: number;
31
31
  expiresAt: string;
32
+ displayAmount?: string;
33
+ priceCents?: number;
32
34
  }
33
35
 
34
36
  export interface ChargeStatus {
@@ -7,18 +7,23 @@ export interface IWatcherCursorStore {
7
7
  get(watcherId: string): Promise<number | null>;
8
8
  /** Save block cursor after processing a range. */
9
9
  save(watcherId: string, cursorBlock: number): Promise<void>;
10
- /** Check if a specific tx has been processed (for watchers without block cursors). */
10
+ /** Check if a specific tx has been fully processed (reached confirmation threshold). */
11
11
  hasProcessedTx(watcherId: string, txId: string): Promise<boolean>;
12
- /** Mark a tx as processed (for watchers without block cursors). */
12
+ /** Mark a tx as fully processed (reached confirmation threshold). */
13
13
  markProcessedTx(watcherId: string, txId: string): Promise<void>;
14
+ /** Get the last-seen confirmation count for a tx (for intermediate confirmation tracking). */
15
+ getConfirmationCount(watcherId: string, txId: string): Promise<number | null>;
16
+ /** Save the current confirmation count for a tx (for intermediate confirmation tracking). */
17
+ saveConfirmationCount(watcherId: string, txId: string, count: number): Promise<void>;
14
18
  }
15
19
 
16
20
  /**
17
21
  * Persists watcher state to PostgreSQL.
18
22
  *
19
- * Two patterns:
23
+ * Three patterns:
20
24
  * - Block cursor (EVM watchers): save/get cursor block number
21
25
  * - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
26
+ * - Confirmation counts (all watchers): getConfirmationCount/saveConfirmationCount
22
27
  *
23
28
  * Eliminates all in-memory watcher state. Clean restart recovery.
24
29
  */
@@ -58,4 +63,27 @@ export class DrizzleWatcherCursorStore implements IWatcherCursorStore {
58
63
  async markProcessedTx(watcherId: string, txId: string): Promise<void> {
59
64
  await this.db.insert(watcherProcessed).values({ watcherId, txId }).onConflictDoNothing();
60
65
  }
66
+
67
+ async getConfirmationCount(watcherId: string, txId: string): Promise<number | null> {
68
+ // Store confirmation counts as synthetic cursor entries: "watcherId:conf:txId" -> count
69
+ const key = `${watcherId}:conf:${txId}`;
70
+ const row = (
71
+ await this.db
72
+ .select({ cursorBlock: watcherCursors.cursorBlock })
73
+ .from(watcherCursors)
74
+ .where(eq(watcherCursors.watcherId, key))
75
+ )[0];
76
+ return row?.cursorBlock ?? null;
77
+ }
78
+
79
+ async saveConfirmationCount(watcherId: string, txId: string, count: number): Promise<void> {
80
+ const key = `${watcherId}:conf:${txId}`;
81
+ await this.db
82
+ .insert(watcherCursors)
83
+ .values({ watcherId: key, cursorBlock: count })
84
+ .onConflictDoUpdate({
85
+ target: watcherCursors.watcherId,
86
+ set: { cursorBlock: count, updatedAt: sql`(now())` },
87
+ });
88
+ }
61
89
  }
@@ -11,6 +11,8 @@ function makeEvent(overrides: Partial<EthPaymentEvent> = {}): EthPaymentEvent {
11
11
  amountUsdCents: 5000,
12
12
  txHash: "0xabc123",
13
13
  blockNumber: 100,
14
+ confirmations: 1,
15
+ confirmationsRequired: 1,
14
16
  ...overrides,
15
17
  };
16
18
  }
@@ -10,8 +10,9 @@ const mockOracle = { getPrice: vi.fn().mockResolvedValue({ priceCents: 350_000,
10
10
  describe("EthWatcher", () => {
11
11
  it("detects native ETH transfer to watched address", async () => {
12
12
  const onPayment = vi.fn();
13
+ // latest = 0xa (10), fromBlock = 10 → scans exactly block 10
13
14
  const rpc = makeRpc({
14
- eth_blockNumber: "0xb",
15
+ eth_blockNumber: "0xa",
15
16
  eth_getBlockByNumber: {
16
17
  transactions: [
17
18
  {
@@ -42,6 +43,8 @@ describe("EthWatcher", () => {
42
43
  expect(event.valueWei).toBe("1000000000000000000");
43
44
  expect(event.amountUsdCents).toBe(350_000); // 1 ETH × $3,500
44
45
  expect(event.txHash).toBe("0xabc");
46
+ expect(event.confirmations).toBe(0);
47
+ expect(event.confirmationsRequired).toBe(1);
45
48
  });
46
49
 
47
50
  it("skips transactions not to watched addresses", async () => {
@@ -90,6 +93,19 @@ describe("EthWatcher", () => {
90
93
 
91
94
  it("does not double-process same txid", async () => {
92
95
  const onPayment = vi.fn();
96
+ const confirmations = new Map<string, number>();
97
+ const cursorStore = {
98
+ get: vi.fn().mockResolvedValue(null),
99
+ save: vi.fn().mockResolvedValue(undefined),
100
+ hasProcessedTx: vi.fn().mockResolvedValue(false),
101
+ markProcessedTx: vi.fn().mockResolvedValue(undefined),
102
+ getConfirmationCount: vi
103
+ .fn()
104
+ .mockImplementation(async (_: string, txId: string) => confirmations.get(txId) ?? null),
105
+ saveConfirmationCount: vi.fn().mockImplementation(async (_: string, txId: string, count: number) => {
106
+ confirmations.set(txId, count);
107
+ }),
108
+ };
93
109
  const rpc = makeRpc({
94
110
  eth_blockNumber: "0xb",
95
111
  eth_getBlockByNumber: {
@@ -104,10 +120,11 @@ describe("EthWatcher", () => {
104
120
  fromBlock: 10,
105
121
  onPayment,
106
122
  watchedAddresses: ["0xDeposit"],
123
+ cursorStore,
107
124
  });
108
125
 
109
126
  await watcher.poll();
110
- // Reset cursor to re-scan same block
127
+ // Second poll same block, same confirmations → no duplicate emission
111
128
  await watcher.poll();
112
129
 
113
130
  expect(onPayment).toHaveBeenCalledOnce();
@@ -132,8 +149,17 @@ describe("EthWatcher", () => {
132
149
 
133
150
  it("does not mark txid as processed if onPayment throws", async () => {
134
151
  const onPayment = vi.fn().mockRejectedValueOnce(new Error("db fail")).mockResolvedValueOnce(undefined);
152
+ const cursorStore = {
153
+ get: vi.fn().mockResolvedValue(null),
154
+ save: vi.fn().mockResolvedValue(undefined),
155
+ hasProcessedTx: vi.fn().mockResolvedValue(false),
156
+ markProcessedTx: vi.fn().mockResolvedValue(undefined),
157
+ getConfirmationCount: vi.fn().mockResolvedValue(null),
158
+ saveConfirmationCount: vi.fn().mockResolvedValue(undefined),
159
+ };
160
+ // latest = 0xa (10) = fromBlock → exactly one block to scan
135
161
  const rpc = makeRpc({
136
- eth_blockNumber: "0xb",
162
+ eth_blockNumber: "0xa",
137
163
  eth_getBlockByNumber: {
138
164
  transactions: [{ hash: "0xabc", from: "0xa", to: "0xdeposit", value: "0xDE0B6B3A7640000", blockNumber: "0xa" }],
139
165
  },
@@ -146,11 +172,12 @@ describe("EthWatcher", () => {
146
172
  fromBlock: 10,
147
173
  onPayment,
148
174
  watchedAddresses: ["0xDeposit"],
175
+ cursorStore,
149
176
  });
150
177
 
151
178
  await expect(watcher.poll()).rejects.toThrow("db fail");
152
179
 
153
- // Retry — should process the same tx again since it wasn't marked
180
+ // Retry — should process the same tx again since confirmationCount wasn't saved (error before save)
154
181
  await watcher.poll();
155
182
  expect(onPayment).toHaveBeenCalledTimes(2);
156
183
  });
@@ -12,6 +12,8 @@ const mockEvent: EvmPaymentEvent = {
12
12
  txHash: "0xtx123",
13
13
  blockNumber: 100,
14
14
  logIndex: 0,
15
+ confirmations: 1,
16
+ confirmationsRequired: 1,
15
17
  };
16
18
 
17
19
  describe("settleEvmPayment", () => {
@@ -0,0 +1,176 @@
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)}`,
12
+ `0x${"00".repeat(12)}${to.slice(2).toLowerCase()}`,
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
+ function makeCursorStore() {
22
+ const cursors = new Map<string, number>();
23
+ return {
24
+ get: vi.fn().mockImplementation(async (id: string) => cursors.get(id) ?? null),
25
+ save: vi.fn().mockImplementation(async (id: string, val: number) => {
26
+ cursors.set(id, val);
27
+ }),
28
+ hasProcessedTx: vi.fn().mockResolvedValue(false),
29
+ markProcessedTx: vi.fn().mockResolvedValue(undefined),
30
+ getConfirmationCount: vi.fn().mockResolvedValue(null),
31
+ saveConfirmationCount: vi.fn().mockResolvedValue(undefined),
32
+ };
33
+ }
34
+
35
+ describe("EvmWatcher — intermediate confirmations", () => {
36
+ it("emits events with confirmation count", async () => {
37
+ const toAddr = `0x${"cc".repeat(20)}`;
38
+ const events: Array<{ confirmations: number; confirmationsRequired: number }> = [];
39
+
40
+ // Base has confirmations: 1. Latest block is 105. Log at block 103 -> 2 confirmations.
41
+ const mockRpc = vi
42
+ .fn()
43
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`) // eth_blockNumber
44
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]); // eth_getLogs
45
+
46
+ const watcher = new EvmWatcher({
47
+ chain: "base",
48
+ token: "USDC",
49
+ rpcCall: mockRpc,
50
+ fromBlock: 100,
51
+ watchedAddresses: [toAddr],
52
+ cursorStore: makeCursorStore(),
53
+ onPayment: (evt) => {
54
+ events.push(evt);
55
+ },
56
+ });
57
+
58
+ await watcher.poll();
59
+
60
+ expect(events).toHaveLength(1);
61
+ expect(events[0].confirmations).toBe(2); // 105 - 103
62
+ expect(events[0].confirmationsRequired).toBe(1); // Base chain config
63
+ });
64
+
65
+ it("skips event when confirmation count unchanged", async () => {
66
+ const toAddr = `0x${"cc".repeat(20)}`;
67
+ const events: Array<{ confirmations: number }> = [];
68
+ const cursorStore = makeCursorStore();
69
+ cursorStore.getConfirmationCount.mockResolvedValue(2);
70
+
71
+ const mockRpc = vi
72
+ .fn()
73
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
74
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]);
75
+
76
+ const watcher = new EvmWatcher({
77
+ chain: "base",
78
+ token: "USDC",
79
+ rpcCall: mockRpc,
80
+ fromBlock: 100,
81
+ watchedAddresses: [toAddr],
82
+ cursorStore,
83
+ onPayment: (evt) => {
84
+ events.push(evt);
85
+ },
86
+ });
87
+
88
+ await watcher.poll();
89
+
90
+ expect(events).toHaveLength(0);
91
+ });
92
+
93
+ it("re-emits when confirmations increase", async () => {
94
+ const toAddr = `0x${"cc".repeat(20)}`;
95
+ const events: Array<{ confirmations: number }> = [];
96
+ const cursorStore = makeCursorStore();
97
+ cursorStore.getConfirmationCount.mockResolvedValue(1);
98
+
99
+ const mockRpc = vi
100
+ .fn()
101
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
102
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]);
103
+
104
+ const watcher = new EvmWatcher({
105
+ chain: "base",
106
+ token: "USDC",
107
+ rpcCall: mockRpc,
108
+ fromBlock: 100,
109
+ watchedAddresses: [toAddr],
110
+ cursorStore,
111
+ onPayment: (evt) => {
112
+ events.push(evt);
113
+ },
114
+ });
115
+
116
+ await watcher.poll();
117
+
118
+ expect(events).toHaveLength(1);
119
+ expect(events[0].confirmations).toBe(2);
120
+ });
121
+
122
+ it("includes confirmationsRequired from chain config", async () => {
123
+ const toAddr = `0x${"cc".repeat(20)}`;
124
+ const events: Array<{ confirmationsRequired: number }> = [];
125
+
126
+ const mockRpc = vi
127
+ .fn()
128
+ .mockResolvedValueOnce(`0x${(110).toString(16)}`)
129
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 105)]);
130
+
131
+ const watcher = new EvmWatcher({
132
+ chain: "base",
133
+ token: "USDC",
134
+ rpcCall: mockRpc,
135
+ fromBlock: 100,
136
+ watchedAddresses: [toAddr],
137
+ cursorStore: makeCursorStore(),
138
+ onPayment: (evt) => {
139
+ events.push(evt);
140
+ },
141
+ });
142
+
143
+ await watcher.poll();
144
+
145
+ expect(events).toHaveLength(1);
146
+ expect(events[0].confirmationsRequired).toBe(1); // Base chain config
147
+ });
148
+
149
+ it("saves confirmation count after emitting", async () => {
150
+ const toAddr = `0x${"cc".repeat(20)}`;
151
+ const cursorStore = makeCursorStore();
152
+
153
+ const mockRpc = vi
154
+ .fn()
155
+ .mockResolvedValueOnce(`0x${(105).toString(16)}`)
156
+ .mockResolvedValueOnce([mockTransferLog(toAddr, 10_000_000n, 103)]);
157
+
158
+ const watcher = new EvmWatcher({
159
+ chain: "base",
160
+ token: "USDC",
161
+ rpcCall: mockRpc,
162
+ fromBlock: 100,
163
+ watchedAddresses: [toAddr],
164
+ cursorStore,
165
+ onPayment: () => {},
166
+ });
167
+
168
+ await watcher.poll();
169
+
170
+ expect(cursorStore.saveConfirmationCount).toHaveBeenCalledWith(
171
+ expect.any(String),
172
+ expect.stringContaining("0x"),
173
+ 2,
174
+ );
175
+ });
176
+ });