@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.
@@ -2,11 +2,11 @@
2
2
  -- Adds atomic derivation counter + path registry + address log.
3
3
 
4
4
  -- 1. Add network column to payment_methods (parallel to chain)
5
- ALTER TABLE "payment_methods" ADD COLUMN "network" text NOT NULL DEFAULT 'mainnet';
5
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "network" text NOT NULL DEFAULT 'mainnet';
6
6
  --> statement-breakpoint
7
7
 
8
8
  -- 2. Add next_index atomic counter to payment_methods
9
- ALTER TABLE "payment_methods" ADD COLUMN "next_index" integer NOT NULL DEFAULT 0;
9
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "next_index" integer NOT NULL DEFAULT 0;
10
10
  --> statement-breakpoint
11
11
 
12
12
  -- 3. BIP-44 path allocation registry
@@ -1,15 +1,15 @@
1
1
  -- Watcher service schema additions: webhook outbox + charge amount tracking.
2
2
 
3
3
  -- 1. callback_url for webhook delivery
4
- ALTER TABLE "crypto_charges" ADD COLUMN "callback_url" text;
4
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "callback_url" text;
5
5
  --> statement-breakpoint
6
6
 
7
7
  -- 2. Expected crypto amount in native base units (locked at charge creation)
8
- ALTER TABLE "crypto_charges" ADD COLUMN "expected_amount" text;
8
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "expected_amount" text;
9
9
  --> statement-breakpoint
10
10
 
11
11
  -- 3. Running total of received crypto in native base units (partial payments)
12
- ALTER TABLE "crypto_charges" ADD COLUMN "received_amount" text;
12
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "received_amount" text;
13
13
  --> statement-breakpoint
14
14
 
15
15
  -- 4. Webhook delivery outbox — durable retry for payment callbacks
@@ -1,4 +1,4 @@
1
- ALTER TABLE "crypto_charges" ADD COLUMN "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
- ALTER TABLE "crypto_charges" ADD COLUMN "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
3
- ALTER TABLE "crypto_charges" ADD COLUMN "tx_hash" text;--> statement-breakpoint
4
- ALTER TABLE "crypto_charges" ADD COLUMN "amount_received_cents" integer DEFAULT 0 NOT NULL;
1
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "confirmations" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
2
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "confirmations_required" integer DEFAULT 1 NOT NULL;--> statement-breakpoint
3
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "tx_hash" text;--> statement-breakpoint
4
+ ALTER TABLE "crypto_charges" ADD COLUMN IF NOT EXISTS "amount_received_cents" integer DEFAULT 0 NOT NULL;
@@ -1,4 +1,4 @@
1
- ALTER TABLE "payment_methods" ADD COLUMN "encoding_params" text DEFAULT '{}' NOT NULL;
1
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "encoding_params" text DEFAULT '{}' NOT NULL;
2
2
  --> statement-breakpoint
3
3
  UPDATE "payment_methods" SET "encoding_params" = '{"hrp":"bc"}' WHERE "address_type" = 'bech32' AND "chain" = 'bitcoin';
4
4
  --> statement-breakpoint
@@ -1,3 +1,3 @@
1
- ALTER TABLE "payment_methods" ADD COLUMN "watcher_type" text DEFAULT 'evm' NOT NULL;
1
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "watcher_type" text DEFAULT 'evm' NOT NULL;
2
2
  --> statement-breakpoint
3
3
  UPDATE "payment_methods" SET "watcher_type" = 'utxo' WHERE "chain" IN ('bitcoin', 'litecoin', 'dogecoin');
@@ -1,4 +1,4 @@
1
- ALTER TABLE "payment_methods" ADD COLUMN "oracle_asset_id" text;
1
+ ALTER TABLE "payment_methods" ADD COLUMN IF NOT EXISTS "oracle_asset_id" text;
2
2
  --> statement-breakpoint
3
3
  UPDATE "payment_methods" SET "oracle_asset_id" = 'bitcoin' WHERE "token" = 'BTC';
4
4
  --> statement-breakpoint
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.66.0",
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();