@wopr-network/platform-core 1.67.0 → 1.68.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.
Files changed (140) hide show
  1. package/dist/auth/better-auth.js +7 -0
  2. package/dist/billing/crypto/btc/checkout.d.ts +4 -0
  3. package/dist/billing/crypto/btc/checkout.js +1 -2
  4. package/dist/billing/crypto/btc/index.d.ts +0 -4
  5. package/dist/billing/crypto/btc/index.js +0 -2
  6. package/dist/billing/crypto/evm/__tests__/checkout.test.js +8 -11
  7. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +15 -1
  8. package/dist/billing/crypto/evm/checkout.d.ts +2 -0
  9. package/dist/billing/crypto/evm/checkout.js +1 -2
  10. package/dist/billing/crypto/evm/eth-checkout.d.ts +13 -2
  11. package/dist/billing/crypto/evm/eth-checkout.js +2 -4
  12. package/dist/billing/crypto/evm/eth-settler.d.ts +1 -1
  13. package/dist/billing/crypto/evm/index.d.ts +2 -8
  14. package/dist/billing/crypto/evm/index.js +0 -3
  15. package/dist/billing/crypto/evm/types.d.ts +16 -0
  16. package/dist/billing/crypto/index.d.ts +1 -6
  17. package/dist/billing/crypto/index.js +2 -3
  18. package/dist/billing/crypto/types.d.ts +0 -43
  19. package/dist/billing/crypto/types.js +1 -24
  20. package/dist/email/client.js +16 -0
  21. package/package.json +4 -7
  22. package/src/auth/better-auth.ts +8 -0
  23. package/src/billing/crypto/btc/checkout.ts +3 -2
  24. package/src/billing/crypto/btc/index.ts +0 -4
  25. package/src/billing/crypto/evm/__tests__/checkout.test.ts +10 -12
  26. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +17 -1
  27. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +1 -1
  28. package/src/billing/crypto/evm/checkout.ts +3 -2
  29. package/src/billing/crypto/evm/eth-checkout.ts +15 -6
  30. package/src/billing/crypto/evm/eth-settler.ts +1 -1
  31. package/src/billing/crypto/evm/index.ts +8 -7
  32. package/src/billing/crypto/evm/types.ts +17 -0
  33. package/src/billing/crypto/index.ts +14 -12
  34. package/src/billing/crypto/types.ts +0 -63
  35. package/src/email/client.ts +18 -0
  36. package/dist/billing/crypto/__tests__/address-gen.test.d.ts +0 -1
  37. package/dist/billing/crypto/__tests__/address-gen.test.js +0 -219
  38. package/dist/billing/crypto/__tests__/key-server.test.d.ts +0 -1
  39. package/dist/billing/crypto/__tests__/key-server.test.js +0 -742
  40. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +0 -1
  41. package/dist/billing/crypto/__tests__/watcher-service.test.js +0 -174
  42. package/dist/billing/crypto/address-gen.d.ts +0 -24
  43. package/dist/billing/crypto/address-gen.js +0 -176
  44. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +0 -1
  45. package/dist/billing/crypto/btc/__tests__/watcher.test.js +0 -170
  46. package/dist/billing/crypto/btc/watcher.d.ts +0 -44
  47. package/dist/billing/crypto/btc/watcher.js +0 -118
  48. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.d.ts +0 -1
  49. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +0 -167
  50. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +0 -1
  51. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +0 -159
  52. package/dist/billing/crypto/evm/__tests__/watcher.test.d.ts +0 -1
  53. package/dist/billing/crypto/evm/__tests__/watcher.test.js +0 -145
  54. package/dist/billing/crypto/evm/eth-watcher.d.ts +0 -66
  55. package/dist/billing/crypto/evm/eth-watcher.js +0 -121
  56. package/dist/billing/crypto/evm/watcher.d.ts +0 -51
  57. package/dist/billing/crypto/evm/watcher.js +0 -156
  58. package/dist/billing/crypto/key-server-entry.d.ts +0 -1
  59. package/dist/billing/crypto/key-server-entry.js +0 -122
  60. package/dist/billing/crypto/key-server.d.ts +0 -32
  61. package/dist/billing/crypto/key-server.js +0 -472
  62. package/dist/billing/crypto/oracle/__tests__/chainlink.test.d.ts +0 -1
  63. package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +0 -83
  64. package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +0 -1
  65. package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +0 -65
  66. package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +0 -1
  67. package/dist/billing/crypto/oracle/__tests__/composite.test.js +0 -48
  68. package/dist/billing/crypto/oracle/__tests__/convert.test.d.ts +0 -1
  69. package/dist/billing/crypto/oracle/__tests__/convert.test.js +0 -61
  70. package/dist/billing/crypto/oracle/__tests__/fixed.test.d.ts +0 -1
  71. package/dist/billing/crypto/oracle/__tests__/fixed.test.js +0 -20
  72. package/dist/billing/crypto/oracle/chainlink.d.ts +0 -26
  73. package/dist/billing/crypto/oracle/chainlink.js +0 -62
  74. package/dist/billing/crypto/oracle/coingecko.d.ts +0 -22
  75. package/dist/billing/crypto/oracle/coingecko.js +0 -71
  76. package/dist/billing/crypto/oracle/composite.d.ts +0 -14
  77. package/dist/billing/crypto/oracle/composite.js +0 -34
  78. package/dist/billing/crypto/oracle/convert.d.ts +0 -30
  79. package/dist/billing/crypto/oracle/convert.js +0 -51
  80. package/dist/billing/crypto/oracle/fixed.d.ts +0 -10
  81. package/dist/billing/crypto/oracle/fixed.js +0 -22
  82. package/dist/billing/crypto/oracle/index.d.ts +0 -9
  83. package/dist/billing/crypto/oracle/index.js +0 -6
  84. package/dist/billing/crypto/oracle/types.d.ts +0 -22
  85. package/dist/billing/crypto/oracle/types.js +0 -7
  86. package/dist/billing/crypto/plugin/__tests__/integration.test.d.ts +0 -1
  87. package/dist/billing/crypto/plugin/__tests__/integration.test.js +0 -58
  88. package/dist/billing/crypto/plugin/__tests__/interfaces.test.d.ts +0 -1
  89. package/dist/billing/crypto/plugin/__tests__/interfaces.test.js +0 -46
  90. package/dist/billing/crypto/plugin/__tests__/registry.test.d.ts +0 -1
  91. package/dist/billing/crypto/plugin/__tests__/registry.test.js +0 -49
  92. package/dist/billing/crypto/plugin/index.d.ts +0 -2
  93. package/dist/billing/crypto/plugin/index.js +0 -1
  94. package/dist/billing/crypto/plugin/interfaces.d.ts +0 -97
  95. package/dist/billing/crypto/plugin/interfaces.js +0 -2
  96. package/dist/billing/crypto/plugin/registry.d.ts +0 -8
  97. package/dist/billing/crypto/plugin/registry.js +0 -21
  98. package/dist/billing/crypto/plugin-watcher-service.d.ts +0 -32
  99. package/dist/billing/crypto/plugin-watcher-service.js +0 -113
  100. package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +0 -1
  101. package/dist/billing/crypto/tron/__tests__/address-convert.test.js +0 -55
  102. package/dist/billing/crypto/tron/address-convert.d.ts +0 -14
  103. package/dist/billing/crypto/tron/address-convert.js +0 -93
  104. package/dist/billing/crypto/watcher-service.d.ts +0 -55
  105. package/dist/billing/crypto/watcher-service.js +0 -438
  106. package/src/billing/crypto/__tests__/address-gen.test.ts +0 -264
  107. package/src/billing/crypto/__tests__/key-server.test.ts +0 -823
  108. package/src/billing/crypto/__tests__/watcher-service.test.ts +0 -242
  109. package/src/billing/crypto/address-gen.ts +0 -185
  110. package/src/billing/crypto/btc/__tests__/watcher.test.ts +0 -201
  111. package/src/billing/crypto/btc/watcher.ts +0 -161
  112. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +0 -190
  113. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +0 -191
  114. package/src/billing/crypto/evm/__tests__/watcher.test.ts +0 -167
  115. package/src/billing/crypto/evm/eth-watcher.ts +0 -182
  116. package/src/billing/crypto/evm/watcher.ts +0 -204
  117. package/src/billing/crypto/key-server-entry.ts +0 -144
  118. package/src/billing/crypto/key-server.ts +0 -617
  119. package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +0 -107
  120. package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +0 -75
  121. package/src/billing/crypto/oracle/__tests__/composite.test.ts +0 -61
  122. package/src/billing/crypto/oracle/__tests__/convert.test.ts +0 -74
  123. package/src/billing/crypto/oracle/__tests__/fixed.test.ts +0 -23
  124. package/src/billing/crypto/oracle/chainlink.ts +0 -86
  125. package/src/billing/crypto/oracle/coingecko.ts +0 -96
  126. package/src/billing/crypto/oracle/composite.ts +0 -35
  127. package/src/billing/crypto/oracle/convert.ts +0 -53
  128. package/src/billing/crypto/oracle/fixed.ts +0 -25
  129. package/src/billing/crypto/oracle/index.ts +0 -9
  130. package/src/billing/crypto/oracle/types.ts +0 -28
  131. package/src/billing/crypto/plugin/__tests__/integration.test.ts +0 -64
  132. package/src/billing/crypto/plugin/__tests__/interfaces.test.ts +0 -51
  133. package/src/billing/crypto/plugin/__tests__/registry.test.ts +0 -58
  134. package/src/billing/crypto/plugin/index.ts +0 -17
  135. package/src/billing/crypto/plugin/interfaces.ts +0 -106
  136. package/src/billing/crypto/plugin/registry.ts +0 -26
  137. package/src/billing/crypto/plugin-watcher-service.ts +0 -148
  138. package/src/billing/crypto/tron/__tests__/address-convert.test.ts +0 -67
  139. package/src/billing/crypto/tron/address-convert.ts +0 -89
  140. package/src/billing/crypto/watcher-service.ts +0 -549
