#!/usr/bin/env -S deno run --allow-env --allow-read --allow-net --node-modules-dir=auto
// ---------------------------------------------------------------------------
// nsec-to-sp.ts (Deno port of nsec-to-sp.mjs)
//
// Convert a Nostr nsec into a BIP-32 xprv and a BIP-352 silent-payment address,
// per NIP-SP §2:
//
// master = HMAC-SHA512("Bitcoin seed", nsec_bytes)
// bspend = master / 352' / coin_type' / 0' / 0' / 0
// bscan = master / 352' / coin_type' / 0' / 1' / 0
// address = bech32m("sp" | "tsp", v0 ‖ Bscan ‖ Bspend)
//
// Use to test cross-wallet interop: import the printed xprv (or the
// account-level xprv at m/352'/coin_type'/0') into any BIP-32-aware silent-
// payment wallet and check it arrives at the same address.
//
// Run:
// deno run scripts/nsec-to-sp.ts # interactive prompt
// deno run scripts/nsec-to-sp.ts <nsec1...|hex> # non-interactive
// deno run scripts/nsec-to-sp.ts --testnet
//
// Or executable (chmod +x first):
// ./scripts/nsec-to-sp.ts
//
// Permissions needed (covered by the shebang):
// --allow-env --allow-read --allow-net --node-modules-dir=auto
// `--allow-net` is only used by Deno on first run to fetch the npm packages
// from the registry; subsequent runs read from the local cache. The
// `--node-modules-dir=auto` flag is required because the project root has
// a Node-style `package.json` — without it Deno tries (and fails) to
// resolve npm specifiers against the project's own `node_modules/` rather
// than its own per-script cache.
// ---------------------------------------------------------------------------
import { HDKey } from 'npm:@scure/bip32@^1.4.0';
import { bech32, bech32m } from 'npm:@scure/base@^1.1.5';
const MAINNET_VERSIONS = { private: 0x0488ade4, public: 0x0488b21e }; // xprv / xpub
const TESTNET_VERSIONS = { private: 0x04358394, public: 0x043587cf }; // tprv / tpub
// ---------------------------------------------------------------------------
// Encoding helpers
// ---------------------------------------------------------------------------
function bytesToHex(b: Uint8Array): string {
return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
}
function parseNsecArg(s: string): Uint8Array {
const t = s.trim();
if (t.startsWith('nsec1')) {
const { prefix, words } = bech32.decode(t.toLowerCase() as `${string}1${string}`, 1023);
if (prefix !== 'nsec') throw new Error(`Expected nsec prefix, got "${prefix}"`);
const data = bech32.fromWords(words);
if (data.length !== 32) throw new Error(`nsec body must be 32 bytes, got ${data.length}`);
return new Uint8Array(data);
}
if (/^[0-9a-f]{64}$/i.test(t)) {
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) out[i] = parseInt(t.slice(i * 2, i * 2 + 2), 16);
return out;
}
throw new Error('Expected nsec1… (bech32) or 64-char hex private key');
}
/**
* BIP-352 address: bech32m("sp"|"tsp", v0 ‖ Bscan ‖ Bspend).
*/
function encodeSpAddress(Bscan: Uint8Array, Bspend: Uint8Array, network: string): string {
const hrp = network === 'mainnet' ? 'sp' : 'tsp';
const payload = new Uint8Array(Bscan.length + Bspend.length);
payload.set(Bscan, 0);
payload.set(Bspend, Bscan.length);
return bech32m.encode(hrp, [0, ...bech32m.toWords(payload)], 1023);
}
/**
* Watch-only scan-key export — bscan_priv (32) ‖ Bspend_compressed (33),
* bech32m HRP "spscan", version 0. 65-byte payload.
*/
function encodeSpScanKey(bscanPriv: Uint8Array, Bspend: Uint8Array): string {
const payload = new Uint8Array(bscanPriv.length + Bspend.length);
payload.set(bscanPriv, 0);
payload.set(Bspend, bscanPriv.length);
return bech32m.encode('spscan', [0, ...bech32m.toWords(payload)], 1023);
}
// ---------------------------------------------------------------------------
// Interactive prompt with terminal echo disabled
// ---------------------------------------------------------------------------
const ENC = new TextEncoder();
async function writeErr(s: string): Promise<void> {
await Deno.stderr.write(ENC.encode(s));
}
/**
* Read a line from stdin without echoing it back. Used so the nsec never
* appears in terminal scrollback or shell history. Prompt text goes to
* stderr so stdout (the key tables) stays pipe-clean.
*/
async function promptHidden(question: string): Promise<string> {
if (!Deno.stdin.isTerminal()) {
throw new Error('stdin is not a TTY — pass nsec as a command-line argument when piping/redirecting');
}
await writeErr(question);
Deno.stdin.setRaw(true);
const buf = new Uint8Array(1024);
let input = '';
try {
outer: while (true) {
const n = await Deno.stdin.read(buf);
if (n === null) break;
for (let i = 0; i < n; i++) {
const code = buf[i];
if (code === 0x0d || code === 0x0a || code === 0x04) {
// Enter or Ctrl-D
break outer;
}
if (code === 0x03) {
// Ctrl-C
Deno.stdin.setRaw(false);
await writeErr('\n');
Deno.exit(130);
}
if (code === 0x7f || code === 0x08) {
// Backspace / DEL
if (input.length > 0) input = input.slice(0, -1);
continue;
}
input += String.fromCharCode(code);
}
}
} finally {
Deno.stdin.setRaw(false);
await writeErr('\n');
}
return input;
}
interface Options {
key: string | null;
network: 'mainnet' | 'testnet';
help: boolean;
}
/**
* Resolve the seed bytes either from `opts.key` (non-interactive) or by
* prompting. Retries up to 3 times on parse failure so a single typo doesn't
* force a full re-run.
*/
async function resolveSeed(opts: Options): Promise<Uint8Array> {
if (opts.key) return parseNsecArg(opts.key);
await writeErr('Enter your nsec (bech32 "nsec1..." or 64-char hex).\n');
await writeErr('Input is hidden; nothing is written to disk or shell history.\n');
await writeErr('Ctrl-C to cancel.\n\n');
let lastErr: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
const input = await promptHidden('nsec: ');
if (!input) {
await writeErr(' (empty input)\n\n');
continue;
}
try {
return parseNsecArg(input);
} catch (e) {
lastErr = e instanceof Error ? e : new Error(String(e));
await writeErr(` ${lastErr.message}\n\n`);
}
}
throw new Error(`Too many invalid attempts${lastErr ? ` (last: ${lastErr.message})` : ''}`);
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
function parseArgs(argv: string[]): Options {
let network: 'mainnet' | 'testnet' = 'mainnet';
let key: string | null = null;
let help = false;
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a === '--mainnet') network = 'mainnet';
else if (a === '--testnet') network = 'testnet';
else if (a === '--network') {
const v = argv[++i];
if (v !== 'mainnet' && v !== 'testnet') throw new Error(`Unknown network "${v}"`);
network = v;
} else if (a.startsWith('--network=')) {
const v = a.slice('--network='.length);
if (v !== 'mainnet' && v !== 'testnet') throw new Error(`Unknown network "${v}"`);
network = v;
} else if (a === '-h' || a === '--help') help = true;
else if (!key) key = a;
else throw new Error(`Unexpected positional arg: ${a}`);
}
return { key, network, help };
}
function usage(): void {
console.log(`Usage: deno run scripts/nsec-to-sp.ts [<nsec1...|hex>] [--network=mainnet|testnet]
Derives the BIP-352 silent-payment keys from a Nostr nsec via BIP-32 hardened
derivation (NIP-SP §2). Prints the BIP-32 master xprv, the account-level
xprv at m/352'/<coin_type>'/0', and the silent-payment address.
If no nsec is given as an argument, the script prompts for it interactively
with terminal echo disabled — preferred for real keys so the secret doesn't
land in shell history.
Options:
--network=mainnet (default) coin_type=0', HRP=sp
--network=testnet coin_type=1', HRP=tsp
--mainnet / --testnet shortcuts for the above
Permissions: --allow-env --allow-read --allow-net (only needed on first run
to fetch the npm packages; cached locally afterwards).
`);
}
async function main(): Promise<void> {
let opts: Options;
try {
opts = parseArgs(Deno.args);
} catch (e) {
console.error(`Error: ${e instanceof Error ? e.message : String(e)}`);
usage();
Deno.exit(1);
}
if (opts.help) {
usage();
Deno.exit(0);
}
const seed = await resolveSeed(opts);
const versions = opts.network === 'mainnet' ? MAINNET_VERSIONS : TESTNET_VERSIONS;
const master = HDKey.fromMasterSeed(seed, versions);
const coinType = opts.network === 'mainnet' ? "0'" : "1'";
const accountPath = `m/352'/${coinType}/0'`;
const scanPath = `${accountPath}/1'/0`;
const spendPath = `${accountPath}/0'/0`;
const accountNode = master.derive(accountPath);
const scanNode = master.derive(scanPath);
const spendNode = master.derive(spendPath);
const Bscan = scanNode.publicKey;
const Bspend = spendNode.publicKey;
if (!Bscan || !Bspend) throw new Error('BIP-32 derivation produced no public key');
if (!scanNode.privateKey) throw new Error('Scan node has no private key (xpub-only master?)');
const address = encodeSpAddress(Bscan, Bspend, opts.network);
const spScanKey = encodeSpScanKey(scanNode.privateKey, Bspend);
// -------------------------------------------------------------------------
// Output
// -------------------------------------------------------------------------
const row = (label: string, value: string) => console.log(` ${label.padEnd(12)} ${value}`);
const section = (title: string) => {
console.log();
console.log(title);
console.log(' ' + '─'.repeat(Math.max(20, title.length - 2)));
};
console.log(`Network: ${opts.network}`);
console.log(`Coin type: ${coinType}`);
section('Seed (raw nsec bytes, used directly as the BIP-32 seed)');
row('hex', bytesToHex(seed));
section('BIP-32 master (m)');
row('xprv', master.privateExtendedKey);
row('xpub', master.publicExtendedKey);
section(`BIP-352 account (${accountPath})`);
row('xprv', accountNode.privateExtendedKey);
row('xpub', accountNode.publicExtendedKey);
section(`Scan key (${scanPath})`);
row('privkey', bytesToHex(scanNode.privateKey));
row('Bscan', bytesToHex(Bscan));
row('xprv', scanNode.privateExtendedKey);
row('xpub', scanNode.publicExtendedKey);
section(`Spend key (${spendPath})`);
if (spendNode.privateKey) row('privkey', bytesToHex(spendNode.privateKey));
row('Bspend', bytesToHex(Bspend));
row('xprv', spendNode.privateExtendedKey);
row('xpub', spendNode.publicExtendedKey);
section('Silent payment address');
row('address', address);
section('Watch-only scan key (bscan_priv ‖ Bspend, HRP "spscan")');
row('spscan key', spScanKey);
console.log();
}
main().catch((err: unknown) => {
const msg = err instanceof Error ? err.message : String(err);
console.error(`\nError: ${msg}`);
Deno.exit(1);
});
Login to reply