@wopr-network/platform-core 1.47.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/billing/payment-processor.d.ts +2 -0
- package/dist/billing/payment-processor.test.js +1 -0
- package/dist/billing/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/billing/stripe/stripe-payment-processor.js +27 -4
- package/dist/billing/stripe/stripe-payment-processor.test.js +95 -1
- 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/dist/monetization/stripe/stripe-payment-processor.d.ts +1 -0
- package/dist/monetization/stripe/stripe-payment-processor.js +25 -3
- package/dist/monetization/stripe/stripe-payment-processor.test.js +73 -1
- package/dist/trpc/org-remove-payment-method-router.test.js +1 -0
- 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/billing/payment-processor.test.ts +1 -0
- package/src/billing/payment-processor.ts +3 -0
- package/src/billing/stripe/stripe-payment-processor.test.ts +113 -1
- package/src/billing/stripe/stripe-payment-processor.ts +33 -5
- package/src/db/schema/crypto.ts +8 -0
- package/src/monetization/crypto/__tests__/webhook.test.ts +2 -1
- package/src/monetization/stripe/stripe-payment-processor.test.ts +89 -1
- package/src/monetization/stripe/stripe-payment-processor.ts +31 -4
- package/src/trpc/org-remove-payment-method-router.test.ts +1 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { BtcWatcher } from "../watcher.js";
|
|
3
|
+
|
|
4
|
+
function makeCursorStore() {
|
|
5
|
+
const processed = new Set<string>();
|
|
6
|
+
const confirmationCounts = new Map<string, number>();
|
|
7
|
+
return {
|
|
8
|
+
get: vi.fn().mockResolvedValue(null),
|
|
9
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
10
|
+
hasProcessedTx: vi.fn().mockImplementation(async (_: string, txId: string) => processed.has(txId)),
|
|
11
|
+
markProcessedTx: vi.fn().mockImplementation(async (_: string, txId: string) => {
|
|
12
|
+
processed.add(txId);
|
|
13
|
+
}),
|
|
14
|
+
getConfirmationCount: vi
|
|
15
|
+
.fn()
|
|
16
|
+
.mockImplementation(async (_: string, txId: string) => confirmationCounts.get(txId) ?? null),
|
|
17
|
+
saveConfirmationCount: vi.fn().mockImplementation(async (_: string, txId: string, count: number) => {
|
|
18
|
+
confirmationCounts.set(txId, count);
|
|
19
|
+
}),
|
|
20
|
+
_processed: processed,
|
|
21
|
+
_confirmationCounts: confirmationCounts,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makeOracle() {
|
|
26
|
+
return { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000 }) };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("BtcWatcher — intermediate confirmations", () => {
|
|
30
|
+
it("fires onPayment at 0 confirmations when tx first detected", async () => {
|
|
31
|
+
const events: Array<{ confirmations: number; confirmationsRequired: number }> = [];
|
|
32
|
+
const cursorStore = makeCursorStore();
|
|
33
|
+
const rpc = vi
|
|
34
|
+
.fn()
|
|
35
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 0, txids: ["tx1"] }])
|
|
36
|
+
.mockResolvedValueOnce({
|
|
37
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
38
|
+
confirmations: 0,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const watcher = new BtcWatcher({
|
|
42
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
43
|
+
rpcCall: rpc,
|
|
44
|
+
watchedAddresses: ["bc1qtest"],
|
|
45
|
+
oracle: makeOracle(),
|
|
46
|
+
cursorStore,
|
|
47
|
+
onPayment: (evt) => {
|
|
48
|
+
events.push(evt);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
await watcher.poll();
|
|
53
|
+
|
|
54
|
+
expect(events).toHaveLength(1);
|
|
55
|
+
expect(events[0].confirmations).toBe(0);
|
|
56
|
+
expect(events[0].confirmationsRequired).toBe(3);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("fires onPayment on each confirmation increment", async () => {
|
|
60
|
+
const events: Array<{ confirmations: number }> = [];
|
|
61
|
+
const cursorStore = makeCursorStore();
|
|
62
|
+
cursorStore._confirmationCounts.set("tx1", 1);
|
|
63
|
+
|
|
64
|
+
const rpc = vi
|
|
65
|
+
.fn()
|
|
66
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
|
|
67
|
+
.mockResolvedValueOnce({
|
|
68
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
69
|
+
confirmations: 2,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const watcher = new BtcWatcher({
|
|
73
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
74
|
+
rpcCall: rpc,
|
|
75
|
+
watchedAddresses: ["bc1qtest"],
|
|
76
|
+
oracle: makeOracle(),
|
|
77
|
+
cursorStore,
|
|
78
|
+
onPayment: (evt) => {
|
|
79
|
+
events.push(evt);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
await watcher.poll();
|
|
84
|
+
|
|
85
|
+
expect(events).toHaveLength(1);
|
|
86
|
+
expect(events[0].confirmations).toBe(2);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("does not fire when confirmation count unchanged", async () => {
|
|
90
|
+
const events: Array<{ confirmations: number }> = [];
|
|
91
|
+
const cursorStore = makeCursorStore();
|
|
92
|
+
cursorStore._confirmationCounts.set("tx1", 2);
|
|
93
|
+
|
|
94
|
+
const rpc = vi
|
|
95
|
+
.fn()
|
|
96
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
|
|
97
|
+
.mockResolvedValueOnce({
|
|
98
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
99
|
+
confirmations: 2,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const watcher = new BtcWatcher({
|
|
103
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
104
|
+
rpcCall: rpc,
|
|
105
|
+
watchedAddresses: ["bc1qtest"],
|
|
106
|
+
oracle: makeOracle(),
|
|
107
|
+
cursorStore,
|
|
108
|
+
onPayment: (evt) => {
|
|
109
|
+
events.push(evt);
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await watcher.poll();
|
|
114
|
+
|
|
115
|
+
expect(events).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("marks tx as processed once confirmations reach threshold", async () => {
|
|
119
|
+
const events: Array<{ confirmations: number }> = [];
|
|
120
|
+
const cursorStore = makeCursorStore();
|
|
121
|
+
cursorStore._confirmationCounts.set("tx1", 2);
|
|
122
|
+
|
|
123
|
+
const rpc = vi
|
|
124
|
+
.fn()
|
|
125
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 3, txids: ["tx1"] }])
|
|
126
|
+
.mockResolvedValueOnce({
|
|
127
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
128
|
+
confirmations: 3,
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
const watcher = new BtcWatcher({
|
|
132
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
133
|
+
rpcCall: rpc,
|
|
134
|
+
watchedAddresses: ["bc1qtest"],
|
|
135
|
+
oracle: makeOracle(),
|
|
136
|
+
cursorStore,
|
|
137
|
+
onPayment: (evt) => {
|
|
138
|
+
events.push(evt);
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await watcher.poll();
|
|
143
|
+
|
|
144
|
+
expect(events).toHaveLength(1);
|
|
145
|
+
expect(events[0].confirmations).toBe(3);
|
|
146
|
+
expect(cursorStore.markProcessedTx).toHaveBeenCalledWith(expect.any(String), "tx1");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("skips fully-processed txids", async () => {
|
|
150
|
+
const events: unknown[] = [];
|
|
151
|
+
const cursorStore = makeCursorStore();
|
|
152
|
+
cursorStore._processed.add("tx1");
|
|
153
|
+
|
|
154
|
+
const rpc = vi
|
|
155
|
+
.fn()
|
|
156
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 6, txids: ["tx1"] }]);
|
|
157
|
+
|
|
158
|
+
const watcher = new BtcWatcher({
|
|
159
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
160
|
+
rpcCall: rpc,
|
|
161
|
+
watchedAddresses: ["bc1qtest"],
|
|
162
|
+
oracle: makeOracle(),
|
|
163
|
+
cursorStore,
|
|
164
|
+
onPayment: (evt) => {
|
|
165
|
+
events.push(evt);
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await watcher.poll();
|
|
170
|
+
|
|
171
|
+
expect(events).toHaveLength(0);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("includes confirmationsRequired in event", async () => {
|
|
175
|
+
const events: Array<{ confirmationsRequired: number }> = [];
|
|
176
|
+
const cursorStore = makeCursorStore();
|
|
177
|
+
|
|
178
|
+
const rpc = vi
|
|
179
|
+
.fn()
|
|
180
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.001, confirmations: 0, txids: ["txNew"] }])
|
|
181
|
+
.mockResolvedValueOnce({
|
|
182
|
+
details: [{ address: "bc1qtest", amount: 0.001, category: "receive" }],
|
|
183
|
+
confirmations: 0,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const watcher = new BtcWatcher({
|
|
187
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 6 },
|
|
188
|
+
rpcCall: rpc,
|
|
189
|
+
watchedAddresses: ["bc1qtest"],
|
|
190
|
+
oracle: makeOracle(),
|
|
191
|
+
cursorStore,
|
|
192
|
+
onPayment: (evt) => {
|
|
193
|
+
events.push(evt);
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await watcher.poll();
|
|
198
|
+
|
|
199
|
+
expect(events[0].confirmationsRequired).toBe(6);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** BTC payment event emitted
|
|
1
|
+
/** BTC payment event emitted on each confirmation increment. */
|
|
2
2
|
export interface BtcPaymentEvent {
|
|
3
3
|
readonly address: string;
|
|
4
4
|
readonly txid: string;
|
|
@@ -7,6 +7,8 @@ export interface BtcPaymentEvent {
|
|
|
7
7
|
/** USD cents equivalent (integer). */
|
|
8
8
|
readonly amountUsdCents: number;
|
|
9
9
|
readonly confirmations: number;
|
|
10
|
+
/** Required confirmations for this chain (from config). */
|
|
11
|
+
readonly confirmationsRequired: number;
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
/** Options for creating a BTC checkout. */
|
|
@@ -74,12 +74,18 @@ export class BtcWatcher {
|
|
|
74
74
|
this.addresses.add(address);
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Poll for payments to watched addresses, including unconfirmed txs.
|
|
79
|
+
*
|
|
80
|
+
* Fires onPayment on every confirmation increment (0, 1, 2, ... threshold).
|
|
81
|
+
* Only marks a tx as fully processed once it reaches the confirmation threshold.
|
|
82
|
+
*/
|
|
78
83
|
async poll(): Promise<void> {
|
|
79
84
|
if (this.addresses.size === 0) return;
|
|
80
85
|
|
|
86
|
+
// Poll with minconf=0 to see unconfirmed txs
|
|
81
87
|
const received = (await this.rpc("listreceivedbyaddress", [
|
|
82
|
-
|
|
88
|
+
0, // minconf=0: see ALL txs including unconfirmed
|
|
83
89
|
false, // include_empty
|
|
84
90
|
true, // include_watchonly
|
|
85
91
|
])) as ReceivedByAddress[];
|
|
@@ -90,7 +96,7 @@ export class BtcWatcher {
|
|
|
90
96
|
if (!this.addresses.has(entry.address)) continue;
|
|
91
97
|
|
|
92
98
|
for (const txid of entry.txids) {
|
|
93
|
-
// Skip
|
|
99
|
+
// Skip fully-processed txids (already reached threshold, persisted to DB)
|
|
94
100
|
if (await this.cursorStore.hasProcessedTx(this.watcherId, txid)) continue;
|
|
95
101
|
|
|
96
102
|
// Get transaction details for the exact amount sent to this address
|
|
@@ -102,8 +108,11 @@ export class BtcWatcher {
|
|
|
102
108
|
const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
|
|
103
109
|
if (!detail) continue;
|
|
104
110
|
|
|
111
|
+
// Check if confirmations have increased since last seen
|
|
112
|
+
const lastSeen = await this.cursorStore.getConfirmationCount(this.watcherId, txid);
|
|
113
|
+
if (lastSeen !== null && tx.confirmations <= lastSeen) continue; // No change
|
|
114
|
+
|
|
105
115
|
const amountSats = Math.round(detail.amount * 100_000_000);
|
|
106
|
-
// priceCents is cents per 1 BTC. detail.amount is in BTC.
|
|
107
116
|
const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
|
|
108
117
|
|
|
109
118
|
const event: BtcPaymentEvent = {
|
|
@@ -112,11 +121,18 @@ export class BtcWatcher {
|
|
|
112
121
|
amountSats,
|
|
113
122
|
amountUsdCents,
|
|
114
123
|
confirmations: tx.confirmations,
|
|
124
|
+
confirmationsRequired: this.minConfirmations,
|
|
115
125
|
};
|
|
116
126
|
|
|
117
127
|
await this.onPayment(event);
|
|
118
|
-
|
|
119
|
-
|
|
128
|
+
|
|
129
|
+
// Persist confirmation count
|
|
130
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, txid, tx.confirmations);
|
|
131
|
+
|
|
132
|
+
// Mark as fully processed once we reach the threshold
|
|
133
|
+
if (tx.confirmations >= this.minConfirmations) {
|
|
134
|
+
await this.cursorStore.markProcessedTx(this.watcherId, txid);
|
|
135
|
+
}
|
|
120
136
|
}
|
|
121
137
|
}
|
|
122
138
|
}
|
|
@@ -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
|
}
|