@wopr-network/platform-core 1.66.1 → 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.
@@ -321,6 +321,385 @@ describe("key-server routes", () => {
321
321
  expect(deps.methodStore.setEnabled).toHaveBeenCalledWith("doge", false);
322
322
  });
323
323
  });
324
+ describe("key-server pool endpoints", () => {
325
+ /** Create a mock db that supports pool queries. */
326
+ function createPoolMockDb(opts) {
327
+ const keyRing = opts?.keyRing ?? null;
328
+ const poolEntries = opts?.poolEntries ?? [];
329
+ const allKeyRings = opts?.allKeyRings ?? (keyRing ? [keyRing] : []);
330
+ const db = {};
331
+ // Track which table is being queried via from()
332
+ db.select = vi.fn().mockReturnValue({
333
+ from: vi.fn().mockImplementation((table) => {
334
+ const tableName = table[Symbol.for("drizzle:Name")];
335
+ if (tableName === "key_rings") {
336
+ return {
337
+ where: vi.fn().mockResolvedValue(keyRing ? [keyRing] : []),
338
+ orderBy: vi.fn().mockResolvedValue(allKeyRings),
339
+ };
340
+ }
341
+ if (tableName === "address_pool") {
342
+ return {
343
+ where: vi.fn().mockImplementation(() => ({
344
+ orderBy: vi.fn().mockImplementation(() => ({
345
+ limit: vi.fn().mockResolvedValue(poolEntries.filter((e) => e.assignedTo === null).slice(0, 1)),
346
+ })),
347
+ // For counting all pool entries for a key ring
348
+ length: poolEntries.length,
349
+ filter: (fn) => poolEntries.filter(fn),
350
+ [Symbol.iterator]: function* () {
351
+ yield* poolEntries;
352
+ },
353
+ })),
354
+ };
355
+ }
356
+ // Default: payment_methods
357
+ return {
358
+ where: vi.fn().mockResolvedValue([]),
359
+ };
360
+ }),
361
+ });
362
+ db.insert = vi.fn().mockReturnValue({
363
+ values: vi.fn().mockReturnValue({
364
+ onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }),
365
+ }),
366
+ });
367
+ db.update = vi.fn().mockReturnValue({
368
+ set: vi.fn().mockReturnValue({
369
+ where: vi.fn().mockReturnValue({
370
+ returning: vi.fn().mockResolvedValue([]),
371
+ }),
372
+ }),
373
+ });
374
+ db.transaction = vi.fn().mockImplementation(async (fn) => fn(db));
375
+ return db;
376
+ }
377
+ it("POST /admin/pool/replenish validates required fields", async () => {
378
+ const deps = mockDeps();
379
+ deps.adminToken = "test-admin";
380
+ const app = createKeyServerApp(deps);
381
+ const res = await app.request("/admin/pool/replenish", {
382
+ method: "POST",
383
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
384
+ body: JSON.stringify({}),
385
+ });
386
+ expect(res.status).toBe(400);
387
+ });
388
+ it("POST /admin/pool/replenish returns 404 for unknown key ring", async () => {
389
+ const db = createPoolMockDb({ keyRing: null });
390
+ const deps = mockDeps();
391
+ deps.adminToken = "test-admin";
392
+ deps.db = db;
393
+ const app = createKeyServerApp(deps);
394
+ const res = await app.request("/admin/pool/replenish", {
395
+ method: "POST",
396
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
397
+ body: JSON.stringify({
398
+ key_ring_id: "sol-main",
399
+ plugin_id: "solana",
400
+ encoding: "base58-solana",
401
+ addresses: [{ index: 0, public_key: "abcd", address: "SolAddr1" }],
402
+ }),
403
+ });
404
+ expect(res.status).toBe(404);
405
+ });
406
+ it("POST /admin/pool/replenish inserts validated addresses", async () => {
407
+ const db = createPoolMockDb({
408
+ keyRing: { id: "sol-main", derivationMode: "pre-derived" },
409
+ poolEntries: [],
410
+ });
411
+ // Override select().from() to handle both keyRings query and the count query
412
+ const selectMock = vi.fn().mockReturnValue({
413
+ from: vi.fn().mockImplementation((table) => {
414
+ const tableName = table[Symbol.for("drizzle:Name")];
415
+ if (tableName === "key_rings") {
416
+ return {
417
+ where: vi.fn().mockResolvedValue([{ id: "sol-main", derivationMode: "pre-derived" }]),
418
+ };
419
+ }
420
+ // address_pool count query (after insert)
421
+ return {
422
+ where: vi.fn().mockResolvedValue([
423
+ {
424
+ id: 1,
425
+ keyRingId: "sol-main",
426
+ derivationIndex: 0,
427
+ publicKey: "ab",
428
+ address: "SolAddr0",
429
+ assignedTo: null,
430
+ },
431
+ {
432
+ id: 2,
433
+ keyRingId: "sol-main",
434
+ derivationIndex: 1,
435
+ publicKey: "cd",
436
+ address: "SolAddr1",
437
+ assignedTo: null,
438
+ },
439
+ ]),
440
+ };
441
+ }),
442
+ });
443
+ db.select = selectMock;
444
+ const deps = mockDeps();
445
+ deps.adminToken = "test-admin";
446
+ deps.db = db;
447
+ // No registry — skip re-encoding validation
448
+ deps.registry = undefined;
449
+ const app = createKeyServerApp(deps);
450
+ const res = await app.request("/admin/pool/replenish", {
451
+ method: "POST",
452
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
453
+ body: JSON.stringify({
454
+ key_ring_id: "sol-main",
455
+ plugin_id: "solana",
456
+ encoding: "base58-solana",
457
+ addresses: [
458
+ { index: 0, public_key: "ab", address: "SolAddr0" },
459
+ { index: 1, public_key: "cd", address: "SolAddr1" },
460
+ ],
461
+ }),
462
+ });
463
+ expect(res.status).toBe(201);
464
+ const body = await res.json();
465
+ expect(body.inserted).toBe(2);
466
+ expect(body.total).toBe(2);
467
+ });
468
+ it("POST /admin/pool/replenish rejects mismatched address when encoder present", async () => {
469
+ const mockEncoder = {
470
+ encode: vi.fn().mockReturnValue("CorrectAddress"),
471
+ encodingType: vi.fn().mockReturnValue("base58-solana"),
472
+ };
473
+ const mockPlugin = {
474
+ pluginId: "solana",
475
+ supportedCurve: "ed25519",
476
+ encoders: { "base58-solana": mockEncoder },
477
+ createWatcher: vi.fn(),
478
+ createSweeper: vi.fn(),
479
+ version: 1,
480
+ };
481
+ const mockRegistry = {
482
+ get: vi.fn().mockReturnValue(mockPlugin),
483
+ getOrThrow: vi.fn(),
484
+ list: vi.fn(),
485
+ register: vi.fn(),
486
+ };
487
+ const db = createPoolMockDb({
488
+ keyRing: { id: "sol-main", derivationMode: "pre-derived" },
489
+ });
490
+ const deps = mockDeps();
491
+ deps.adminToken = "test-admin";
492
+ deps.db = db;
493
+ deps.registry = mockRegistry;
494
+ const app = createKeyServerApp(deps);
495
+ const res = await app.request("/admin/pool/replenish", {
496
+ method: "POST",
497
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
498
+ body: JSON.stringify({
499
+ key_ring_id: "sol-main",
500
+ plugin_id: "solana",
501
+ encoding: "base58-solana",
502
+ addresses: [{ index: 0, public_key: "abcd1234", address: "WrongAddress" }],
503
+ }),
504
+ });
505
+ expect(res.status).toBe(400);
506
+ const body = await res.json();
507
+ expect(body.error).toContain("Address mismatch");
508
+ });
509
+ it("GET /admin/pool/status returns pool stats", async () => {
510
+ const db = createPoolMockDb({
511
+ allKeyRings: [{ id: "sol-main" }],
512
+ poolEntries: [
513
+ { id: 1, keyRingId: "sol-main", derivationIndex: 0, publicKey: "a", address: "A", assignedTo: null },
514
+ { id: 2, keyRingId: "sol-main", derivationIndex: 1, publicKey: "b", address: "B", assignedTo: "tenant:1" },
515
+ { id: 3, keyRingId: "sol-main", derivationIndex: 2, publicKey: "c", address: "C", assignedTo: null },
516
+ ],
517
+ });
518
+ // Override select to handle the two different query patterns in pool/status
519
+ let selectCallCount = 0;
520
+ const poolEntries = [
521
+ { id: 1, keyRingId: "sol-main", derivationIndex: 0, publicKey: "a", address: "A", assignedTo: null },
522
+ { id: 2, keyRingId: "sol-main", derivationIndex: 1, publicKey: "b", address: "B", assignedTo: "tenant:1" },
523
+ { id: 3, keyRingId: "sol-main", derivationIndex: 2, publicKey: "c", address: "C", assignedTo: null },
524
+ ];
525
+ db.select = vi.fn().mockReturnValue({
526
+ from: vi.fn().mockImplementation(() => {
527
+ selectCallCount++;
528
+ if (selectCallCount === 1) {
529
+ // First call: select from keyRings (no where clause)
530
+ return [{ id: "sol-main" }];
531
+ }
532
+ // Second call: select from addressPool where keyRingId = ring.id
533
+ return {
534
+ where: vi.fn().mockResolvedValue(poolEntries),
535
+ };
536
+ }),
537
+ });
538
+ const deps = mockDeps();
539
+ deps.adminToken = "test-admin";
540
+ deps.db = db;
541
+ const app = createKeyServerApp(deps);
542
+ const res = await app.request("/admin/pool/status", {
543
+ headers: { Authorization: "Bearer test-admin" },
544
+ });
545
+ expect(res.status).toBe(200);
546
+ const body = await res.json();
547
+ expect(body.pools).toHaveLength(1);
548
+ expect(body.pools[0].key_ring_id).toBe("sol-main");
549
+ expect(body.pools[0].total).toBe(3);
550
+ expect(body.pools[0].available).toBe(2);
551
+ expect(body.pools[0].assigned).toBe(1);
552
+ });
553
+ it("POST /address uses pool for pre-derived key ring", async () => {
554
+ const poolEntry = {
555
+ id: 1,
556
+ keyRingId: "sol-main",
557
+ derivationIndex: 7,
558
+ publicKey: "deadbeef",
559
+ address: "SolanaAddr7",
560
+ assignedTo: null,
561
+ createdAt: "2026-01-01",
562
+ };
563
+ const solMethod = {
564
+ id: "sol",
565
+ type: "native",
566
+ token: "SOL",
567
+ chain: "solana",
568
+ xpub: null,
569
+ keyRingId: "sol-main",
570
+ nextIndex: 0,
571
+ decimals: 9,
572
+ addressType: "base58-solana",
573
+ encodingParams: "{}",
574
+ watcherType: "solana",
575
+ oracleAssetId: "solana",
576
+ confirmations: 1,
577
+ pluginId: "solana",
578
+ encoding: "base58-solana",
579
+ };
580
+ const keyRing = {
581
+ id: "sol-main",
582
+ curve: "ed25519",
583
+ derivationScheme: "slip10",
584
+ derivationMode: "pre-derived",
585
+ keyMaterial: "{}",
586
+ coinType: 501,
587
+ accountIndex: 0,
588
+ };
589
+ // Build a mock that handles the pool flow:
590
+ // 1. select from paymentMethods where id=sol -> solMethod
591
+ // 2. select from keyRings where id=sol-main -> keyRing
592
+ // 3. transaction: select from addressPool -> poolEntry, update addressPool, insert derivedAddresses
593
+ let selectCallCount = 0;
594
+ const db = {
595
+ select: vi.fn().mockImplementation(() => ({
596
+ from: vi.fn().mockImplementation(() => {
597
+ selectCallCount++;
598
+ if (selectCallCount === 1) {
599
+ // paymentMethods lookup
600
+ return { where: vi.fn().mockResolvedValue([solMethod]) };
601
+ }
602
+ if (selectCallCount === 2) {
603
+ // keyRings lookup
604
+ return { where: vi.fn().mockResolvedValue([keyRing]) };
605
+ }
606
+ // addressPool query inside transaction
607
+ return {
608
+ where: vi.fn().mockReturnValue({
609
+ orderBy: vi.fn().mockReturnValue({
610
+ limit: vi.fn().mockResolvedValue([poolEntry]),
611
+ }),
612
+ }),
613
+ };
614
+ }),
615
+ })),
616
+ update: vi.fn().mockReturnValue({
617
+ set: vi.fn().mockReturnValue({
618
+ where: vi.fn().mockResolvedValue(undefined),
619
+ }),
620
+ }),
621
+ insert: vi.fn().mockReturnValue({
622
+ values: vi.fn().mockResolvedValue(undefined),
623
+ }),
624
+ transaction: vi.fn().mockImplementation(async (fn) => fn(db)),
625
+ };
626
+ const deps = mockDeps();
627
+ deps.db = db;
628
+ const app = createKeyServerApp(deps);
629
+ const res = await app.request("/address", {
630
+ method: "POST",
631
+ headers: { "Content-Type": "application/json" },
632
+ body: JSON.stringify({ chain: "sol" }),
633
+ });
634
+ expect(res.status).toBe(201);
635
+ const body = await res.json();
636
+ expect(body.address).toBe("SolanaAddr7");
637
+ expect(body.index).toBe(7);
638
+ expect(body.chain).toBe("solana");
639
+ expect(body.token).toBe("SOL");
640
+ });
641
+ it("POST /address throws when pool is empty", async () => {
642
+ const solMethod = {
643
+ id: "sol",
644
+ type: "native",
645
+ token: "SOL",
646
+ chain: "solana",
647
+ xpub: null,
648
+ keyRingId: "sol-main",
649
+ nextIndex: 0,
650
+ decimals: 9,
651
+ addressType: "base58-solana",
652
+ encodingParams: "{}",
653
+ watcherType: "solana",
654
+ oracleAssetId: "solana",
655
+ confirmations: 1,
656
+ pluginId: "solana",
657
+ encoding: "base58-solana",
658
+ };
659
+ const keyRing = {
660
+ id: "sol-main",
661
+ curve: "ed25519",
662
+ derivationScheme: "slip10",
663
+ derivationMode: "pre-derived",
664
+ keyMaterial: "{}",
665
+ coinType: 501,
666
+ accountIndex: 0,
667
+ };
668
+ let selectCallCount = 0;
669
+ const db = {
670
+ select: vi.fn().mockImplementation(() => ({
671
+ from: vi.fn().mockImplementation(() => {
672
+ selectCallCount++;
673
+ if (selectCallCount === 1) {
674
+ return { where: vi.fn().mockResolvedValue([solMethod]) };
675
+ }
676
+ if (selectCallCount === 2) {
677
+ return { where: vi.fn().mockResolvedValue([keyRing]) };
678
+ }
679
+ // Empty pool
680
+ return {
681
+ where: vi.fn().mockReturnValue({
682
+ orderBy: vi.fn().mockReturnValue({
683
+ limit: vi.fn().mockResolvedValue([]),
684
+ }),
685
+ }),
686
+ };
687
+ }),
688
+ })),
689
+ transaction: vi.fn().mockImplementation(async (fn) => fn(db)),
690
+ };
691
+ const deps = mockDeps();
692
+ deps.db = db;
693
+ const app = createKeyServerApp(deps);
694
+ const res = await app.request("/address", {
695
+ method: "POST",
696
+ headers: { "Content-Type": "application/json" },
697
+ body: JSON.stringify({ chain: "sol" }),
698
+ });
699
+ // Hono catches the thrown error and returns 500
700
+ expect(res.status).toBe(500);
701
+ });
702
+ });
324
703
  describe("key-server auth", () => {
325
704
  it("rejects unauthenticated request when serviceKey is set", async () => {
326
705
  const deps = mockDeps();
@@ -8,23 +8,69 @@
8
8
  * ~200 lines of new code wrapping platform-core's existing crypto modules.
9
9
  */
10
10
  import { HDKey } from "@scure/bip32";
11
- import { eq, sql } from "drizzle-orm";
11
+ import { and, eq, isNull, sql } from "drizzle-orm";
12
12
  import { Hono } from "hono";
13
- import { derivedAddresses, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
13
+ import { addressPool, derivedAddresses, keyRings, pathAllocations, paymentMethods } from "../../db/schema/crypto.js";
14
14
  import { deriveAddress } from "./address-gen.js";
15
15
  import { centsToNative } from "./oracle/convert.js";
16
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
+ }
17
48
  /**
18
49
  * Derive the next unused address for a chain.
19
- * Atomically increments next_index and records address in a single transaction.
20
50
  *
21
- * EVM chains share an xpub (coin type 60), so ETH index 0 = USDC index 0 = same
22
- * address. The unique constraint on derived_addresses.address prevents reuse.
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.
23
57
  * On collision, we skip the index and retry (up to maxRetries).
24
58
  */
25
59
  async function deriveNextAddress(db, chainId, tenantId, registry) {
26
60
  const maxRetries = 10;
27
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
+ }
28
74
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
29
75
  // Step 1: Atomically claim the next index OUTSIDE the transaction.
30
76
  // This survives even if the transaction below rolls back on address collision.
@@ -344,5 +390,83 @@ export function createKeyServerApp(deps) {
344
390
  await deps.methodStore.setEnabled(c.req.param("id"), false);
345
391
  return c.body(null, 204);
346
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
+ });
347
462
  return app;
348
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.66.1",
3
+ "version": "1.67.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -3,6 +3,7 @@ import type { ICryptoChargeRepository } from "../charge-store.js";
3
3
  import type { KeyServerDeps } from "../key-server.js";
4
4
  import { createKeyServerApp } from "../key-server.js";
5
5
  import type { IPaymentMethodStore } from "../payment-method-store.js";
6
+ import type { PluginRegistry } from "../plugin/registry.js";
6
7
 
7
8
  /** Create a mock db that supports transaction() by passing itself to the callback. */
8
9
  function createMockDb() {
@@ -350,6 +351,433 @@ describe("key-server routes", () => {
350
351
  });
351
352
  });
352
353
 
354
+ describe("key-server pool endpoints", () => {
355
+ /** Create a mock db that supports pool queries. */
356
+ function createPoolMockDb(opts?: {
357
+ keyRing?: { id: string; derivationMode: string } | null;
358
+ poolEntries?: Array<{
359
+ id: number;
360
+ keyRingId: string;
361
+ derivationIndex: number;
362
+ publicKey: string;
363
+ address: string;
364
+ assignedTo: string | null;
365
+ }>;
366
+ allKeyRings?: Array<{ id: string }>;
367
+ }) {
368
+ const keyRing = opts?.keyRing ?? null;
369
+ const poolEntries = opts?.poolEntries ?? [];
370
+ const allKeyRings = opts?.allKeyRings ?? (keyRing ? [keyRing] : []);
371
+
372
+ const db: Record<string, unknown> = {};
373
+
374
+ // Track which table is being queried via from()
375
+ db.select = vi.fn().mockReturnValue({
376
+ from: vi.fn().mockImplementation((table: unknown) => {
377
+ const tableName = (table as Record<symbol, string>)[Symbol.for("drizzle:Name")];
378
+ if (tableName === "key_rings") {
379
+ return {
380
+ where: vi.fn().mockResolvedValue(keyRing ? [keyRing] : []),
381
+ orderBy: vi.fn().mockResolvedValue(allKeyRings),
382
+ };
383
+ }
384
+ if (tableName === "address_pool") {
385
+ return {
386
+ where: vi.fn().mockImplementation(() => ({
387
+ orderBy: vi.fn().mockImplementation(() => ({
388
+ limit: vi.fn().mockResolvedValue(poolEntries.filter((e) => e.assignedTo === null).slice(0, 1)),
389
+ })),
390
+ // For counting all pool entries for a key ring
391
+ length: poolEntries.length,
392
+ filter: (fn: (e: unknown) => boolean) => poolEntries.filter(fn),
393
+ [Symbol.iterator]: function* () {
394
+ yield* poolEntries;
395
+ },
396
+ })),
397
+ };
398
+ }
399
+ // Default: payment_methods
400
+ return {
401
+ where: vi.fn().mockResolvedValue([]),
402
+ };
403
+ }),
404
+ });
405
+
406
+ db.insert = vi.fn().mockReturnValue({
407
+ values: vi.fn().mockReturnValue({
408
+ onConflictDoNothing: vi.fn().mockResolvedValue({ rowCount: 1 }),
409
+ }),
410
+ });
411
+
412
+ db.update = vi.fn().mockReturnValue({
413
+ set: vi.fn().mockReturnValue({
414
+ where: vi.fn().mockReturnValue({
415
+ returning: vi.fn().mockResolvedValue([]),
416
+ }),
417
+ }),
418
+ });
419
+
420
+ db.transaction = vi.fn().mockImplementation(async (fn: (tx: unknown) => unknown) => fn(db));
421
+
422
+ return db;
423
+ }
424
+
425
+ it("POST /admin/pool/replenish validates required fields", async () => {
426
+ const deps = mockDeps();
427
+ deps.adminToken = "test-admin";
428
+ const app = createKeyServerApp(deps);
429
+ const res = await app.request("/admin/pool/replenish", {
430
+ method: "POST",
431
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
432
+ body: JSON.stringify({}),
433
+ });
434
+ expect(res.status).toBe(400);
435
+ });
436
+
437
+ it("POST /admin/pool/replenish returns 404 for unknown key ring", async () => {
438
+ const db = createPoolMockDb({ keyRing: null });
439
+ const deps = mockDeps();
440
+ deps.adminToken = "test-admin";
441
+ (deps as unknown as { db: unknown }).db = db;
442
+ const app = createKeyServerApp(deps);
443
+ const res = await app.request("/admin/pool/replenish", {
444
+ method: "POST",
445
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
446
+ body: JSON.stringify({
447
+ key_ring_id: "sol-main",
448
+ plugin_id: "solana",
449
+ encoding: "base58-solana",
450
+ addresses: [{ index: 0, public_key: "abcd", address: "SolAddr1" }],
451
+ }),
452
+ });
453
+ expect(res.status).toBe(404);
454
+ });
455
+
456
+ it("POST /admin/pool/replenish inserts validated addresses", async () => {
457
+ const db = createPoolMockDb({
458
+ keyRing: { id: "sol-main", derivationMode: "pre-derived" },
459
+ poolEntries: [],
460
+ });
461
+
462
+ // Override select().from() to handle both keyRings query and the count query
463
+ const selectMock = vi.fn().mockReturnValue({
464
+ from: vi.fn().mockImplementation((table: unknown) => {
465
+ const tableName = (table as Record<symbol, string>)[Symbol.for("drizzle:Name")];
466
+ if (tableName === "key_rings") {
467
+ return {
468
+ where: vi.fn().mockResolvedValue([{ id: "sol-main", derivationMode: "pre-derived" }]),
469
+ };
470
+ }
471
+ // address_pool count query (after insert)
472
+ return {
473
+ where: vi.fn().mockResolvedValue([
474
+ {
475
+ id: 1,
476
+ keyRingId: "sol-main",
477
+ derivationIndex: 0,
478
+ publicKey: "ab",
479
+ address: "SolAddr0",
480
+ assignedTo: null,
481
+ },
482
+ {
483
+ id: 2,
484
+ keyRingId: "sol-main",
485
+ derivationIndex: 1,
486
+ publicKey: "cd",
487
+ address: "SolAddr1",
488
+ assignedTo: null,
489
+ },
490
+ ]),
491
+ };
492
+ }),
493
+ });
494
+ (db as Record<string, unknown>).select = selectMock;
495
+
496
+ const deps = mockDeps();
497
+ deps.adminToken = "test-admin";
498
+ (deps as unknown as { db: unknown }).db = db;
499
+ // No registry — skip re-encoding validation
500
+ deps.registry = undefined;
501
+
502
+ const app = createKeyServerApp(deps);
503
+ const res = await app.request("/admin/pool/replenish", {
504
+ method: "POST",
505
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
506
+ body: JSON.stringify({
507
+ key_ring_id: "sol-main",
508
+ plugin_id: "solana",
509
+ encoding: "base58-solana",
510
+ addresses: [
511
+ { index: 0, public_key: "ab", address: "SolAddr0" },
512
+ { index: 1, public_key: "cd", address: "SolAddr1" },
513
+ ],
514
+ }),
515
+ });
516
+
517
+ expect(res.status).toBe(201);
518
+ const body = await res.json();
519
+ expect(body.inserted).toBe(2);
520
+ expect(body.total).toBe(2);
521
+ });
522
+
523
+ it("POST /admin/pool/replenish rejects mismatched address when encoder present", async () => {
524
+ const mockEncoder = {
525
+ encode: vi.fn().mockReturnValue("CorrectAddress"),
526
+ encodingType: vi.fn().mockReturnValue("base58-solana"),
527
+ };
528
+ const mockPlugin = {
529
+ pluginId: "solana",
530
+ supportedCurve: "ed25519" as const,
531
+ encoders: { "base58-solana": mockEncoder },
532
+ createWatcher: vi.fn(),
533
+ createSweeper: vi.fn(),
534
+ version: 1,
535
+ };
536
+ const mockRegistry = {
537
+ get: vi.fn().mockReturnValue(mockPlugin),
538
+ getOrThrow: vi.fn(),
539
+ list: vi.fn(),
540
+ register: vi.fn(),
541
+ };
542
+
543
+ const db = createPoolMockDb({
544
+ keyRing: { id: "sol-main", derivationMode: "pre-derived" },
545
+ });
546
+
547
+ const deps = mockDeps();
548
+ deps.adminToken = "test-admin";
549
+ (deps as unknown as { db: unknown }).db = db;
550
+ deps.registry = mockRegistry as unknown as PluginRegistry;
551
+
552
+ const app = createKeyServerApp(deps);
553
+ const res = await app.request("/admin/pool/replenish", {
554
+ method: "POST",
555
+ headers: { "Content-Type": "application/json", Authorization: "Bearer test-admin" },
556
+ body: JSON.stringify({
557
+ key_ring_id: "sol-main",
558
+ plugin_id: "solana",
559
+ encoding: "base58-solana",
560
+ addresses: [{ index: 0, public_key: "abcd1234", address: "WrongAddress" }],
561
+ }),
562
+ });
563
+
564
+ expect(res.status).toBe(400);
565
+ const body = await res.json();
566
+ expect(body.error).toContain("Address mismatch");
567
+ });
568
+
569
+ it("GET /admin/pool/status returns pool stats", async () => {
570
+ const db = createPoolMockDb({
571
+ allKeyRings: [{ id: "sol-main" }],
572
+ poolEntries: [
573
+ { id: 1, keyRingId: "sol-main", derivationIndex: 0, publicKey: "a", address: "A", assignedTo: null },
574
+ { id: 2, keyRingId: "sol-main", derivationIndex: 1, publicKey: "b", address: "B", assignedTo: "tenant:1" },
575
+ { id: 3, keyRingId: "sol-main", derivationIndex: 2, publicKey: "c", address: "C", assignedTo: null },
576
+ ],
577
+ });
578
+
579
+ // Override select to handle the two different query patterns in pool/status
580
+ let selectCallCount = 0;
581
+ const poolEntries = [
582
+ { id: 1, keyRingId: "sol-main", derivationIndex: 0, publicKey: "a", address: "A", assignedTo: null },
583
+ { id: 2, keyRingId: "sol-main", derivationIndex: 1, publicKey: "b", address: "B", assignedTo: "tenant:1" },
584
+ { id: 3, keyRingId: "sol-main", derivationIndex: 2, publicKey: "c", address: "C", assignedTo: null },
585
+ ];
586
+ (db as Record<string, unknown>).select = vi.fn().mockReturnValue({
587
+ from: vi.fn().mockImplementation(() => {
588
+ selectCallCount++;
589
+ if (selectCallCount === 1) {
590
+ // First call: select from keyRings (no where clause)
591
+ return [{ id: "sol-main" }];
592
+ }
593
+ // Second call: select from addressPool where keyRingId = ring.id
594
+ return {
595
+ where: vi.fn().mockResolvedValue(poolEntries),
596
+ };
597
+ }),
598
+ });
599
+
600
+ const deps = mockDeps();
601
+ deps.adminToken = "test-admin";
602
+ (deps as unknown as { db: unknown }).db = db;
603
+ const app = createKeyServerApp(deps);
604
+
605
+ const res = await app.request("/admin/pool/status", {
606
+ headers: { Authorization: "Bearer test-admin" },
607
+ });
608
+
609
+ expect(res.status).toBe(200);
610
+ const body = await res.json();
611
+ expect(body.pools).toHaveLength(1);
612
+ expect(body.pools[0].key_ring_id).toBe("sol-main");
613
+ expect(body.pools[0].total).toBe(3);
614
+ expect(body.pools[0].available).toBe(2);
615
+ expect(body.pools[0].assigned).toBe(1);
616
+ });
617
+
618
+ it("POST /address uses pool for pre-derived key ring", async () => {
619
+ const poolEntry = {
620
+ id: 1,
621
+ keyRingId: "sol-main",
622
+ derivationIndex: 7,
623
+ publicKey: "deadbeef",
624
+ address: "SolanaAddr7",
625
+ assignedTo: null,
626
+ createdAt: "2026-01-01",
627
+ };
628
+
629
+ const solMethod = {
630
+ id: "sol",
631
+ type: "native",
632
+ token: "SOL",
633
+ chain: "solana",
634
+ xpub: null,
635
+ keyRingId: "sol-main",
636
+ nextIndex: 0,
637
+ decimals: 9,
638
+ addressType: "base58-solana",
639
+ encodingParams: "{}",
640
+ watcherType: "solana",
641
+ oracleAssetId: "solana",
642
+ confirmations: 1,
643
+ pluginId: "solana",
644
+ encoding: "base58-solana",
645
+ };
646
+
647
+ const keyRing = {
648
+ id: "sol-main",
649
+ curve: "ed25519",
650
+ derivationScheme: "slip10",
651
+ derivationMode: "pre-derived",
652
+ keyMaterial: "{}",
653
+ coinType: 501,
654
+ accountIndex: 0,
655
+ };
656
+
657
+ // Build a mock that handles the pool flow:
658
+ // 1. select from paymentMethods where id=sol -> solMethod
659
+ // 2. select from keyRings where id=sol-main -> keyRing
660
+ // 3. transaction: select from addressPool -> poolEntry, update addressPool, insert derivedAddresses
661
+ let selectCallCount = 0;
662
+ const db = {
663
+ select: vi.fn().mockImplementation(() => ({
664
+ from: vi.fn().mockImplementation(() => {
665
+ selectCallCount++;
666
+ if (selectCallCount === 1) {
667
+ // paymentMethods lookup
668
+ return { where: vi.fn().mockResolvedValue([solMethod]) };
669
+ }
670
+ if (selectCallCount === 2) {
671
+ // keyRings lookup
672
+ return { where: vi.fn().mockResolvedValue([keyRing]) };
673
+ }
674
+ // addressPool query inside transaction
675
+ return {
676
+ where: vi.fn().mockReturnValue({
677
+ orderBy: vi.fn().mockReturnValue({
678
+ limit: vi.fn().mockResolvedValue([poolEntry]),
679
+ }),
680
+ }),
681
+ };
682
+ }),
683
+ })),
684
+ update: vi.fn().mockReturnValue({
685
+ set: vi.fn().mockReturnValue({
686
+ where: vi.fn().mockResolvedValue(undefined),
687
+ }),
688
+ }),
689
+ insert: vi.fn().mockReturnValue({
690
+ values: vi.fn().mockResolvedValue(undefined),
691
+ }),
692
+ transaction: vi.fn().mockImplementation(async (fn: (tx: unknown) => unknown) => fn(db)),
693
+ };
694
+
695
+ const deps = mockDeps();
696
+ (deps as unknown as { db: unknown }).db = db;
697
+ const app = createKeyServerApp(deps);
698
+
699
+ const res = await app.request("/address", {
700
+ method: "POST",
701
+ headers: { "Content-Type": "application/json" },
702
+ body: JSON.stringify({ chain: "sol" }),
703
+ });
704
+
705
+ expect(res.status).toBe(201);
706
+ const body = await res.json();
707
+ expect(body.address).toBe("SolanaAddr7");
708
+ expect(body.index).toBe(7);
709
+ expect(body.chain).toBe("solana");
710
+ expect(body.token).toBe("SOL");
711
+ });
712
+
713
+ it("POST /address throws when pool is empty", async () => {
714
+ const solMethod = {
715
+ id: "sol",
716
+ type: "native",
717
+ token: "SOL",
718
+ chain: "solana",
719
+ xpub: null,
720
+ keyRingId: "sol-main",
721
+ nextIndex: 0,
722
+ decimals: 9,
723
+ addressType: "base58-solana",
724
+ encodingParams: "{}",
725
+ watcherType: "solana",
726
+ oracleAssetId: "solana",
727
+ confirmations: 1,
728
+ pluginId: "solana",
729
+ encoding: "base58-solana",
730
+ };
731
+
732
+ const keyRing = {
733
+ id: "sol-main",
734
+ curve: "ed25519",
735
+ derivationScheme: "slip10",
736
+ derivationMode: "pre-derived",
737
+ keyMaterial: "{}",
738
+ coinType: 501,
739
+ accountIndex: 0,
740
+ };
741
+
742
+ let selectCallCount = 0;
743
+ const db = {
744
+ select: vi.fn().mockImplementation(() => ({
745
+ from: vi.fn().mockImplementation(() => {
746
+ selectCallCount++;
747
+ if (selectCallCount === 1) {
748
+ return { where: vi.fn().mockResolvedValue([solMethod]) };
749
+ }
750
+ if (selectCallCount === 2) {
751
+ return { where: vi.fn().mockResolvedValue([keyRing]) };
752
+ }
753
+ // Empty pool
754
+ return {
755
+ where: vi.fn().mockReturnValue({
756
+ orderBy: vi.fn().mockReturnValue({
757
+ limit: vi.fn().mockResolvedValue([]),
758
+ }),
759
+ }),
760
+ };
761
+ }),
762
+ })),
763
+ transaction: vi.fn().mockImplementation(async (fn: (tx: unknown) => unknown) => fn(db)),
764
+ };
765
+
766
+ const deps = mockDeps();
767
+ (deps as unknown as { db: unknown }).db = db;
768
+ const app = createKeyServerApp(deps);
769
+
770
+ const res = await app.request("/address", {
771
+ method: "POST",
772
+ headers: { "Content-Type": "application/json" },
773
+ body: JSON.stringify({ chain: "sol" }),
774
+ });
775
+
776
+ // Hono catches the thrown error and returns 500
777
+ expect(res.status).toBe(500);
778
+ });
779
+ });
780
+
353
781
  describe("key-server auth", () => {
354
782
  it("rejects unauthenticated request when serviceKey is set", async () => {
355
783
  const deps = mockDeps();
@@ -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
+ }