@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
|
-
*
|
|
22
|
-
* address
|
|
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
|
@@ -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
|
-
*
|
|
43
|
-
* address
|
|
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
|
+
}
|