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.InputToSignLike[] { const inputsToSign: ccc.InputToSignLike[] = []; 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({ 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.SignPsbtOptionsLike, ): Promise<{ psbtBase64: string; signInputs: Record; }> { let inputsToSign = options?.inputsToSign; if (!inputsToSign || !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 }; } /** * 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 * * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptionsLike, ): Promise { const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), options, ); const signedPsbtBase64 = ( await checkResponse( this.provider.request("signPsbt", { psbt: psbtBase64, signInputs, broadcast: false, }), ) ).psbt; return ccc.hexFrom(ccc.bytesFrom(signedPsbtBase64, "base64")); } /** * 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 { // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), 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); } }