import type { NostrSigner } from '@nostrify/types'; import { NSecSigner, NBrowserSigner, NConnectSigner } from '@nostrify/nostrify'; import type { NConnectSignerOpts } from '@nostrify/nostrify'; import { signPsbtLocal } from '@/lib/bitcoin'; // --------------------------------------------------------------------------- // BtcSigner interface // --------------------------------------------------------------------------- /** * A Nostr signer extended with Bitcoin PSBT signing capability. * * Implementations receive a hex-encoded unsigned PSBT, sign all Taproot * inputs whose `tapInternalKey` matches the signer's key, and return the * hex-encoded signed (but not finalized) PSBT. */ export interface BtcSigner extends NostrSigner { signPsbt(psbtHex: string): Promise; } /** Runtime check for whether a signer supports `signPsbt`. */ export function hasBtcSigning(signer: NostrSigner): signer is BtcSigner { return typeof (signer as BtcSigner).signPsbt === 'function'; } // --------------------------------------------------------------------------- // NSecSignerBtc — local nsec signing // --------------------------------------------------------------------------- /** * Extends `NSecSigner` with local Taproot PSBT signing. * * `NSecSigner` stores the secret key in a JS `#private` field that subclasses * cannot access. To work around this, the constructor accepts the raw secret * key bytes, passes them to `super()`, and keeps its own copy in a true * runtime-private `#secretKeyBytes` field so the key is not reachable via * property enumeration or reflection on the instance. */ export class NSecSignerBtc extends NSecSigner implements BtcSigner { readonly #secretKeyBytes: Uint8Array; constructor(secretKey: Uint8Array) { super(secretKey); this.#secretKeyBytes = new Uint8Array(secretKey); } async signPsbt(psbtHex: string): Promise { const privateKeyHex = Buffer.from(this.#secretKeyBytes).toString('hex'); return signPsbtLocal(psbtHex, privateKeyHex); } } // --------------------------------------------------------------------------- // NBrowserSignerBtc — NIP-07 extension signing // --------------------------------------------------------------------------- /** * Extends `NBrowserSigner` with NIP-07 `window.nostr.signPsbt()` support. * * Calls the extension's `signPsbt` method if available. If the extension does * not expose `signPsbt`, an error is thrown with a user-friendly message. */ export class NBrowserSignerBtc extends NBrowserSigner implements BtcSigner { constructor(opts?: { timeout?: number }) { super(opts); } async signPsbt(psbtHex: string): Promise { // `awaitNostr` is TypeScript-private but JavaScript-public at runtime. const nostr = await (this as unknown as { awaitNostr(): Promise> }).awaitNostr(); if (typeof nostr.signPsbt !== 'function') { throw new Error( "Your browser extension doesn't support sending Bitcoin. Try a different extension, or log in with your secret key.", ); } const signPsbt = nostr.signPsbt as (hex: string) => Promise; return signPsbt(psbtHex); } } // --------------------------------------------------------------------------- // NConnectSignerBtc — NIP-46 remote signer // --------------------------------------------------------------------------- /** * Heuristics for detecting whether a NIP-46 `sign_psbt` error reflects a * missing-capability rejection (e.g. "method not supported", "unknown * command") versus a transient operational failure (network, user rejection, * malformed input). We have to match on strings because NIP-46 errors are * plain strings without structured codes. */ const CAPABILITY_ERROR_PATTERNS = [ /unknown\s+(method|command)/i, /not\s+(implemented|supported|found)/i, /unsupported\s+method/i, /method\s+not\s+found/i, /invalid\s+method/i, /no\s+such\s+method/i, ]; function looksLikeCapabilityError(msg: string): boolean { return CAPABILITY_ERROR_PATTERNS.some((re) => re.test(msg)); } /** * Extends `NConnectSigner` with NIP-46 `sign_psbt` RPC support. * * Sends a `sign_psbt` command over the NIP-46 relay channel. The remote * signer handles the TapTweak and Schnorr signing internally. * * NIP-46 returns unstructured string errors, so we use pattern matching to * distinguish capability failures (the signer doesn't know the method) from * operational failures (network, user rejection, bad input). Only capability * failures are re-wrapped with the "doesn't support sending Bitcoin" message * that flips the UI into the unsupported state; everything else propagates * unchanged so the caller can surface the real error. */ export class NConnectSignerBtc extends NConnectSigner implements BtcSigner { constructor(opts: NConnectSignerOpts) { super(opts); } async signPsbt(psbtHex: string): Promise { // `cmd` is TypeScript-private but JavaScript-public at runtime. const cmd = (this as unknown as { cmd(method: string, params: string[]): Promise }).cmd; try { return await cmd.call(this, 'sign_psbt', [psbtHex]); } catch (error) { const msg = error instanceof Error ? error.message : String(error); if (looksLikeCapabilityError(msg)) { throw new Error( `Your remote signer doesn't support sending Bitcoin. Update your signer, or log in with your secret key. (${msg})`, ); } // Not a capability failure — propagate the original error so the user // sees the actual reason (timeout, rejection, malformed PSBT, etc.). throw error; } } }