@wopr-network/platform-core 1.43.0 → 1.44.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__/key-server.test.js +16 -1
- package/dist/billing/crypto/btc/watcher.d.ts +2 -0
- package/dist/billing/crypto/btc/watcher.js +1 -1
- package/dist/billing/crypto/charge-store.d.ts +7 -1
- package/dist/billing/crypto/charge-store.js +7 -1
- package/dist/billing/crypto/client.d.ts +0 -26
- package/dist/billing/crypto/client.js +0 -13
- package/dist/billing/crypto/client.test.js +1 -11
- package/dist/billing/crypto/index.d.ts +5 -7
- package/dist/billing/crypto/index.js +3 -5
- package/dist/billing/crypto/key-server-entry.js +43 -2
- package/dist/billing/crypto/key-server-webhook.d.ts +33 -0
- package/dist/billing/crypto/key-server-webhook.js +73 -0
- package/dist/billing/crypto/key-server.d.ts +2 -0
- package/dist/billing/crypto/key-server.js +25 -1
- package/dist/billing/crypto/watcher-service.d.ts +33 -0
- package/dist/billing/crypto/watcher-service.js +295 -0
- package/dist/billing/index.js +1 -1
- package/dist/db/schema/crypto.d.ts +217 -2
- package/dist/db/schema/crypto.js +25 -2
- package/dist/monetization/crypto/__tests__/webhook.test.js +57 -92
- package/dist/monetization/crypto/index.d.ts +4 -4
- package/dist/monetization/crypto/index.js +2 -2
- package/dist/monetization/crypto/webhook.d.ts +13 -14
- package/dist/monetization/crypto/webhook.js +12 -83
- package/dist/monetization/index.d.ts +2 -2
- package/dist/monetization/index.js +1 -1
- package/drizzle/migrations/0015_callback_url.sql +32 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +16 -1
- package/src/billing/crypto/btc/watcher.ts +3 -1
- package/src/billing/crypto/charge-store.ts +13 -1
- package/src/billing/crypto/client.test.ts +1 -13
- package/src/billing/crypto/client.ts +0 -21
- package/src/billing/crypto/index.ts +9 -13
- package/src/billing/crypto/key-server-entry.ts +46 -2
- package/src/billing/crypto/key-server-webhook.ts +119 -0
- package/src/billing/crypto/key-server.ts +29 -1
- package/src/billing/crypto/watcher-service.ts +381 -0
- package/src/billing/index.ts +1 -1
- package/src/db/schema/crypto.ts +30 -2
- package/src/monetization/crypto/__tests__/webhook.test.ts +61 -104
- package/src/monetization/crypto/index.ts +9 -11
- package/src/monetization/crypto/webhook.ts +25 -99
- package/src/monetization/index.ts +3 -7
- package/dist/billing/crypto/checkout.d.ts +0 -18
- package/dist/billing/crypto/checkout.js +0 -35
- package/dist/billing/crypto/checkout.test.d.ts +0 -1
- package/dist/billing/crypto/checkout.test.js +0 -71
- package/dist/billing/crypto/webhook.d.ts +0 -34
- package/dist/billing/crypto/webhook.js +0 -107
- package/dist/billing/crypto/webhook.test.d.ts +0 -1
- package/dist/billing/crypto/webhook.test.js +0 -266
- package/src/billing/crypto/checkout.test.ts +0 -93
- package/src/billing/crypto/checkout.ts +0 -48
- package/src/billing/crypto/webhook.test.ts +0 -340
- package/src/billing/crypto/webhook.ts +0 -136
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watcher Service — boots chain watchers and sends webhook callbacks.
|
|
3
|
+
*
|
|
4
|
+
* Payment flow:
|
|
5
|
+
* 1. Watcher detects payment → handlePayment()
|
|
6
|
+
* 2. Accumulate native amount (supports partial payments)
|
|
7
|
+
* 3. When totalReceived >= expectedAmount → settle + credit
|
|
8
|
+
* 4. Every payment (partial or full) enqueues a webhook delivery
|
|
9
|
+
* 5. Outbox processor retries failed deliveries with exponential backoff
|
|
10
|
+
*
|
|
11
|
+
* Amount comparison is ALWAYS in native crypto units (sats, wei, token base units).
|
|
12
|
+
* The exchange rate is locked at charge creation — no live price comparison.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { and, eq, isNull, lte, or } from "drizzle-orm";
|
|
16
|
+
import type { DrizzleDb } from "../../db/index.js";
|
|
17
|
+
import { cryptoCharges, webhookDeliveries } from "../../db/schema/crypto.js";
|
|
18
|
+
import type { BtcPaymentEvent } from "./btc/types.js";
|
|
19
|
+
import { BtcWatcher, createBitcoindRpc } from "./btc/watcher.js";
|
|
20
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
21
|
+
import type { IWatcherCursorStore } from "./cursor-store.js";
|
|
22
|
+
import type { EvmChain, EvmPaymentEvent, StablecoinToken } from "./evm/types.js";
|
|
23
|
+
import { createRpcCaller, EvmWatcher } from "./evm/watcher.js";
|
|
24
|
+
import type { IPriceOracle } from "./oracle/types.js";
|
|
25
|
+
import type { IPaymentMethodStore } from "./payment-method-store.js";
|
|
26
|
+
import type { CryptoPaymentState } from "./types.js";
|
|
27
|
+
|
|
28
|
+
const MAX_DELIVERY_ATTEMPTS = 10;
|
|
29
|
+
const BACKOFF_BASE_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
export interface WatcherServiceOpts {
|
|
32
|
+
db: DrizzleDb;
|
|
33
|
+
chargeStore: ICryptoChargeRepository;
|
|
34
|
+
methodStore: IPaymentMethodStore;
|
|
35
|
+
cursorStore: IWatcherCursorStore;
|
|
36
|
+
oracle: IPriceOracle;
|
|
37
|
+
bitcoindUser?: string;
|
|
38
|
+
bitcoindPassword?: string;
|
|
39
|
+
pollIntervalMs?: number;
|
|
40
|
+
deliveryIntervalMs?: number;
|
|
41
|
+
log?: (msg: string, meta?: Record<string, unknown>) => void;
|
|
42
|
+
/** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
|
|
43
|
+
allowedCallbackPrefixes?: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// --- SSRF validation ---
|
|
47
|
+
|
|
48
|
+
function isValidCallbackUrl(url: string, allowedPrefixes: string[]): boolean {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = new URL(url);
|
|
51
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
52
|
+
const host = parsed.hostname;
|
|
53
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "::1") return false;
|
|
54
|
+
if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254.")) return false;
|
|
55
|
+
return allowedPrefixes.some((prefix) => url.startsWith(prefix));
|
|
56
|
+
} catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Webhook outbox ---
|
|
62
|
+
|
|
63
|
+
async function enqueueWebhook(
|
|
64
|
+
db: DrizzleDb,
|
|
65
|
+
chargeId: string,
|
|
66
|
+
callbackUrl: string,
|
|
67
|
+
payload: Record<string, unknown>,
|
|
68
|
+
): Promise<void> {
|
|
69
|
+
await db.insert(webhookDeliveries).values({
|
|
70
|
+
chargeId,
|
|
71
|
+
callbackUrl,
|
|
72
|
+
payload: JSON.stringify(payload),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function processDeliveries(
|
|
77
|
+
db: DrizzleDb,
|
|
78
|
+
allowedPrefixes: string[],
|
|
79
|
+
log: (msg: string, meta?: Record<string, unknown>) => void,
|
|
80
|
+
): Promise<number> {
|
|
81
|
+
const now = new Date().toISOString();
|
|
82
|
+
const pending = await db
|
|
83
|
+
.select()
|
|
84
|
+
.from(webhookDeliveries)
|
|
85
|
+
.where(
|
|
86
|
+
and(
|
|
87
|
+
eq(webhookDeliveries.status, "pending"),
|
|
88
|
+
or(isNull(webhookDeliveries.nextRetryAt), lte(webhookDeliveries.nextRetryAt, now)),
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
.limit(50);
|
|
92
|
+
|
|
93
|
+
let delivered = 0;
|
|
94
|
+
for (const row of pending) {
|
|
95
|
+
if (!isValidCallbackUrl(row.callbackUrl, allowedPrefixes)) {
|
|
96
|
+
await db
|
|
97
|
+
.update(webhookDeliveries)
|
|
98
|
+
.set({ status: "failed", lastError: "Invalid callbackUrl (SSRF blocked)" })
|
|
99
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const res = await fetch(row.callbackUrl, {
|
|
105
|
+
method: "POST",
|
|
106
|
+
headers: { "Content-Type": "application/json" },
|
|
107
|
+
body: row.payload,
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
110
|
+
|
|
111
|
+
await db.update(webhookDeliveries).set({ status: "delivered" }).where(eq(webhookDeliveries.id, row.id));
|
|
112
|
+
delivered++;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
const attempts = row.attempts + 1;
|
|
115
|
+
if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
116
|
+
await db
|
|
117
|
+
.update(webhookDeliveries)
|
|
118
|
+
.set({ status: "failed", attempts, lastError: String(err) })
|
|
119
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
120
|
+
log("Webhook permanently failed", { chargeId: row.chargeId, attempts });
|
|
121
|
+
} else {
|
|
122
|
+
const backoffMs = BACKOFF_BASE_MS * 2 ** (attempts - 1);
|
|
123
|
+
const nextRetry = new Date(Date.now() + backoffMs).toISOString();
|
|
124
|
+
await db
|
|
125
|
+
.update(webhookDeliveries)
|
|
126
|
+
.set({ attempts, nextRetryAt: nextRetry, lastError: String(err) })
|
|
127
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return delivered;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// --- Payment handling (partial + full) ---
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Handle a payment event. Accumulates partial payments in native units.
|
|
138
|
+
* Settles when totalReceived >= expectedAmount. Fires webhook on every payment.
|
|
139
|
+
*
|
|
140
|
+
* @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20)
|
|
141
|
+
*/
|
|
142
|
+
async function handlePayment(
|
|
143
|
+
db: DrizzleDb,
|
|
144
|
+
chargeStore: ICryptoChargeRepository,
|
|
145
|
+
address: string,
|
|
146
|
+
nativeAmount: string,
|
|
147
|
+
payload: Record<string, unknown>,
|
|
148
|
+
log: (msg: string, meta?: Record<string, unknown>) => void,
|
|
149
|
+
): Promise<void> {
|
|
150
|
+
const charge = await chargeStore.getByDepositAddress(address);
|
|
151
|
+
if (!charge) {
|
|
152
|
+
log("Payment to unknown address", { address });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (charge.creditedAt) {
|
|
156
|
+
return; // Already fully paid and credited
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Accumulate: add this payment to the running total
|
|
160
|
+
const prevReceived = BigInt(charge.receivedAmount ?? "0");
|
|
161
|
+
const thisPayment = BigInt(nativeAmount);
|
|
162
|
+
const totalReceived = (prevReceived + thisPayment).toString();
|
|
163
|
+
const expected = BigInt(charge.expectedAmount ?? "0");
|
|
164
|
+
const isFull = expected > 0n && BigInt(totalReceived) >= expected;
|
|
165
|
+
|
|
166
|
+
// Update received_amount in DB
|
|
167
|
+
await db
|
|
168
|
+
.update(cryptoCharges)
|
|
169
|
+
.set({ receivedAmount: totalReceived, filledAmount: totalReceived })
|
|
170
|
+
.where(eq(cryptoCharges.referenceId, charge.referenceId));
|
|
171
|
+
|
|
172
|
+
if (isFull) {
|
|
173
|
+
const settled: CryptoPaymentState = "Settled";
|
|
174
|
+
await chargeStore.updateStatus(charge.referenceId, settled, charge.token ?? undefined, totalReceived);
|
|
175
|
+
await chargeStore.markCredited(charge.referenceId);
|
|
176
|
+
log("Charge settled", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
|
|
177
|
+
} else {
|
|
178
|
+
const processing: CryptoPaymentState = "Processing";
|
|
179
|
+
await chargeStore.updateStatus(charge.referenceId, processing, charge.token ?? undefined, totalReceived);
|
|
180
|
+
log("Partial payment", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Webhook on every payment — product shows progress to user
|
|
184
|
+
if (charge.callbackUrl) {
|
|
185
|
+
await enqueueWebhook(db, charge.referenceId, charge.callbackUrl, {
|
|
186
|
+
chargeId: charge.referenceId,
|
|
187
|
+
chain: charge.chain,
|
|
188
|
+
address: charge.depositAddress,
|
|
189
|
+
expectedAmount: expected.toString(),
|
|
190
|
+
receivedAmount: totalReceived,
|
|
191
|
+
amountUsdCents: charge.amountUsdCents,
|
|
192
|
+
status: isFull ? "confirmed" : "partial",
|
|
193
|
+
...payload,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- Watcher boot ---
|
|
199
|
+
|
|
200
|
+
export async function startWatchers(opts: WatcherServiceOpts): Promise<() => void> {
|
|
201
|
+
const { db, chargeStore, methodStore, cursorStore, oracle } = opts;
|
|
202
|
+
const pollMs = opts.pollIntervalMs ?? 15_000;
|
|
203
|
+
const deliveryMs = opts.deliveryIntervalMs ?? 10_000;
|
|
204
|
+
const log = opts.log ?? (() => {});
|
|
205
|
+
const allowedPrefixes = opts.allowedCallbackPrefixes ?? ["https://"];
|
|
206
|
+
const timers: ReturnType<typeof setInterval>[] = [];
|
|
207
|
+
|
|
208
|
+
const methods = await methodStore.listEnabled();
|
|
209
|
+
|
|
210
|
+
const utxoMethods = methods.filter(
|
|
211
|
+
(m) => m.type === "native" && (m.chain === "bitcoin" || m.chain === "litecoin" || m.chain === "dogecoin"),
|
|
212
|
+
);
|
|
213
|
+
const evmMethods = methods.filter(
|
|
214
|
+
(m) =>
|
|
215
|
+
m.type === "erc20" ||
|
|
216
|
+
(m.type === "native" && m.chain !== "bitcoin" && m.chain !== "litecoin" && m.chain !== "dogecoin"),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// --- UTXO Watchers (BTC, LTC, DOGE) ---
|
|
220
|
+
for (const method of utxoMethods) {
|
|
221
|
+
if (!method.rpcUrl) continue;
|
|
222
|
+
|
|
223
|
+
const rpcCall = createBitcoindRpc({
|
|
224
|
+
rpcUrl: method.rpcUrl,
|
|
225
|
+
rpcUser: opts.bitcoindUser ?? "btcpay",
|
|
226
|
+
rpcPassword: opts.bitcoindPassword ?? "",
|
|
227
|
+
network: "mainnet",
|
|
228
|
+
confirmations: method.confirmations,
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const activeAddresses = await chargeStore.listActiveDepositAddresses();
|
|
232
|
+
const chainAddresses = activeAddresses.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
233
|
+
|
|
234
|
+
const watcher = new BtcWatcher({
|
|
235
|
+
config: {
|
|
236
|
+
rpcUrl: method.rpcUrl,
|
|
237
|
+
rpcUser: opts.bitcoindUser ?? "btcpay",
|
|
238
|
+
rpcPassword: opts.bitcoindPassword ?? "",
|
|
239
|
+
network: "mainnet",
|
|
240
|
+
confirmations: method.confirmations,
|
|
241
|
+
},
|
|
242
|
+
chainId: method.chain,
|
|
243
|
+
rpcCall,
|
|
244
|
+
watchedAddresses: chainAddresses,
|
|
245
|
+
oracle,
|
|
246
|
+
cursorStore,
|
|
247
|
+
onPayment: async (event: BtcPaymentEvent) => {
|
|
248
|
+
log("UTXO payment", { chain: method.chain, address: event.address, txid: event.txid, sats: event.amountSats });
|
|
249
|
+
// Pass native amount (sats) — NOT USD cents
|
|
250
|
+
await handlePayment(
|
|
251
|
+
db,
|
|
252
|
+
chargeStore,
|
|
253
|
+
event.address,
|
|
254
|
+
String(event.amountSats),
|
|
255
|
+
{
|
|
256
|
+
txHash: event.txid,
|
|
257
|
+
confirmations: event.confirmations,
|
|
258
|
+
},
|
|
259
|
+
log,
|
|
260
|
+
);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
const importedAddresses = new Set<string>();
|
|
265
|
+
for (const addr of chainAddresses) {
|
|
266
|
+
try {
|
|
267
|
+
await watcher.importAddress(addr);
|
|
268
|
+
importedAddresses.add(addr);
|
|
269
|
+
} catch {
|
|
270
|
+
log("Failed to import address", { chain: method.chain, address: addr });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
log(`UTXO watcher started (${method.chain})`, { addresses: importedAddresses.size });
|
|
275
|
+
|
|
276
|
+
let utxoPolling = false;
|
|
277
|
+
timers.push(
|
|
278
|
+
setInterval(async () => {
|
|
279
|
+
if (utxoPolling) return; // Prevent overlapping polls
|
|
280
|
+
utxoPolling = true;
|
|
281
|
+
try {
|
|
282
|
+
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
283
|
+
const freshChain = fresh.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
284
|
+
|
|
285
|
+
for (const addr of freshChain) {
|
|
286
|
+
if (!importedAddresses.has(addr)) {
|
|
287
|
+
try {
|
|
288
|
+
await watcher.importAddress(addr);
|
|
289
|
+
importedAddresses.add(addr);
|
|
290
|
+
} catch {
|
|
291
|
+
log("Failed to import new address (will retry)", { chain: method.chain, address: addr });
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
watcher.setWatchedAddresses(freshChain);
|
|
297
|
+
await watcher.poll();
|
|
298
|
+
} catch (err) {
|
|
299
|
+
log("UTXO poll error", { chain: method.chain, error: String(err) });
|
|
300
|
+
} finally {
|
|
301
|
+
utxoPolling = false;
|
|
302
|
+
}
|
|
303
|
+
}, pollMs),
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// --- EVM Watchers ---
|
|
308
|
+
for (const method of evmMethods) {
|
|
309
|
+
if (!method.rpcUrl || !method.contractAddress) continue;
|
|
310
|
+
|
|
311
|
+
const rpcCall = createRpcCaller(method.rpcUrl);
|
|
312
|
+
const latestHex = (await rpcCall("eth_blockNumber", [])) as string;
|
|
313
|
+
const latestBlock = Number.parseInt(latestHex, 16);
|
|
314
|
+
|
|
315
|
+
const activeAddresses = await chargeStore.listActiveDepositAddresses();
|
|
316
|
+
const chainAddresses = activeAddresses.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
317
|
+
|
|
318
|
+
const watcher = new EvmWatcher({
|
|
319
|
+
chain: method.chain as EvmChain,
|
|
320
|
+
token: method.token as StablecoinToken,
|
|
321
|
+
rpcCall,
|
|
322
|
+
fromBlock: latestBlock,
|
|
323
|
+
watchedAddresses: chainAddresses,
|
|
324
|
+
cursorStore,
|
|
325
|
+
onPayment: async (event: EvmPaymentEvent) => {
|
|
326
|
+
log("EVM payment", { chain: event.chain, token: event.token, to: event.to, txHash: event.txHash });
|
|
327
|
+
// Pass native amount (raw token units) — NOT USD cents
|
|
328
|
+
await handlePayment(
|
|
329
|
+
db,
|
|
330
|
+
chargeStore,
|
|
331
|
+
event.to,
|
|
332
|
+
event.rawAmount,
|
|
333
|
+
{
|
|
334
|
+
txHash: event.txHash,
|
|
335
|
+
confirmations: method.confirmations,
|
|
336
|
+
},
|
|
337
|
+
log,
|
|
338
|
+
);
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
await watcher.init();
|
|
343
|
+
log(`EVM watcher started (${method.chain}:${method.token})`, { addresses: chainAddresses.length });
|
|
344
|
+
|
|
345
|
+
let evmPolling = false;
|
|
346
|
+
timers.push(
|
|
347
|
+
setInterval(async () => {
|
|
348
|
+
if (evmPolling) return; // Prevent overlapping polls
|
|
349
|
+
evmPolling = true;
|
|
350
|
+
try {
|
|
351
|
+
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
352
|
+
const freshChain = fresh.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
353
|
+
watcher.setWatchedAddresses(freshChain);
|
|
354
|
+
await watcher.poll();
|
|
355
|
+
} catch (err) {
|
|
356
|
+
log("EVM poll error", { chain: method.chain, token: method.token, error: String(err) });
|
|
357
|
+
} finally {
|
|
358
|
+
evmPolling = false;
|
|
359
|
+
}
|
|
360
|
+
}, pollMs),
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --- Webhook delivery outbox processor ---
|
|
365
|
+
timers.push(
|
|
366
|
+
setInterval(async () => {
|
|
367
|
+
try {
|
|
368
|
+
const count = await processDeliveries(db, allowedPrefixes, log);
|
|
369
|
+
if (count > 0) log("Webhooks delivered", { count });
|
|
370
|
+
} catch (err) {
|
|
371
|
+
log("Delivery loop error", { error: String(err) });
|
|
372
|
+
}
|
|
373
|
+
}, deliveryMs),
|
|
374
|
+
);
|
|
375
|
+
|
|
376
|
+
log("All watchers started", { utxo: utxoMethods.length, evm: evmMethods.length, pollMs, deliveryMs });
|
|
377
|
+
|
|
378
|
+
return () => {
|
|
379
|
+
for (const t of timers) clearInterval(t);
|
|
380
|
+
};
|
|
381
|
+
}
|
package/src/billing/index.ts
CHANGED
package/src/db/schema/crypto.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { sql } from "drizzle-orm";
|
|
|
2
2
|
import { boolean, index, integer, pgTable, primaryKey, text } from "drizzle-orm/pg-core";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Crypto payment charges — tracks the lifecycle of each
|
|
6
|
-
* reference_id is the
|
|
5
|
+
* Crypto payment charges — tracks the lifecycle of each payment.
|
|
6
|
+
* reference_id is the charge ID (e.g. "btc:bc1q...").
|
|
7
7
|
*
|
|
8
8
|
* amountUsdCents stores the requested amount in USD cents (integer).
|
|
9
9
|
* This is NOT nanodollars — Credit.fromCents() handles the conversion
|
|
@@ -25,6 +25,11 @@ export const cryptoCharges = pgTable(
|
|
|
25
25
|
token: text("token"),
|
|
26
26
|
depositAddress: text("deposit_address"),
|
|
27
27
|
derivationIndex: integer("derivation_index"),
|
|
28
|
+
callbackUrl: text("callback_url"),
|
|
29
|
+
/** Expected crypto amount in native units (e.g. "76923" sats, "50000000" USDC base units). Locked at creation. */
|
|
30
|
+
expectedAmount: text("expected_amount"),
|
|
31
|
+
/** Running total of received crypto in native units. Accumulates across partial payments. */
|
|
32
|
+
receivedAmount: text("received_amount"),
|
|
28
33
|
},
|
|
29
34
|
(table) => [
|
|
30
35
|
index("idx_crypto_charges_tenant").on(table.tenantId),
|
|
@@ -90,6 +95,29 @@ export const pathAllocations = pgTable(
|
|
|
90
95
|
(table) => [primaryKey({ columns: [table.coinType, table.accountIndex] })],
|
|
91
96
|
);
|
|
92
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Webhook delivery outbox — durable retry for payment callbacks.
|
|
100
|
+
* Inserted when a payment is confirmed. Retried until the receiver ACKs.
|
|
101
|
+
*/
|
|
102
|
+
export const webhookDeliveries = pgTable(
|
|
103
|
+
"webhook_deliveries",
|
|
104
|
+
{
|
|
105
|
+
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
|
106
|
+
chargeId: text("charge_id").notNull(),
|
|
107
|
+
callbackUrl: text("callback_url").notNull(),
|
|
108
|
+
payload: text("payload").notNull(), // JSON stringified
|
|
109
|
+
status: text("status").notNull().default("pending"), // pending, delivered, failed
|
|
110
|
+
attempts: integer("attempts").notNull().default(0),
|
|
111
|
+
nextRetryAt: text("next_retry_at"),
|
|
112
|
+
lastError: text("last_error"),
|
|
113
|
+
createdAt: text("created_at").notNull().default(sql`(now())`),
|
|
114
|
+
},
|
|
115
|
+
(table) => [
|
|
116
|
+
index("idx_webhook_deliveries_status").on(table.status),
|
|
117
|
+
index("idx_webhook_deliveries_charge").on(table.chargeId),
|
|
118
|
+
],
|
|
119
|
+
);
|
|
120
|
+
|
|
93
121
|
/**
|
|
94
122
|
* Every address ever derived — immutable append-only log.
|
|
95
123
|
* Used for auditing and ensuring no address is ever reused.
|