@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.
- package/dist/billing/crypto/__tests__/unified-checkout.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/unified-checkout.test.js +63 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/watcher-service.test.js +174 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/__tests__/webhook-confirmations.test.js +304 -0
- package/dist/billing/crypto/btc/__tests__/settler.test.js +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +1 -0
- package/dist/billing/crypto/btc/__tests__/watcher.test.js +170 -0
- package/dist/billing/crypto/btc/types.d.ts +3 -1
- package/dist/billing/crypto/btc/watcher.d.ts +6 -1
- package/dist/billing/crypto/btc/watcher.js +20 -6
- package/dist/billing/crypto/charge-store.d.ts +27 -2
- package/dist/billing/crypto/charge-store.js +67 -1
- package/dist/billing/crypto/charge-store.test.js +180 -1
- package/dist/billing/crypto/client.d.ts +2 -0
- package/dist/billing/crypto/cursor-store.d.ts +10 -3
- package/dist/billing/crypto/cursor-store.js +21 -1
- package/dist/billing/crypto/evm/__tests__/eth-settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +31 -4
- package/dist/billing/crypto/evm/__tests__/settler.test.js +2 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +1 -0
- package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +144 -0
- package/dist/billing/crypto/evm/__tests__/watcher.test.js +6 -2
- package/dist/billing/crypto/evm/eth-watcher.d.ts +11 -8
- package/dist/billing/crypto/evm/eth-watcher.js +27 -13
- package/dist/billing/crypto/evm/types.d.ts +5 -1
- package/dist/billing/crypto/evm/watcher.d.ts +9 -1
- package/dist/billing/crypto/evm/watcher.js +36 -13
- package/dist/billing/crypto/index.d.ts +3 -3
- package/dist/billing/crypto/index.js +1 -1
- package/dist/billing/crypto/key-server-webhook.d.ts +17 -4
- package/dist/billing/crypto/key-server-webhook.js +76 -15
- package/dist/billing/crypto/types.d.ts +16 -0
- package/dist/billing/crypto/unified-checkout.d.ts +8 -17
- package/dist/billing/crypto/unified-checkout.js +17 -131
- package/dist/billing/crypto/watcher-service.d.ts +22 -2
- package/dist/billing/crypto/watcher-service.js +71 -30
- package/dist/db/schema/crypto.d.ts +68 -0
- package/dist/db/schema/crypto.js +8 -0
- package/dist/monetization/crypto/__tests__/webhook.test.js +2 -1
- package/drizzle/migrations/0016_charge_progress_columns.sql +4 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/unified-checkout.test.ts +83 -0
- package/src/billing/crypto/__tests__/watcher-service.test.ts +242 -0
- package/src/billing/crypto/__tests__/webhook-confirmations.test.ts +367 -0
- package/src/billing/crypto/btc/__tests__/settler.test.ts +1 -0
- package/src/billing/crypto/btc/__tests__/watcher.test.ts +201 -0
- package/src/billing/crypto/btc/types.ts +3 -1
- package/src/billing/crypto/btc/watcher.ts +22 -6
- package/src/billing/crypto/charge-store.test.ts +204 -1
- package/src/billing/crypto/charge-store.ts +86 -2
- package/src/billing/crypto/client.ts +2 -0
- package/src/billing/crypto/cursor-store.ts +31 -3
- package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +31 -4
- package/src/billing/crypto/evm/__tests__/settler.test.ts +2 -0
- package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +176 -0
- package/src/billing/crypto/evm/__tests__/watcher.test.ts +6 -2
- package/src/billing/crypto/evm/eth-watcher.ts +34 -14
- package/src/billing/crypto/evm/types.ts +5 -1
- package/src/billing/crypto/evm/watcher.ts +39 -13
- package/src/billing/crypto/index.ts +12 -3
- package/src/billing/crypto/key-server-webhook.ts +92 -21
- package/src/billing/crypto/types.ts +18 -0
- package/src/billing/crypto/unified-checkout.ts +20 -179
- package/src/billing/crypto/watcher-service.ts +85 -32
- package/src/db/schema/crypto.ts +8 -0
- 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
|
|
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,
|
|
@@ -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 (
|
|
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 (
|
|
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
|
-
*
|
|
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
|
}
|
|
@@ -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: "
|
|
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
|
-
//
|
|
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: "
|
|
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
|
|
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
|
});
|
|
@@ -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
|
+
});
|