Files
routstrd/tests/utils/nip98.test.ts
T
redshift 66b5f6ac59 feat: ensure wallet exists before deriving auth identity in remote flow
routstrd remote now runs the same ensure-wallet sequence as init (stop
legacy cocod, migrate a legacy cocod wallet so its mnemonic wins, then
initialize a fresh wallet) before deriving the NIP-98 identity, so the
operator nsec is mnemonic-derived and recoverable from the wallet backup
regardless of which command runs first. This avoids shadowing a legacy
wallet with a fresh mnemonic, which would have made later migration fail
on divergent wallets. An already-configured nsec is never replaced.

- extract ensureLocalWallet() shared by init and remote
- move initializeWallet into wallet-config.ts, return created status,
  handle concurrent-initializer EEXIST races
- warn (but still derive) when the wallet mnemonic fails BIP-39 checksum
  validation, keeping auth/NPC/wallet identities self-consistent
- relocate initializeWallet tests next to wallet-config, add
  ensure-wallet integration tests (fresh, idempotent, legacy migration,
  divergent-wallets refusal)
2026-08-17 08:24:33 +01:00

84 lines
3.2 KiB
TypeScript

import { describe, expect, test } from "bun:test";
import { getPublicKey, nip19 } from "nostr-tools";
import { validateMnemonic } from "@scure/bip39";
import { wordlist } from "@scure/bip39/wordlists/english.js";
import {
AUTH_NOSTR_ACCOUNT_INDEX,
nsecFromMnemonic,
parseSecretKey,
} from "../../src/utils/nip98";
// BIP-39 test mnemonic from the spec. Reference values derived with the same
// nostr-tools / @scure/bip39 stack the project uses.
const MNEMONIC =
"legal winner thank year wave sausage worth useful legal winner thank yellow";
const ACCOUNT_0 = {
nsec: "nsec1pcxghvh7hgr6dery5eampqdgjwplnuqh4gu470v6hk7ugcgcty3q26maau",
npub: "npub1mx07p7jvpdf4g5lgatea9sgk6mjyfrld947k2nvmwmas94q6sjhssl4jwc",
pubkey: "d99fe0fa4c0b535453e8eaf3d2c116d6e4448fed2d7d654d9b76fb02d41a84af",
};
const ACCOUNT_1 = {
nsec: "nsec12wejnr55hhl7wds0f0lzgmvad6s3r786hekrwnf7vt9yyt9vu4gsfe59ve",
npub: "npub1lj3rmlalqw3kuj5swhj9mpqyw3d4lgcs65ndmd4ee8h20azpvgwqjqkd6x",
pubkey: "fca23dffbf03a36e4a9075e45d8404745b5fa310d526ddb6b9c9eea7f441621c",
};
describe("nsecFromMnemonic", () => {
test("uses NIP-06 account index 0 by default", () => {
expect(AUTH_NOSTR_ACCOUNT_INDEX).toBe(0);
const { nsec, npub } = nsecFromMnemonic(MNEMONIC);
expect(nsec).toBe(ACCOUNT_0.nsec);
expect(npub).toBe(ACCOUNT_0.npub);
});
test("matches the reference account index 0 vector", () => {
const identity = nsecFromMnemonic(MNEMONIC, 0);
expect(identity.secretKey).toHaveLength(32);
expect(identity.nsec).toBe(ACCOUNT_0.nsec);
expect(identity.npub).toBe(ACCOUNT_0.npub);
expect(getPublicKey(identity.secretKey)).toBe(ACCOUNT_0.pubkey);
});
test("derives a distinct key for a different account index", () => {
const identity = nsecFromMnemonic(MNEMONIC, 1);
expect(identity.nsec).toBe(ACCOUNT_1.nsec);
expect(identity.npub).toBe(ACCOUNT_1.npub);
expect(getPublicKey(identity.secretKey)).toBe(ACCOUNT_1.pubkey);
expect(identity.nsec).not.toBe(ACCOUNT_0.nsec);
expect(identity.npub).not.toBe(ACCOUNT_0.npub);
});
test("is deterministic", () => {
expect(nsecFromMnemonic(MNEMONIC)).toEqual(nsecFromMnemonic(MNEMONIC));
});
test("round-trips through parseSecretKey and nip19", () => {
const { secretKey, nsec, npub } = nsecFromMnemonic(MNEMONIC);
expect(parseSecretKey(nsec)).toEqual(secretKey);
expect(nip19.nsecEncode(secretKey)).toBe(nsec);
const decoded = nip19.decode(npub);
expect(decoded.type).toBe("npub");
expect(decoded.data).toBe(getPublicKey(secretKey));
});
test("derives deterministically even from a checksum-invalid mnemonic", () => {
// Same words, last word swapped so the BIP-39 checksum fails. The wallet
// and the NPC plugin derive their keys from the raw string without
// checksum validation, so the auth identity must do the same to stay
// consistent with them rather than erroring out.
const corrupted = MNEMONIC.replace(/yellow$/, "zebra");
expect(validateMnemonic(corrupted, wordlist)).toBe(false);
const identity = nsecFromMnemonic(corrupted);
expect(identity.secretKey).toHaveLength(32);
expect(nsecFromMnemonic(corrupted)).toEqual(identity);
expect(identity.nsec).not.toBe(ACCOUNT_0.nsec);
});
});