import { ccc } from "@ckb-ccc/core"; import { Psbt } from "bitcoinjs-lib"; import * as v from "valibot"; import { Address, AddressPurpose, BtcProvider, MessageSigningProtocols, Requests, Return, RpcErrorCode, RpcResponse, WalletNetworkName, rpcErrorResponseMessageSchema, rpcSuccessResponseMessageSchema, } from "./advancedBarrel.js"; async function checkResponse( response: Promise>, ): Promise> { const res = await response; if (v.is(rpcErrorResponseMessageSchema, res)) { // eslint-disable-next-line @typescript-eslint/only-throw-error throw res.error; } if (v.is(rpcSuccessResponseMessageSchema, res)) { return res.result; } // eslint-disable-next-line @typescript-eslint/only-throw-error throw { code: RpcErrorCode.INTERNAL_ERROR, message: "Received unknown response from provider.", data: res, }; } /** * Class representing a Bitcoin signer that extends SignerBtc * @public */ export class Signer extends ccc.SignerBtc { private addressCache: Promise
| undefined; /** * Creates an instance of Signer. * @param client - The client instance. * @param provider - The provider instance. */ constructor( client: ccc.Client, public readonly provider: BtcProvider, _preferredNetworks?: ccc.NetworkPreference[], public readonly network?: WalletNetworkName, ) { super(client); } async assertAddress(): Promise
{ const request = this.addressCache ?? this.requestAddress(); this.addressCache = request; let address: Address | undefined; try { address = await request; } catch (error) { if (this.addressCache === request) { this.addressCache = undefined; } throw error; } if (address) { return address; } this.addressCache = undefined; throw Error("Not connected"); } private async requestAddress(): Promise
{ if (this.network && (await this.getNetwork()) !== this.network) { return; } return ( await checkResponse( this.provider.request("getAddresses", { purposes: [AddressPurpose.Payment], }), ) ).addresses[0]; } /** * Gets the Bitcoin account address. * @returns A promise that resolves to the Bitcoin account address. */ async getBtcAccount(): Promise { return (await this.assertAddress()).address; } /** * Gets the Bitcoin public key. * @returns A promise that resolves to the Bitcoin public key. */ async getBtcPublicKey(): Promise { return ccc.hexFrom((await this.assertAddress()).publicKey); } /** * Connects to the provider by requesting accounts. * @returns A promise that resolves when the connection is established. */ async connect(): Promise { if (await this.isConnected()) { return; } await checkResponse( this.provider.request("wallet_requestPermissions", undefined), ); const currentNetwork = this.network ? await this.getNetwork() : undefined; if (this.network && currentNetwork !== this.network) { await checkResponse( this.provider.request("wallet_changeNetwork", { name: this.network }), ); this.addressCache = undefined; } } async disconnect(): Promise { this.addressCache = undefined; } onReplaced(listener: () => void): () => void { const stops: (() => void)[] = []; const stop = () => { stops.splice(0).forEach((unregister) => unregister()); }; const replacer = () => { listener(); stop(); }; stops.push( this.provider.addListener("accountChange", replacer), this.provider.addListener("networkChange", replacer), ); return stop; } /** * Checks if the signer is connected. * @returns A promise that resolves to true if connected, false otherwise. */ async isConnected(): Promise { try { const address = await this.requestAddress(); if (address) { this.addressCache = Promise.resolve(address); } else { this.addressCache = undefined; } return address !== undefined; } catch (_error) { this.addressCache = undefined; return false; } } private async getNetwork(): Promise { return ( await checkResponse(this.provider.request("wallet_getNetwork", null)) ).bitcoin.name; } /** * Signs a raw message with the Bitcoin account. * @param message - The message to sign. * @returns A promise that resolves to the signed message. */ async signMessageRaw(message: string | ccc.BytesLike): Promise { const challenge = typeof message === "string" ? message : ccc.hexFrom(message).slice(2); return ( await checkResponse( this.provider.request("signMessage", { message: challenge, address: (await this.assertAddress()).address, protocol: MessageSigningProtocols.ECDSA, }), ) ).signature; } /** * Build default inputsToSign for all unsigned inputs */ private buildDefaultinputsToSign( psbtHex: ccc.Hex, address: string, ): ccc.InputToSign[] { const inputsToSign: ccc.InputToSign[] = []; try { // Collect all unsigned inputs const psbt = Psbt.fromHex(psbtHex.slice(2)); psbt.data.inputs.forEach((input, index) => { const isSigned = input.finalScriptSig || input.finalScriptWitness || input.tapKeySig || (input.partialSig && input.partialSig.length > 0) || (input.tapScriptSig && input.tapScriptSig.length > 0); if (!isSigned) { inputsToSign.push(ccc.InputToSign.from({ index, address })); } }); // If no unsigned inputs found, the PSBT is already fully signed // Let the wallet handle this case (likely a no-op or error) } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); throw new Error( `Failed to parse PSBT hex. Please provide inputsToSign explicitly in options. Original error: ${errorMessage}`, { cause: error }, ); } return inputsToSign; } private async prepareSignPsbtParams( psbtHex: ccc.Hex, options: ccc.SignPsbtOptions, ): Promise<{ psbtBase64: string; signInputs: Record; }> { let inputsToSign = options.inputsToSign; if (!inputsToSign.length) { const address = await this.getBtcAccount(); inputsToSign = this.buildDefaultinputsToSign(psbtHex, address); } const psbtBase64 = ccc.bytesTo(psbtHex, "base64"); const signInputs = inputsToSign.reduce( (acc, input) => { if (!input.address) { throw new Error( "Xverse only supports signing with address. Please provide 'address' in inputsToSign.", ); } if (acc[input.address]) { acc[input.address].push(input.index); } else { acc[input.address] = [input.index]; } return acc; }, {} as Record, ); return { psbtBase64, signInputs }; } /** * Finalize the inputs Xverse was asked to sign. * * Xverse returns the signed PSBT without finalizing it, so this is done * locally to honor `autoFinalized`. Only the requested inputs are touched, * leaving inputs owned by other signers untouched. */ private finalizeSignedInputs( psbt: Psbt, signInputs: Record, ): void { const indexes = new Set(Object.values(signInputs).flat()); try { for (const index of indexes) { const input = psbt.data.inputs[index]; if (input?.finalScriptSig || input?.finalScriptWitness) { continue; } psbt.finalizeInput(index); } } catch (error) { throw new Error( "Failed to finalize the PSBT signed by Xverse. " + "Use { autoFinalized: false } for partial or multisig signing.", { cause: error }, ); } } /** * Signs a PSBT using Xverse wallet. * * @param psbtHex - The hex string of PSBT to sign. * @param options - Options for signing the PSBT * @returns A promise that resolves to the signed PSBT as a Hex string * * @remarks * Xverse accepts: * - psbt: A string representing the PSBT to sign, encoded in base64 * - signInputs: A Record where: * - keys are the addresses to use for signing * - values are the indexes of the inputs to sign with each address * * Xverse returns: * - psbt: The base64 encoded signed PSBT, which is not finalized * * Xverse has no equivalent of `autoFinalized`, so when it is enabled the * requested inputs are finalized locally after signing. `sighashTypes` and * `disableTweakSigner` in `inputsToSign` are not supported by Xverse and * are ignored. * * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptionsLike, ): Promise { const normalized = ccc.SignPsbtOptions.from(options); const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), normalized, ); const signedPsbtBase64 = ( await checkResponse( this.provider.request("signPsbt", { psbt: psbtBase64, signInputs, broadcast: false, }), ) ).psbt; if (!normalized.autoFinalized) { return ccc.hexFrom(ccc.bytesFrom(signedPsbtBase64, "base64")); } const signedPsbt = Psbt.fromBase64(signedPsbtBase64); this.finalizeSignedInputs(signedPsbt, signInputs); return ccc.hexFrom(signedPsbt.toBuffer()); } /** * Broadcasts a PSBT to the Bitcoin network. * * @remarks * Xverse does not support broadcasting a signed PSBT directly. * It only supports "Sign and Broadcast" as a single atomic operation via `signAndBroadcastPsbt`. */ async broadcastPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptionsLike, ): Promise { throw new Error( "Xverse does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", ); } async signAndBroadcastPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptionsLike, ): Promise { // Xverse finalizes and broadcasts on its own side, so autoFinalized is // not needed here. const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), ccc.SignPsbtOptions.from(options), ); const result = await checkResponse( this.provider.request("signPsbt", { psbt: psbtBase64, signInputs, broadcast: true, }), ); if (!result.txid) { throw new Error("Failed to broadcast PSBT"); } return ccc.hexFrom(result.txid); } }