@wopr-network/platform-core 1.42.3 → 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/.github/workflows/key-server-image.yml +35 -0
- package/Dockerfile.key-server +20 -0
- package/GATEWAY_BILLING_RESEARCH.md +430 -0
- package/biome.json +2 -9
- package/dist/billing/crypto/__tests__/key-server.test.js +240 -0
- 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 +68 -30
- package/dist/billing/crypto/client.js +63 -46
- package/dist/billing/crypto/client.test.js +66 -83
- package/dist/billing/crypto/index.d.ts +8 -8
- package/dist/billing/crypto/index.js +4 -5
- package/dist/billing/crypto/key-server-entry.js +84 -0
- 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 +20 -0
- package/dist/billing/crypto/key-server.js +263 -0
- 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 +464 -2
- package/dist/db/schema/crypto.js +60 -6
- 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/0014_crypto_key_server.sql +60 -0
- package/drizzle/migrations/0015_callback_url.sql +32 -0
- package/drizzle/migrations/meta/_journal.json +28 -0
- package/package.json +2 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +262 -0
- 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 +70 -98
- package/src/billing/crypto/client.ts +118 -59
- package/src/billing/crypto/index.ts +19 -14
- package/src/billing/crypto/key-server-entry.ts +96 -0
- package/src/billing/crypto/key-server-webhook.ts +119 -0
- package/src/billing/crypto/key-server.ts +343 -0
- package/src/billing/crypto/watcher-service.ts +381 -0
- package/src/billing/index.ts +1 -1
- package/src/db/schema/crypto.ts +75 -6
- 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.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.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
- /package/dist/billing/crypto/{checkout.test.d.ts → __tests__/key-server.test.d.ts} +0 -0
- /package/dist/billing/crypto/{webhook.test.d.ts → key-server-entry.d.ts} +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crypto Key Server — shared address derivation + charge management.
|
|
3
|
+
*
|
|
4
|
+
* Deploys on the chain server (pay.wopr.bot) alongside bitcoind.
|
|
5
|
+
* Products don't run watchers or hold xpubs. They request addresses
|
|
6
|
+
* and receive webhooks.
|
|
7
|
+
*
|
|
8
|
+
* ~200 lines of new code wrapping platform-core's existing crypto modules.
|
|
9
|
+
*/
|
|
10
|
+
import { eq, sql } from "drizzle-orm";
|
|
11
|
+
import { Hono } from "hono";
|
|
12
|
+
import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
|
|
13
|
+
import { deriveAddress, deriveP2pkhAddress } from "./btc/address-gen.js";
|
|
14
|
+
import { deriveDepositAddress } from "./evm/address-gen.js";
|
|
15
|
+
import { centsToNative } from "./oracle/convert.js";
|
|
16
|
+
/**
|
|
17
|
+
* Derive the next unused address for a chain.
|
|
18
|
+
* Atomically increments next_index and records address in a single transaction.
|
|
19
|
+
*/
|
|
20
|
+
async function deriveNextAddress(db, chainId, tenantId) {
|
|
21
|
+
// Wrap in transaction: if the address insert fails, next_index is not consumed.
|
|
22
|
+
return db.transaction(async (tx) => {
|
|
23
|
+
// Atomic increment: UPDATE ... SET next_index = next_index + 1 RETURNING *
|
|
24
|
+
const [method] = await tx
|
|
25
|
+
.update(paymentMethods)
|
|
26
|
+
.set({ nextIndex: sql `${paymentMethods.nextIndex} + 1` })
|
|
27
|
+
.where(eq(paymentMethods.id, chainId))
|
|
28
|
+
.returning();
|
|
29
|
+
if (!method)
|
|
30
|
+
throw new Error(`Chain not found: ${chainId}`);
|
|
31
|
+
if (!method.xpub)
|
|
32
|
+
throw new Error(`No xpub configured for chain: ${chainId}`);
|
|
33
|
+
// The index we use is the value BEFORE increment (returned value - 1)
|
|
34
|
+
const index = method.nextIndex - 1;
|
|
35
|
+
// Route to the right derivation function
|
|
36
|
+
let address;
|
|
37
|
+
if (method.type === "native" && method.chain === "dogecoin") {
|
|
38
|
+
address = deriveP2pkhAddress(method.xpub, index, "dogecoin");
|
|
39
|
+
}
|
|
40
|
+
else if (method.type === "native" && (method.chain === "bitcoin" || method.chain === "litecoin")) {
|
|
41
|
+
address = deriveAddress(method.xpub, index, "mainnet", method.chain);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
// EVM (all ERC20 + native ETH) — same derivation
|
|
45
|
+
address = deriveDepositAddress(method.xpub, index);
|
|
46
|
+
}
|
|
47
|
+
// Record in immutable log (inside same transaction)
|
|
48
|
+
await tx.insert(derivedAddresses).values({
|
|
49
|
+
chainId,
|
|
50
|
+
derivationIndex: index,
|
|
51
|
+
address: address.toLowerCase(),
|
|
52
|
+
tenantId,
|
|
53
|
+
});
|
|
54
|
+
return { address, index, chain: method.chain, token: method.token };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Validate Bearer token from Authorization header. */
|
|
58
|
+
function requireAuth(header, expected) {
|
|
59
|
+
if (!expected)
|
|
60
|
+
return true; // auth disabled
|
|
61
|
+
return header === `Bearer ${expected}`;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Create the Hono app for the crypto key server.
|
|
65
|
+
* Mount this on the chain server at the root.
|
|
66
|
+
*/
|
|
67
|
+
export function createKeyServerApp(deps) {
|
|
68
|
+
const app = new Hono();
|
|
69
|
+
// --- Auth middleware for product routes ---
|
|
70
|
+
app.use("/address", async (c, next) => {
|
|
71
|
+
if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
|
|
72
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
73
|
+
}
|
|
74
|
+
await next();
|
|
75
|
+
});
|
|
76
|
+
app.use("/charges/*", async (c, next) => {
|
|
77
|
+
if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
|
|
78
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
79
|
+
}
|
|
80
|
+
await next();
|
|
81
|
+
});
|
|
82
|
+
app.use("/charges", async (c, next) => {
|
|
83
|
+
if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
|
|
84
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
85
|
+
}
|
|
86
|
+
await next();
|
|
87
|
+
});
|
|
88
|
+
// --- Auth middleware for admin routes ---
|
|
89
|
+
app.use("/admin/*", async (c, next) => {
|
|
90
|
+
if (!deps.adminToken)
|
|
91
|
+
return c.json({ error: "Admin API disabled" }, 403);
|
|
92
|
+
if (!requireAuth(c.req.header("Authorization"), deps.adminToken)) {
|
|
93
|
+
return c.json({ error: "Unauthorized" }, 401);
|
|
94
|
+
}
|
|
95
|
+
await next();
|
|
96
|
+
});
|
|
97
|
+
// --- Product API ---
|
|
98
|
+
/** POST /address — derive next unused address */
|
|
99
|
+
app.post("/address", async (c) => {
|
|
100
|
+
const body = await c.req.json();
|
|
101
|
+
if (!body.chain)
|
|
102
|
+
return c.json({ error: "chain is required" }, 400);
|
|
103
|
+
const tenantId = c.req.header("X-Tenant-Id");
|
|
104
|
+
const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined);
|
|
105
|
+
return c.json(result, 201);
|
|
106
|
+
});
|
|
107
|
+
/** POST /charges — create charge + derive address + start watching */
|
|
108
|
+
app.post("/charges", async (c) => {
|
|
109
|
+
const body = await c.req.json();
|
|
110
|
+
if (!body.chain || typeof body.amountUsd !== "number" || !Number.isFinite(body.amountUsd) || body.amountUsd <= 0) {
|
|
111
|
+
return c.json({ error: "chain is required and amountUsd must be a positive finite number" }, 400);
|
|
112
|
+
}
|
|
113
|
+
const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
|
|
114
|
+
const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId);
|
|
115
|
+
// Look up payment method for decimals + oracle config
|
|
116
|
+
const method = await deps.methodStore.getById(body.chain);
|
|
117
|
+
if (!method)
|
|
118
|
+
return c.json({ error: `Unknown chain: ${body.chain}` }, 400);
|
|
119
|
+
const amountUsdCents = Math.round(body.amountUsd * 100);
|
|
120
|
+
// Compute expected crypto amount in native base units.
|
|
121
|
+
// Price is locked NOW — this is what the user must send.
|
|
122
|
+
let expectedAmount;
|
|
123
|
+
if (method.oracleAddress) {
|
|
124
|
+
// Volatile asset (BTC, ETH, DOGE) — oracle-priced
|
|
125
|
+
const { priceCents } = await deps.oracle.getPrice(token);
|
|
126
|
+
expectedAmount = centsToNative(amountUsdCents, priceCents, method.decimals);
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
// Stablecoin (1:1 USD) — e.g. $50 USDC = 50_000_000 base units (6 decimals)
|
|
130
|
+
expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
|
|
131
|
+
}
|
|
132
|
+
const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
|
|
133
|
+
await deps.chargeStore.createStablecoinCharge({
|
|
134
|
+
referenceId,
|
|
135
|
+
tenantId,
|
|
136
|
+
amountUsdCents,
|
|
137
|
+
chain,
|
|
138
|
+
token,
|
|
139
|
+
depositAddress: address,
|
|
140
|
+
derivationIndex: index,
|
|
141
|
+
callbackUrl: body.callbackUrl,
|
|
142
|
+
expectedAmount: expectedAmount.toString(),
|
|
143
|
+
});
|
|
144
|
+
// Format display amount for the client
|
|
145
|
+
const divisor = 10 ** method.decimals;
|
|
146
|
+
const displayAmount = `${(Number(expectedAmount) / divisor).toFixed(Math.min(method.decimals, 8))} ${token}`;
|
|
147
|
+
return c.json({
|
|
148
|
+
chargeId: referenceId,
|
|
149
|
+
address,
|
|
150
|
+
chain,
|
|
151
|
+
token,
|
|
152
|
+
amountUsd: body.amountUsd,
|
|
153
|
+
expectedAmount: expectedAmount.toString(),
|
|
154
|
+
displayAmount,
|
|
155
|
+
derivationIndex: index,
|
|
156
|
+
expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
|
|
157
|
+
}, 201);
|
|
158
|
+
});
|
|
159
|
+
/** GET /charges/:id — check charge status */
|
|
160
|
+
app.get("/charges/:id", async (c) => {
|
|
161
|
+
const charge = await deps.chargeStore.getByReferenceId(c.req.param("id"));
|
|
162
|
+
if (!charge)
|
|
163
|
+
return c.json({ error: "Charge not found" }, 404);
|
|
164
|
+
return c.json({
|
|
165
|
+
chargeId: charge.referenceId,
|
|
166
|
+
status: charge.status,
|
|
167
|
+
address: charge.depositAddress,
|
|
168
|
+
chain: charge.chain,
|
|
169
|
+
token: charge.token,
|
|
170
|
+
amountUsdCents: charge.amountUsdCents,
|
|
171
|
+
creditedAt: charge.creditedAt,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
/** GET /chains — list enabled payment methods (for checkout UI) */
|
|
175
|
+
app.get("/chains", async (c) => {
|
|
176
|
+
const methods = await deps.methodStore.listEnabled();
|
|
177
|
+
return c.json(methods.map((m) => ({
|
|
178
|
+
id: m.id,
|
|
179
|
+
token: m.token,
|
|
180
|
+
chain: m.chain,
|
|
181
|
+
decimals: m.decimals,
|
|
182
|
+
displayName: m.displayName,
|
|
183
|
+
contractAddress: m.contractAddress,
|
|
184
|
+
confirmations: m.confirmations,
|
|
185
|
+
})));
|
|
186
|
+
});
|
|
187
|
+
// --- Admin API ---
|
|
188
|
+
/** GET /admin/next-path — which derivation path to use for a coin type */
|
|
189
|
+
app.get("/admin/next-path", async (c) => {
|
|
190
|
+
const coinType = Number(c.req.query("coin_type"));
|
|
191
|
+
if (!Number.isInteger(coinType))
|
|
192
|
+
return c.json({ error: "coin_type must be an integer" }, 400);
|
|
193
|
+
// Find all allocations for this coin type
|
|
194
|
+
const existing = await deps.db.select().from(pathAllocations).where(eq(pathAllocations.coinType, coinType));
|
|
195
|
+
if (existing.length === 0) {
|
|
196
|
+
return c.json({
|
|
197
|
+
coin_type: coinType,
|
|
198
|
+
account_index: 0,
|
|
199
|
+
path: `m/44'/${coinType}'/0'`,
|
|
200
|
+
status: "available",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// If already allocated, return info about existing allocation
|
|
204
|
+
const latest = existing.sort((a, b) => b.accountIndex - a.accountIndex)[0];
|
|
205
|
+
// Find chains using this coin type's allocations
|
|
206
|
+
const chainIds = existing.map((a) => a.chainId).filter(Boolean);
|
|
207
|
+
return c.json({
|
|
208
|
+
coin_type: coinType,
|
|
209
|
+
account_index: latest.accountIndex,
|
|
210
|
+
path: `m/44'/${coinType}'/${latest.accountIndex}'`,
|
|
211
|
+
status: "allocated",
|
|
212
|
+
allocated_to: chainIds,
|
|
213
|
+
note: "xpub already registered — reuse for new chains with same key type",
|
|
214
|
+
next_available: {
|
|
215
|
+
account_index: latest.accountIndex + 1,
|
|
216
|
+
path: `m/44'/${coinType}'/${latest.accountIndex + 1}'`,
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
/** POST /admin/chains — register a new chain with its xpub */
|
|
221
|
+
app.post("/admin/chains", async (c) => {
|
|
222
|
+
const body = await c.req.json();
|
|
223
|
+
if (!body.id || !body.xpub || !body.token) {
|
|
224
|
+
return c.json({ error: "id, xpub, and token are required" }, 400);
|
|
225
|
+
}
|
|
226
|
+
// Record the path allocation (idempotent — ignore if already exists)
|
|
227
|
+
const inserted = (await deps.db
|
|
228
|
+
.insert(pathAllocations)
|
|
229
|
+
.values({
|
|
230
|
+
coinType: body.coin_type,
|
|
231
|
+
accountIndex: body.account_index,
|
|
232
|
+
chainId: body.id,
|
|
233
|
+
xpub: body.xpub,
|
|
234
|
+
})
|
|
235
|
+
.onConflictDoNothing());
|
|
236
|
+
if (inserted.rowCount === 0) {
|
|
237
|
+
return c.json({ error: "Path allocation already exists", path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 409);
|
|
238
|
+
}
|
|
239
|
+
// Upsert the payment method
|
|
240
|
+
await deps.methodStore.upsert({
|
|
241
|
+
id: body.id,
|
|
242
|
+
type: body.type ?? "native",
|
|
243
|
+
token: body.token,
|
|
244
|
+
chain: body.chain ?? body.network,
|
|
245
|
+
contractAddress: body.contract ?? null,
|
|
246
|
+
decimals: body.decimals,
|
|
247
|
+
displayName: body.display_name ?? `${body.token} on ${body.network}`,
|
|
248
|
+
enabled: true,
|
|
249
|
+
displayOrder: 0,
|
|
250
|
+
rpcUrl: body.rpc_url,
|
|
251
|
+
oracleAddress: body.oracle_address ?? null,
|
|
252
|
+
xpub: body.xpub,
|
|
253
|
+
confirmations: body.confirmations ?? 6,
|
|
254
|
+
});
|
|
255
|
+
return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
|
|
256
|
+
});
|
|
257
|
+
/** DELETE /admin/chains/:id — soft disable */
|
|
258
|
+
app.delete("/admin/chains/:id", async (c) => {
|
|
259
|
+
await deps.methodStore.setEnabled(c.req.param("id"), false);
|
|
260
|
+
return c.body(null, 204);
|
|
261
|
+
});
|
|
262
|
+
return app;
|
|
263
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
import type { DrizzleDb } from "../../db/index.js";
|
|
15
|
+
import type { ICryptoChargeRepository } from "./charge-store.js";
|
|
16
|
+
import type { IWatcherCursorStore } from "./cursor-store.js";
|
|
17
|
+
import type { IPriceOracle } from "./oracle/types.js";
|
|
18
|
+
import type { IPaymentMethodStore } from "./payment-method-store.js";
|
|
19
|
+
export interface WatcherServiceOpts {
|
|
20
|
+
db: DrizzleDb;
|
|
21
|
+
chargeStore: ICryptoChargeRepository;
|
|
22
|
+
methodStore: IPaymentMethodStore;
|
|
23
|
+
cursorStore: IWatcherCursorStore;
|
|
24
|
+
oracle: IPriceOracle;
|
|
25
|
+
bitcoindUser?: string;
|
|
26
|
+
bitcoindPassword?: string;
|
|
27
|
+
pollIntervalMs?: number;
|
|
28
|
+
deliveryIntervalMs?: number;
|
|
29
|
+
log?: (msg: string, meta?: Record<string, unknown>) => void;
|
|
30
|
+
/** Allowed callback URL prefixes. Default: ["https://"] — enforces HTTPS. */
|
|
31
|
+
allowedCallbackPrefixes?: string[];
|
|
32
|
+
}
|
|
33
|
+
export declare function startWatchers(opts: WatcherServiceOpts): Promise<() => void>;
|
|
@@ -0,0 +1,295 @@
|
|
|
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
|
+
import { and, eq, isNull, lte, or } from "drizzle-orm";
|
|
15
|
+
import { cryptoCharges, webhookDeliveries } from "../../db/schema/crypto.js";
|
|
16
|
+
import { BtcWatcher, createBitcoindRpc } from "./btc/watcher.js";
|
|
17
|
+
import { createRpcCaller, EvmWatcher } from "./evm/watcher.js";
|
|
18
|
+
const MAX_DELIVERY_ATTEMPTS = 10;
|
|
19
|
+
const BACKOFF_BASE_MS = 5_000;
|
|
20
|
+
// --- SSRF validation ---
|
|
21
|
+
function isValidCallbackUrl(url, allowedPrefixes) {
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(url);
|
|
24
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
|
|
25
|
+
return false;
|
|
26
|
+
const host = parsed.hostname;
|
|
27
|
+
if (host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "::1")
|
|
28
|
+
return false;
|
|
29
|
+
if (host.startsWith("10.") || host.startsWith("192.168.") || host.startsWith("169.254."))
|
|
30
|
+
return false;
|
|
31
|
+
return allowedPrefixes.some((prefix) => url.startsWith(prefix));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// --- Webhook outbox ---
|
|
38
|
+
async function enqueueWebhook(db, chargeId, callbackUrl, payload) {
|
|
39
|
+
await db.insert(webhookDeliveries).values({
|
|
40
|
+
chargeId,
|
|
41
|
+
callbackUrl,
|
|
42
|
+
payload: JSON.stringify(payload),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
async function processDeliveries(db, allowedPrefixes, log) {
|
|
46
|
+
const now = new Date().toISOString();
|
|
47
|
+
const pending = await db
|
|
48
|
+
.select()
|
|
49
|
+
.from(webhookDeliveries)
|
|
50
|
+
.where(and(eq(webhookDeliveries.status, "pending"), or(isNull(webhookDeliveries.nextRetryAt), lte(webhookDeliveries.nextRetryAt, now))))
|
|
51
|
+
.limit(50);
|
|
52
|
+
let delivered = 0;
|
|
53
|
+
for (const row of pending) {
|
|
54
|
+
if (!isValidCallbackUrl(row.callbackUrl, allowedPrefixes)) {
|
|
55
|
+
await db
|
|
56
|
+
.update(webhookDeliveries)
|
|
57
|
+
.set({ status: "failed", lastError: "Invalid callbackUrl (SSRF blocked)" })
|
|
58
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const res = await fetch(row.callbackUrl, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: { "Content-Type": "application/json" },
|
|
65
|
+
body: row.payload,
|
|
66
|
+
});
|
|
67
|
+
if (!res.ok)
|
|
68
|
+
throw new Error(`HTTP ${res.status}`);
|
|
69
|
+
await db.update(webhookDeliveries).set({ status: "delivered" }).where(eq(webhookDeliveries.id, row.id));
|
|
70
|
+
delivered++;
|
|
71
|
+
}
|
|
72
|
+
catch (err) {
|
|
73
|
+
const attempts = row.attempts + 1;
|
|
74
|
+
if (attempts >= MAX_DELIVERY_ATTEMPTS) {
|
|
75
|
+
await db
|
|
76
|
+
.update(webhookDeliveries)
|
|
77
|
+
.set({ status: "failed", attempts, lastError: String(err) })
|
|
78
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
79
|
+
log("Webhook permanently failed", { chargeId: row.chargeId, attempts });
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
const backoffMs = BACKOFF_BASE_MS * 2 ** (attempts - 1);
|
|
83
|
+
const nextRetry = new Date(Date.now() + backoffMs).toISOString();
|
|
84
|
+
await db
|
|
85
|
+
.update(webhookDeliveries)
|
|
86
|
+
.set({ attempts, nextRetryAt: nextRetry, lastError: String(err) })
|
|
87
|
+
.where(eq(webhookDeliveries.id, row.id));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return delivered;
|
|
92
|
+
}
|
|
93
|
+
// --- Payment handling (partial + full) ---
|
|
94
|
+
/**
|
|
95
|
+
* Handle a payment event. Accumulates partial payments in native units.
|
|
96
|
+
* Settles when totalReceived >= expectedAmount. Fires webhook on every payment.
|
|
97
|
+
*
|
|
98
|
+
* @param nativeAmount — received amount in native base units (sats for BTC/DOGE, raw token units for ERC20)
|
|
99
|
+
*/
|
|
100
|
+
async function handlePayment(db, chargeStore, address, nativeAmount, payload, log) {
|
|
101
|
+
const charge = await chargeStore.getByDepositAddress(address);
|
|
102
|
+
if (!charge) {
|
|
103
|
+
log("Payment to unknown address", { address });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (charge.creditedAt) {
|
|
107
|
+
return; // Already fully paid and credited
|
|
108
|
+
}
|
|
109
|
+
// Accumulate: add this payment to the running total
|
|
110
|
+
const prevReceived = BigInt(charge.receivedAmount ?? "0");
|
|
111
|
+
const thisPayment = BigInt(nativeAmount);
|
|
112
|
+
const totalReceived = (prevReceived + thisPayment).toString();
|
|
113
|
+
const expected = BigInt(charge.expectedAmount ?? "0");
|
|
114
|
+
const isFull = expected > 0n && BigInt(totalReceived) >= expected;
|
|
115
|
+
// Update received_amount in DB
|
|
116
|
+
await db
|
|
117
|
+
.update(cryptoCharges)
|
|
118
|
+
.set({ receivedAmount: totalReceived, filledAmount: totalReceived })
|
|
119
|
+
.where(eq(cryptoCharges.referenceId, charge.referenceId));
|
|
120
|
+
if (isFull) {
|
|
121
|
+
const settled = "Settled";
|
|
122
|
+
await chargeStore.updateStatus(charge.referenceId, settled, charge.token ?? undefined, totalReceived);
|
|
123
|
+
await chargeStore.markCredited(charge.referenceId);
|
|
124
|
+
log("Charge settled", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const processing = "Processing";
|
|
128
|
+
await chargeStore.updateStatus(charge.referenceId, processing, charge.token ?? undefined, totalReceived);
|
|
129
|
+
log("Partial payment", { chargeId: charge.referenceId, expected: expected.toString(), received: totalReceived });
|
|
130
|
+
}
|
|
131
|
+
// Webhook on every payment — product shows progress to user
|
|
132
|
+
if (charge.callbackUrl) {
|
|
133
|
+
await enqueueWebhook(db, charge.referenceId, charge.callbackUrl, {
|
|
134
|
+
chargeId: charge.referenceId,
|
|
135
|
+
chain: charge.chain,
|
|
136
|
+
address: charge.depositAddress,
|
|
137
|
+
expectedAmount: expected.toString(),
|
|
138
|
+
receivedAmount: totalReceived,
|
|
139
|
+
amountUsdCents: charge.amountUsdCents,
|
|
140
|
+
status: isFull ? "confirmed" : "partial",
|
|
141
|
+
...payload,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// --- Watcher boot ---
|
|
146
|
+
export async function startWatchers(opts) {
|
|
147
|
+
const { db, chargeStore, methodStore, cursorStore, oracle } = opts;
|
|
148
|
+
const pollMs = opts.pollIntervalMs ?? 15_000;
|
|
149
|
+
const deliveryMs = opts.deliveryIntervalMs ?? 10_000;
|
|
150
|
+
const log = opts.log ?? (() => { });
|
|
151
|
+
const allowedPrefixes = opts.allowedCallbackPrefixes ?? ["https://"];
|
|
152
|
+
const timers = [];
|
|
153
|
+
const methods = await methodStore.listEnabled();
|
|
154
|
+
const utxoMethods = methods.filter((m) => m.type === "native" && (m.chain === "bitcoin" || m.chain === "litecoin" || m.chain === "dogecoin"));
|
|
155
|
+
const evmMethods = methods.filter((m) => m.type === "erc20" ||
|
|
156
|
+
(m.type === "native" && m.chain !== "bitcoin" && m.chain !== "litecoin" && m.chain !== "dogecoin"));
|
|
157
|
+
// --- UTXO Watchers (BTC, LTC, DOGE) ---
|
|
158
|
+
for (const method of utxoMethods) {
|
|
159
|
+
if (!method.rpcUrl)
|
|
160
|
+
continue;
|
|
161
|
+
const rpcCall = createBitcoindRpc({
|
|
162
|
+
rpcUrl: method.rpcUrl,
|
|
163
|
+
rpcUser: opts.bitcoindUser ?? "btcpay",
|
|
164
|
+
rpcPassword: opts.bitcoindPassword ?? "",
|
|
165
|
+
network: "mainnet",
|
|
166
|
+
confirmations: method.confirmations,
|
|
167
|
+
});
|
|
168
|
+
const activeAddresses = await chargeStore.listActiveDepositAddresses();
|
|
169
|
+
const chainAddresses = activeAddresses.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
170
|
+
const watcher = new BtcWatcher({
|
|
171
|
+
config: {
|
|
172
|
+
rpcUrl: method.rpcUrl,
|
|
173
|
+
rpcUser: opts.bitcoindUser ?? "btcpay",
|
|
174
|
+
rpcPassword: opts.bitcoindPassword ?? "",
|
|
175
|
+
network: "mainnet",
|
|
176
|
+
confirmations: method.confirmations,
|
|
177
|
+
},
|
|
178
|
+
chainId: method.chain,
|
|
179
|
+
rpcCall,
|
|
180
|
+
watchedAddresses: chainAddresses,
|
|
181
|
+
oracle,
|
|
182
|
+
cursorStore,
|
|
183
|
+
onPayment: async (event) => {
|
|
184
|
+
log("UTXO payment", { chain: method.chain, address: event.address, txid: event.txid, sats: event.amountSats });
|
|
185
|
+
// Pass native amount (sats) — NOT USD cents
|
|
186
|
+
await handlePayment(db, chargeStore, event.address, String(event.amountSats), {
|
|
187
|
+
txHash: event.txid,
|
|
188
|
+
confirmations: event.confirmations,
|
|
189
|
+
}, log);
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
const importedAddresses = new Set();
|
|
193
|
+
for (const addr of chainAddresses) {
|
|
194
|
+
try {
|
|
195
|
+
await watcher.importAddress(addr);
|
|
196
|
+
importedAddresses.add(addr);
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
log("Failed to import address", { chain: method.chain, address: addr });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
log(`UTXO watcher started (${method.chain})`, { addresses: importedAddresses.size });
|
|
203
|
+
let utxoPolling = false;
|
|
204
|
+
timers.push(setInterval(async () => {
|
|
205
|
+
if (utxoPolling)
|
|
206
|
+
return; // Prevent overlapping polls
|
|
207
|
+
utxoPolling = true;
|
|
208
|
+
try {
|
|
209
|
+
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
210
|
+
const freshChain = fresh.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
211
|
+
for (const addr of freshChain) {
|
|
212
|
+
if (!importedAddresses.has(addr)) {
|
|
213
|
+
try {
|
|
214
|
+
await watcher.importAddress(addr);
|
|
215
|
+
importedAddresses.add(addr);
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
log("Failed to import new address (will retry)", { chain: method.chain, address: addr });
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
watcher.setWatchedAddresses(freshChain);
|
|
223
|
+
await watcher.poll();
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
log("UTXO poll error", { chain: method.chain, error: String(err) });
|
|
227
|
+
}
|
|
228
|
+
finally {
|
|
229
|
+
utxoPolling = false;
|
|
230
|
+
}
|
|
231
|
+
}, pollMs));
|
|
232
|
+
}
|
|
233
|
+
// --- EVM Watchers ---
|
|
234
|
+
for (const method of evmMethods) {
|
|
235
|
+
if (!method.rpcUrl || !method.contractAddress)
|
|
236
|
+
continue;
|
|
237
|
+
const rpcCall = createRpcCaller(method.rpcUrl);
|
|
238
|
+
const latestHex = (await rpcCall("eth_blockNumber", []));
|
|
239
|
+
const latestBlock = Number.parseInt(latestHex, 16);
|
|
240
|
+
const activeAddresses = await chargeStore.listActiveDepositAddresses();
|
|
241
|
+
const chainAddresses = activeAddresses.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
242
|
+
const watcher = new EvmWatcher({
|
|
243
|
+
chain: method.chain,
|
|
244
|
+
token: method.token,
|
|
245
|
+
rpcCall,
|
|
246
|
+
fromBlock: latestBlock,
|
|
247
|
+
watchedAddresses: chainAddresses,
|
|
248
|
+
cursorStore,
|
|
249
|
+
onPayment: async (event) => {
|
|
250
|
+
log("EVM payment", { chain: event.chain, token: event.token, to: event.to, txHash: event.txHash });
|
|
251
|
+
// Pass native amount (raw token units) — NOT USD cents
|
|
252
|
+
await handlePayment(db, chargeStore, event.to, event.rawAmount, {
|
|
253
|
+
txHash: event.txHash,
|
|
254
|
+
confirmations: method.confirmations,
|
|
255
|
+
}, log);
|
|
256
|
+
},
|
|
257
|
+
});
|
|
258
|
+
await watcher.init();
|
|
259
|
+
log(`EVM watcher started (${method.chain}:${method.token})`, { addresses: chainAddresses.length });
|
|
260
|
+
let evmPolling = false;
|
|
261
|
+
timers.push(setInterval(async () => {
|
|
262
|
+
if (evmPolling)
|
|
263
|
+
return; // Prevent overlapping polls
|
|
264
|
+
evmPolling = true;
|
|
265
|
+
try {
|
|
266
|
+
const fresh = await chargeStore.listActiveDepositAddresses();
|
|
267
|
+
const freshChain = fresh.filter((a) => a.chain === method.chain).map((a) => a.address);
|
|
268
|
+
watcher.setWatchedAddresses(freshChain);
|
|
269
|
+
await watcher.poll();
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
log("EVM poll error", { chain: method.chain, token: method.token, error: String(err) });
|
|
273
|
+
}
|
|
274
|
+
finally {
|
|
275
|
+
evmPolling = false;
|
|
276
|
+
}
|
|
277
|
+
}, pollMs));
|
|
278
|
+
}
|
|
279
|
+
// --- Webhook delivery outbox processor ---
|
|
280
|
+
timers.push(setInterval(async () => {
|
|
281
|
+
try {
|
|
282
|
+
const count = await processDeliveries(db, allowedPrefixes, log);
|
|
283
|
+
if (count > 0)
|
|
284
|
+
log("Webhooks delivered", { count });
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
log("Delivery loop error", { error: String(err) });
|
|
288
|
+
}
|
|
289
|
+
}, deliveryMs));
|
|
290
|
+
log("All watchers started", { utxo: utxoMethods.length, evm: evmMethods.length, pollMs, deliveryMs });
|
|
291
|
+
return () => {
|
|
292
|
+
for (const t of timers)
|
|
293
|
+
clearInterval(t);
|
|
294
|
+
};
|
|
295
|
+
}
|
package/dist/billing/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Crypto (
|
|
1
|
+
// Crypto (key server — native BTC/EVM watchers)
|
|
2
2
|
export * from "./crypto/index.js";
|
|
3
3
|
export { DrizzleWebhookSeenRepository } from "./drizzle-webhook-seen-repository.js";
|
|
4
4
|
export { PaymentMethodOwnershipError } from "./payment-processor.js";
|