From e9a3a6e9358e036aba9e05f601fcfaf24ce89ac4 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 00:11:23 -0500 Subject: [PATCH 1/8] feat(btc): add PSBT signing and broadcasting support --- packages/core/src/signer/btc/index.ts | 1 + packages/core/src/signer/btc/psbt.ts | 56 ++++++ packages/core/src/signer/btc/signerBtc.ts | 50 +++++ .../signer/btc/signerBtcPublicKeyReadonly.ts | 9 + packages/joy-id/src/btc/index.ts | 72 ++++++++ packages/okx/src/advancedBarrel.ts | 17 +- packages/okx/src/btc/index.ts | 27 +++ packages/uni-sat/src/advancedBarrel.ts | 15 ++ packages/uni-sat/src/signer.ts | 26 +++ packages/utxo-global/src/btc/index.ts | 25 +++ packages/xverse/package.json | 1 + packages/xverse/src/signer.ts | 171 ++++++++++++++++++ pnpm-lock.yaml | 59 ++++++ 13 files changed, 527 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/signer/btc/psbt.ts diff --git a/packages/core/src/signer/btc/index.ts b/packages/core/src/signer/btc/index.ts index d0aa15884..694ff7250 100644 --- a/packages/core/src/signer/btc/index.ts +++ b/packages/core/src/signer/btc/index.ts @@ -1,3 +1,4 @@ +export * from "./psbt.js"; export * from "./signerBtc.js"; export * from "./signerBtcPublicKeyReadonly.js"; export * from "./verify.js"; diff --git a/packages/core/src/signer/btc/psbt.ts b/packages/core/src/signer/btc/psbt.ts new file mode 100644 index 000000000..77ac646f7 --- /dev/null +++ b/packages/core/src/signer/btc/psbt.ts @@ -0,0 +1,56 @@ +/** + * Options for signing a PSBT (Partially Signed Bitcoin Transaction) + */ +export type SignPsbtOptions = { + /** + * Whether to finalize the PSBT after signing. + * Default is true. + */ + autoFinalized?: boolean; + /** + * Array of inputs to sign + */ + toSignInputs?: ToSignInput[]; +}; + +/** + * Specification for an input to sign in a PSBT. + * Must specify at least one of: address or pubkey. + */ +export type ToSignInput = { + /** + * Which input to sign (index in the PSBT inputs array) + */ + index: number; + /** + * (Optional) Sighash types to use for signing. + */ + sighashTypes?: number[]; + /** + * (Optional) When signing and unlocking Taproot addresses, the tweakSigner is used by default + * for signature generation. Setting this to true allows for signing with the original private key. + * Default value is false. + */ + disableTweakSigner?: boolean; +} & ( + | { + /** + * The address whose corresponding private key to use for signing. + */ + address: string; + /** + * The public key whose corresponding private key to use for signing. + */ + publicKey?: string; + } + | { + /** + * The address whose corresponding private key to use for signing. + */ + address?: string; + /** + * The public key whose corresponding private key to use for signing. + */ + publicKey: string; + } +); diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 64112a74a..6f31946cd 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -5,6 +5,7 @@ import { KnownScript } from "../../client/index.js"; import { HexLike, hexFrom } from "../../hex/index.js"; import { numToBytes } from "../../num/index.js"; import { Signer, SignerSignType, SignerType } from "../signer/index.js"; +import { SignPsbtOptions } from "./psbt.js"; import { btcEcdsaPublicKeyHash } from "./verify.js"; /** @@ -22,6 +23,32 @@ export abstract class SignerBtc extends Signer { return SignerSignType.BtcEcdsa; } + /** + * Whether the wallet supports a single call to sign + broadcast (combined flow). + * Default false; override in implementations like Xverse/JoyID. + */ + get supportsSingleCallSignAndBroadcast(): boolean { + return false; + } + + /** + * Sign and broadcast a PSBT in one call when supported, otherwise falls back + * to sign then push. Prefer this over manual sign+push to avoid double popups. + */ + async signAndPushPsbt( + psbtHex: string, + options?: SignPsbtOptions, + ): Promise { + if (this.supportsSingleCallSignAndBroadcast) { + // Wallet handles sign+broadcast internally (e.g., Xverse/JoyID) + return this.pushPsbt(psbtHex, options); + } + + // Split-mode wallets: sign first, then broadcast + const signedPsbt = await this.signPsbt(psbtHex, options); + return this.pushPsbt(signedPsbt, options); + } + /** * Gets the Bitcoin account associated with the signer. * @@ -123,4 +150,27 @@ export abstract class SignerBtc extends Signer { tx.setWitnessArgsAt(info.position, witness); return tx; } + + /** + * Signs a Partially Signed Bitcoin Transaction (PSBT). + * + * @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 hex string + */ + abstract signPsbt( + psbtHex: string, + options?: SignPsbtOptions, + ): Promise; + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + */ + abstract pushPsbt( + psbtHex: string, + options?: SignPsbtOptions, + ): Promise; } diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index 50096db7e..f5293a952 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -1,5 +1,6 @@ import { Client } from "../../client/index.js"; import { Hex, HexLike, hexFrom } from "../../hex/index.js"; +import { SignPsbtOptions } from "./psbt.js"; import { SignerBtc } from "./signerBtc.js"; /** @@ -70,4 +71,12 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { async getBtcPublicKey(): Promise { return this.publicKey; } + + async signPsbt(_: string, __?: SignPsbtOptions): Promise { + throw new Error("Read-only signer does not support signPsbt"); + } + + async pushPsbt(_: string, __?: SignPsbtOptions): Promise { + throw new Error("Read-only signer does not support pushPsbt"); + } } diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 4a1e92fad..885528659 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -60,6 +60,10 @@ export class BitcoinSigner extends ccc.SignerBtc { super(client); } + get supportsSingleCallSignAndBroadcast(): boolean { + return true; + } + /** * Gets the configuration for JoyID. * @returns The configuration object. @@ -198,4 +202,72 @@ export class BitcoinSigner extends ccc.SignerBtc { ); return signature; } + + /** + * Signs a PSBT using JoyID wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @returns A promise that resolves to the signed PSBT hex string + */ + async signPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + const { address } = await this.assertConnection(); + + const config = this.getConfig(); + const { tx: signedPsbtHex } = await createPopup( + buildJoyIDURL( + { + ...config, + tx: psbtHex, + options, + signerAddress: address, + autoFinalized: options?.autoFinalized ?? true, + }, + "popup", + "/sign-psbt", + ), + { ...config, type: DappRequestType.SignPsbt }, + ); + + return signedPsbtHex; + } + + /** + * Signs and broadcasts a PSBT to the Bitcoin network using JoyID wallet. + * + * This method combines both signing and broadcasting in a single operation. + * + * @param psbtHex - The hex string of PSBT to sign and broadcast + * @returns A promise that resolves to the transaction ID + * + * @remarks + * Use this method directly for sign+broadcast operations to avoid double popups. + * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. + */ + async pushPsbt( + psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + const { address } = await this.assertConnection(); + + const config = this.getConfig(); + const { tx: txid } = await createPopup( + buildJoyIDURL( + { + ...config, + tx: psbtHex, + signerAddress: address, + autoFinalized: true, // sendPsbt always finalizes + isSend: true, + }, + "popup", + "/sign-psbt", + ), + { ...config, type: DappRequestType.SignPsbt }, // Use SignPsbt type for both operations + ); + + return txid; + } } diff --git a/packages/okx/src/advancedBarrel.ts b/packages/okx/src/advancedBarrel.ts index 4704b662f..bdd6b3e04 100644 --- a/packages/okx/src/advancedBarrel.ts +++ b/packages/okx/src/advancedBarrel.ts @@ -2,8 +2,21 @@ import { Nip07A } from "@ckb-ccc/nip07/advanced"; import { UniSatA } from "@ckb-ccc/uni-sat/advanced"; export interface BitcoinProvider - extends Pick, - Partial> { + extends Pick< + UniSatA.Provider, + "on" | "removeListener" | "signMessage" | "signPsbt" | "pushPsbt" + >, + Partial< + Omit< + UniSatA.Provider, + | "on" + | "removeListener" + | "signMessage" + | "signPsbt" + | "pushPsbt" + | "pushTx" + > + > { connect?(): Promise<{ address: string; publicKey: string; diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index c35b9a480..7e3b20b65 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -176,4 +176,31 @@ export class BitcoinSigner extends ccc.SignerBtc { return this.provider.signMessage(challenge, "ecdsa"); } + + /** + * Signs a PSBT using OKX 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 hex string + */ + async signPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.signPsbt(psbtHex, options); + } + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + */ + async pushPsbt( + psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.pushPsbt(psbtHex); + } } diff --git a/packages/uni-sat/src/advancedBarrel.ts b/packages/uni-sat/src/advancedBarrel.ts index e6ae56b58..6a97061cf 100644 --- a/packages/uni-sat/src/advancedBarrel.ts +++ b/packages/uni-sat/src/advancedBarrel.ts @@ -1,7 +1,22 @@ +import { ccc } from "@ckb-ccc/core"; + /** * Interface representing a provider for interacting with accounts and signing messages. */ export interface Provider { + /** + * Signs a PSBT using UniSat 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 hex string + */ + signPsbt(psbtHex: string, options?: ccc.SignPsbtOptions): Promise; + + pushPsbt(psbtHex: string): Promise; + + pushTx(tx: { rawtx: string }): Promise; + /** * Requests user accounts. * @returns A promise that resolves to an array of account addresses. diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index 653bba8ee..bf28c7de5 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -150,4 +150,30 @@ export class Signer extends ccc.SignerBtc { return this.provider.signMessage(challenge, "ecdsa"); } + + /** + * Signs a PSBT using UniSat wallet. + * + * @param psbtHex - The hex string of PSBT to sign + * @returns A promise that resolves to the signed PSBT hex string + */ + async signPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.signPsbt(psbtHex, options); + } + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + */ + async pushPsbt( + psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + return this.provider.pushPsbt(psbtHex); + } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index 57e73594d..5cede753c 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -127,4 +127,29 @@ export class SignerBtc extends ccc.SignerBtc { this.accountCache ?? (await this.getBtcAccount()), ); } + + /** + * Signs a PSBT using UTXO Global 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 hex string + */ + async signPsbt( + _psbtHex: string, + _options?: ccc.SignPsbtOptions, + ): Promise { + throw new Error("UTXO Global PSBT signing not implemented yet"); + } + + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of signed PSBT to broadcast + * @returns A promise that resolves to the transaction ID + * @todo Implement PSBT broadcasting with UTXO Global + */ + async pushPsbt(_: string, __?: ccc.SignPsbtOptions): Promise { + throw new Error("UTXO Global PSBT broadcasting not implemented yet"); + } } diff --git a/packages/xverse/package.json b/packages/xverse/package.json index 6dcfcc970..21f9ae545 100644 --- a/packages/xverse/package.json +++ b/packages/xverse/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@ckb-ccc/core": "workspace:*", + "bitcoinjs-lib": "^7.0.0", "valibot": "^1.1.0" }, "packageManager": "pnpm@10.8.1" diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index af243a11a..5507e4006 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -1,4 +1,5 @@ import { ccc } from "@ckb-ccc/core"; +import { Psbt } from "bitcoinjs-lib"; import * as v from "valibot"; import { Address, @@ -65,6 +66,10 @@ export class Signer extends ccc.SignerBtc { super(client); } + get supportsSingleCallSignAndBroadcast(): boolean { + return true; + } + async assertAddress(): Promise
{ this.addressCache = this.addressCache ?? @@ -169,4 +174,170 @@ export class Signer extends ccc.SignerBtc { ) ).signature; } + + /** + * Build default toSignInputs for all unsigned inputs + */ + private buildDefaultToSignInputs( + psbtHex: string, + address: string, + ): ccc.ToSignInput[] { + const toSignInputs: ccc.ToSignInput[] = []; + + try { + const psbt = Psbt.fromHex(psbtHex); + + // Collect all unsigned inputs + 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) { + toSignInputs.push({ index, address } as ccc.ToSignInput); + } + }); + + // If no unsigned inputs found, assume we need to sign all inputs + if (toSignInputs.length === 0) { + for (let i = 0; i < psbt.data.inputs.length; i++) { + toSignInputs.push({ index: i, address } as ccc.ToSignInput); + } + } + } catch (error) { + // Fallback: if PSBT parsing fails, assume single input at index 0 + console.warn("Failed to parse PSBT, assuming single input:", error); + toSignInputs.push({ index: 0, address } as ccc.ToSignInput); + } + + return toSignInputs; + } + + private async prepareSignPsbtParams( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise<{ + psbtBase64: string; + signInputs: Record; + }> { + let toSignInputs = options?.toSignInputs; + if (!toSignInputs || !toSignInputs.length) { + const address = await this.getBtcAccount(); + toSignInputs = this.buildDefaultToSignInputs(psbtHex, address); + } + + const psbtBytes = ccc.bytesFrom(psbtHex); + const psbtBase64 = ccc.bytesTo(psbtBytes, "base64"); + + const signInputs = toSignInputs.reduce( + (acc, input) => { + if (!input.address) { + throw new Error( + "Xverse only supports signing with address. Please provide 'address' in toSignInputs.", + ); + } + 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 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: string, + options?: ccc.SignPsbtOptions, + ): Promise { + const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( + psbtHex, + options, + ); + + const signedPsbtBase64 = ( + await checkResponse( + this.provider.request("signPsbt", { + psbt: psbtBase64, + signInputs, + broadcast: false, + }), + ) + ).psbt; + + const signedPsbtBytes = ccc.bytesFrom(signedPsbtBase64, "base64"); + return ccc.hexFrom(signedPsbtBytes).slice(2); + } + + /** + * Signs and broadcasts a PSBT using Xverse wallet (single popup). + * + * @param psbtHex - The hex string of PSBT to sign and broadcast + * @param options - Options for signing the PSBT + * @returns A promise that resolves to SignPsbtResult: + * - psbt: base64 encoded signed PSBT + * - txid: transaction id (only when broadcast succeeds) + * + * @remarks + * Xverse accepts: + * - psbt: base64 encoded PSBT + * - signInputs: Record input indexes to sign + * - broadcast: set to true to broadcast + * + * @remarks + * Use this method directly for sign+broadcast operations to avoid double popups. + * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. + * + * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt + */ + async pushPsbt( + psbtHex: string, + options?: ccc.SignPsbtOptions, + ): Promise { + const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( + psbtHex, + options, + ); + + const result = await checkResponse( + this.provider.request("signPsbt", { + psbt: psbtBase64, + // Build signInputs: Record + // Multiple inputs with the same address should be grouped together + signInputs, + broadcast: true, + }), + ); + + if (!result.txid) { + throw new Error("Failed to broadcast PSBT"); + } + + return result.txid; + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fa2ac90a..704be8ca0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1238,6 +1238,9 @@ importers: '@ckb-ccc/core': specifier: workspace:* version: link:../core + bitcoinjs-lib: + specifier: ^7.0.0 + version: 7.0.0(typescript@5.9.2) valibot: specifier: ^1.1.0 version: 1.1.0(typescript@5.9.2) @@ -4993,6 +4996,14 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bip174@3.0.0: + resolution: {integrity: sha512-N3vz3rqikLEu0d6yQL8GTrSkpYb35NQKWMR7Hlza0lOj6ZOlvQ3Xr7N9Y+JPebaCVoEUHdBeBSuLxcHr71r+Lw==} + engines: {node: '>=18.0.0'} + + bitcoinjs-lib@7.0.0: + resolution: {integrity: sha512-2W6dGXFd1KG3Bs90Bzb5+ViCeSKNIYkCUWZ4cvUzUgwnneiNNZ6Sk8twGNcjlesmxC0JyLc/958QycfpvXLg7A==} + engines: {node: '>=18.0.0'} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -10107,6 +10118,14 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + uint8array-tools@0.0.8: + resolution: {integrity: sha512-xS6+s8e0Xbx++5/0L+yyexukU7pz//Yg6IHg3BKhXotg1JcYtgxVcUctQ0HxLByiJzpAkNFawz1Nz5Xadzo82g==} + engines: {node: '>=14.0.0'} + + uint8array-tools@0.0.9: + resolution: {integrity: sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==} + engines: {node: '>=14.0.0'} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -10240,6 +10259,14 @@ packages: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} engines: {node: '>=10.12.0'} + valibot@0.38.0: + resolution: {integrity: sha512-RCJa0fetnzp+h+KN9BdgYOgtsMAG9bfoJ9JSjIhFHobKWVWyzM3jjaeNTdpFK9tQtf3q1sguXeERJ/LcmdFE7w==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valibot@1.1.0: resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} peerDependencies: @@ -10258,6 +10285,9 @@ packages: value-equal@1.0.1: resolution: {integrity: sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==} + varuint-bitcoin@2.0.0: + resolution: {integrity: sha512-6QZbU/rHO2ZQYpWFDALCDSRsXbAs1VOEmXAxtbtjLtKuMJ/FQ8YbhfxlaiKv5nklci0M6lZtlZyxo9Q+qNnyog==} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -15588,6 +15618,23 @@ snapshots: binary-extensions@2.3.0: {} + bip174@3.0.0: + dependencies: + uint8array-tools: 0.0.9 + varuint-bitcoin: 2.0.0 + + bitcoinjs-lib@7.0.0(typescript@5.9.2): + dependencies: + '@noble/hashes': 1.8.0 + bech32: 2.0.0 + bip174: 3.0.0 + bs58check: 4.0.0(patch_hash=0848a2e3956f24abf1dd8620cba2a3f468393e489185d9536ad109f7e5712d26) + uint8array-tools: 0.0.9 + valibot: 0.38.0(typescript@5.9.2) + varuint-bitcoin: 2.0.0 + transitivePeerDependencies: + - typescript + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -21869,6 +21916,10 @@ snapshots: uint8array-extras@1.5.0: {} + uint8array-tools@0.0.8: {} + + uint8array-tools@0.0.9: {} + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -22031,6 +22082,10 @@ snapshots: '@types/istanbul-lib-coverage': 2.0.6 convert-source-map: 2.0.0 + valibot@0.38.0(typescript@5.9.2): + optionalDependencies: + typescript: 5.9.2 + valibot@1.1.0(typescript@5.9.2): optionalDependencies: typescript: 5.9.2 @@ -22044,6 +22099,10 @@ snapshots: value-equal@1.0.1: {} + varuint-bitcoin@2.0.0: + dependencies: + uint8array-tools: 0.0.8 + vary@1.1.2: {} vfile-location@5.0.3: From 19490f46cecab30cec71cfcf3560f230bce308f3 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 00:44:08 -0500 Subject: [PATCH 2/8] fix(btc): improve PSBT signing safety and error handling --- packages/core/src/signer/btc/signerBtc.ts | 11 ++++++++--- packages/xverse/src/signer.ts | 18 +++++------------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 6f31946cd..6dd8ba4c6 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -164,10 +164,15 @@ export abstract class SignerBtc extends Signer { ): Promise; /** - * Broadcasts a signed PSBT to the Bitcoin network. + * Pushes a PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * For wallets that support a single call for signing and broadcasting (where `supportsSingleCallSignAndBroadcast` is true), + * this method takes an **unsigned** PSBT, signs it, and broadcasts it. + * For other wallets, this method takes a **signed** PSBT and only broadcasts it. + * + * @param psbtHex - The hex string of the PSBT to push. Can be signed or unsigned depending on the wallet's capabilities. + * @param options - Options for signing the PSBT. Only used by wallets that perform signing in this step. + * @returns A promise that resolves to the transaction ID. */ abstract pushPsbt( psbtHex: string, diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 5507e4006..f28d798eb 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -201,16 +201,12 @@ export class Signer extends ccc.SignerBtc { } }); - // If no unsigned inputs found, assume we need to sign all inputs - if (toSignInputs.length === 0) { - for (let i = 0; i < psbt.data.inputs.length; i++) { - toSignInputs.push({ index: i, address } as ccc.ToSignInput); - } - } + // 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) { - // Fallback: if PSBT parsing fails, assume single input at index 0 - console.warn("Failed to parse PSBT, assuming single input:", error); - toSignInputs.push({ index: 0, address } as ccc.ToSignInput); + throw new Error( + `Failed to parse PSBT hex. Please provide toSignInputs explicitly in options. Original error: ${String(error)}`, + ); } return toSignInputs; @@ -309,10 +305,6 @@ export class Signer extends ccc.SignerBtc { * - signInputs: Record input indexes to sign * - broadcast: set to true to broadcast * - * @remarks - * Use this method directly for sign+broadcast operations to avoid double popups. - * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. - * * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt */ async pushPsbt( From b4d9e5ed42b784c098bdb4e8d381e46efc3090a4 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 01:10:20 -0500 Subject: [PATCH 3/8] chore(btc): improve PSBT documentation and remove redundant code --- packages/uni-sat/src/advancedBarrel.ts | 8 ++++++-- packages/uni-sat/src/signer.ts | 1 + packages/xverse/src/signer.ts | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/uni-sat/src/advancedBarrel.ts b/packages/uni-sat/src/advancedBarrel.ts index 6a97061cf..8ff7e0c04 100644 --- a/packages/uni-sat/src/advancedBarrel.ts +++ b/packages/uni-sat/src/advancedBarrel.ts @@ -13,10 +13,14 @@ export interface Provider { */ signPsbt(psbtHex: string, options?: ccc.SignPsbtOptions): Promise; + /** + * Broadcasts a signed PSBT to the Bitcoin network. + * + * @param psbtHex - The hex string of the signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID. + */ pushPsbt(psbtHex: string): Promise; - pushTx(tx: { rawtx: string }): Promise; - /** * Requests user accounts. * @returns A promise that resolves to an array of account addresses. diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index bf28c7de5..1c4461c49 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -155,6 +155,7 @@ export class Signer extends ccc.SignerBtc { * Signs a PSBT using UniSat 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 hex string */ async signPsbt( diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index f28d798eb..34d10c239 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -197,7 +197,7 @@ export class Signer extends ccc.SignerBtc { (input.tapScriptSig && input.tapScriptSig.length > 0); if (!isSigned) { - toSignInputs.push({ index, address } as ccc.ToSignInput); + toSignInputs.push({ index, address }); } }); From efcdcd8507fb84ca110f464dca9f8cd288e99295 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 10 Dec 2025 01:28:05 -0500 Subject: [PATCH 4/8] refactor(xverse): use bytesTo instead of hexFrom+slice for PSBT conversion --- packages/xverse/src/signer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 34d10c239..11344c12f 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -287,7 +287,7 @@ export class Signer extends ccc.SignerBtc { ).psbt; const signedPsbtBytes = ccc.bytesFrom(signedPsbtBase64, "base64"); - return ccc.hexFrom(signedPsbtBytes).slice(2); + return ccc.bytesTo(signedPsbtBytes, "hex"); // no leading "0x" } /** From 7b168318d0d1a1fccde9c4f1b907fd24857943d5 Mon Sep 17 00:00:00 2001 From: fgh Date: Thu, 8 Jan 2026 00:06:19 -0500 Subject: [PATCH 5/8] fix(joy-id): pass options parameter to buildJoyIDURL in pushPsbt --- packages/joy-id/src/btc/index.ts | 3 ++- packages/xverse/src/signer.ts | 2 -- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 885528659..0cf634a79 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -248,7 +248,7 @@ export class BitcoinSigner extends ccc.SignerBtc { */ async pushPsbt( psbtHex: string, - _options?: ccc.SignPsbtOptions, + options?: ccc.SignPsbtOptions, ): Promise { const { address } = await this.assertConnection(); @@ -258,6 +258,7 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, tx: psbtHex, + options, signerAddress: address, autoFinalized: true, // sendPsbt always finalizes isSend: true, diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 11344c12f..c793d6a19 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -319,8 +319,6 @@ export class Signer extends ccc.SignerBtc { const result = await checkResponse( this.provider.request("signPsbt", { psbt: psbtBase64, - // Build signInputs: Record - // Multiple inputs with the same address should be grouped together signInputs, broadcast: true, }), From 8f4091f3ff49d8eb3577b76357989ce99a9f3ab4 Mon Sep 17 00:00:00 2001 From: fgh Date: Tue, 13 Jan 2026 13:16:04 -0500 Subject: [PATCH 6/8] refactor(SignerBtc): standardize BTC signer methods and types --- packages/core/src/signer/btc/signerBtc.ts | 58 ++++++++----------- .../signer/btc/signerBtcPublicKeyReadonly.ts | 6 +- packages/joy-id/src/btc/index.ts | 39 +++++++------ packages/okx/src/btc/index.ts | 18 +++--- packages/uni-sat/src/signer.ts | 18 +++--- packages/utxo-global/src/btc/index.ts | 15 +++-- packages/xverse/src/signer.ts | 58 +++++++++---------- 7 files changed, 100 insertions(+), 112 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 6dd8ba4c6..30c090c6d 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -24,29 +24,19 @@ export abstract class SignerBtc extends Signer { } /** - * Whether the wallet supports a single call to sign + broadcast (combined flow). - * Default false; override in implementations like Xverse/JoyID. - */ - get supportsSingleCallSignAndBroadcast(): boolean { - return false; - } - - /** - * Sign and broadcast a PSBT in one call when supported, otherwise falls back - * to sign then push. Prefer this over manual sign+push to avoid double popups. + * Sign and broadcast a PSBT. + * + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign and broadcast. + * @param options - Options for signing the PSBT. + * @returns A promise that resolves to the transaction ID (non-0x prefixed hex). */ - async signAndPushPsbt( - psbtHex: string, + async signAndBroadcastPsbt( + psbtHex: HexLike, options?: SignPsbtOptions, ): Promise { - if (this.supportsSingleCallSignAndBroadcast) { - // Wallet handles sign+broadcast internally (e.g., Xverse/JoyID) - return this.pushPsbt(psbtHex, options); - } - - // Split-mode wallets: sign first, then broadcast - const signedPsbt = await this.signPsbt(psbtHex, options); - return this.pushPsbt(signedPsbt, options); + // ccc.hexFrom adds 0x prefix, but BTC expects non-0x + const signedPsbt = await this.signPsbt(hexFrom(psbtHex).slice(2), options); + return this.broadcastPsbt(signedPsbt, options); } /** @@ -154,28 +144,26 @@ export abstract class SignerBtc extends Signer { /** * Signs a Partially Signed Bitcoin Transaction (PSBT). * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ abstract signPsbt( - psbtHex: string, + psbtHex: HexLike, options?: SignPsbtOptions, ): Promise; /** - * Pushes a PSBT to the Bitcoin network. - * - * For wallets that support a single call for signing and broadcasting (where `supportsSingleCallSignAndBroadcast` is true), - * this method takes an **unsigned** PSBT, signs it, and broadcasts it. - * For other wallets, this method takes a **signed** PSBT and only broadcasts it. + * Broadcasts a PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of the PSBT to push. Can be signed or unsigned depending on the wallet's capabilities. - * @param options - Options for signing the PSBT. Only used by wallets that perform signing in this step. - * @returns A promise that resolves to the transaction ID. + * @param psbtHex - The hex string (without 0x prefix) of the PSBT to broadcast. + * @param options - Options for broadcasting the PSBT. + * @returns A promise that resolves to the transaction ID (without 0x prefix). */ - abstract pushPsbt( - psbtHex: string, - options?: SignPsbtOptions, - ): Promise; + async broadcastPsbt( + _psbtHex: HexLike, + _options?: SignPsbtOptions, + ): Promise { + throw new Error("Not implemented"); + } } diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index f5293a952..e35be4f9d 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -72,11 +72,11 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { return this.publicKey; } - async signPsbt(_: string, __?: SignPsbtOptions): Promise { + async signPsbt(_: HexLike, __?: SignPsbtOptions): Promise { throw new Error("Read-only signer does not support signPsbt"); } - async pushPsbt(_: string, __?: SignPsbtOptions): Promise { - throw new Error("Read-only signer does not support pushPsbt"); + async broadcastPsbt(_: HexLike, __?: SignPsbtOptions): Promise { + throw new Error("Read-only signer does not support broadcastPsbt"); } } diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 0cf634a79..8d5686fc2 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -60,10 +60,6 @@ export class BitcoinSigner extends ccc.SignerBtc { super(client); } - get supportsSingleCallSignAndBroadcast(): boolean { - return true; - } - /** * Gets the configuration for JoyID. * @returns The configuration object. @@ -206,11 +202,11 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using JoyID wallet. * - * @param psbtHex - The hex string of PSBT to sign - * @returns A promise that resolves to the signed PSBT hex string + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { const { address } = await this.assertConnection(); @@ -220,7 +216,7 @@ export class BitcoinSigner extends ccc.SignerBtc { buildJoyIDURL( { ...config, - tx: psbtHex, + tx: ccc.hexFrom(psbtHex).slice(2), options, signerAddress: address, autoFinalized: options?.autoFinalized ?? true, @@ -235,29 +231,34 @@ export class BitcoinSigner extends ccc.SignerBtc { } /** - * Signs and broadcasts a PSBT to the Bitcoin network using JoyID wallet. - * - * This method combines both signing and broadcasting in a single operation. - * - * @param psbtHex - The hex string of PSBT to sign and broadcast - * @returns A promise that resolves to the transaction ID + * Broadcasts a PSBT to the Bitcoin network. * * @remarks - * Use this method directly for sign+broadcast operations to avoid double popups. - * While calling signPsbt() then pushPsbt() will still work, it triggers two popups and requires double signing. + * JoyID does not support broadcasting a signed PSBT directly. + * It only supports "Sign and Broadcast" as a single atomic operation via `signAndBroadcastPsbt`. */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + _psbtHex: ccc.HexLike, + _options?: ccc.SignPsbtOptions, + ): Promise { + throw new Error( + "JoyID does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", + ); + } + + async signAndBroadcastPsbt( + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { const { address } = await this.assertConnection(); const config = this.getConfig(); + // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { tx: txid } = await createPopup( buildJoyIDURL( { ...config, - tx: psbtHex, + tx: ccc.hexFrom(psbtHex).slice(2), options, signerAddress: address, autoFinalized: true, // sendPsbt always finalizes diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index 7e3b20b65..f9ff02512 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -180,27 +180,27 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using OKX wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.signPsbt(psbtHex, options); + return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); } /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID (without 0x prefix) */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.pushPsbt(psbtHex); + return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); } } diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index 1c4461c49..4eba70d5d 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -154,27 +154,27 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using UniSat wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.signPsbt(psbtHex, options); + return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); } /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID (without 0x prefix) */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, ): Promise { - return this.provider.pushPsbt(psbtHex); + return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index 5cede753c..bfd74c499 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -131,12 +131,12 @@ export class SignerBtc extends ccc.SignerBtc { /** * Signs a PSBT using UTXO Global wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) */ async signPsbt( - _psbtHex: string, + _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, ): Promise { throw new Error("UTXO Global PSBT signing not implemented yet"); @@ -145,11 +145,14 @@ export class SignerBtc extends ccc.SignerBtc { /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string of signed PSBT to broadcast - * @returns A promise that resolves to the transaction ID + * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID (without 0x prefix) * @todo Implement PSBT broadcasting with UTXO Global */ - async pushPsbt(_: string, __?: ccc.SignPsbtOptions): Promise { + async broadcastPsbt( + _: ccc.HexLike, + __?: ccc.SignPsbtOptions, + ): Promise { throw new Error("UTXO Global PSBT broadcasting not implemented yet"); } } diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index c793d6a19..f04df272d 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -66,10 +66,6 @@ export class Signer extends ccc.SignerBtc { super(client); } - get supportsSingleCallSignAndBroadcast(): boolean { - return true; - } - async assertAddress(): Promise
{ this.addressCache = this.addressCache ?? @@ -179,15 +175,14 @@ export class Signer extends ccc.SignerBtc { * Build default toSignInputs for all unsigned inputs */ private buildDefaultToSignInputs( - psbtHex: string, + psbtHex: ccc.Hex, address: string, ): ccc.ToSignInput[] { const toSignInputs: ccc.ToSignInput[] = []; try { - const psbt = Psbt.fromHex(psbtHex); - // Collect all unsigned inputs + const psbt = Psbt.fromHex(psbtHex.slice(2)); psbt.data.inputs.forEach((input, index) => { const isSigned = input.finalScriptSig || @@ -204,8 +199,10 @@ export class Signer extends ccc.SignerBtc { // 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 toSignInputs explicitly in options. Original error: ${String(error)}`, + `Failed to parse PSBT hex. Please provide toSignInputs explicitly in options. Original error: ${errorMessage}`, ); } @@ -213,7 +210,7 @@ export class Signer extends ccc.SignerBtc { } private async prepareSignPsbtParams( - psbtHex: string, + psbtHex: ccc.Hex, options?: ccc.SignPsbtOptions, ): Promise<{ psbtBase64: string; @@ -225,8 +222,7 @@ export class Signer extends ccc.SignerBtc { toSignInputs = this.buildDefaultToSignInputs(psbtHex, address); } - const psbtBytes = ccc.bytesFrom(psbtHex); - const psbtBase64 = ccc.bytesTo(psbtBytes, "base64"); + const psbtBase64 = ccc.bytesTo(psbtHex, "base64"); const signInputs = toSignInputs.reduce( (acc, input) => { @@ -251,9 +247,9 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using Xverse wallet. * - * @param psbtHex - The hex string of PSBT to sign + * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. * @param options - Options for signing the PSBT - * @returns A promise that resolves to the signed PSBT hex string + * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) * * @remarks * Xverse accepts: @@ -268,11 +264,11 @@ export class Signer extends ccc.SignerBtc { * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt */ async signPsbt( - psbtHex: string, + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( - psbtHex, + ccc.hexFrom(psbtHex), options, ); @@ -291,28 +287,28 @@ export class Signer extends ccc.SignerBtc { } /** - * Signs and broadcasts a PSBT using Xverse wallet (single popup). - * - * @param psbtHex - The hex string of PSBT to sign and broadcast - * @param options - Options for signing the PSBT - * @returns A promise that resolves to SignPsbtResult: - * - psbt: base64 encoded signed PSBT - * - txid: transaction id (only when broadcast succeeds) + * Broadcasts a PSBT to the Bitcoin network. * * @remarks - * Xverse accepts: - * - psbt: base64 encoded PSBT - * - signInputs: Record input indexes to sign - * - broadcast: set to true to broadcast - * - * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt + * Xverse does not support broadcasting a signed PSBT directly. + * It only supports "Sign and Broadcast" as a single atomic operation via `signAndBroadcastPsbt`. */ - async pushPsbt( - psbtHex: string, + async broadcastPsbt( + _psbtHex: ccc.HexLike, + _options?: ccc.SignPsbtOptions, + ): Promise { + throw new Error( + "Xverse does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", + ); + } + + async signAndBroadcastPsbt( + psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, ): Promise { + // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( - psbtHex, + ccc.hexFrom(psbtHex), options, ); From f3e3c7ff339e69f00708fbd2e3ca2d5bca5995ea Mon Sep 17 00:00:00 2001 From: fgh Date: Tue, 13 Jan 2026 13:38:38 -0500 Subject: [PATCH 7/8] refactor: improve parameter naming in PSBT methods --- .../core/src/signer/btc/signerBtcPublicKeyReadonly.ts | 10 ++++++++-- packages/utxo-global/src/btc/index.ts | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index e35be4f9d..5f2d7a729 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -72,11 +72,17 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { return this.publicKey; } - async signPsbt(_: HexLike, __?: SignPsbtOptions): Promise { + async signPsbt( + _psbtHex: HexLike, + _options?: SignPsbtOptions, + ): Promise { throw new Error("Read-only signer does not support signPsbt"); } - async broadcastPsbt(_: HexLike, __?: SignPsbtOptions): Promise { + async broadcastPsbt( + _psbtHex: HexLike, + _options?: SignPsbtOptions, + ): Promise { throw new Error("Read-only signer does not support broadcastPsbt"); } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index bfd74c499..7c285453c 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -150,8 +150,8 @@ export class SignerBtc extends ccc.SignerBtc { * @todo Implement PSBT broadcasting with UTXO Global */ async broadcastPsbt( - _: ccc.HexLike, - __?: ccc.SignPsbtOptions, + _psbtHex: ccc.HexLike, + _options?: ccc.SignPsbtOptions, ): Promise { throw new Error("UTXO Global PSBT broadcasting not implemented yet"); } From 6c31f8c917def23c5b74e045a51a0886c4bda6bd Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 14 Jan 2026 04:41:13 -0500 Subject: [PATCH 8/8] refactor(SignerBtc): standardize PSBT method signatures and return types --- packages/core/src/signer/btc/signerBtc.ts | 26 ++++++++----------- .../signer/btc/signerBtcPublicKeyReadonly.ts | 7 ++--- packages/joy-id/src/btc/index.ts | 14 +++++----- packages/okx/src/btc/index.ts | 15 ++++++----- packages/uni-sat/src/signer.ts | 15 ++++++----- packages/utxo-global/src/btc/index.ts | 12 ++++----- packages/xverse/src/signer.ts | 15 +++++------ 7 files changed, 51 insertions(+), 53 deletions(-) diff --git a/packages/core/src/signer/btc/signerBtc.ts b/packages/core/src/signer/btc/signerBtc.ts index 30c090c6d..b450feefb 100644 --- a/packages/core/src/signer/btc/signerBtc.ts +++ b/packages/core/src/signer/btc/signerBtc.ts @@ -2,7 +2,7 @@ import { Address } from "../../address/index.js"; import { bytesConcat, bytesFrom } from "../../bytes/index.js"; import { Transaction, TransactionLike, WitnessArgs } from "../../ckb/index.js"; import { KnownScript } from "../../client/index.js"; -import { HexLike, hexFrom } from "../../hex/index.js"; +import { Hex, HexLike, hexFrom } from "../../hex/index.js"; import { numToBytes } from "../../num/index.js"; import { Signer, SignerSignType, SignerType } from "../signer/index.js"; import { SignPsbtOptions } from "./psbt.js"; @@ -26,16 +26,15 @@ export abstract class SignerBtc extends Signer { /** * Sign and broadcast a PSBT. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign and broadcast. + * @param psbtHex - The hex string of PSBT to sign and broadcast. * @param options - Options for signing the PSBT. - * @returns A promise that resolves to the transaction ID (non-0x prefixed hex). + * @returns A promise that resolves to the transaction ID as a Hex string. */ async signAndBroadcastPsbt( psbtHex: HexLike, options?: SignPsbtOptions, - ): Promise { - // ccc.hexFrom adds 0x prefix, but BTC expects non-0x - const signedPsbt = await this.signPsbt(hexFrom(psbtHex).slice(2), options); + ): Promise { + const signedPsbt = await this.signPsbt(psbtHex, options); return this.broadcastPsbt(signedPsbt, options); } @@ -144,26 +143,23 @@ export abstract class SignerBtc extends Signer { /** * Signs a Partially Signed Bitcoin Transaction (PSBT). * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @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 hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string. */ - abstract signPsbt( - psbtHex: HexLike, - options?: SignPsbtOptions, - ): Promise; + abstract signPsbt(psbtHex: HexLike, options?: SignPsbtOptions): Promise; /** * Broadcasts a PSBT to the Bitcoin network. * - * @param psbtHex - The hex string (without 0x prefix) of the PSBT to broadcast. + * @param psbtHex - The hex string of the PSBT to broadcast. * @param options - Options for broadcasting the PSBT. - * @returns A promise that resolves to the transaction ID (without 0x prefix). + * @returns A promise that resolves to the transaction ID as a Hex string. */ async broadcastPsbt( _psbtHex: HexLike, _options?: SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("Not implemented"); } } diff --git a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts index 5f2d7a729..1b2a403d6 100644 --- a/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts +++ b/packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts @@ -72,17 +72,14 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc { return this.publicKey; } - async signPsbt( - _psbtHex: HexLike, - _options?: SignPsbtOptions, - ): Promise { + async signPsbt(_psbtHex: HexLike, _options?: SignPsbtOptions): Promise { throw new Error("Read-only signer does not support signPsbt"); } async broadcastPsbt( _psbtHex: HexLike, _options?: SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("Read-only signer does not support broadcastPsbt"); } } diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 8d5686fc2..b4efe236b 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -202,13 +202,13 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using JoyID wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. - * @returns A promise that resolves to the signed PSBT hex string (without 0x prefix) + * @param psbtHex - The hex string of PSBT to sign. + * @returns A promise that resolves to the signed PSBT as a Hex string. */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { const { address } = await this.assertConnection(); const config = this.getConfig(); @@ -227,7 +227,7 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, type: DappRequestType.SignPsbt }, ); - return signedPsbtHex; + return ccc.hexFrom(signedPsbtHex); } /** @@ -240,7 +240,7 @@ export class BitcoinSigner extends ccc.SignerBtc { async broadcastPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error( "JoyID does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", ); @@ -249,7 +249,7 @@ export class BitcoinSigner extends ccc.SignerBtc { async signAndBroadcastPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { const { address } = await this.assertConnection(); const config = this.getConfig(); @@ -270,6 +270,6 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, type: DappRequestType.SignPsbt }, // Use SignPsbt type for both operations ); - return txid; + return ccc.hexFrom(txid); } } diff --git a/packages/okx/src/btc/index.ts b/packages/okx/src/btc/index.ts index f9ff02512..3497e79fd 100644 --- a/packages/okx/src/btc/index.ts +++ b/packages/okx/src/btc/index.ts @@ -180,15 +180,17 @@ export class BitcoinSigner extends ccc.SignerBtc { /** * Signs a PSBT using OKX wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @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 hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); + ): Promise { + return ccc.hexFrom( + await this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options), + ); } /** @@ -200,7 +202,8 @@ export class BitcoinSigner extends ccc.SignerBtc { async broadcastPsbt( psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + ): Promise { + const txid = await this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + return ccc.hexFrom(txid); } } diff --git a/packages/uni-sat/src/signer.ts b/packages/uni-sat/src/signer.ts index 4eba70d5d..333683f58 100644 --- a/packages/uni-sat/src/signer.ts +++ b/packages/uni-sat/src/signer.ts @@ -154,15 +154,17 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using UniSat wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @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 hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string */ async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); + ): Promise { + return ccc.hexFrom( + await this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options), + ); } /** @@ -174,7 +176,8 @@ export class Signer extends ccc.SignerBtc { async broadcastPsbt( psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { - return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + ): Promise { + const txid = await this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); + return ccc.hexFrom(txid); } } diff --git a/packages/utxo-global/src/btc/index.ts b/packages/utxo-global/src/btc/index.ts index 7c285453c..7aee417cd 100644 --- a/packages/utxo-global/src/btc/index.ts +++ b/packages/utxo-global/src/btc/index.ts @@ -131,28 +131,28 @@ export class SignerBtc extends ccc.SignerBtc { /** * Signs a PSBT using UTXO Global wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @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 hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string */ async signPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("UTXO Global PSBT signing not implemented yet"); } /** * Broadcasts a signed PSBT to the Bitcoin network. * - * @param psbtHex - The hex string (without 0x prefix) of signed PSBT to broadcast. - * @returns A promise that resolves to the transaction ID (without 0x prefix) + * @param psbtHex - The hex string of signed PSBT to broadcast. + * @returns A promise that resolves to the transaction ID as a Hex string * @todo Implement PSBT broadcasting with UTXO Global */ async broadcastPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error("UTXO Global PSBT broadcasting not implemented yet"); } } diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index f04df272d..441fdc0cc 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -247,9 +247,9 @@ export class Signer extends ccc.SignerBtc { /** * Signs a PSBT using Xverse wallet. * - * @param psbtHex - The hex string (without 0x prefix) of PSBT to sign. + * @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 hex string (without 0x prefix) + * @returns A promise that resolves to the signed PSBT as a Hex string * * @remarks * Xverse accepts: @@ -266,7 +266,7 @@ export class Signer extends ccc.SignerBtc { async signPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), options, @@ -282,8 +282,7 @@ export class Signer extends ccc.SignerBtc { ) ).psbt; - const signedPsbtBytes = ccc.bytesFrom(signedPsbtBase64, "base64"); - return ccc.bytesTo(signedPsbtBytes, "hex"); // no leading "0x" + return ccc.hexFrom(ccc.bytesFrom(signedPsbtBase64, "base64")); } /** @@ -296,7 +295,7 @@ export class Signer extends ccc.SignerBtc { async broadcastPsbt( _psbtHex: ccc.HexLike, _options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { throw new Error( "Xverse does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", ); @@ -305,7 +304,7 @@ export class Signer extends ccc.SignerBtc { async signAndBroadcastPsbt( psbtHex: ccc.HexLike, options?: ccc.SignPsbtOptions, - ): Promise { + ): Promise { // ccc.hexFrom adds 0x prefix, but BTC expects non-0x const { psbtBase64, signInputs } = await this.prepareSignPsbtParams( ccc.hexFrom(psbtHex), @@ -324,6 +323,6 @@ export class Signer extends ccc.SignerBtc { throw new Error("Failed to broadcast PSBT"); } - return result.txid; + return ccc.hexFrom(result.txid); } }