SkateSpots/.agents/skills/onchain-bitcoin/files/lib/bitcoin-signers.ts
shakespeare.diy 06fae294b8 New project created with Shakespeare
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 19:49:07 +07:00

143 lines
5.5 KiB
TypeScript

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<string>;
}
/** 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<string> {
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<string> {
// `awaitNostr` is TypeScript-private but JavaScript-public at runtime.
const nostr = await (this as unknown as { awaitNostr(): Promise<Record<string, unknown>> }).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<string>;
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<string> {
// `cmd` is TypeScript-private but JavaScript-public at runtime.
const cmd = (this as unknown as { cmd(method: string, params: string[]): Promise<string> }).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;
}
}
}