@wopr-network/platform-core 1.67.0 → 1.67.1

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 (136) hide show
  1. package/dist/billing/crypto/btc/checkout.d.ts +4 -0
  2. package/dist/billing/crypto/btc/checkout.js +1 -2
  3. package/dist/billing/crypto/btc/index.d.ts +0 -4
  4. package/dist/billing/crypto/btc/index.js +0 -2
  5. package/dist/billing/crypto/evm/__tests__/checkout.test.js +8 -11
  6. package/dist/billing/crypto/evm/__tests__/eth-checkout.test.js +15 -1
  7. package/dist/billing/crypto/evm/checkout.d.ts +2 -0
  8. package/dist/billing/crypto/evm/checkout.js +1 -2
  9. package/dist/billing/crypto/evm/eth-checkout.d.ts +13 -2
  10. package/dist/billing/crypto/evm/eth-checkout.js +2 -4
  11. package/dist/billing/crypto/evm/eth-settler.d.ts +1 -1
  12. package/dist/billing/crypto/evm/index.d.ts +2 -8
  13. package/dist/billing/crypto/evm/index.js +0 -3
  14. package/dist/billing/crypto/evm/types.d.ts +16 -0
  15. package/dist/billing/crypto/index.d.ts +1 -6
  16. package/dist/billing/crypto/index.js +2 -3
  17. package/dist/billing/crypto/types.d.ts +0 -43
  18. package/dist/billing/crypto/types.js +1 -24
  19. package/package.json +1 -5
  20. package/src/billing/crypto/btc/checkout.ts +3 -2
  21. package/src/billing/crypto/btc/index.ts +0 -4
  22. package/src/billing/crypto/evm/__tests__/checkout.test.ts +10 -12
  23. package/src/billing/crypto/evm/__tests__/eth-checkout.test.ts +17 -1
  24. package/src/billing/crypto/evm/__tests__/eth-settler.test.ts +1 -1
  25. package/src/billing/crypto/evm/checkout.ts +3 -2
  26. package/src/billing/crypto/evm/eth-checkout.ts +15 -6
  27. package/src/billing/crypto/evm/eth-settler.ts +1 -1
  28. package/src/billing/crypto/evm/index.ts +8 -7
  29. package/src/billing/crypto/evm/types.ts +17 -0
  30. package/src/billing/crypto/index.ts +14 -12
  31. package/src/billing/crypto/types.ts +0 -63
  32. package/dist/billing/crypto/__tests__/address-gen.test.d.ts +0 -1
  33. package/dist/billing/crypto/__tests__/address-gen.test.js +0 -219
  34. package/dist/billing/crypto/__tests__/key-server.test.d.ts +0 -1
  35. package/dist/billing/crypto/__tests__/key-server.test.js +0 -742
  36. package/dist/billing/crypto/__tests__/watcher-service.test.d.ts +0 -1
  37. package/dist/billing/crypto/__tests__/watcher-service.test.js +0 -174
  38. package/dist/billing/crypto/address-gen.d.ts +0 -24
  39. package/dist/billing/crypto/address-gen.js +0 -176
  40. package/dist/billing/crypto/btc/__tests__/watcher.test.d.ts +0 -1
  41. package/dist/billing/crypto/btc/__tests__/watcher.test.js +0 -170
  42. package/dist/billing/crypto/btc/watcher.d.ts +0 -44
  43. package/dist/billing/crypto/btc/watcher.js +0 -118
  44. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.d.ts +0 -1
  45. package/dist/billing/crypto/evm/__tests__/eth-watcher.test.js +0 -167
  46. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.d.ts +0 -1
  47. package/dist/billing/crypto/evm/__tests__/watcher-confirmations.test.js +0 -159
  48. package/dist/billing/crypto/evm/__tests__/watcher.test.d.ts +0 -1
  49. package/dist/billing/crypto/evm/__tests__/watcher.test.js +0 -145
  50. package/dist/billing/crypto/evm/eth-watcher.d.ts +0 -66
  51. package/dist/billing/crypto/evm/eth-watcher.js +0 -121
  52. package/dist/billing/crypto/evm/watcher.d.ts +0 -51
  53. package/dist/billing/crypto/evm/watcher.js +0 -156
  54. package/dist/billing/crypto/key-server-entry.d.ts +0 -1
  55. package/dist/billing/crypto/key-server-entry.js +0 -122
  56. package/dist/billing/crypto/key-server.d.ts +0 -32
  57. package/dist/billing/crypto/key-server.js +0 -472
  58. package/dist/billing/crypto/oracle/__tests__/chainlink.test.d.ts +0 -1
  59. package/dist/billing/crypto/oracle/__tests__/chainlink.test.js +0 -83
  60. package/dist/billing/crypto/oracle/__tests__/coingecko.test.d.ts +0 -1
  61. package/dist/billing/crypto/oracle/__tests__/coingecko.test.js +0 -65
  62. package/dist/billing/crypto/oracle/__tests__/composite.test.d.ts +0 -1
  63. package/dist/billing/crypto/oracle/__tests__/composite.test.js +0 -48
  64. package/dist/billing/crypto/oracle/__tests__/convert.test.d.ts +0 -1
  65. package/dist/billing/crypto/oracle/__tests__/convert.test.js +0 -61
  66. package/dist/billing/crypto/oracle/__tests__/fixed.test.d.ts +0 -1
  67. package/dist/billing/crypto/oracle/__tests__/fixed.test.js +0 -20
  68. package/dist/billing/crypto/oracle/chainlink.d.ts +0 -26
  69. package/dist/billing/crypto/oracle/chainlink.js +0 -62
  70. package/dist/billing/crypto/oracle/coingecko.d.ts +0 -22
  71. package/dist/billing/crypto/oracle/coingecko.js +0 -71
  72. package/dist/billing/crypto/oracle/composite.d.ts +0 -14
  73. package/dist/billing/crypto/oracle/composite.js +0 -34
  74. package/dist/billing/crypto/oracle/convert.d.ts +0 -30
  75. package/dist/billing/crypto/oracle/convert.js +0 -51
  76. package/dist/billing/crypto/oracle/fixed.d.ts +0 -10
  77. package/dist/billing/crypto/oracle/fixed.js +0 -22
  78. package/dist/billing/crypto/oracle/index.d.ts +0 -9
  79. package/dist/billing/crypto/oracle/index.js +0 -6
  80. package/dist/billing/crypto/oracle/types.d.ts +0 -22
  81. package/dist/billing/crypto/oracle/types.js +0 -7
  82. package/dist/billing/crypto/plugin/__tests__/integration.test.d.ts +0 -1
  83. package/dist/billing/crypto/plugin/__tests__/integration.test.js +0 -58
  84. package/dist/billing/crypto/plugin/__tests__/interfaces.test.d.ts +0 -1
  85. package/dist/billing/crypto/plugin/__tests__/interfaces.test.js +0 -46
  86. package/dist/billing/crypto/plugin/__tests__/registry.test.d.ts +0 -1
  87. package/dist/billing/crypto/plugin/__tests__/registry.test.js +0 -49
  88. package/dist/billing/crypto/plugin/index.d.ts +0 -2
  89. package/dist/billing/crypto/plugin/index.js +0 -1
  90. package/dist/billing/crypto/plugin/interfaces.d.ts +0 -97
  91. package/dist/billing/crypto/plugin/interfaces.js +0 -2
  92. package/dist/billing/crypto/plugin/registry.d.ts +0 -8
  93. package/dist/billing/crypto/plugin/registry.js +0 -21
  94. package/dist/billing/crypto/plugin-watcher-service.d.ts +0 -32
  95. package/dist/billing/crypto/plugin-watcher-service.js +0 -113
  96. package/dist/billing/crypto/tron/__tests__/address-convert.test.d.ts +0 -1
  97. package/dist/billing/crypto/tron/__tests__/address-convert.test.js +0 -55
  98. package/dist/billing/crypto/tron/address-convert.d.ts +0 -14
  99. package/dist/billing/crypto/tron/address-convert.js +0 -93
  100. package/dist/billing/crypto/watcher-service.d.ts +0 -55
  101. package/dist/billing/crypto/watcher-service.js +0 -438
  102. package/src/billing/crypto/__tests__/address-gen.test.ts +0 -264
  103. package/src/billing/crypto/__tests__/key-server.test.ts +0 -823
  104. package/src/billing/crypto/__tests__/watcher-service.test.ts +0 -242
  105. package/src/billing/crypto/address-gen.ts +0 -185
  106. package/src/billing/crypto/btc/__tests__/watcher.test.ts +0 -201
  107. package/src/billing/crypto/btc/watcher.ts +0 -161
  108. package/src/billing/crypto/evm/__tests__/eth-watcher.test.ts +0 -190
  109. package/src/billing/crypto/evm/__tests__/watcher-confirmations.test.ts +0 -191
  110. package/src/billing/crypto/evm/__tests__/watcher.test.ts +0 -167
  111. package/src/billing/crypto/evm/eth-watcher.ts +0 -182
  112. package/src/billing/crypto/evm/watcher.ts +0 -204
  113. package/src/billing/crypto/key-server-entry.ts +0 -144
  114. package/src/billing/crypto/key-server.ts +0 -617
  115. package/src/billing/crypto/oracle/__tests__/chainlink.test.ts +0 -107
  116. package/src/billing/crypto/oracle/__tests__/coingecko.test.ts +0 -75
  117. package/src/billing/crypto/oracle/__tests__/composite.test.ts +0 -61
  118. package/src/billing/crypto/oracle/__tests__/convert.test.ts +0 -74
  119. package/src/billing/crypto/oracle/__tests__/fixed.test.ts +0 -23
  120. package/src/billing/crypto/oracle/chainlink.ts +0 -86
  121. package/src/billing/crypto/oracle/coingecko.ts +0 -96
  122. package/src/billing/crypto/oracle/composite.ts +0 -35
  123. package/src/billing/crypto/oracle/convert.ts +0 -53
  124. package/src/billing/crypto/oracle/fixed.ts +0 -25
  125. package/src/billing/crypto/oracle/index.ts +0 -9
  126. package/src/billing/crypto/oracle/types.ts +0 -28
  127. package/src/billing/crypto/plugin/__tests__/integration.test.ts +0 -64
  128. package/src/billing/crypto/plugin/__tests__/interfaces.test.ts +0 -51
  129. package/src/billing/crypto/plugin/__tests__/registry.test.ts +0 -58
  130. package/src/billing/crypto/plugin/index.ts +0 -17
  131. package/src/billing/crypto/plugin/interfaces.ts +0 -106
  132. package/src/billing/crypto/plugin/registry.ts +0 -26
  133. package/src/billing/crypto/plugin-watcher-service.ts +0 -148
  134. package/src/billing/crypto/tron/__tests__/address-convert.test.ts +0 -67
  135. package/src/billing/crypto/tron/address-convert.ts +0 -89
  136. package/src/billing/crypto/watcher-service.ts +0 -549
