@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.
@@ -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
+ }