@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.
- package/dist/billing/crypto/__tests__/key-server.test.js +379 -0
- package/dist/billing/crypto/key-server.js +129 -5
- package/drizzle/migrations/0000_slippery_mandrill.sql +133 -133
- package/drizzle/migrations/0001_infrastructure_extraction.sql +102 -102
- package/drizzle/migrations/0002_gateway_service_keys.sql +4 -4
- package/drizzle/migrations/0003_double_entry_ledger.sql +15 -15
- package/drizzle/migrations/0005_stablecoin_columns.sql +7 -7
- package/drizzle/migrations/0006_invite_acceptance.sql +2 -2
- package/drizzle/migrations/0010_oracle_address.sql +2 -2
- package/drizzle/migrations/0011_notification_templates.sql +1 -1
- package/drizzle/migrations/0014_crypto_key_server.sql +2 -2
- package/drizzle/migrations/0015_callback_url.sql +3 -3
- package/drizzle/migrations/0016_charge_progress_columns.sql +4 -4
- package/drizzle/migrations/0020_encoding_params_column.sql +1 -1
- package/drizzle/migrations/0021_watcher_type_column.sql +1 -1
- package/drizzle/migrations/0022_oracle_asset_id_column.sql +1 -1
- package/package.json +1 -1
- package/src/billing/crypto/__tests__/key-server.test.ts +428 -0
- package/src/billing/crypto/key-server.ts +178 -5
|
@@ -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
|
+
}
|