@@ -1,472 +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
- import { HDKey } from "@scure/bip32";
11
- import { and, eq, isNull, sql } from "drizzle-orm";
12
- import { Hono } from "hono";
13
- import { addressPool, derivedAddresses, keyRings, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
14
- import { deriveAddress } from "./address-gen.js";
15
- import { centsToNative } from "./oracle/convert.js";
16
- import { AssetNotSupportedError } from "./oracle/types.js";
17
- /**
18
- * Claim the next address from the pre-derived address pool.
19
- * Used for Ed25519 chains (Solana, etc.) that can't derive from an xpub.
20
- */
21
- async function claimFromPool(db, keyRingId, chainId, tenantId) {
22
- const dbWithTx = db;
23
- const result = await dbWithTx.transaction(async (tx) => {
24
- // Find the next unassigned address (lowest index first)
25
- const [poolEntry] = await tx
26
- .select()
27
- .from(addressPool)
28
- .where(and(eq(addressPool.keyRingId, keyRingId), isNull(addressPool.assignedTo)))
29
- .orderBy(addressPool.derivationIndex)
30
- .limit(1);
31
- if (!poolEntry) {
32
- throw new Error(`No available addresses in pool for ${keyRingId}. Run crypto-sweep replenish.`);
33
- }
34
- // Mark as assigned
35
- const assignmentId = tenantId ? `${chainId}:${tenantId}` : chainId;
36
- await tx.update(addressPool).set({ assignedTo: assignmentId }).where(eq(addressPool.id, poolEntry.id));
37
- // Record in derived_addresses for tracking
38
- await tx.insert(derivedAddresses).values({
39
- chainId,
40
- derivationIndex: poolEntry.derivationIndex,
41
- address: poolEntry.address,
42
- tenantId,
43
- });
44
- return { address: poolEntry.address, index: poolEntry.derivationIndex };
45
- });
46
- return result;
47
- }
48
- /**
49
- * Derive the next unused address for a chain.
50
- *
51
- * For Ed25519 chains with a key ring in "pre-derived" mode (or no xpub),
52
- * claims from the address pool instead of deriving from an xpub.
53
- *
54
- * For xpub-based chains, atomically increments next_index and records
55
- * the address in a single transaction. EVM chains share an xpub (coin type 60),
56
- * so the unique constraint on derived_addresses.address prevents reuse.
57
- * On collision, we skip the index and retry (up to maxRetries).
58
- */
59
- async function deriveNextAddress(db, chainId, tenantId, registry) {
60
- const maxRetries = 10;
61
- const dbWithTx = db;
62
- // Check if this payment method uses pool-based derivation (Ed25519 chains).
63
- // Look up the method first to check for key_ring_id with derivation_mode = 'pre-derived'.
64
- const [methodCheck] = await db.select().from(paymentMethods).where(eq(paymentMethods.id, chainId));
65
- if (methodCheck?.keyRingId) {
66
- // Check the key ring's derivation mode
67
- const [ring] = await db.select().from(keyRings).where(eq(keyRings.id, methodCheck.keyRingId));
68
- if (ring?.derivationMode === "pre-derived" || (!methodCheck.xpub && ring)) {
69
- // Pool mode: claim from pre-derived addresses
70
- const { address, index } = await claimFromPool(db, methodCheck.keyRingId, chainId, tenantId);
71
- return { address, index, chain: methodCheck.chain, token: methodCheck.token };
72
- }
73
- }
74
- for (let attempt = 0; attempt <= maxRetries; attempt++) {
75
- // Step 1: Atomically claim the next index OUTSIDE the transaction.
76
- // This survives even if the transaction below rolls back on address collision.
77
- const [method] = await db
78
- .update(paymentMethods)
79
- .set({ nextIndex: sql `${paymentMethods.nextIndex} + 1` })
80
- .where(eq(paymentMethods.id, chainId))
81
- .returning();
82
- if (!method)
83
- throw new Error(`Chain not found: ${chainId}`);
84
- if (!method.xpub)
85
- throw new Error(`No xpub configured for chain: ${chainId}`);
86
- const index = method.nextIndex - 1;
87
- // Universal address derivation — encoding type + params are DB-driven.
88
- // Adding a new chain is a DB INSERT, not a code change.
89
- let encodingParams = {};
90
- try {
91
- encodingParams = JSON.parse(method.encodingParams ?? "{}");
92
- }
93
- catch {
94
- throw new Error(`Invalid encoding_params JSON for chain ${chainId}: ${method.encodingParams}`);
95
- }
96
- // Plugin-driven encoding: look up the plugin, use its encoder.
97
- // Falls back to legacy deriveAddress() when no registry or no matching plugin.
98
- let address;
99
- const pluginId = method.pluginId ?? (method.watcherType === "utxo" ? "bitcoin" : method.watcherType);
100
- const plugin = registry?.get(pluginId ?? "");
101
- const encodingKey = method.encoding ?? method.addressType;
102
- const encoder = plugin?.encoders[encodingKey];
103
- if (encoder) {
104
- const master = HDKey.fromExtendedKey(method.xpub);
105
- const child = master.deriveChild(0).deriveChild(index);
106
- if (!child.publicKey)
107
- throw new Error("Failed to derive public key");
108
- address = encoder.encode(child.publicKey, encodingParams);
109
- }
110
- else {
111
- address = deriveAddress(method.xpub, index, method.addressType, encodingParams);
112
- }
113
- // Step 2: Record in immutable log. If this address was already derived by a
114
- // sibling chain (shared xpub), the unique constraint fires and we retry
115
- // with the next index (which is already incremented above).
116
- try {
117
- await dbWithTx.transaction(async (tx) => {
118
- // bech32/evm addresses are case-insensitive (lowercase by spec).
119
- // p2pkh (Base58Check) addresses are case-sensitive — do NOT lowercase.
120
- const normalizedAddress = method.addressType === "p2pkh" ? address : address.toLowerCase();
121
- await tx.insert(derivedAddresses).values({
122
- chainId,
123
- derivationIndex: index,
124
- address: normalizedAddress,
125
- tenantId,
126
- });
127
- });
128
- return { address, index, chain: method.chain, token: method.token };
129
- }
130
- catch (err) {
131
- // Drizzle wraps PG errors — check both top-level and cause for the constraint violation code
132
- const code = err.code ?? err.cause?.code;
133
- if (code === "23505" && attempt < maxRetries)
134
- continue; // collision — index already advanced, retry
135
- throw err;
136
- }
137
- }
138
- throw new Error(`Failed to derive unique address for ${chainId} after ${maxRetries} retries`);
139
- }
140
- /** Validate Bearer token from Authorization header. */
141
- function requireAuth(header, expected) {
142
- if (!expected)
143
- return true; // auth disabled
144
- return header === `Bearer ${expected}`;
145
- }
146
- /**
147
- * Create the Hono app for the crypto key server.
148
- * Mount this on the chain server at the root.
149
- */
150
- export function createKeyServerApp(deps) {
151
- const app = new Hono();
152
- // --- Auth middleware for product routes ---
153
- app.use("/address", async (c, next) => {
154
- if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
155
- return c.json({ error: "Unauthorized" }, 401);
156
- }
157
- await next();
158
- });
159
- app.use("/charges/*", async (c, next) => {
160
- if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
161
- return c.json({ error: "Unauthorized" }, 401);
162
- }
163
- await next();
164
- });
165
- app.use("/charges", async (c, next) => {
166
- if (deps.serviceKey && !requireAuth(c.req.header("Authorization"), deps.serviceKey)) {
167
- return c.json({ error: "Unauthorized" }, 401);
168
- }
169
- await next();
170
- });
171
- // --- Auth middleware for admin routes ---
172
- app.use("/admin/*", async (c, next) => {
173
- if (!deps.adminToken)
174
- return c.json({ error: "Admin API disabled" }, 403);
175
- if (!requireAuth(c.req.header("Authorization"), deps.adminToken)) {
176
- return c.json({ error: "Unauthorized" }, 401);
177
- }
178
- await next();
179
- });
180
- // --- Product API ---
181
- /** POST /address — derive next unused address */
182
- app.post("/address", async (c) => {
183
- const body = await c.req.json();
184
- if (!body.chain)
185
- return c.json({ error: "chain is required" }, 400);
186
- const tenantId = c.req.header("X-Tenant-Id");
187
- const result = await deriveNextAddress(deps.db, body.chain, tenantId ?? undefined, deps.registry);
188
- return c.json(result, 201);
189
- });
190
- /** POST /charges — create charge + derive address + start watching */
191
- app.post("/charges", async (c) => {
192
- const body = await c.req.json();
193
- if (!body.chain || typeof body.amountUsd !== "number" || !Number.isFinite(body.amountUsd) || body.amountUsd <= 0) {
194
- return c.json({ error: "chain is required and amountUsd must be a positive finite number" }, 400);
195
- }
196
- const tenantId = c.req.header("X-Tenant-Id") ?? "unknown";
197
- const { address, index, chain, token } = await deriveNextAddress(deps.db, body.chain, tenantId, deps.registry);
198
- // Look up payment method for decimals + oracle config
199
- const method = await deps.methodStore.getById(body.chain);
200
- if (!method)
201
- return c.json({ error: `Unknown chain: ${body.chain}` }, 400);
202
- const amountUsdCents = Math.round(body.amountUsd * 100);
203
- // Compute expected crypto amount in native base units.
204
- // Price is locked NOW — this is what the user must send.
205
- let expectedAmount;
206
- const feedAddress = method.oracleAddress ? method.oracleAddress : undefined;
207
- try {
208
- // Try oracle pricing (Chainlink for BTC/ETH, CoinGecko for DOGE/LTC).
209
- // feedAddress is a hint for Chainlink — undefined is fine, CompositeOracle
210
- // falls through to CoinGecko or built-in feed maps.
211
- const { priceMicros } = await deps.oracle.getPrice(token, feedAddress);
212
- expectedAmount = centsToNative(amountUsdCents, priceMicros, method.decimals);
213
- }
214
- catch (err) {
215
- if (err instanceof AssetNotSupportedError) {
216
- // No oracle knows this token (e.g. USDC, DAI) — stablecoin 1:1 USD.
217
- expectedAmount = (BigInt(amountUsdCents) * 10n ** BigInt(method.decimals)) / 100n;
218
- }
219
- else {
220
- // Transient oracle failure (network, rate limit, stale feed).
221
- // Reject the charge — silently pricing BTC at $1 would be catastrophic.
222
- return c.json({ error: `Price oracle unavailable for ${token}: ${err.message}` }, 503);
223
- }
224
- }
225
- const referenceId = `${token.toLowerCase()}:${address.toLowerCase()}`;
226
- await deps.chargeStore.createStablecoinCharge({
227
- referenceId,
228
- tenantId,
229
- amountUsdCents,
230
- chain,
231
- token,
232
- depositAddress: address,
233
- derivationIndex: index,
234
- callbackUrl: body.callbackUrl,
235
- expectedAmount: expectedAmount.toString(),
236
- });
237
- // Format display amount for the client (BigInt-safe, no Number overflow)
238
- const divisor = 10n ** BigInt(method.decimals);
239
- const whole = expectedAmount / divisor;
240
- const frac = expectedAmount % divisor;
241
- const fracStr = frac.toString().padStart(method.decimals, "0").slice(0, 8).replace(/0+$/, "");
242
- const displayAmount = `${whole}${fracStr ? `.${fracStr}` : ""} ${token}`;
243
- return c.json({
244
- chargeId: referenceId,
245
- address,
246
- chain,
247
- token,
248
- amountUsd: body.amountUsd,
249
- expectedAmount: expectedAmount.toString(),
250
- displayAmount,
251
- derivationIndex: index,
252
- expiresAt: new Date(Date.now() + 30 * 60 * 1000).toISOString(), // 30 min
253
- }, 201);
254
- });
255
- /** GET /charges/:id — check charge status */
256
- app.get("/charges/:id", async (c) => {
257
- const charge = await deps.chargeStore.getByReferenceId(c.req.param("id"));
258
- if (!charge)
259
- return c.json({ error: "Charge not found" }, 404);
260
- return c.json({
261
- chargeId: charge.referenceId,
262
- status: charge.status,
263
- address: charge.depositAddress,
264
- chain: charge.chain,
265
- token: charge.token,
266
- amountUsdCents: charge.amountUsdCents,
267
- creditedAt: charge.creditedAt,
268
- });
269
- });
270
- /** GET /chains — list enabled payment methods (for checkout UI) */
271
- app.get("/chains", async (c) => {
272
- const methods = await deps.methodStore.listEnabled();
273
- return c.json(methods.map((m) => ({
274
- id: m.id,
275
- token: m.token,
276
- chain: m.chain,
277
- decimals: m.decimals,
278
- displayName: m.displayName,
279
- contractAddress: m.contractAddress,
280
- confirmations: m.confirmations,
281
- iconUrl: m.iconUrl,
282
- })));
283
- });
284
- // --- Admin API ---
285
- /** GET /admin/next-path — which derivation path to use for a coin type */
286
- app.get("/admin/next-path", async (c) => {
287
- const coinType = Number(c.req.query("coin_type"));
288
- if (!Number.isInteger(coinType))
289
- return c.json({ error: "coin_type must be an integer" }, 400);
290
- // Find all allocations for this coin type
291
- const existing = await deps.db.select().from(pathAllocations).where(eq(pathAllocations.coinType, coinType));
292
- if (existing.length === 0) {
293
- return c.json({
294
- coin_type: coinType,
295
- account_index: 0,
296
- path: `m/44'/${coinType}'/0'`,
297
- status: "available",
298
- });
299
- }
300
- // If already allocated, return info about existing allocation
301
- const latest = existing.sort((a, b) => b.accountIndex - a.accountIndex)[0];
302
- // Find chains using this coin type's allocations
303
- const chainIds = existing.map((a) => a.chainId).filter(Boolean);
304
- return c.json({
305
- coin_type: coinType,
306
- account_index: latest.accountIndex,
307
- path: `m/44'/${coinType}'/${latest.accountIndex}'`,
308
- status: "allocated",
309
- allocated_to: chainIds,
310
- note: "xpub already registered — reuse for new chains with same key type",
311
- next_available: {
312
- account_index: latest.accountIndex + 1,
313
- path: `m/44'/${coinType}'/${latest.accountIndex + 1}'`,
314
- },
315
- });
316
- });
317
- /** POST /admin/chains — register a new chain with its xpub */
318
- app.post("/admin/chains", async (c) => {
319
- const body = await c.req.json();
320
- if (!body.id || !body.xpub || !body.token) {
321
- return c.json({ error: "id, xpub, and token are required" }, 400);
322
- }
323
- // Validate encoding_params match address_type requirements
324
- const addrType = body.address_type ?? "evm";
325
- const encParams = body.encoding_params ?? {};
326
- if (addrType === "bech32" && !encParams.hrp) {
327
- return c.json({ error: "bech32 address_type requires encoding_params.hrp" }, 400);
328
- }
329
- if (addrType === "p2pkh" && !encParams.version) {
330
- return c.json({ error: "p2pkh address_type requires encoding_params.version" }, 400);
331
- }
332
- // Upsert payment method FIRST (path_allocations has FK to payment_methods.id)
333
- await deps.methodStore.upsert({
334
- id: body.id,
335
- type: body.type ?? "native",
336
- token: body.token,
337
- chain: body.chain ?? body.network,
338
- contractAddress: body.contract ?? null,
339
- decimals: body.decimals,
340
- displayName: body.display_name ?? `${body.token} on ${body.network}`,
341
- enabled: true,
342
- displayOrder: body.display_order ?? 0,
343
- iconUrl: body.icon_url ?? null,
344
- rpcUrl: body.rpc_url,
345
- rpcHeaders: JSON.stringify(body.rpc_headers ?? {}),
346
- oracleAddress: body.oracle_address ?? null,
347
- xpub: body.xpub,
348
- addressType: body.address_type ?? "evm",
349
- encodingParams: JSON.stringify(body.encoding_params ?? {}),
350
- watcherType: body.watcher_type ?? "evm",
351
- oracleAssetId: body.oracle_asset_id ?? null,
352
- confirmations: body.confirmations ?? 6,
353
- keyRingId: null,
354
- encoding: null,
355
- pluginId: null,
356
- });
357
- // Record the path allocation (idempotent — ignore if already exists)
358
- const inserted = (await deps.db
359
- .insert(pathAllocations)
360
- .values({
361
- coinType: body.coin_type,
362
- accountIndex: body.account_index,
363
- chainId: body.id,
364
- xpub: body.xpub,
365
- })
366
- .onConflictDoNothing());
367
- if (inserted.rowCount === 0) {
368
- return c.json({
369
- message: "Path allocation already exists, payment method updated",
370
- path: `m/44'/${body.coin_type}'/${body.account_index}'`,
371
- }, 200);
372
- }
373
- return c.json({ id: body.id, path: `m/44'/${body.coin_type}'/${body.account_index}'` }, 201);
374
- });
375
- /** PATCH /admin/chains/:id — update metadata (icon_url, display_order, display_name) */
376
- app.patch("/admin/chains/:id", async (c) => {
377
- const id = c.req.param("id");
378
- const body = await c.req.json();
379
- const updated = await deps.methodStore.patchMetadata(id, {
380
- iconUrl: body.icon_url,
381
- displayOrder: body.display_order,
382
- displayName: body.display_name,
383
- });
384
- if (!updated)
385
- return c.json({ id, updated: false }, 200);
386
- return c.json({ id, updated: true });
387
- });
388
- /** DELETE /admin/chains/:id — soft disable */
389
- app.delete("/admin/chains/:id", async (c) => {
390
- await deps.methodStore.setEnabled(c.req.param("id"), false);
391
- return c.body(null, 204);
392
- });
393
- /** POST /admin/pool/replenish — upload pre-derived addresses for Ed25519 chains */
394
- app.post("/admin/pool/replenish", async (c) => {
395
- const body = await c.req.json();
396
- if (!body.key_ring_id ||
397
- !body.plugin_id ||
398
- !body.encoding ||
399
- !Array.isArray(body.addresses) ||
400
- body.addresses.length === 0) {
401
- return c.json({ error: "key_ring_id, plugin_id, encoding, and a non-empty addresses array are required" }, 400);
402
- }
403
- // Validate the key ring exists
404
- const [ring] = await deps.db.select().from(keyRings).where(eq(keyRings.id, body.key_ring_id));
405
- if (!ring) {
406
- return c.json({ error: `Key ring not found: ${body.key_ring_id}` }, 404);
407
- }
408
- // Look up the plugin encoder for validation
409
- const plugin = deps.registry?.get(body.plugin_id);
410
- const encoder = plugin?.encoders[body.encoding];
411
- // Validate each address against the public key
412
- for (const entry of body.addresses) {
413
- if (typeof entry.index !== "number" || !entry.public_key || !entry.address) {
414
- return c.json({ error: `Invalid entry at index ${entry.index}: index, public_key, and address are required` }, 400);
415
- }
416
- // If we have an encoder, validate the address by re-encoding the public key
417
- if (encoder) {
418
- const pubKeyBytes = hexToBytes(entry.public_key);
419
- const reEncoded = encoder.encode(pubKeyBytes, {});
420
- if (reEncoded !== entry.address) {
421
- return c.json({
422
- error: `Address mismatch at index ${entry.index}: expected ${reEncoded}, got ${entry.address}`,
423
- }, 400);
424
- }
425
- }
426
- }
427
- // Insert validated addresses into the pool
428
- let inserted = 0;
429
- for (const entry of body.addresses) {
430
- const result = (await deps.db
431
- .insert(addressPool)
432
- .values({
433
- keyRingId: body.key_ring_id,
434
- derivationIndex: entry.index,
435
- publicKey: entry.public_key,
436
- address: entry.address,
437
- })
438
- .onConflictDoNothing());
439
- inserted += result.rowCount;
440
- }
441
- // Get total pool size for this key ring
442
- const totalRows = await deps.db.select().from(addressPool).where(eq(addressPool.keyRingId, body.key_ring_id));
443
- return c.json({ inserted, total: totalRows.length }, 201);
444
- });
445
- /** GET /admin/pool/status — pool stats per key ring */
446
- app.get("/admin/pool/status", async (c) => {
447
- // Get all key rings
448
- const rings = await deps.db.select().from(keyRings);
449
- const pools = await Promise.all(rings.map(async (ring) => {
450
- const allEntries = await deps.db.select().from(addressPool).where(eq(addressPool.keyRingId, ring.id));
451
- const available = allEntries.filter((e) => e.assignedTo === null).length;
452
- const assigned = allEntries.length - available;
453
- return {
454
- key_ring_id: ring.id,
455
- total: allEntries.length,
456
- available,
457
- assigned,
458
- };
459
- }));
460
- return c.json({ pools });
461
- });
462
- return app;
463
- }
464
- /** Convert a hex string to Uint8Array. */
465
- function hexToBytes(hex) {
466
- const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
467
- const bytes = new Uint8Array(clean.length / 2);
468
- for (let i = 0; i < bytes.length; i++) {
469
- bytes[i] = Number.parseInt(clean.slice(i * 2, i * 2 + 2), 16);
470
- }
471
- return bytes;
472
- }
@@ -1,83 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { ChainlinkOracle } from "../chainlink.js";
3
- /**
4
- * Encode a mock latestRoundData() response.
5
- * Chainlink returns 5 × 32-byte ABI-encoded words:
6
- * roundId, answer, startedAt, updatedAt, answeredInRound
7
- */
8
- function encodeRoundData(answer, updatedAtSec) {
9
- const pad = (v) => v.toString(16).padStart(64, "0");
10
- return ("0x" +
11
- pad(1n) + // roundId
12
- pad(answer) + // answer (price × 10^8)
13
- pad(BigInt(updatedAtSec)) + // startedAt
14
- pad(BigInt(updatedAtSec)) + // updatedAt
15
- pad(1n) // answeredInRound
16
- );
17
- }
18
- describe("ChainlinkOracle", () => {
19
- const nowSec = Math.floor(Date.now() / 1000);
20
- it("decodes ETH/USD price from latestRoundData", async () => {
21
- // ETH at $3,500.00 → answer = 3500 × 10^8 = 350_000_000_000
22
- const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, nowSec));
23
- const oracle = new ChainlinkOracle({ rpcCall: rpc });
24
- const result = await oracle.getPrice("ETH");
25
- expect(result.priceMicros).toBe(3_500_000_000); // $3,500.00
26
- expect(result.updatedAt).toBeInstanceOf(Date);
27
- expect(rpc).toHaveBeenCalledWith("eth_call", [
28
- { to: "0x71041dddad3595F9CEd3DcCFBe3D1F4b0a16Bb70", data: "0xfeaf968c" },
29
- "latest",
30
- ]);
31
- });
32
- it("decodes BTC/USD price from latestRoundData", async () => {
33
- // BTC at $65,000.00 → answer = 65000 × 10^8 = 6_500_000_000_000
34
- const rpc = vi.fn().mockResolvedValue(encodeRoundData(6500000000000n, nowSec));
35
- const oracle = new ChainlinkOracle({ rpcCall: rpc });
36
- const result = await oracle.getPrice("BTC");
37
- expect(result.priceMicros).toBe(65_000_000_000); // $65,000.00
38
- });
39
- it("handles fractional dollar prices correctly", async () => {
40
- // ETH at $3,456.78 → answer = 345_678_000_000
41
- const rpc = vi.fn().mockResolvedValue(encodeRoundData(345678000000n, nowSec));
42
- const oracle = new ChainlinkOracle({ rpcCall: rpc });
43
- const result = await oracle.getPrice("ETH");
44
- expect(result.priceMicros).toBe(3_456_780_000); // $3,456.78
45
- });
46
- it("rejects stale prices", async () => {
47
- const staleTime = nowSec - 7200; // 2 hours ago
48
- const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, staleTime));
49
- const oracle = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 3600_000 });
50
- await expect(oracle.getPrice("ETH")).rejects.toThrow("stale");
51
- });
52
- it("rejects zero price", async () => {
53
- const rpc = vi.fn().mockResolvedValue(encodeRoundData(0n, nowSec));
54
- const oracle = new ChainlinkOracle({ rpcCall: rpc });
55
- await expect(oracle.getPrice("ETH")).rejects.toThrow("Invalid price");
56
- });
57
- it("rejects malformed response", async () => {
58
- const rpc = vi.fn().mockResolvedValue("0xdead");
59
- const oracle = new ChainlinkOracle({ rpcCall: rpc });
60
- await expect(oracle.getPrice("ETH")).rejects.toThrow("Malformed");
61
- });
62
- it("accepts custom feed addresses", async () => {
63
- const customFeed = "0x1234567890abcdef1234567890abcdef12345678";
64
- const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, nowSec));
65
- const oracle = new ChainlinkOracle({
66
- rpcCall: rpc,
67
- feedAddresses: { ETH: customFeed },
68
- });
69
- await oracle.getPrice("ETH");
70
- expect(rpc).toHaveBeenCalledWith("eth_call", [{ to: customFeed, data: "0xfeaf968c" }, "latest"]);
71
- });
72
- it("respects custom staleness threshold", async () => {
73
- const thirtyMinAgo = nowSec - 1800;
74
- const rpc = vi.fn().mockResolvedValue(encodeRoundData(350000000000n, thirtyMinAgo));
75
- // 20-minute threshold → stale
76
- const strict = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 20 * 60 * 1000 });
77
- await expect(strict.getPrice("ETH")).rejects.toThrow("stale");
78
- // 60-minute threshold → fresh
79
- const relaxed = new ChainlinkOracle({ rpcCall: rpc, maxStalenessMs: 60 * 60 * 1000 });
80
- const result = await relaxed.getPrice("ETH");
81
- expect(result.priceMicros).toBe(3_500_000_000);
82
- });
83
- });
@@ -1,65 +0,0 @@
1
- import { describe, expect, it, vi } from "vitest";
2
- import { CoinGeckoOracle } from "../coingecko.js";
3
- describe("CoinGeckoOracle", () => {
4
- const mockFetch = (price) => vi.fn().mockResolvedValue({
5
- ok: true,
6
- json: () => Promise.resolve({ bitcoin: { usd: price } }),
7
- });
8
- it("returns price in microdollars from CoinGecko API", async () => {
9
- const oracle = new CoinGeckoOracle({ fetchFn: mockFetch(84_532.17) });
10
- const result = await oracle.getPrice("BTC");
11
- expect(result.priceMicros).toBe(84_532_170_000);
12
- expect(result.updatedAt).toBeInstanceOf(Date);
13
- });
14
- it("caches prices within TTL", async () => {
15
- const fn = mockFetch(84_532.17);
16
- const oracle = new CoinGeckoOracle({ fetchFn: fn, cacheTtlMs: 60_000 });
17
- await oracle.getPrice("BTC");
18
- await oracle.getPrice("BTC");
19
- expect(fn).toHaveBeenCalledTimes(1);
20
- });
21
- it("re-fetches after cache expires", async () => {
22
- const fn = mockFetch(84_532.17);
23
- const oracle = new CoinGeckoOracle({ fetchFn: fn, cacheTtlMs: 0 });
24
- await oracle.getPrice("BTC");
25
- await oracle.getPrice("BTC");
26
- expect(fn).toHaveBeenCalledTimes(2);
27
- });
28
- it("throws for unknown asset", async () => {
29
- const oracle = new CoinGeckoOracle({ fetchFn: mockFetch(100) });
30
- await expect(oracle.getPrice("UNKNOWN")).rejects.toThrow("No price oracle supports asset: UNKNOWN");
31
- });
32
- it("throws on API error", async () => {
33
- const fn = vi.fn().mockResolvedValue({ ok: false, status: 429, statusText: "Too Many Requests" });
34
- const oracle = new CoinGeckoOracle({ fetchFn: fn });
35
- await expect(oracle.getPrice("BTC")).rejects.toThrow("CoinGecko API error");
36
- });
37
- it("throws on zero price", async () => {
38
- const fn = vi.fn().mockResolvedValue({
39
- ok: true,
40
- json: () => Promise.resolve({ bitcoin: { usd: 0 } }),
41
- });
42
- const oracle = new CoinGeckoOracle({ fetchFn: fn });
43
- await expect(oracle.getPrice("BTC")).rejects.toThrow("Invalid CoinGecko price");
44
- });
45
- it("resolves DOGE via coingecko ID mapping", async () => {
46
- const fn = vi.fn().mockResolvedValue({
47
- ok: true,
48
- json: () => Promise.resolve({ dogecoin: { usd: 0.1742 } }),
49
- });
50
- const oracle = new CoinGeckoOracle({ fetchFn: fn });
51
- const result = await oracle.getPrice("DOGE");
52
- expect(result.priceMicros).toBe(174_200);
53
- expect(fn).toHaveBeenCalledWith(expect.stringContaining("ids=dogecoin"));
54
- });
55
- it("resolves LTC via coingecko ID mapping", async () => {
56
- const fn = vi.fn().mockResolvedValue({
57
- ok: true,
58
- json: () => Promise.resolve({ litecoin: { usd: 92.45 } }),
59
- });
60
- const oracle = new CoinGeckoOracle({ fetchFn: fn });
61
- const result = await oracle.getPrice("LTC");
62
- expect(result.priceMicros).toBe(92_450_000);
63
- expect(fn).toHaveBeenCalledWith(expect.stringContaining("ids=litecoin"));
64
- });
65
- });