wallet/pq: PQAccount TS mirror of luxfi/sdk wallet/account

TypeScript module under pkgs/wallet/src/features/wallet/pq/ that
mirrors the PQAccount shape, the cSHAKE-256 AccountID derivation, the
HD-path formatters, and the role/customization mappings defined on the
Go side in github.com/luxfi/sdk/wallet/account.

The TS side intentionally does NOT pull in an ML-DSA-65 implementation.
ML-DSA itself is software-in-browser; the cost of bundling FIPS 204 is
not worth paying for read-only consumers (UI list views, route
handlers). Consumers that need to sign in-browser supply a concrete
MLDSAProvider implementation (typically backed by @noble/post-quantum
mlDsa65 or a hardware-wallet bridge). Browser keys are session-scoped;
long-lived keys MUST live in a native wallet.

Wire compatibility: AccountID KAT cross-checked against the Go side.
For the canonical vector (chainID=9000, scheme=0x42, pubkey="test-
pubkey-for-accountid-kat-vector-stable-across-runs") both
implementations produce
76a03630148103ec558cf4d8f7e8a2d8766ea205d96a4529249c3aaf9c5d078cc9f9fdb8f21a5e7ef6bb4c4ea29c9654.

Adds @noble/hashes ^1.7.1 to pkgs/wallet dependencies (already
transitively present via @scure/bip32; declaration is for strict
node_modules resolution). pnpm-lock.yaml refresh on next install.

Patch-bump.
This commit is contained in:
Hanzo AI
2026-06-10 22:35:41 -07:00
committed by zeekay
parent 9e53916781
commit d3807df45d
4 changed files with 576 additions and 0 deletions
+1
View File
@@ -41,6 +41,7 @@
"@l.x/api": "^1.0.8",
"@l.x/gating": "^1.0.7",
"@l.x/sessions": "^1.0.8",
"@noble/hashes": "^1.7.1",
"apollo3-cache-persist": "0.14.1",
"dayjs": "1.11.7",
"ethers": "5.7.2",
@@ -0,0 +1,27 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
export type { PQAccount, AccountRole, MLDSAProvider, TxAuthEnvelope } from './pqAccount'
export {
ACCOUNT_ID_SIZE,
ACCOUNT_ROLE_IDENTITY,
ACCOUNT_ROLE_RECOVERY,
ACCOUNT_ROLE_SESSION,
ACCOUNT_ROLE_TX,
CSHAKE_CUSTOMIZATION_ACCOUNT_ID,
CSHAKE_CUSTOMIZATION_IDENTITY,
CSHAKE_CUSTOMIZATION_RECOVERY,
CSHAKE_CUSTOMIZATION_SESSION,
CSHAKE_CUSTOMIZATION_TX,
WalletSchemeID,
deriveAccountID,
expandChildSeed,
formatPQPath,
formatRecoveryPath,
isClassicalCompat,
isPostQuantum,
roleCustomization,
roleHDIndex,
signTx,
walletSchemeName,
} from './pqAccount'
@@ -0,0 +1,191 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
import { cshake256 } from '@noble/hashes/sha3-addons'
import {
ACCOUNT_ID_SIZE,
CSHAKE_CUSTOMIZATION_ACCOUNT_ID,
CSHAKE_CUSTOMIZATION_IDENTITY,
CSHAKE_CUSTOMIZATION_RECOVERY,
CSHAKE_CUSTOMIZATION_SESSION,
CSHAKE_CUSTOMIZATION_TX,
WalletSchemeID,
deriveAccountID,
expandChildSeed,
formatPQPath,
formatRecoveryPath,
isClassicalCompat,
isPostQuantum,
roleCustomization,
roleHDIndex,
walletSchemeName,
} from './pqAccount'
describe('cSHAKE customization strings', () => {
it('pin to canonical V1 byte content', () => {
// These strings are wire-compatibility critical. A change here
// is a wallet-incompatible regression; pin the literal content.
expect(CSHAKE_CUSTOMIZATION_IDENTITY).toBe('LUX/WALLET/IDENTITY/V1')
expect(CSHAKE_CUSTOMIZATION_TX).toBe('LUX/WALLET/TX/V1')
expect(CSHAKE_CUSTOMIZATION_SESSION).toBe('LUX/WALLET/SESSION/V1')
expect(CSHAKE_CUSTOMIZATION_RECOVERY).toBe('LUX/WALLET/RECOVERY/V1')
expect(CSHAKE_CUSTOMIZATION_ACCOUNT_ID).toBe('LUX/WALLET/ACCOUNT_ID/V1')
})
it('all start with LUX/ and end with /V1', () => {
const all = [
CSHAKE_CUSTOMIZATION_IDENTITY,
CSHAKE_CUSTOMIZATION_TX,
CSHAKE_CUSTOMIZATION_SESSION,
CSHAKE_CUSTOMIZATION_RECOVERY,
CSHAKE_CUSTOMIZATION_ACCOUNT_ID,
]
for (const s of all) {
expect(s.startsWith('LUX/')).toBe(true)
expect(s.endsWith('/V1')).toBe(true)
}
})
})
describe('deriveAccountID', () => {
it('produces a deterministic 48-byte AccountID', () => {
const pubkey = new Uint8Array([1, 2, 3, 4, 5])
const a = deriveAccountID(7, WalletSchemeID.MLDSA65, pubkey)
const b = deriveAccountID(7, WalletSchemeID.MLDSA65, pubkey)
expect(a.length).toBe(ACCOUNT_ID_SIZE)
expect(a).toEqual(b)
})
it('produces different IDs for different chains', () => {
const pubkey = new Uint8Array([1, 2, 3])
const a = deriveAccountID(1, WalletSchemeID.MLDSA65, pubkey)
const b = deriveAccountID(2, WalletSchemeID.MLDSA65, pubkey)
expect(a).not.toEqual(b)
})
it('produces different IDs for different schemes', () => {
const pubkey = new Uint8Array([1, 2, 3])
const a = deriveAccountID(1, WalletSchemeID.MLDSA65, pubkey)
const b = deriveAccountID(1, WalletSchemeID.MLDSA87, pubkey)
expect(a).not.toEqual(b)
})
it('matches a hand-computed cSHAKE output', () => {
// KAT: re-compute the AccountID using a fresh cshake256 call so a
// regression in deriveAccountID's input layout (e.g. wrong byte
// order on chainID, missing scheme byte) is caught.
const pubkey = new TextEncoder().encode('test-pubkey-for-accountid-kat-vector-stable-across-runs')
const chainID = 9000
const scheme = WalletSchemeID.MLDSA65
const got = deriveAccountID(chainID, scheme, pubkey)
const cidBytes = new Uint8Array(4)
new DataView(cidBytes.buffer).setUint32(0, chainID, false)
const msg = new Uint8Array(cidBytes.length + 1 + pubkey.length)
msg.set(cidBytes, 0)
msg.set(new Uint8Array([scheme]), cidBytes.length)
msg.set(pubkey, cidBytes.length + 1)
const want = cshake256(msg, {
personalization: CSHAKE_CUSTOMIZATION_ACCOUNT_ID,
NISTfn: 'LUX_ACCOUNT_V1',
dkLen: ACCOUNT_ID_SIZE,
})
expect(got).toEqual(want)
})
it('throws on nil pubkey', () => {
expect(() =>
// Cast through unknown so TS doesn't reject the test of a
// runtime guard.
deriveAccountID(1, WalletSchemeID.MLDSA65, null as unknown as Uint8Array),
).toThrow(/pubkey is required/)
})
it('throws on out-of-range chainID', () => {
expect(() => deriveAccountID(-1, WalletSchemeID.MLDSA65, new Uint8Array(1))).toThrow(/chainID/)
expect(() => deriveAccountID(2 ** 32, WalletSchemeID.MLDSA65, new Uint8Array(1))).toThrow(/chainID/)
})
})
describe('expandChildSeed', () => {
it('is deterministic for the same (seed, customization, outLen)', () => {
const seed = new TextEncoder().encode('test-child-seed-32-bytes-long-ok!')
const a = expandChildSeed(seed, CSHAKE_CUSTOMIZATION_IDENTITY, 32)
const b = expandChildSeed(seed, CSHAKE_CUSTOMIZATION_IDENTITY, 32)
expect(a).toEqual(b)
expect(a.length).toBe(32)
})
it('gives different output for different customizations', () => {
const seed = new TextEncoder().encode('test-child-seed-32-bytes-long-ok!')
const a = expandChildSeed(seed, CSHAKE_CUSTOMIZATION_IDENTITY, 32)
const b = expandChildSeed(seed, CSHAKE_CUSTOMIZATION_TX, 32)
expect(a).not.toEqual(b)
})
it('throws on bad inputs', () => {
expect(() => expandChildSeed(new Uint8Array(0), CSHAKE_CUSTOMIZATION_IDENTITY, 32)).toThrow(/empty/)
expect(() => expandChildSeed(new Uint8Array([1]), CSHAKE_CUSTOMIZATION_IDENTITY, 0)).toThrow(/outLen/)
expect(() => expandChildSeed(new Uint8Array([1]), '', 32)).toThrow(/customization/)
})
})
describe('WalletSchemeID helpers', () => {
it('walletSchemeName returns the canonical name', () => {
expect(walletSchemeName(WalletSchemeID.None)).toBe('none')
expect(walletSchemeName(WalletSchemeID.Secp256k1)).toBe('secp256k1')
expect(walletSchemeName(WalletSchemeID.MLDSA65)).toBe('ml-dsa-65')
expect(walletSchemeName(0xaa as WalletSchemeID)).toBe('wallet-scheme(0xaa)')
})
it('isPostQuantum is true for the ML-DSA block only', () => {
expect(isPostQuantum(WalletSchemeID.MLDSA44)).toBe(true)
expect(isPostQuantum(WalletSchemeID.MLDSA65)).toBe(true)
expect(isPostQuantum(WalletSchemeID.MLDSA87)).toBe(true)
expect(isPostQuantum(WalletSchemeID.Secp256k1)).toBe(false)
expect(isPostQuantum(WalletSchemeID.None)).toBe(false)
})
it('isClassicalCompat is true for secp256k1 only', () => {
expect(isClassicalCompat(WalletSchemeID.Secp256k1)).toBe(true)
expect(isClassicalCompat(WalletSchemeID.MLDSA65)).toBe(false)
})
})
describe('AccountRole helpers', () => {
it('roleHDIndex returns the canonical indices', () => {
expect(roleHDIndex('identity')).toBe(0)
expect(roleHDIndex('tx')).toBe(1)
expect(roleHDIndex('session')).toBe(2)
})
it('roleHDIndex throws on recovery', () => {
expect(() => roleHDIndex('recovery')).toThrow(/recovery/i)
})
it('roleCustomization maps to the canonical strings', () => {
expect(roleCustomization('identity')).toBe(CSHAKE_CUSTOMIZATION_IDENTITY)
expect(roleCustomization('tx')).toBe(CSHAKE_CUSTOMIZATION_TX)
expect(roleCustomization('session')).toBe(CSHAKE_CUSTOMIZATION_SESSION)
expect(roleCustomization('recovery')).toBe(CSHAKE_CUSTOMIZATION_RECOVERY)
})
})
describe('HD path formatters', () => {
it('formatPQPath produces the canonical wallet path', () => {
expect(formatPQPath(9000, 0, 0)).toBe("m/44'/9000'/9000'/0'/0'/0'")
expect(formatPQPath(1, 1, 42)).toBe("m/44'/9000'/1'/0'/1'/42'")
})
it('formatRecoveryPath produces the canonical recovery path', () => {
expect(formatRecoveryPath(9000, 0)).toBe("m/44'/9000'/9000'/2'/0'")
expect(formatRecoveryPath(1, 42)).toBe("m/44'/9000'/1'/2'/42'")
})
it('formatPQPath rejects out-of-range indices', () => {
expect(() => formatPQPath(-1, 0, 0)).toThrow(/chainID/)
expect(() => formatPQPath(0, 2 ** 31, 0)).toThrow(/roleIdx/)
expect(() => formatPQPath(0, 0, 2 ** 31)).toThrow(/accountIdx/)
})
})
@@ -0,0 +1,357 @@
// Copyright (C) 2019-2025, Lux Industries, Inc. All rights reserved.
// See the file LICENSE for licensing terms.
/**
* pqAccount — TypeScript shape mirror of the Go PQAccount type defined in
* github.com/luxfi/sdk/wallet/account.
*
* The TS side intentionally does NOT implement ML-DSA-65 in this file.
* ML-DSA itself is software-in-browser (no WebCrypto exposure of FIPS 204);
* the cost of pulling a full ML-DSA implementation into the wallet bundle
* is large and only justified for users who actually intend to sign
* locally. Instead, this module:
*
* 1. Defines the canonical PQAccount shape (AccountID, scheme, role,
* derivation path, public key) so consumers can serialise / display
* / route accounts without owning the signing implementation.
*
* 2. Implements the cSHAKE-256 AccountID derivation (pure-TS via
* @noble/hashes) so a TS-side consumer can validate that a Go-signed
* transaction carries the AccountID the consumer expects.
*
* 3. Declares a tight MLDSAProvider interface. Consumers that need to
* sign in-browser plug a concrete provider (typically backed by
* @noble/post-quantum mlDsa65) at the call site. Browser keys are
* session-scoped; long-lived keys MUST live in a hardware or native
* wallet, not in this module's memory.
*
* The customization strings and HD-path constants here are byte-identical
* to the Go side. A mismatch is a wallet-incompatible regression; the
* unit tests in pqAccount.test.ts pin the strings.
*/
import { cshake256 } from '@noble/hashes/sha3-addons'
// ---------------------------------------------------------------------------
// Scheme enum (mirrors github.com/luxfi/sdk/wallet/account.WalletSchemeID).
// ---------------------------------------------------------------------------
export enum WalletSchemeID {
None = 0x00,
Secp256k1 = 0x10,
MLDSA44 = 0x41,
MLDSA65 = 0x42, // production default
MLDSA87 = 0x43,
}
export function walletSchemeName(s: WalletSchemeID): string {
switch (s) {
case WalletSchemeID.None:
return 'none'
case WalletSchemeID.Secp256k1:
return 'secp256k1'
case WalletSchemeID.MLDSA44:
return 'ml-dsa-44'
case WalletSchemeID.MLDSA65:
return 'ml-dsa-65'
case WalletSchemeID.MLDSA87:
return 'ml-dsa-87'
default:
return `wallet-scheme(0x${s.toString(16).padStart(2, '0')})`
}
}
export function isPostQuantum(s: WalletSchemeID): boolean {
return s === WalletSchemeID.MLDSA44 || s === WalletSchemeID.MLDSA65 || s === WalletSchemeID.MLDSA87
}
export function isClassicalCompat(s: WalletSchemeID): boolean {
return s === WalletSchemeID.Secp256k1
}
// ---------------------------------------------------------------------------
// Account roles (mirrors account.AccountRole).
// ---------------------------------------------------------------------------
export type AccountRole = 'identity' | 'tx' | 'session' | 'recovery'
export const ACCOUNT_ROLE_IDENTITY: AccountRole = 'identity'
export const ACCOUNT_ROLE_TX: AccountRole = 'tx'
export const ACCOUNT_ROLE_SESSION: AccountRole = 'session'
export const ACCOUNT_ROLE_RECOVERY: AccountRole = 'recovery'
// Conventional HD indices on the ML-DSA branch (0'). Mirrors
// account.AccountRole.HDIndex on the Go side.
export function roleHDIndex(role: AccountRole): number {
switch (role) {
case 'identity':
return 0
case 'tx':
return 1
case 'session':
return 2
case 'recovery':
throw new Error('pqAccount: recovery role is reserved for SLH-DSA accounts')
default: {
const _exhaustive: never = role
throw new Error(`pqAccount: unknown role ${_exhaustive as string}`)
}
}
}
// ---------------------------------------------------------------------------
// cSHAKE customization strings (mirrors account/cshake.go).
//
// MUST be byte-identical to the Go side. Changes here are
// wallet-incompatible.
// ---------------------------------------------------------------------------
export const CSHAKE_CUSTOMIZATION_IDENTITY = 'LUX/WALLET/IDENTITY/V1'
export const CSHAKE_CUSTOMIZATION_TX = 'LUX/WALLET/TX/V1'
export const CSHAKE_CUSTOMIZATION_SESSION = 'LUX/WALLET/SESSION/V1'
export const CSHAKE_CUSTOMIZATION_RECOVERY = 'LUX/WALLET/RECOVERY/V1'
export const CSHAKE_CUSTOMIZATION_ACCOUNT_ID = 'LUX/WALLET/ACCOUNT_ID/V1'
const ACCOUNT_ID_LABEL = 'LUX_ACCOUNT_V1'
export const ACCOUNT_ID_SIZE = 48
// ---------------------------------------------------------------------------
// AccountID derivation (mirrors account.DeriveAccountID).
// ---------------------------------------------------------------------------
/**
* Compute the canonical 48-byte AccountID for a (chainID, scheme, pubkey)
* triple. Pure function; same inputs → same output. Throws if pubkey is
* null/undefined.
*
* The function-name (N) passed to cSHAKE is "LUX_ACCOUNT_V1"; the
* customization (S) is "LUX/WALLET/ACCOUNT_ID/V1"; the message is
* u32be(chainID) || u8(scheme) || pubkey. Output length is fixed at 48
* bytes (SHAKE256-384 equivalent). Identical to the Go implementation.
*/
export function deriveAccountID(chainID: number, scheme: WalletSchemeID, pubkey: Uint8Array): Uint8Array {
if (!pubkey) {
throw new Error('pqAccount: pubkey is required')
}
if (!Number.isInteger(chainID) || chainID < 0 || chainID >= 2 ** 32) {
throw new Error(`pqAccount: chainID ${chainID} out of u32 range`)
}
const cidBytes = new Uint8Array(4)
new DataView(cidBytes.buffer).setUint32(0, chainID, false) // big-endian
const schemeByte = new Uint8Array([scheme])
// cshake256 in @noble/hashes accepts an input message; we concatenate
// (chainBytes || schemeByte || pubkey) into a single buffer because the
// Go side feeds these to cSHAKE in three sequential Write calls. cSHAKE
// does NOT length-prefix consecutive writes (per NIST SP 800-185), so
// multi-Write and single-Write of the concatenation are identical.
const msg = new Uint8Array(cidBytes.length + 1 + pubkey.length)
msg.set(cidBytes, 0)
msg.set(schemeByte, cidBytes.length)
msg.set(pubkey, cidBytes.length + 1)
return cshake256(msg, {
personalization: CSHAKE_CUSTOMIZATION_ACCOUNT_ID,
NISTfn: ACCOUNT_ID_LABEL,
dkLen: ACCOUNT_ID_SIZE,
})
}
/**
* Expand a BIP-32 child seed into a fixed-length keygen-input stream
* under a role-bound cSHAKE-256 customization. Mirrors
* account.expandChildSeed. Function-name (N) is "LUX_PQ_KEYGEN_V1".
*/
export function expandChildSeed(childSeed: Uint8Array, customization: string, outLen: number): Uint8Array {
if (!childSeed || childSeed.length === 0) {
throw new Error('pqAccount: child seed is empty')
}
if (outLen <= 0) {
throw new Error(`pqAccount: outLen ${outLen} must be positive`)
}
if (!customization) {
throw new Error('pqAccount: customization is empty')
}
return cshake256(childSeed, {
personalization: customization,
NISTfn: 'LUX_PQ_KEYGEN_V1',
dkLen: outLen,
})
}
/**
* Map an AccountRole to its cSHAKE customization string.
*/
export function roleCustomization(role: AccountRole): string {
switch (role) {
case 'identity':
return CSHAKE_CUSTOMIZATION_IDENTITY
case 'tx':
return CSHAKE_CUSTOMIZATION_TX
case 'session':
return CSHAKE_CUSTOMIZATION_SESSION
case 'recovery':
return CSHAKE_CUSTOMIZATION_RECOVERY
default: {
const _exhaustive: never = role
throw new Error(`pqAccount: unknown role ${_exhaustive as string}`)
}
}
}
// ---------------------------------------------------------------------------
// PQAccount shape (mirrors account.PQAccount).
// ---------------------------------------------------------------------------
/**
* PQAccount is the TS-side view of a PQ-native wallet account.
*
* accountID — 48 bytes, derived via deriveAccountID().
* schemeID — WalletSchemeID.MLDSA65 in production.
* publicKey — packed FIPS-204 public key bytes.
* privateKey — packed FIPS-204 private key bytes; OPTIONAL. Read-only
* consumers (UI list views, route handlers) MUST NOT
* receive privateKey-bearing accounts; only the signer
* layer should ever hold these bytes.
* role — application semantic role; persisted on the struct for routing.
* derivationPath — BIP-32 path string for record-keeping.
*/
export interface PQAccount {
accountID: Uint8Array
schemeID: WalletSchemeID
publicKey: Uint8Array
privateKey?: Uint8Array
role: AccountRole
derivationPath: string
}
// ---------------------------------------------------------------------------
// MLDSA provider interface (consumer-supplied).
// ---------------------------------------------------------------------------
/**
* MLDSAProvider is the minimal capability a TS consumer must supply in
* order to sign or verify ML-DSA-65 signatures in-browser. A concrete
* provider would typically wrap @noble/post-quantum's mlDsa65 export, an
* @luxfi/crypto-pq build, or a hardware-wallet bridge.
*
* The wallet pkg deliberately does NOT pull in an ML-DSA implementation:
* (a) browser bundle size is precious; (b) most consumers route signing
* through a native/hardware wallet anyway; (c) keeping the provider
* abstract lets us swap implementations as the ML-DSA JS ecosystem
* settles.
*
* sign() MUST use the "LUX/WALLET/TX/V1" context (CSHAKE_CUSTOMIZATION_TX)
* so signatures issued from the browser are wire-compatible with the Go
* signer. verify() MUST verify with the same context.
*/
export interface MLDSAProvider {
readonly schemeID: WalletSchemeID
readonly publicKeySize: number
readonly privateKeySize: number
readonly signatureSize: number
/** Sign a 48-byte digest. Returns the packed FIPS-204 signature. */
sign(privateKey: Uint8Array, digest: Uint8Array): Uint8Array
/** Verify a packed FIPS-204 signature over the digest. */
verify(publicKey: Uint8Array, digest: Uint8Array, signature: Uint8Array): boolean
/** Derive (pk, sk) from a 32-byte seed. Used for HD derivation tests. */
newKeyFromSeed(seed: Uint8Array): { publicKey: Uint8Array; privateKey: Uint8Array }
}
// ---------------------------------------------------------------------------
// TxAuthEnvelope interface (mirrors account.TxAuthEnvelope).
// ---------------------------------------------------------------------------
/**
* TxAuthEnvelope is the wallet-side view of a transaction-authorization
* envelope. Consumers implement this to expose a deterministic 48-byte
* signing digest and to receive the wallet's signature after signTx
* returns.
*/
export interface TxAuthEnvelope {
signingDigest(): Uint8Array
attachSignature(scheme: WalletSchemeID, publicKey: Uint8Array, signature: Uint8Array): void
}
/**
* signTx is the high-level signing entry point. Takes a PQAccount with
* privateKey present, asks the envelope for its digest, signs the
* digest through the supplied provider, and attaches the signature back
* to the envelope.
*
* Returns the raw signature bytes for callers who want to log / audit.
*/
export function signTx(account: PQAccount, env: TxAuthEnvelope, provider: MLDSAProvider): Uint8Array {
if (!account) {
throw new Error('pqAccount: signTx called with null account')
}
if (!env) {
throw new Error('pqAccount: signTx called with null envelope')
}
if (!provider) {
throw new Error('pqAccount: signTx called with null provider')
}
if (account.schemeID !== provider.schemeID) {
throw new Error(
`pqAccount: account scheme ${walletSchemeName(account.schemeID)} does not match provider scheme ${walletSchemeName(provider.schemeID)}`,
)
}
if (!account.privateKey || account.privateKey.length !== provider.privateKeySize) {
throw new Error('pqAccount: account.privateKey missing or wrong length')
}
const digest = env.signingDigest()
if (digest.length !== ACCOUNT_ID_SIZE) {
throw new Error(`pqAccount: digest length ${digest.length} must be ${ACCOUNT_ID_SIZE}`)
}
const sig = provider.sign(account.privateKey, digest)
env.attachSignature(account.schemeID, account.publicKey, sig)
return sig
}
// ---------------------------------------------------------------------------
// HD path utilities (mirror genesis/keys.go + account/pq_account.go).
// ---------------------------------------------------------------------------
/**
* formatPQPath returns the canonical HD-path string for an ML-DSA wallet
* account:
*
* m/44'/9000'/<chainID>'/0'/<roleIdx>'/<accountIdx>'
*
* Pure-string formatter — does not derive any key. Use a BIP-32
* implementation (e.g. @scure/bip32) plus expandChildSeed() to produce
* the actual 32-byte ξ for ML-DSA KeyGen.
*/
export function formatPQPath(chainID: number, roleIdx: number, accountIdx: number): string {
if (!Number.isInteger(chainID) || chainID < 0 || chainID >= 2 ** 31) {
throw new Error(`pqAccount: chainID ${chainID} out of [0, 2^31) range`)
}
if (!Number.isInteger(roleIdx) || roleIdx < 0 || roleIdx >= 2 ** 31) {
throw new Error(`pqAccount: roleIdx ${roleIdx} out of [0, 2^31) range`)
}
if (!Number.isInteger(accountIdx) || accountIdx < 0 || accountIdx >= 2 ** 31) {
throw new Error(`pqAccount: accountIdx ${accountIdx} out of [0, 2^31) range`)
}
return `m/44'/9000'/${chainID}'/0'/${roleIdx}'/${accountIdx}'`
}
/**
* formatRecoveryPath returns the canonical HD-path string for an SLH-DSA
* recovery account:
*
* m/44'/9000'/<chainID>'/2'/<accountIdx>'
*/
export function formatRecoveryPath(chainID: number, accountIdx: number): string {
if (!Number.isInteger(chainID) || chainID < 0 || chainID >= 2 ** 31) {
throw new Error(`pqAccount: chainID ${chainID} out of [0, 2^31) range`)
}
if (!Number.isInteger(accountIdx) || accountIdx < 0 || accountIdx >= 2 ** 31) {
throw new Error(`pqAccount: accountIdx ${accountIdx} out of [0, 2^31) range`)
}
return `m/44'/9000'/${chainID}'/2'/${accountIdx}'`
}