@@ -1,617 +0,0 @@
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
-
11
- import { HDKey } from "@scure/bip32";
12
- import { and, eq, isNull, sql } from "drizzle-orm";
13
- import { Hono } from "hono";
14
- import type { DrizzleDb } from "../../db/index.js";
15
- import { addressPool, derivedAddresses, keyRings, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
16
- import type { EncodingParams } from "./address-gen.js";
17
- import { deriveAddress } from "./address-gen.js";
18
- import type { ICryptoChargeRepository } from "./charge-store.js";
19
- import { centsToNative } from "./oracle/convert.js";
20
- import type { IPriceOracle } from "./oracle/types.js";
21
- import { AssetNotSupportedError } from "./oracle/types.js";
22
- import type { IPaymentMethodStore } from "./payment-method-store.js";
23
- import type { PluginRegistry } from "./plugin/registry.js";
24
-
25
- export interface KeyServerDeps {
26
- db: DrizzleDb;
27
- chargeStore: ICryptoChargeRepository;
28
- methodStore: IPaymentMethodStore;
29
- oracle: IPriceOracle;
30
- /** Bearer token for product API routes. If unset, auth is disabled. */
31
- serviceKey?: string;
32
- /** Bearer token for admin routes. If unset, admin routes are disabled. */
33
- adminToken?: string;
34
- /** Plugin registry for address encoding. Falls back to address-gen.ts when absent. */
35
- registry?: PluginRegistry;
36
- }
37
-
38
- /**
39
- * Claim the next address from the pre-derived address pool.
40
- * Used for Ed25519 chains (Solana, etc.) that can't derive from an xpub.
41
- */
42
- async function claimFromPool(
43
- db: DrizzleDb,
44
- keyRingId: string,
45
- chainId: string,
46
- tenantId?: string,
47
- ): Promise<{ address: string; index: number }> {
48
- const dbWithTx = db as unknown as { transaction: (fn: (tx: DrizzleDb) => Promise<unknown>) => Promise<unknown> };
49
-
50
- const result = await dbWithTx.transaction(async (tx: DrizzleDb) => {
51
- // Find the next unassigned address (lowest index first)
52
- const [poolEntry] = await tx
53
- .select()
54
- .from(addressPool)
55
- .where(and(eq(addressPool.keyRingId, keyRingId), isNull(addressPool.assignedTo)))
56
- .orderBy(addressPool.derivationIndex)
57
- .limit(1);
58
-
59
- if (!poolEntry) {
60
- throw new Error(`No available addresses in pool for ${keyRingId}. Run crypto-sweep replenish.`);
61
- }
62
-
63
- // Mark as assigned
64
- const assignmentId = tenantId ? `${chainId}:${tenantId}` : chainId;
65
- await tx.update(addressPool).set({ assignedTo: assignmentId }).where(eq(addressPool.id, poolEntry.id));
66
-
67
- // Record in derived_addresses for tracking
68
- await tx.insert(derivedAddresses).values({
69
- chainId,
70
- derivationIndex: poolEntry.derivationIndex,
71
- address: poolEntry.address,
72
- tenantId,
73
- });
74
-
75
- return { address: poolEntry.address, index: poolEntry.derivationIndex };
76
- });
77
-
78
- return result as { address: string; index: number };
79
- }
80
-
81
- /**
82
- * Derive the next unused address for a chain.
83
- *
84
- * For Ed25519 chains with a key ring in "pre-derived" mode (or no xpub),
85
- * claims from the address pool instead of deriving from an xpub.
86
- *
87
- * For xpub-based chains, atomically increments next_index and records
88
- * the address in a single transaction. EVM chains share an xpub (coin type 60),
89
- * so the unique constraint on derived_addresses.address prevents reuse.
90
- * On collision, we skip the index and retry (up to maxRetries).
91
- */
92
- async function deriveNextAddress(
93
- db: DrizzleDb,
94
- chainId: string,
95
- tenantId?: string,
96
- registry?: PluginRegistry,
97
- ): Promise<{ address: string; index: number; chain: string; token: string }> {
98
- const maxRetries = 10;
99
- const dbWithTx = db as unknown as { transaction: (fn: (tx: DrizzleDb) => Promise<unknown>) => Promise<unknown> };
100
-
101
- // Check if this payment method uses pool-based derivation (Ed25519 chains).
102
- // Look up the method first to check for key_ring_id with derivation_mode = 'pre-derived'.
103
- const [methodCheck] = await db.select().from(paymentMethods).where(eq(paymentMethods.id, chainId));
104
-
105
- if (methodCheck?.keyRingId) {
106
- // Check the key ring's derivation mode
107
- const [ring] = await db.select().from(keyRings).where(eq(keyRings.id, methodCheck.keyRingId));
108
-
109
- if (ring?.derivationMode === "pre-derived" || (!methodCheck.xpub && ring)) {
110
- // Pool mode: claim from pre-derived addresses
111
- const { address, index } = await claimFromPool(db, methodCheck.keyRingId, chainId, tenantId);
112
- return { address, index, chain: methodCheck.chain, token: methodCheck.token };
113
- }
114
- }
115
-
116
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
117
- // Step 1: Atomically claim the next index OUTSIDE the transaction.
118
- // This survives even if the transaction below rolls back on address collision.
119
- const [method] = await db
120
- .update(paymentMethods)
121
- .set({ nextIndex: sql`${paymentMethods.nextIndex} + 1` })
122
- .where(eq(paymentMethods.id, chainId))
123
- .returning();
124
-
125
- if (!method) throw new Error(`Chain not found: ${chainId}`);
126
- if (!method.xpub) throw new Error(`No xpub configured for chain: ${chainId}`);
127
-
128
- const index = method.nextIndex - 1;
129
-
130
- // Universal address derivation — encoding type + params are DB-driven.
131
- // Adding a new chain is a DB INSERT, not a code change.
132
- let encodingParams: EncodingParams = {};
133
- try {
134
- encodingParams = JSON.parse(method.encodingParams ?? "{}");
135
- } catch {
136
- throw new Error(`Invalid encoding_params JSON for chain ${chainId}: ${method.encodingParams}`);
137
- }
138
-
139
- // Plugin-driven encoding: look up the plugin, use its encoder.
140
- // Falls back to legacy deriveAddress() when no registry or no matching plugin.
141
- let address: string;
142
- const pluginId = method.pluginId ?? (method.watcherType === "utxo" ? "bitcoin" : method.watcherType);
143
- const plugin = registry?.get(pluginId ?? "");
144
- const encodingKey = method.encoding ?? method.addressType;
145
- const encoder = plugin?.encoders[encodingKey];
146
-
147
- if (encoder) {
148
- const master = HDKey.fromExtendedKey(method.xpub);
149
- const child = master.deriveChild(0).deriveChild(index);
150
- if (!child.publicKey) throw new Error("Failed to derive public key");
151
- address = encoder.encode(child.publicKey, encodingParams as Record<string, string | undefined>);
152
- } else {
153
- address = deriveAddress(method.xpub, index, method.addressType, encodingParams);
154
- }
155
-
156
- // Step 2: Record in immutable log. If this address was already derived by a
157
- // sibling chain (shared xpub), the unique constraint fires and we retry
158
- // with the next index (which is already incremented above).
159
- try {
160
- await dbWithTx.transaction(async (tx: DrizzleDb) => {
161
- // bech32/evm addresses are case-insensitive (lowercase by spec).
162
- // p2pkh (Base58Check) addresses are case-sensitive — do NOT lowercase.
163
- const normalizedAddress = method.addressType === "p2pkh" ? address : address.toLowerCase();
164
- await tx.insert(derivedAddresses).values({
165
- chainId,
166
- derivationIndex: index,
167
- address: normalizedAddress,
168
- tenantId,
169
- });
170
- });
171
- return { address, index, chain: method.chain, token: method.token };
172
- } catch (err: unknown) {
173
- // Drizzle wraps PG errors — check both top-level and cause for the constraint violation code
174
- const code = (err as { code?: string }).code ?? (err as { cause?: { code?: string } }).cause?.code;
175
- if (code === "23505" && attempt < maxRetries) continue; // collision — index already advanced, retry
176
- throw err;
177
- }
178
- }
179
- throw new Error(`Failed to derive unique address for ${chainId} after ${maxRetries} retries`);
180
- }
181
-
182
- /** Validate Bearer token from Authorization header. */
183
- function requireAuth(header: string | undefined, expected: string): boolean {
184
- if (!expected) return true; // auth disabled
185
- return header === `Bearer ${expected}`;
186
- }
187
-
188
- /**
189
- * Create the Hono app for the crypto key server.
190
- * Mount this on the chain server at the root.
191
- */
192
- export function createKeyServerApp(deps: KeyServerDeps): Hono {
193
- const app = new Hono();
194
-
195
- // --- Auth middleware for product routes ---
196
- app.use("/address", async (c, next) => {
197
- if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
198
- return c.json({ error: "Unauthorized" }, 401);
199
- }
200
- await next();
201
- });
202
- app.use("/charges/*", async (c, next) => {
203
- if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
204
- return c.json({ error: "Unauthorized" }, 401);
205
- }
206
- await next();
207
- });
208
- app.use("/charges", async (c, next) => {
209
- if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
210
- return c.json({ error: "Unauthorized" }, 401);
211
- }
212
- await next();
213
- });
214
-
215
- // --- Auth middleware for admin routes ---
216
- app.use("/admin/*", async (c, next) => {
217
- if (!deps.adminToken) return c.json({ error: "Admin API disabled" }, 403);
218
- if (!requireAuth(c.req.header("Authorization"), deps.adminToken)) {
219
- return c.json({ error: "Unauthorized" }, 401);
220
- }
221
- await next();
222
- });
223
-
224
- // --- Product API ---
225
-
226
- /** POST /address — derive next unused address */
227
- app.post("/address", async (c) => {
228
- const body = await c.req.json<{ chain: string }>();
229
- if (!body.chain) return c.json({ error: "chain is required" }, 400);
230
-
231
- const tenantId = c.req.header("X-Tenant-Id");
232
- const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined, deps.registry);
233
- return c.json(result, 201);
234
- });
235
-
236
- /** POST /charges — create charge + derive address + start watching */
237
- app.post("/charges", async (c) => {
238
- const body = await c.req.json<{
239
- chain: string;
240
- amountUsd: number;
241
- callbackUrl?: string;
242
- metadata?: Record<string, unknown>;
243
- }>();
244
-
245
- if (!body.chain || typeof body.amountUsd !== "number" || !Number.isFinite(body.amountUsd) || body.amountUsd <= 0) {
246
- return c.json({ error: "chain is required and amountUsd must be a positive finite number" }, 400);
247
- }
248
-
249
- const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
250
- const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId, deps.registry);
251
-
252
- // Look up payment method for decimals + oracle config
253
- const method = await deps.methodStore.getById(body.chain);
254
- if (!method) return c.json({ error: `Unknown chain: ${body.chain}` }, 400);
255
-
256
- const amountUsdCents = Math.round(body.amountUsd * 100);
257
-
258
- // Compute expected crypto amount in native base units.
259
- // Price is locked NOW — this is what the user must send.
260
- let expectedAmount: bigint;
261
- const feedAddress = method.oracleAddress ? (method.oracleAddress as `0x${string}`) : undefined;
262
- try {
263
- // Try oracle pricing (Chainlink for BTC/ETH, CoinGecko for DOGE/LTC).
264
- // feedAddress is a hint for Chainlink — undefined is fine, CompositeOracle
265
- // falls through to CoinGecko or built-in feed maps.
266
- const { priceMicros } = await deps.oracle.getPrice(token, feedAddress);
267
- expectedAmount = centsToNative(amountUsdCents, priceMicros, method.decimals);
268
- } catch (err) {
269
- if (err instanceof AssetNotSupportedError) {
270
- // No oracle knows this token (e.g. USDC, DAI) — stablecoin 1:1 USD.
271
- expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
272
- } else {
273
- // Transient oracle failure (network, rate limit, stale feed).
274
- // Reject the charge — silently pricing BTC at $1 would be catastrophic.
275
- return c.json({ error: `Price oracle unavailable for ${token}: ${(err as Error).message}` }, 503);
276
- }
277
- }
278
-
279
- const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
280
-
281
- await deps.chargeStore.createStablecoinCharge({
282
- referenceId,
283
- tenantId,
284
- amountUsdCents,
285
- chain,
286
- token,
287
- depositAddress: address,
288
- derivationIndex: index,
289
- callbackUrl: body.callbackUrl,
290
- expectedAmount: expectedAmount.toString(),
291
- });
292
-
293
- // Format display amount for the client (BigInt-safe, no Number overflow)
294
- const divisor = 10n ** BigInt(method.decimals);
295
- const whole = expectedAmount / divisor;
296
- const frac = expectedAmount % divisor;
297
- const fracStr = frac.toString().padStart(method.decimals, "0").slice(0, 8).replace(/0+$/, "");
298
- const displayAmount = `${whole}${fracStr ? `.${fracStr}` : ""} ${token}`;
299
-
300
- return c.json(
301
- {
302
- chargeId: referenceId,
303
- address,
304
- chain,
305
- token,
306
- amountUsd: body.amountUsd,
307
- expectedAmount: expectedAmount.toString(),
308
- displayAmount,
309
- derivationIndex: index,
310
- expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
311
- },
312
- 201,
313
- );
314
- });
315
-
316
- /** GET /charges/:id — check charge status */
317
- app.get("/charges/:id", async (c) => {
318
- const charge = await deps.chargeStore.getByReferenceId(c.req.param("id"));
319
- if (!charge) return c.json({ error: "Charge not found" }, 404);
320
-
321
- return c.json({
322
- chargeId: charge.referenceId,
323
- status: charge.status,
324
- address: charge.depositAddress,
325
- chain: charge.chain,
326
- token: charge.token,
327
- amountUsdCents: charge.amountUsdCents,
328
- creditedAt: charge.creditedAt,
329
- });
330
- });
331
-
332
- /** GET /chains — list enabled payment methods (for checkout UI) */
333
- app.get("/chains", async (c) => {
334
- const methods = await deps.methodStore.listEnabled();
335
- return c.json(
336
- methods.map((m) => ({
337
- id: m.id,
338
- token: m.token,
339
- chain: m.chain,
340
- decimals: m.decimals,
341
- displayName: m.displayName,
342
- contractAddress: m.contractAddress,
343
- confirmations: m.confirmations,
344
- iconUrl: m.iconUrl,
345
- })),
346
- );
347
- });
348
-
349
- // --- Admin API ---
350
-
351
- /** GET /admin/next-path — which derivation path to use for a coin type */
352
- app.get("/admin/next-path", async (c) => {
353
- const coinType = Number(c.req.query("coin_type"));
354
- if (!Number.isInteger(coinType)) return c.json({ error: "coin_type must be an integer" }, 400);
355
-
356
- // Find all allocations for this coin type
357
- const existing = await deps.db.select().from(pathAllocations).where(eq(pathAllocations.coinType, coinType));
358
-
359
- if (existing.length === 0) {
360
- return c.json({
361
- coin_type: coinType,
362
- account_index: 0,
363
- path: `m/44'/${coinType}'/0'`,
364
- status: "available",
365
- });
366
- }
367
-
368
- // If already allocated, return info about existing allocation
369
- const latest = existing.sort(
370
- (a: { accountIndex: number }, b: { accountIndex: number }) => b.accountIndex - a.accountIndex,
371
- )[0];
372
-
373
- // Find chains using this coin type's allocations
374
- const chainIds = existing.map((a: { chainId: string | null }) => a.chainId).filter(Boolean);
375
- return c.json({
376
- coin_type: coinType,
377
- account_index: latest.accountIndex,
378
- path: `m/44'/${coinType}'/${latest.accountIndex}'`,
379
- status: "allocated",
380
- allocated_to: chainIds,
381
- note: "xpub already registered — reuse for new chains with same key type",
382
- next_available: {
383
- account_index: latest.accountIndex + 1,
384
- path: `m/44'/${coinType}'/${latest.accountIndex + 1}'`,
385
- },
386
- });
387
- });
388
-
389
- /** POST /admin/chains — register a new chain with its xpub */
390
- app.post("/admin/chains", async (c) => {
391
- const body = await c.req.json<{
392
- id: string;
393
- coin_type: number;
394
- account_index: number;
395
- network: string;
396
- type: string;
397
- token: string;
398
- chain: string;
399
- contract?: string;
400
- decimals: number;
401
- xpub: string;
402
- rpc_url: string;
403
- rpc_headers?: Record<string, string>;
404
- confirmations?: number;
405
- display_name?: string;
406
- oracle_address?: string;
407
- address_type?: string;
408
- encoding_params?: Record<string, string>;
409
- watcher_type?: string;
410
- oracle_asset_id?: string;
411
- icon_url?: string;
412
- display_order?: number;
413
- }>();
414
-
415
- if (!body.id || !body.xpub || !body.token) {
416
- return c.json({ error: "id, xpub, and token are required" }, 400);
417
- }
418
-
419
- // Validate encoding_params match address_type requirements
420
- const addrType = body.address_type ?? "evm";
421
- const encParams = body.encoding_params ?? {};
422
- if (addrType === "bech32" && !encParams.hrp) {
423
- return c.json({ error: "bech32 address_type requires encoding_params.hrp" }, 400);
424
- }
425
- if (addrType === "p2pkh" && !encParams.version) {
426
- return c.json({ error: "p2pkh address_type requires encoding_params.version" }, 400);
427
- }
428
-
429
- // Upsert payment method FIRST (path_allocations has FK to payment_methods.id)
430
- await deps.methodStore.upsert({
431
- id: body.id,
432
- type: body.type ?? "native",
433
- token: body.token,
434
- chain: body.chain ?? body.network,
435
- contractAddress: body.contract ?? null,
436
- decimals: body.decimals,
437
- displayName: body.display_name ?? `${body.token} on ${body.network}`,
438
- enabled: true,
439
- displayOrder: body.display_order ?? 0,
440
- iconUrl: body.icon_url ?? null,
441
- rpcUrl: body.rpc_url,
442
- rpcHeaders: JSON.stringify(body.rpc_headers ?? {}),
443
- oracleAddress: body.oracle_address ?? null,
444
- xpub: body.xpub,
445
- addressType: body.address_type ?? "evm",
446
- encodingParams: JSON.stringify(body.encoding_params ?? {}),
447
- watcherType: body.watcher_type ?? "evm",
448
- oracleAssetId: body.oracle_asset_id ?? null,
449
- confirmations: body.confirmations ?? 6,
450
- keyRingId: null,
451
- encoding: null,
452
- pluginId: null,
453
- });
454
-
455
- // Record the path allocation (idempotent — ignore if already exists)
456
- const inserted = (await deps.db
457
- .insert(pathAllocations)
458
- .values({
459
- coinType: body.coin_type,
460
- accountIndex: body.account_index,
461
- chainId: body.id,
462
- xpub: body.xpub,
463
- })
464
- .onConflictDoNothing()) as { rowCount: number };
465
-
466
- if (inserted.rowCount === 0) {
467
- return c.json(
468
- {
469
- message: "Path allocation already exists, payment method updated",
470
- path: `m/44'/${body.coin_type}'/${body.account_index}'`,
471
- },
472
- 200,
473
- );
474
- }
475
-
476
- return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
477
- });
478
-
479
- /** PATCH /admin/chains/:id — update metadata (icon_url, display_order, display_name) */
480
- app.patch("/admin/chains/:id", async (c) => {
481
- const id = c.req.param("id");
482
- const body = await c.req.json<{
483
- icon_url?: string | null;
484
- display_order?: number;
485
- display_name?: string;
486
- }>();
487
-
488
- const updated = await deps.methodStore.patchMetadata(id, {
489
- iconUrl: body.icon_url,
490
- displayOrder: body.display_order,
491
- displayName: body.display_name,
492
- });
493
-
494
- if (!updated) return c.json({ id, updated: false }, 200);
495
- return c.json({ id, updated: true });
496
- });
497
-
498
- /** DELETE /admin/chains/:id — soft disable */
499
- app.delete("/admin/chains/:id", async (c) => {
500
- await deps.methodStore.setEnabled(c.req.param("id"), false);
501
- return c.body(null, 204);
502
- });
503
-
504
- /** POST /admin/pool/replenish — upload pre-derived addresses for Ed25519 chains */
505
- app.post("/admin/pool/replenish", async (c) => {
506
- const body = await c.req.json<{
507
- key_ring_id: string;
508
- plugin_id: string;
509
- encoding: string;
510
- addresses: Array<{
511
- index: number;
512
- public_key: string;
513
- address: string;
514
- }>;
515
- }>();
516
-
517
- if (
518
- !body.key_ring_id ||
519
- !body.plugin_id ||
520
- !body.encoding ||
521
- !Array.isArray(body.addresses) ||
522
- body.addresses.length === 0
523
- ) {
524
- return c.json({ error: "key_ring_id, plugin_id, encoding, and a non-empty addresses array are required" }, 400);
525
- }
526
-
527
- // Validate the key ring exists
528
- const [ring] = await deps.db.select().from(keyRings).where(eq(keyRings.id, body.key_ring_id));
529
- if (!ring) {
530
- return c.json({ error: `Key ring not found: ${body.key_ring_id}` }, 404);
531
- }
532
-
533
- // Look up the plugin encoder for validation
534
- const plugin = deps.registry?.get(body.plugin_id);
535
- const encoder = plugin?.encoders[body.encoding];
536
-
537
- // Validate each address against the public key
538
- for (const entry of body.addresses) {
539
- if (typeof entry.index !== "number" || !entry.public_key || !entry.address) {
540
- return c.json(
541
- { error: `Invalid entry at index ${entry.index}: index, public_key, and address are required` },
542
- 400,
543
- );
544
- }
545
-
546
- // If we have an encoder, validate the address by re-encoding the public key
547
- if (encoder) {
548
- const pubKeyBytes = hexToBytes(entry.public_key);
549
- const reEncoded = encoder.encode(pubKeyBytes, {});
550
- if (reEncoded !== entry.address) {
551
- return c.json(
552
- {
553
- error: `Address mismatch at index ${entry.index}: expected ${reEncoded}, got ${entry.address}`,
554
- },
555
- 400,
556
- );
557
- }
558
- }
559
- }
560
-
561
- // Insert validated addresses into the pool
562
- let inserted = 0;
563
- for (const entry of body.addresses) {
564
- const result = (await deps.db
565
- .insert(addressPool)
566
- .values({
567
- keyRingId: body.key_ring_id,
568
- derivationIndex: entry.index,
569
- publicKey: entry.public_key,
570
- address: entry.address,
571
- })
572
- .onConflictDoNothing()) as { rowCount: number };
573
- inserted += result.rowCount;
574
- }
575
-
576
- // Get total pool size for this key ring
577
- const totalRows = await deps.db.select().from(addressPool).where(eq(addressPool.keyRingId, body.key_ring_id));
578
-
579
- return c.json({ inserted, total: totalRows.length }, 201);
580
- });
581
-
582
- /** GET /admin/pool/status — pool stats per key ring */
583
- app.get("/admin/pool/status", async (c) => {
584
- // Get all key rings
585
- const rings = await deps.db.select().from(keyRings);
586
-
587
- const pools = await Promise.all(
588
- rings.map(async (ring) => {
589
- const allEntries = await deps.db.select().from(addressPool).where(eq(addressPool.keyRingId, ring.id));
590
-
591
- const available = allEntries.filter((e) => e.assignedTo === null).length;
592
- const assigned = allEntries.length - available;
593
-
594
- return {
595
- key_ring_id: ring.id,
596
- total: allEntries.length,
597
- available,
598
- assigned,
599
- };
600
- }),
601
- );
602
-
603
- return c.json({ pools });
604
- });
605
-
606
- return app;
607
- }
608
-
609
- /** Convert a hex string to Uint8Array. */
610
- function hexToBytes(hex: string): Uint8Array {
611
- const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
612
- const bytes = new Uint8Array(clean.length / 2);
613
- for (let i = 0; i < bytes.length; i++) {
614
- bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
615
- }
616
- return bytes;
617
- }