@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,170 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { BtcWatcher } from "../watcher.js";
|
|
3
|
+
function makeCursorStore() {
|
|
4
|
+
const processed = new Set();
|
|
5
|
+
const confirmationCounts = new Map();
|
|
6
|
+
return {
|
|
7
|
+
get: vi.fn().mockResolvedValue(null),
|
|
8
|
+
save: vi.fn().mockResolvedValue(undefined),
|
|
9
|
+
hasProcessedTx: vi.fn().mockImplementation(async (_, txId) => processed.has(txId)),
|
|
10
|
+
markProcessedTx: vi.fn().mockImplementation(async (_, txId) => {
|
|
11
|
+
processed.add(txId);
|
|
12
|
+
}),
|
|
13
|
+
getConfirmationCount: vi
|
|
14
|
+
.fn()
|
|
15
|
+
.mockImplementation(async (_, txId) => confirmationCounts.get(txId) ?? null),
|
|
16
|
+
saveConfirmationCount: vi.fn().mockImplementation(async (_, txId, count) => {
|
|
17
|
+
confirmationCounts.set(txId, count);
|
|
18
|
+
}),
|
|
19
|
+
_processed: processed,
|
|
20
|
+
_confirmationCounts: confirmationCounts,
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
function makeOracle() {
|
|
24
|
+
return { getPrice: vi.fn().mockResolvedValue({ priceCents: 6_500_000 }) };
|
|
25
|
+
}
|
|
26
|
+
describe("BtcWatcher — intermediate confirmations", () => {
|
|
27
|
+
it("fires onPayment at 0 confirmations when tx first detected", async () => {
|
|
28
|
+
const events = [];
|
|
29
|
+
const cursorStore = makeCursorStore();
|
|
30
|
+
const rpc = vi
|
|
31
|
+
.fn()
|
|
32
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 0, txids: ["tx1"] }])
|
|
33
|
+
.mockResolvedValueOnce({
|
|
34
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
35
|
+
confirmations: 0,
|
|
36
|
+
});
|
|
37
|
+
const watcher = new BtcWatcher({
|
|
38
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
39
|
+
rpcCall: rpc,
|
|
40
|
+
watchedAddresses: ["bc1qtest"],
|
|
41
|
+
oracle: makeOracle(),
|
|
42
|
+
cursorStore,
|
|
43
|
+
onPayment: (evt) => {
|
|
44
|
+
events.push(evt);
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
await watcher.poll();
|
|
48
|
+
expect(events).toHaveLength(1);
|
|
49
|
+
expect(events[0].confirmations).toBe(0);
|
|
50
|
+
expect(events[0].confirmationsRequired).toBe(3);
|
|
51
|
+
});
|
|
52
|
+
it("fires onPayment on each confirmation increment", async () => {
|
|
53
|
+
const events = [];
|
|
54
|
+
const cursorStore = makeCursorStore();
|
|
55
|
+
cursorStore._confirmationCounts.set("tx1", 1);
|
|
56
|
+
const rpc = vi
|
|
57
|
+
.fn()
|
|
58
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
|
|
59
|
+
.mockResolvedValueOnce({
|
|
60
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
61
|
+
confirmations: 2,
|
|
62
|
+
});
|
|
63
|
+
const watcher = new BtcWatcher({
|
|
64
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
65
|
+
rpcCall: rpc,
|
|
66
|
+
watchedAddresses: ["bc1qtest"],
|
|
67
|
+
oracle: makeOracle(),
|
|
68
|
+
cursorStore,
|
|
69
|
+
onPayment: (evt) => {
|
|
70
|
+
events.push(evt);
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
await watcher.poll();
|
|
74
|
+
expect(events).toHaveLength(1);
|
|
75
|
+
expect(events[0].confirmations).toBe(2);
|
|
76
|
+
});
|
|
77
|
+
it("does not fire when confirmation count unchanged", async () => {
|
|
78
|
+
const events = [];
|
|
79
|
+
const cursorStore = makeCursorStore();
|
|
80
|
+
cursorStore._confirmationCounts.set("tx1", 2);
|
|
81
|
+
const rpc = vi
|
|
82
|
+
.fn()
|
|
83
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 2, txids: ["tx1"] }])
|
|
84
|
+
.mockResolvedValueOnce({
|
|
85
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
86
|
+
confirmations: 2,
|
|
87
|
+
});
|
|
88
|
+
const watcher = new BtcWatcher({
|
|
89
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
90
|
+
rpcCall: rpc,
|
|
91
|
+
watchedAddresses: ["bc1qtest"],
|
|
92
|
+
oracle: makeOracle(),
|
|
93
|
+
cursorStore,
|
|
94
|
+
onPayment: (evt) => {
|
|
95
|
+
events.push(evt);
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
await watcher.poll();
|
|
99
|
+
expect(events).toHaveLength(0);
|
|
100
|
+
});
|
|
101
|
+
it("marks tx as processed once confirmations reach threshold", async () => {
|
|
102
|
+
const events = [];
|
|
103
|
+
const cursorStore = makeCursorStore();
|
|
104
|
+
cursorStore._confirmationCounts.set("tx1", 2);
|
|
105
|
+
const rpc = vi
|
|
106
|
+
.fn()
|
|
107
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 3, txids: ["tx1"] }])
|
|
108
|
+
.mockResolvedValueOnce({
|
|
109
|
+
details: [{ address: "bc1qtest", amount: 0.0005, category: "receive" }],
|
|
110
|
+
confirmations: 3,
|
|
111
|
+
});
|
|
112
|
+
const watcher = new BtcWatcher({
|
|
113
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
114
|
+
rpcCall: rpc,
|
|
115
|
+
watchedAddresses: ["bc1qtest"],
|
|
116
|
+
oracle: makeOracle(),
|
|
117
|
+
cursorStore,
|
|
118
|
+
onPayment: (evt) => {
|
|
119
|
+
events.push(evt);
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
await watcher.poll();
|
|
123
|
+
expect(events).toHaveLength(1);
|
|
124
|
+
expect(events[0].confirmations).toBe(3);
|
|
125
|
+
expect(cursorStore.markProcessedTx).toHaveBeenCalledWith(expect.any(String), "tx1");
|
|
126
|
+
});
|
|
127
|
+
it("skips fully-processed txids", async () => {
|
|
128
|
+
const events = [];
|
|
129
|
+
const cursorStore = makeCursorStore();
|
|
130
|
+
cursorStore._processed.add("tx1");
|
|
131
|
+
const rpc = vi
|
|
132
|
+
.fn()
|
|
133
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.0005, confirmations: 6, txids: ["tx1"] }]);
|
|
134
|
+
const watcher = new BtcWatcher({
|
|
135
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 3 },
|
|
136
|
+
rpcCall: rpc,
|
|
137
|
+
watchedAddresses: ["bc1qtest"],
|
|
138
|
+
oracle: makeOracle(),
|
|
139
|
+
cursorStore,
|
|
140
|
+
onPayment: (evt) => {
|
|
141
|
+
events.push(evt);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
await watcher.poll();
|
|
145
|
+
expect(events).toHaveLength(0);
|
|
146
|
+
});
|
|
147
|
+
it("includes confirmationsRequired in event", async () => {
|
|
148
|
+
const events = [];
|
|
149
|
+
const cursorStore = makeCursorStore();
|
|
150
|
+
const rpc = vi
|
|
151
|
+
.fn()
|
|
152
|
+
.mockResolvedValueOnce([{ address: "bc1qtest", amount: 0.001, confirmations: 0, txids: ["txNew"] }])
|
|
153
|
+
.mockResolvedValueOnce({
|
|
154
|
+
details: [{ address: "bc1qtest", amount: 0.001, category: "receive" }],
|
|
155
|
+
confirmations: 0,
|
|
156
|
+
});
|
|
157
|
+
const watcher = new BtcWatcher({
|
|
158
|
+
config: { rpcUrl: "http://localhost", rpcUser: "u", rpcPassword: "p", network: "regtest", confirmations: 6 },
|
|
159
|
+
rpcCall: rpc,
|
|
160
|
+
watchedAddresses: ["bc1qtest"],
|
|
161
|
+
oracle: makeOracle(),
|
|
162
|
+
cursorStore,
|
|
163
|
+
onPayment: (evt) => {
|
|
164
|
+
events.push(evt);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
await watcher.poll();
|
|
168
|
+
expect(events[0].confirmationsRequired).toBe(6);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -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
|
/** Options for creating a BTC checkout. */
|
|
12
14
|
export interface BtcCheckoutOpts {
|
|
@@ -31,7 +31,12 @@ export declare class BtcWatcher {
|
|
|
31
31
|
* Uses `importdescriptors` (modern bitcoind v24+) with fallback to legacy `importaddress`.
|
|
32
32
|
*/
|
|
33
33
|
importAddress(address: string): Promise<void>;
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Poll for payments to watched addresses, including unconfirmed txs.
|
|
36
|
+
*
|
|
37
|
+
* Fires onPayment on every confirmation increment (0, 1, 2, ... threshold).
|
|
38
|
+
* Only marks a tx as fully processed once it reaches the confirmation threshold.
|
|
39
|
+
*/
|
|
35
40
|
poll(): Promise<void>;
|
|
36
41
|
}
|
|
37
42
|
/** Create a bitcoind JSON-RPC caller with basic auth. */
|
|
@@ -40,12 +40,18 @@ export class BtcWatcher {
|
|
|
40
40
|
}
|
|
41
41
|
this.addresses.add(address);
|
|
42
42
|
}
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* Poll for payments to watched addresses, including unconfirmed txs.
|
|
45
|
+
*
|
|
46
|
+
* Fires onPayment on every confirmation increment (0, 1, 2, ... threshold).
|
|
47
|
+
* Only marks a tx as fully processed once it reaches the confirmation threshold.
|
|
48
|
+
*/
|
|
44
49
|
async poll() {
|
|
45
50
|
if (this.addresses.size === 0)
|
|
46
51
|
return;
|
|
52
|
+
// Poll with minconf=0 to see unconfirmed txs
|
|
47
53
|
const received = (await this.rpc("listreceivedbyaddress", [
|
|
48
|
-
|
|
54
|
+
0, // minconf=0: see ALL txs including unconfirmed
|
|
49
55
|
false, // include_empty
|
|
50
56
|
true, // include_watchonly
|
|
51
57
|
]));
|
|
@@ -54,7 +60,7 @@ export class BtcWatcher {
|
|
|
54
60
|
if (!this.addresses.has(entry.address))
|
|
55
61
|
continue;
|
|
56
62
|
for (const txid of entry.txids) {
|
|
57
|
-
// Skip
|
|
63
|
+
// Skip fully-processed txids (already reached threshold, persisted to DB)
|
|
58
64
|
if (await this.cursorStore.hasProcessedTx(this.watcherId, txid))
|
|
59
65
|
continue;
|
|
60
66
|
// Get transaction details for the exact amount sent to this address
|
|
@@ -62,8 +68,11 @@ export class BtcWatcher {
|
|
|
62
68
|
const detail = tx.details.find((d) => d.address === entry.address && d.category === "receive");
|
|
63
69
|
if (!detail)
|
|
64
70
|
continue;
|
|
71
|
+
// Check if confirmations have increased since last seen
|
|
72
|
+
const lastSeen = await this.cursorStore.getConfirmationCount(this.watcherId, txid);
|
|
73
|
+
if (lastSeen !== null && tx.confirmations <= lastSeen)
|
|
74
|
+
continue; // No change
|
|
65
75
|
const amountSats = Math.round(detail.amount * 100_000_000);
|
|
66
|
-
// priceCents is cents per 1 BTC. detail.amount is in BTC.
|
|
67
76
|
const amountUsdCents = Math.round((amountSats * priceCents) / 100_000_000);
|
|
68
77
|
const event = {
|
|
69
78
|
address: entry.address,
|
|
@@ -71,10 +80,15 @@ export class BtcWatcher {
|
|
|
71
80
|
amountSats,
|
|
72
81
|
amountUsdCents,
|
|
73
82
|
confirmations: tx.confirmations,
|
|
83
|
+
confirmationsRequired: this.minConfirmations,
|
|
74
84
|
};
|
|
75
85
|
await this.onPayment(event);
|
|
76
|
-
// Persist
|
|
77
|
-
await this.cursorStore.
|
|
86
|
+
// Persist confirmation count
|
|
87
|
+
await this.cursorStore.saveConfirmationCount(this.watcherId, txid, tx.confirmations);
|
|
88
|
+
// Mark as fully processed once we reach the threshold
|
|
89
|
+
if (tx.confirmations >= this.minConfirmations) {
|
|
90
|
+
await this.cursorStore.markProcessedTx(this.watcherId, txid);
|
|
91
|
+
}
|
|
78
92
|
}
|
|
79
93
|
}
|
|
80
94
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { PlatformDb } from "../../db/index.js";
|
|
2
|
-
import type { CryptoPaymentState } from "./types.js";
|
|
2
|
+
import type { CryptoCharge, CryptoChargeStatus, CryptoPaymentState } from "./types.js";
|
|
3
3
|
export interface CryptoChargeRecord {
|
|
4
4
|
referenceId: string;
|
|
5
5
|
tenantId: string;
|
|
@@ -17,6 +17,10 @@ export interface CryptoChargeRecord {
|
|
|
17
17
|
callbackUrl: string | null;
|
|
18
18
|
expectedAmount: string | null;
|
|
19
19
|
receivedAmount: string | null;
|
|
20
|
+
confirmations: number;
|
|
21
|
+
confirmationsRequired: number;
|
|
22
|
+
txHash: string | null;
|
|
23
|
+
amountReceivedCents: number;
|
|
20
24
|
}
|
|
21
25
|
export interface CryptoDepositChargeInput {
|
|
22
26
|
referenceId: string;
|
|
@@ -30,10 +34,22 @@ export interface CryptoDepositChargeInput {
|
|
|
30
34
|
/** Expected crypto amount in native base units (sats for BTC, base units for ERC20). */
|
|
31
35
|
expectedAmount?: string;
|
|
32
36
|
}
|
|
37
|
+
export interface CryptoChargeProgressUpdate {
|
|
38
|
+
status: CryptoChargeStatus;
|
|
39
|
+
amountReceivedCents: number;
|
|
40
|
+
confirmations: number;
|
|
41
|
+
confirmationsRequired: number;
|
|
42
|
+
txHash?: string;
|
|
43
|
+
}
|
|
33
44
|
export interface ICryptoChargeRepository {
|
|
34
45
|
create(referenceId: string, tenantId: string, amountUsdCents: number): Promise<void>;
|
|
35
46
|
getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
|
|
47
|
+
/** @deprecated Use updateProgress() instead. Kept for one release cycle. */
|
|
36
48
|
updateStatus(referenceId: string, status: CryptoPaymentState, currency?: string, filledAmount?: string): Promise<void>;
|
|
49
|
+
/** Update partial payment progress, confirmations, and tx hash. */
|
|
50
|
+
updateProgress(referenceId: string, update: CryptoChargeProgressUpdate): Promise<void>;
|
|
51
|
+
/** Get a charge as a UI-facing CryptoCharge with all progress fields. */
|
|
52
|
+
get(referenceId: string): Promise<CryptoCharge | null>;
|
|
37
53
|
markCredited(referenceId: string): Promise<void>;
|
|
38
54
|
isCredited(referenceId: string): Promise<boolean>;
|
|
39
55
|
createStablecoinCharge(input: CryptoDepositChargeInput): Promise<void>;
|
|
@@ -63,7 +79,16 @@ export declare class DrizzleCryptoChargeRepository implements ICryptoChargeRepos
|
|
|
63
79
|
/** Get a charge by reference ID. Returns null if not found. */
|
|
64
80
|
getByReferenceId(referenceId: string): Promise<CryptoChargeRecord | null>;
|
|
65
81
|
private toRecord;
|
|
66
|
-
/**
|
|
82
|
+
/** Map DB status strings to CryptoChargeStatus for UI consumption. */
|
|
83
|
+
private mapStatus;
|
|
84
|
+
/** Get a charge as a UI-facing CryptoCharge with all progress fields. */
|
|
85
|
+
get(referenceId: string): Promise<CryptoCharge | null>;
|
|
86
|
+
/** Update partial payment progress, confirmations, and tx hash. */
|
|
87
|
+
updateProgress(referenceId: string, update: CryptoChargeProgressUpdate): Promise<void>;
|
|
88
|
+
/**
|
|
89
|
+
* @deprecated Use updateProgress() instead. Kept for one release cycle.
|
|
90
|
+
* Update charge status and payment details from webhook.
|
|
91
|
+
*/
|
|
67
92
|
updateStatus(referenceId: string, status: CryptoPaymentState, currency?: string, filledAmount?: string): Promise<void>;
|
|
68
93
|
/** Mark a charge as credited (idempotency flag). */
|
|
69
94
|
markCredited(referenceId: string): Promise<void>;
|
|
@@ -49,9 +49,75 @@ export class DrizzleCryptoChargeRepository {
|
|
|
49
49
|
callbackUrl: row.callbackUrl ?? null,
|
|
50
50
|
expectedAmount: row.expectedAmount ?? null,
|
|
51
51
|
receivedAmount: row.receivedAmount ?? null,
|
|
52
|
+
confirmations: row.confirmations,
|
|
53
|
+
confirmationsRequired: row.confirmationsRequired,
|
|
54
|
+
txHash: row.txHash ?? null,
|
|
55
|
+
amountReceivedCents: row.amountReceivedCents,
|
|
52
56
|
};
|
|
53
57
|
}
|
|
54
|
-
/**
|
|
58
|
+
/** Map DB status strings to CryptoChargeStatus for UI consumption. */
|
|
59
|
+
mapStatus(dbStatus, credited) {
|
|
60
|
+
if (credited)
|
|
61
|
+
return "confirmed";
|
|
62
|
+
switch (dbStatus) {
|
|
63
|
+
case "New":
|
|
64
|
+
return "pending";
|
|
65
|
+
case "Processing":
|
|
66
|
+
return "partial";
|
|
67
|
+
case "Settled":
|
|
68
|
+
return "confirmed";
|
|
69
|
+
case "Expired":
|
|
70
|
+
return "expired";
|
|
71
|
+
case "Invalid":
|
|
72
|
+
return "failed";
|
|
73
|
+
default:
|
|
74
|
+
return "pending";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** Get a charge as a UI-facing CryptoCharge with all progress fields. */
|
|
78
|
+
async get(referenceId) {
|
|
79
|
+
const row = (await this.db.select().from(cryptoCharges).where(eq(cryptoCharges.referenceId, referenceId)))[0];
|
|
80
|
+
if (!row)
|
|
81
|
+
return null;
|
|
82
|
+
return {
|
|
83
|
+
id: row.referenceId,
|
|
84
|
+
tenantId: row.tenantId,
|
|
85
|
+
chain: row.chain ?? "unknown",
|
|
86
|
+
status: this.mapStatus(row.status, row.creditedAt != null),
|
|
87
|
+
amountExpectedCents: row.amountUsdCents,
|
|
88
|
+
amountReceivedCents: row.amountReceivedCents,
|
|
89
|
+
confirmations: row.confirmations,
|
|
90
|
+
confirmationsRequired: row.confirmationsRequired,
|
|
91
|
+
txHash: row.txHash ?? undefined,
|
|
92
|
+
credited: row.creditedAt != null,
|
|
93
|
+
createdAt: new Date(row.createdAt),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
/** Update partial payment progress, confirmations, and tx hash. */
|
|
97
|
+
async updateProgress(referenceId, update) {
|
|
98
|
+
const statusMap = {
|
|
99
|
+
pending: "New",
|
|
100
|
+
partial: "Processing",
|
|
101
|
+
confirmed: "Settled",
|
|
102
|
+
expired: "Expired",
|
|
103
|
+
failed: "Invalid",
|
|
104
|
+
};
|
|
105
|
+
await this.db
|
|
106
|
+
.update(cryptoCharges)
|
|
107
|
+
.set({
|
|
108
|
+
status: statusMap[update.status],
|
|
109
|
+
amountReceivedCents: update.amountReceivedCents,
|
|
110
|
+
confirmations: update.confirmations,
|
|
111
|
+
confirmationsRequired: update.confirmationsRequired,
|
|
112
|
+
txHash: update.txHash,
|
|
113
|
+
updatedAt: sql `now()`,
|
|
114
|
+
})
|
|
115
|
+
.where(eq(cryptoCharges.referenceId, referenceId));
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* @deprecated Use updateProgress() instead. Kept for one release cycle.
|
|
119
|
+
* Update charge status and payment details from webhook.
|
|
120
|
+
*/
|
|
55
121
|
async updateStatus(referenceId, status, currency, filledAmount) {
|
|
56
122
|
await this.db
|
|
57
123
|
.update(cryptoCharges)
|
|
@@ -26,12 +26,16 @@ describe("CryptoChargeRepository", () => {
|
|
|
26
26
|
expect(charge?.amountUsdCents).toBe(2500);
|
|
27
27
|
expect(charge?.status).toBe("New");
|
|
28
28
|
expect(charge?.creditedAt).toBeNull();
|
|
29
|
+
expect(charge?.confirmations).toBe(0);
|
|
30
|
+
expect(charge?.confirmationsRequired).toBe(1);
|
|
31
|
+
expect(charge?.txHash).toBeNull();
|
|
32
|
+
expect(charge?.amountReceivedCents).toBe(0);
|
|
29
33
|
});
|
|
30
34
|
it("getByReferenceId() returns null when not found", async () => {
|
|
31
35
|
const charge = await store.getByReferenceId("inv-nonexistent");
|
|
32
36
|
expect(charge).toBeNull();
|
|
33
37
|
});
|
|
34
|
-
it("updateStatus() updates status, currency and filled_amount", async () => {
|
|
38
|
+
it("updateStatus() updates status, currency and filled_amount (deprecated compat)", async () => {
|
|
35
39
|
await store.create("inv-002", "tenant-2", 5000);
|
|
36
40
|
await store.updateStatus("inv-002", "Settled", "BTC", "0.00025");
|
|
37
41
|
const charge = await store.getByReferenceId("inv-002");
|
|
@@ -61,6 +65,181 @@ describe("CryptoChargeRepository", () => {
|
|
|
61
65
|
await store.markCredited("inv-006");
|
|
62
66
|
expect(await store.isCredited("inv-006")).toBe(true);
|
|
63
67
|
});
|
|
68
|
+
describe("updateProgress", () => {
|
|
69
|
+
it("updates partial payment progress", async () => {
|
|
70
|
+
await store.createStablecoinCharge({
|
|
71
|
+
referenceId: "prog-001",
|
|
72
|
+
tenantId: "t-1",
|
|
73
|
+
amountUsdCents: 5000,
|
|
74
|
+
chain: "base",
|
|
75
|
+
token: "USDC",
|
|
76
|
+
depositAddress: "0xprog001",
|
|
77
|
+
derivationIndex: 0,
|
|
78
|
+
});
|
|
79
|
+
await store.updateProgress("prog-001", {
|
|
80
|
+
status: "partial",
|
|
81
|
+
amountReceivedCents: 2500,
|
|
82
|
+
confirmations: 2,
|
|
83
|
+
confirmationsRequired: 6,
|
|
84
|
+
txHash: "0xabc123",
|
|
85
|
+
});
|
|
86
|
+
const record = await store.getByReferenceId("prog-001");
|
|
87
|
+
expect(record?.status).toBe("Processing");
|
|
88
|
+
expect(record?.amountReceivedCents).toBe(2500);
|
|
89
|
+
expect(record?.confirmations).toBe(2);
|
|
90
|
+
expect(record?.confirmationsRequired).toBe(6);
|
|
91
|
+
expect(record?.txHash).toBe("0xabc123");
|
|
92
|
+
});
|
|
93
|
+
it("increments confirmations over multiple updates", async () => {
|
|
94
|
+
await store.createStablecoinCharge({
|
|
95
|
+
referenceId: "prog-002",
|
|
96
|
+
tenantId: "t-2",
|
|
97
|
+
amountUsdCents: 1000,
|
|
98
|
+
chain: "base",
|
|
99
|
+
token: "USDC",
|
|
100
|
+
depositAddress: "0xprog002",
|
|
101
|
+
derivationIndex: 1,
|
|
102
|
+
});
|
|
103
|
+
await store.updateProgress("prog-002", {
|
|
104
|
+
status: "partial",
|
|
105
|
+
amountReceivedCents: 1000,
|
|
106
|
+
confirmations: 1,
|
|
107
|
+
confirmationsRequired: 6,
|
|
108
|
+
txHash: "0xdef456",
|
|
109
|
+
});
|
|
110
|
+
await store.updateProgress("prog-002", {
|
|
111
|
+
status: "partial",
|
|
112
|
+
amountReceivedCents: 1000,
|
|
113
|
+
confirmations: 3,
|
|
114
|
+
confirmationsRequired: 6,
|
|
115
|
+
txHash: "0xdef456",
|
|
116
|
+
});
|
|
117
|
+
const record = await store.getByReferenceId("prog-002");
|
|
118
|
+
expect(record?.confirmations).toBe(3);
|
|
119
|
+
});
|
|
120
|
+
it("maps confirmed status to Settled in DB", async () => {
|
|
121
|
+
await store.createStablecoinCharge({
|
|
122
|
+
referenceId: "prog-003",
|
|
123
|
+
tenantId: "t-3",
|
|
124
|
+
amountUsdCents: 2000,
|
|
125
|
+
chain: "base",
|
|
126
|
+
token: "USDC",
|
|
127
|
+
depositAddress: "0xprog003",
|
|
128
|
+
derivationIndex: 2,
|
|
129
|
+
});
|
|
130
|
+
await store.updateProgress("prog-003", {
|
|
131
|
+
status: "confirmed",
|
|
132
|
+
amountReceivedCents: 2000,
|
|
133
|
+
confirmations: 6,
|
|
134
|
+
confirmationsRequired: 6,
|
|
135
|
+
txHash: "0xfinal",
|
|
136
|
+
});
|
|
137
|
+
const record = await store.getByReferenceId("prog-003");
|
|
138
|
+
expect(record?.status).toBe("Settled");
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("get (UI-facing CryptoCharge)", () => {
|
|
142
|
+
it("returns null when not found", async () => {
|
|
143
|
+
const charge = await store.get("nonexistent");
|
|
144
|
+
expect(charge).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
it("returns full CryptoCharge with all fields for a new charge", async () => {
|
|
147
|
+
await store.createStablecoinCharge({
|
|
148
|
+
referenceId: "get-001",
|
|
149
|
+
tenantId: "t-get",
|
|
150
|
+
amountUsdCents: 5000,
|
|
151
|
+
chain: "base",
|
|
152
|
+
token: "USDC",
|
|
153
|
+
depositAddress: "0xget001",
|
|
154
|
+
derivationIndex: 10,
|
|
155
|
+
});
|
|
156
|
+
const charge = await store.get("get-001");
|
|
157
|
+
expect(charge).not.toBeNull();
|
|
158
|
+
expect(charge?.id).toBe("get-001");
|
|
159
|
+
expect(charge?.tenantId).toBe("t-get");
|
|
160
|
+
expect(charge?.chain).toBe("base");
|
|
161
|
+
expect(charge?.status).toBe("pending");
|
|
162
|
+
expect(charge?.amountExpectedCents).toBe(5000);
|
|
163
|
+
expect(charge?.amountReceivedCents).toBe(0);
|
|
164
|
+
expect(charge?.confirmations).toBe(0);
|
|
165
|
+
expect(charge?.confirmationsRequired).toBe(1);
|
|
166
|
+
expect(charge?.txHash).toBeUndefined();
|
|
167
|
+
expect(charge?.credited).toBe(false);
|
|
168
|
+
expect(charge?.createdAt).toBeInstanceOf(Date);
|
|
169
|
+
});
|
|
170
|
+
it("reflects partial payment progress", async () => {
|
|
171
|
+
await store.createStablecoinCharge({
|
|
172
|
+
referenceId: "get-002",
|
|
173
|
+
tenantId: "t-get2",
|
|
174
|
+
amountUsdCents: 5000,
|
|
175
|
+
chain: "base",
|
|
176
|
+
token: "USDC",
|
|
177
|
+
depositAddress: "0xget002",
|
|
178
|
+
derivationIndex: 11,
|
|
179
|
+
});
|
|
180
|
+
await store.updateProgress("get-002", {
|
|
181
|
+
status: "partial",
|
|
182
|
+
amountReceivedCents: 2500,
|
|
183
|
+
confirmations: 3,
|
|
184
|
+
confirmationsRequired: 6,
|
|
185
|
+
txHash: "0xpartial",
|
|
186
|
+
});
|
|
187
|
+
const charge = await store.get("get-002");
|
|
188
|
+
expect(charge?.status).toBe("partial");
|
|
189
|
+
expect(charge?.amountReceivedCents).toBe(2500);
|
|
190
|
+
expect(charge?.confirmations).toBe(3);
|
|
191
|
+
expect(charge?.confirmationsRequired).toBe(6);
|
|
192
|
+
expect(charge?.txHash).toBe("0xpartial");
|
|
193
|
+
expect(charge?.credited).toBe(false);
|
|
194
|
+
});
|
|
195
|
+
it("shows confirmed+credited status after markCredited", async () => {
|
|
196
|
+
await store.createStablecoinCharge({
|
|
197
|
+
referenceId: "get-003",
|
|
198
|
+
tenantId: "t-get3",
|
|
199
|
+
amountUsdCents: 1000,
|
|
200
|
+
chain: "base",
|
|
201
|
+
token: "USDC",
|
|
202
|
+
depositAddress: "0xget003",
|
|
203
|
+
derivationIndex: 12,
|
|
204
|
+
});
|
|
205
|
+
await store.updateProgress("get-003", {
|
|
206
|
+
status: "confirmed",
|
|
207
|
+
amountReceivedCents: 1000,
|
|
208
|
+
confirmations: 6,
|
|
209
|
+
confirmationsRequired: 6,
|
|
210
|
+
txHash: "0xfull",
|
|
211
|
+
});
|
|
212
|
+
await store.markCredited("get-003");
|
|
213
|
+
const charge = await store.get("get-003");
|
|
214
|
+
expect(charge?.status).toBe("confirmed");
|
|
215
|
+
expect(charge?.credited).toBe(true);
|
|
216
|
+
expect(charge?.amountReceivedCents).toBe(1000);
|
|
217
|
+
});
|
|
218
|
+
it("maps expired status correctly", async () => {
|
|
219
|
+
await store.createStablecoinCharge({
|
|
220
|
+
referenceId: "get-004",
|
|
221
|
+
tenantId: "t-get4",
|
|
222
|
+
amountUsdCents: 3000,
|
|
223
|
+
chain: "base",
|
|
224
|
+
token: "USDC",
|
|
225
|
+
depositAddress: "0xget004",
|
|
226
|
+
derivationIndex: 13,
|
|
227
|
+
});
|
|
228
|
+
await store.updateProgress("get-004", {
|
|
229
|
+
status: "expired",
|
|
230
|
+
amountReceivedCents: 0,
|
|
231
|
+
confirmations: 0,
|
|
232
|
+
confirmationsRequired: 6,
|
|
233
|
+
});
|
|
234
|
+
const charge = await store.get("get-004");
|
|
235
|
+
expect(charge?.status).toBe("expired");
|
|
236
|
+
});
|
|
237
|
+
it("returns chain as 'unknown' for legacy charges without chain", async () => {
|
|
238
|
+
await store.create("get-005", "t-get5", 500);
|
|
239
|
+
const charge = await store.get("get-005");
|
|
240
|
+
expect(charge?.chain).toBe("unknown");
|
|
241
|
+
});
|
|
242
|
+
});
|
|
64
243
|
describe("stablecoin charges", () => {
|
|
65
244
|
it("creates a stablecoin charge with chain/token/address", async () => {
|
|
66
245
|
await store.createStablecoinCharge({
|
|
@@ -4,17 +4,22 @@ export interface IWatcherCursorStore {
|
|
|
4
4
|
get(watcherId: string): Promise<number | null>;
|
|
5
5
|
/** Save block cursor after processing a range. */
|
|
6
6
|
save(watcherId: string, cursorBlock: number): Promise<void>;
|
|
7
|
-
/** Check if a specific tx has been processed (
|
|
7
|
+
/** Check if a specific tx has been fully processed (reached confirmation threshold). */
|
|
8
8
|
hasProcessedTx(watcherId: string, txId: string): Promise<boolean>;
|
|
9
|
-
/** Mark a tx as processed (
|
|
9
|
+
/** Mark a tx as fully processed (reached confirmation threshold). */
|
|
10
10
|
markProcessedTx(watcherId: string, txId: string): Promise<void>;
|
|
11
|
+
/** Get the last-seen confirmation count for a tx (for intermediate confirmation tracking). */
|
|
12
|
+
getConfirmationCount(watcherId: string, txId: string): Promise<number | null>;
|
|
13
|
+
/** Save the current confirmation count for a tx (for intermediate confirmation tracking). */
|
|
14
|
+
saveConfirmationCount(watcherId: string, txId: string, count: number): Promise<void>;
|
|
11
15
|
}
|
|
12
16
|
/**
|
|
13
17
|
* Persists watcher state to PostgreSQL.
|
|
14
18
|
*
|
|
15
|
-
*
|
|
19
|
+
* Three patterns:
|
|
16
20
|
* - Block cursor (EVM watchers): save/get cursor block number
|
|
17
21
|
* - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
|
|
22
|
+
* - Confirmation counts (all watchers): getConfirmationCount/saveConfirmationCount
|
|
18
23
|
*
|
|
19
24
|
* Eliminates all in-memory watcher state. Clean restart recovery.
|
|
20
25
|
*/
|
|
@@ -25,4 +30,6 @@ export declare class DrizzleWatcherCursorStore implements IWatcherCursorStore {
|
|
|
25
30
|
save(watcherId: string, cursorBlock: number): Promise<void>;
|
|
26
31
|
hasProcessedTx(watcherId: string, txId: string): Promise<boolean>;
|
|
27
32
|
markProcessedTx(watcherId: string, txId: string): Promise<void>;
|
|
33
|
+
getConfirmationCount(watcherId: string, txId: string): Promise<number | null>;
|
|
34
|
+
saveConfirmationCount(watcherId: string, txId: string, count: number): Promise<void>;
|
|
28
35
|
}
|
|
@@ -3,9 +3,10 @@ import { watcherCursors, watcherProcessed } from "../../db/schema/crypto.js";
|
|
|
3
3
|
/**
|
|
4
4
|
* Persists watcher state to PostgreSQL.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* Three patterns:
|
|
7
7
|
* - Block cursor (EVM watchers): save/get cursor block number
|
|
8
8
|
* - Processed txids (BTC watcher): hasProcessedTx/markProcessedTx
|
|
9
|
+
* - Confirmation counts (all watchers): getConfirmationCount/saveConfirmationCount
|
|
9
10
|
*
|
|
10
11
|
* Eliminates all in-memory watcher state. Clean restart recovery.
|
|
11
12
|
*/
|
|
@@ -40,4 +41,23 @@ export class DrizzleWatcherCursorStore {
|
|
|
40
41
|
async markProcessedTx(watcherId, txId) {
|
|
41
42
|
await this.db.insert(watcherProcessed).values({ watcherId, txId }).onConflictDoNothing();
|
|
42
43
|
}
|
|
44
|
+
async getConfirmationCount(watcherId, txId) {
|
|
45
|
+
// Store confirmation counts as synthetic cursor entries: "watcherId:conf:txId" -> count
|
|
46
|
+
const key = `${watcherId}:conf:${txId}`;
|
|
47
|
+
const row = (await this.db
|
|
48
|
+
.select({ cursorBlock: watcherCursors.cursorBlock })
|
|
49
|
+
.from(watcherCursors)
|
|
50
|
+
.where(eq(watcherCursors.watcherId, key)))[0];
|
|
51
|
+
return row?.cursorBlock ?? null;
|
|
52
|
+
}
|
|
53
|
+
async saveConfirmationCount(watcherId, txId, count) {
|
|
54
|
+
const key = `${watcherId}:conf:${txId}`;
|
|
55
|
+
await this.db
|
|
56
|
+
.insert(watcherCursors)
|
|
57
|
+
.values({ watcherId: key, cursorBlock: count })
|
|
58
|
+
.onConflictDoUpdate({
|
|
59
|
+
target: watcherCursors.watcherId,
|
|
60
|
+
set: { cursorBlock: count, updatedAt: sql `(now())` },
|
|
61
|
+
});
|
|
62
|
+
}
|
|
43
63
|
}
|