@wopr-network/platform-core 1.66.0 → 1.67.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.
@@ -9,10 +9,10 @@
9
9
  */
10
10
 
11
11
  import { HDKey } from "@scure/bip32";
12
- import { eq, sql } from "drizzle-orm";
12
+ import { and, eq, isNull, sql } from "drizzle-orm";
13
13
  import { Hono } from "hono";
14
14
  import type { DrizzleDb } from "../../db/index.js";
15
- import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
15
+ import { addressPool, derivedAddresses, keyRings, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
16
16
  import type { EncodingParams } from "./address-gen.js";
17
17
  import { deriveAddress } from "./address-gen.js";
18
18
  import type { ICryptoChargeRepository } from "./charge-store.js";
@@ -35,12 +35,58 @@ export interface KeyServerDeps {
35
35
  registry?: PluginRegistry;
36
36
  }
37
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
+
38
81
  /**
39
82
  * Derive the next unused address for a chain.
40
- * Atomically increments next_index and records address in a single transaction.
41
83
  *
42
- * EVM chains share an xpub (coin type 60), so ETH index 0 = USDC index 0 = same
43
- * address. The unique constraint on derived_addresses.address prevents reuse.
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.
44
90
  * On collision, we skip the index and retry (up to maxRetries).
45
91
  */
46
92
  async function deriveNextAddress(
@@ -52,6 +98,21 @@ async function deriveNextAddress(
52
98
  const maxRetries = 10;
53
99
  const dbWithTx = db as unknown as { transaction: (fn: (tx: DrizzleDb) => Promise<unknown>) => Promise<unknown> };
54
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
+
55
116
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
56
117
  // Step 1: Atomically claim the next index OUTSIDE the transaction.
57
118
  // This survives even if the transaction below rolls back on address collision.
@@ -440,5 +501,117 @@ export function createKeyServerApp(deps: KeyServerDeps): Hono {
440
501
  return c.body(null, 204);
441
502
  });
442
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
+
443
606
  return app;
444
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
+ }