Repository Public
1import { HDKey } from '@scure/bip32';2import { mnemonicToSeedSync, validateMnemonic } from '@scure/bip39';3import { wordlist } from '@scure/bip39/wordlists/english.js';4import { p2pkh, p2wpkh } from '@scure/btc-signer';5import { ed25519 } from '@noble/curves/ed25519.js';6import { secp256k1 } from '@noble/curves/secp256k1.js';7import { sha256 } from '@noble/hashes/sha2.js';8import { base58 } from '@scure/base';9import Slip10 from 'micro-key-producer/slip10.js';10import { privateKeyToAccount } from 'viem/accounts';11import { btcParams, NETWORKS, pluginFor, type KeyHandle, type NetworkDef } from '@spw/core';1213export const PATHS = { evm: "m/44'/60'/0'/0/0", btc: "m/84'/0'/0'/0/0", sol: "m/44'/501'/0'/0'" } as const;14/** BIP-84 for native SegWit chains, BIP-44 for legacy P2PKH chains; coin type from the registry. */15export const utxoPath = (n: NetworkDef) => `m/${n.utxo?.kind === 'pkh' ? 44 : 84}'/${n.coinType ?? 0}'/0'/0/0`;1617export interface Keys {18 keyId: string;19 /** address per network id (plus the legacy family keys evm/btc/sol) */20 addresses: Record<string, string> & { evm: string; btc: string; sol: string };21 evmPriv: Uint8Array; solSeed: Uint8Array;22 /** secp256k1 private key of a Bitcoin-style network */23 utxoPriv(n: NetworkDef): Uint8Array;24 /** EVM key of an agent vault: HD account `index` ≥ 1 (account 0 is the owner's wallet) */25 vault(index: number): { priv: Uint8Array; address: string };26 /** signing handle for a plugin family network; the plugin never sees the private key */27 handle(n: NetworkDef): KeyHandle;28 /** dedicated trading key of a derivatives venue (Hyperliquid API wallet): its own path, one key per network, never the wallet key */29 venueKey(venue: 'hyperliquid', network: 'mainnet' | 'testnet'): { priv: Uint8Array; address: string; path: string };30}31/** HD account reserved for venue trading keys: `m/44'/60'/9000'/<venue index>/<i>`, i = 0 mainnet, 1 testnet. Never a vault. */32export const VENUE_ACCOUNT = 9000;33export const venuePath = (venue: 'hyperliquid', network: 'mainnet' | 'testnet') => `m/44'/60'/${VENUE_ACCOUNT}'/${{ hyperliquid: 0 }[venue]}/${network === 'mainnet' ? 0 : 1}`;3435/** Standard derivations: BIP-44 (EVM), BIP-84/44 (UTXO chains), SLIP-0010 ed25519 (Solana and ed25519 plugins). One curve per path. */36export function deriveKeys(mnemonic: string): Keys {37 if (!validateMnemonic(mnemonic, wordlist)) throw new Error('invalid mnemonic');38 const seed = mnemonicToSeedSync(mnemonic);39 const root = HDKey.fromMasterSeed(seed);40 const secp = (path: string) => { const k = root.derive(path); if (!k.privateKey || !k.publicKey) throw new Error('derivation failed'); return { priv: k.privateKey, pub: k.publicKey }; };41 const ed = (path: string) => { const priv = Slip10.fromMasterSeed(seed).derive(path).privateKey; return { priv, pub: ed25519.getPublicKey(priv) }; };42 const evmPriv = secp(PATHS.evm).priv, solSeed = ed(PATHS.sol).priv;43 const evm = privateKeyToAccount(`0x${Buffer.from(evmPriv).toString('hex')}`).address;44 const sol = base58.encode(ed25519.getPublicKey(solSeed));45 const utxoAddr = (n: NetworkDef) => { const { pub } = secp(utxoPath(n)); const a = (n.utxo!.kind === 'pkh' ? p2pkh(pub, btcParams(n)) : p2wpkh(pub, btcParams(n))).address; if (!a) throw new Error('address derivation failed'); return a; };46 const handle = (n: NetworkDef): KeyHandle => {47 const p = pluginFor(n);48 if (p.curve === 'secp256k1') {49 const { priv, pub } = secp(p.path(n));50 return { curve: 'secp256k1', publicKey: pub, signDigest: (digest) => {51 if (digest.length !== 32) throw new Error('digest must be 32 bytes');52 const sig = secp256k1.sign(digest, priv, { prehash: false, lowS: true, format: 'recovered' }); // [recovery, r, s]53 const compact = sig.slice(1);54 return { compact, recovery: sig[0]!, der: secp256k1.Signature.fromBytes(compact, 'compact').toBytes('der') };55 } };56 }57 const { priv, pub } = ed(p.path(n));58 return { curve: 'ed25519', publicKey: pub, signMessage: (m) => ed25519.sign(m, priv) };59 };60 const addresses = { evm, sol, btc: '' } as Record<string, string> & { evm: string; btc: string; sol: string };61 for (const n of Object.values(NETWORKS) as NetworkDef[]) {62 if (n.family === 'evm') addresses[n.id] = evm;63 else if (n.family === 'sol') addresses[n.id] = sol;64 else if (n.family === 'btc') addresses[n.id] = utxoAddr(n);65 else { const p = pluginFor(n); addresses[n.id] = p.address(n, handle(n).publicKey); }66 }67 addresses.btc = addresses.bitcoin!;68 if (!addresses.btc) throw new Error('bitcoin address missing');69 // the key id stays bound to the three original addresses so it does not change when networks are added70 const keyId = 'spw-' + Buffer.from(sha256(new TextEncoder().encode(`${evm}|${addresses.btc}|${sol}`))).toString('hex').slice(0, 16);71 const vault = (index: number) => {72 if (!Number.isInteger(index) || index < 1 || index > 100_000) throw new Error('vault index out of range');73 // m/44'/60'/9000'/0/0 would be the Hyperliquid mainnet trading key: that account is reserved, no vault may ever share a key with it74 if (index === VENUE_ACCOUNT) throw new Error('vault index reserved for venue trading keys');75 const priv = secp(`m/44'/60'/${index}'/0/0`).priv;76 return { priv, address: privateKeyToAccount(`0x${Buffer.from(priv).toString('hex')}`).address };77 };78 const venueKey = (venue: 'hyperliquid', network: 'mainnet' | 'testnet') => {79 if (venue !== 'hyperliquid' || (network !== 'mainnet' && network !== 'testnet')) throw new Error('no trading key for this venue');80 const path = venuePath(venue, network), priv = secp(path).priv;81 return { priv, address: privateKeyToAccount(`0x${Buffer.from(priv).toString('hex')}`).address.toLowerCase(), path };82 };83 return { keyId, addresses, evmPriv, solSeed, utxoPriv: (n) => secp(utxoPath(n)).priv, handle, vault, venueKey };84}85