-
Notifications
You must be signed in to change notification settings - Fork 34
feat(btc): add PSBT signing and broadcasting support #346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 6 commits
e9a3a6e
19490f4
b4d9e5e
efcdcd8
7b16831
8f4091f
f3e3c7f
6c31f8c
fa639c3
bc82b34
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| export * from "./psbt.js"; | ||
| export * from "./signerBtc.js"; | ||
| export * from "./signerBtcPublicKeyReadonly.js"; | ||
| export * from "./verify.js"; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| ); | ||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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,22 @@ export abstract class SignerBtc extends Signer { | |||||
| return SignerSignType.BtcEcdsa; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * 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 signAndBroadcastPsbt( | ||||||
| psbtHex: HexLike, | ||||||
| options?: SignPsbtOptions, | ||||||
| ): Promise<string> { | ||||||
|
||||||
| // ccc.hexFrom adds 0x prefix, but BTC expects non-0x | ||||||
| const signedPsbt = await this.signPsbt(hexFrom(psbtHex).slice(2), options); | ||||||
|
||||||
| const signedPsbt = await this.signPsbt(hexFrom(psbtHex).slice(2), options); | |
| const signedPsbt = await this.signPsbt(bytesTo(bytesFrom(psbtHex), "hex"), options); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In the signAndBroadcastPsbt flow, there are no other intermediate steps that could modify it. The current approach remains direct, clear.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's actually a problem. signPsbt should (also) accept a HexLike and automatically convert it. We shouldn't have to handle these type conversions here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
True that. Fixed.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm confused why the signPsbt is abstract but the broadcastPsbt is Not implemented.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good catch! Changed to abstract for better type safety and consistency.
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<Hex> { | ||||||
| return this.publicKey; | ||||||
| } | ||||||
|
|
||||||
| async signPsbt(_: HexLike, __?: SignPsbtOptions): Promise<string> { | ||||||
Hanssen0 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||||||
| throw new Error("Read-only signer does not support signPsbt"); | ||||||
| } | ||||||
|
|
||||||
| async broadcastPsbt(_: HexLike, __?: SignPsbtOptions): Promise<string> { | ||||||
|
||||||
| async broadcastPsbt(_: HexLike, __?: SignPsbtOptions): Promise<string> { | |
| async broadcastPsbt(_psbtHex: HexLike, _options?: SignPsbtOptions): Promise<string> { |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -198,4 +198,78 @@ export class BitcoinSigner extends ccc.SignerBtc { | |||||
| ); | ||||||
| return signature; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * 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) | ||||||
| */ | ||||||
| async signPsbt( | ||||||
| psbtHex: ccc.HexLike, | ||||||
| options?: ccc.SignPsbtOptions, | ||||||
| ): Promise<string> { | ||||||
| const { address } = await this.assertConnection(); | ||||||
|
|
||||||
| const config = this.getConfig(); | ||||||
| const { tx: signedPsbtHex } = await createPopup( | ||||||
| buildJoyIDURL( | ||||||
| { | ||||||
| ...config, | ||||||
| tx: ccc.hexFrom(psbtHex).slice(2), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The expression
Suggested change
|
||||||
| options, | ||||||
| signerAddress: address, | ||||||
| autoFinalized: options?.autoFinalized ?? true, | ||||||
| }, | ||||||
| "popup", | ||||||
| "/sign-psbt", | ||||||
| ), | ||||||
| { ...config, type: DappRequestType.SignPsbt }, | ||||||
| ); | ||||||
|
|
||||||
| return signedPsbtHex; | ||||||
| } | ||||||
|
|
||||||
| /** | ||||||
| * Broadcasts a PSBT to the Bitcoin network. | ||||||
| * | ||||||
| * @remarks | ||||||
| * JoyID 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.SignPsbtOptions, | ||||||
| ): Promise<string> { | ||||||
| throw new Error( | ||||||
| "JoyID does not support broadcasting signed PSBTs directly. Use signAndBroadcastPsbt instead.", | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| async signAndBroadcastPsbt( | ||||||
| psbtHex: ccc.HexLike, | ||||||
| options?: ccc.SignPsbtOptions, | ||||||
| ): Promise<string> { | ||||||
| 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: ccc.hexFrom(psbtHex).slice(2), | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The expression
Suggested change
|
||||||
| options, | ||||||
| signerAddress: address, | ||||||
| autoFinalized: true, // sendPsbt always finalizes | ||||||
| isSend: true, | ||||||
| }, | ||||||
| "popup", | ||||||
| "/sign-psbt", | ||||||
| ), | ||||||
| { ...config, type: DappRequestType.SignPsbt }, // Use SignPsbt type for both operations | ||||||
| ); | ||||||
|
|
||||||
| return txid; | ||||||
| } | ||||||
| } | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 (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 (without 0x prefix) | ||||||||||
| */ | ||||||||||
| async signPsbt( | ||||||||||
| psbtHex: ccc.HexLike, | ||||||||||
| options?: ccc.SignPsbtOptions, | ||||||||||
| ): Promise<string> { | ||||||||||
| return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); | ||||||||||
|
||||||||||
| return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); | |
| return this.provider.signPsbt(ccc.bytesTo(ccc.bytesFrom(psbtHex), "hex"), options); |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The documentation for @param psbtHex and @returns is slightly misleading. The psbtHex parameter is of type ccc.HexLike, which can be a hex string with or without a "0x" prefix, as ccc.hexFrom handles both cases. The documentation, however, states it must be without the prefix. Similarly, the return value is a ccc.Hex string, which always includes a "0x" prefix, but the documentation states it's without one. To improve clarity and align with the implementation, I recommend updating the JSDoc.
| * @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 the signed PSBT to broadcast. | |
| * @returns A promise that resolves to the transaction ID as a Hex string. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expression ccc.hexFrom(psbtHex).slice(2) is used to get a non-0x-prefixed hex string. A more direct and readable approach is to use ccc.bytesTo(ccc.bytesFrom(psbtHex), "hex"). This avoids the intermediate step of potentially adding and then immediately removing the "0x" prefix.
| return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); | |
| return this.provider.pushPsbt(ccc.bytesTo(ccc.bytesFrom(psbtHex), "hex")); |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -150,4 +150,31 @@ export class Signer extends ccc.SignerBtc { | |||||||||
|
|
||||||||||
| return this.provider.signMessage(challenge, "ecdsa"); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Signs a PSBT using UniSat wallet. | ||||||||||
| * | ||||||||||
| * @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 (without 0x prefix) | ||||||||||
| */ | ||||||||||
| async signPsbt( | ||||||||||
| psbtHex: ccc.HexLike, | ||||||||||
| options?: ccc.SignPsbtOptions, | ||||||||||
| ): Promise<string> { | ||||||||||
| return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); | ||||||||||
|
||||||||||
| return this.provider.signPsbt(ccc.hexFrom(psbtHex).slice(2), options); | |
| return this.provider.signPsbt(ccc.bytesTo(ccc.bytesFrom(psbtHex), "hex"), options); |
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The JSDoc for this method could be more precise. The @param psbtHex is documented as a hex string "without 0x prefix", but the implementation correctly handles hex strings both with and without the prefix thanks to ccc.hexFrom. Also, the @returns JSDoc states the transaction ID is returned "without 0x prefix", but ccc.hexFrom(txid) will ensure it is prefixed. I suggest updating the documentation to reflect the actual behavior.
| * @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 the signed PSBT to broadcast. | |
| * @returns A promise that resolves to the transaction ID as a Hex string. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed.
Outdated
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The expression ccc.hexFrom(psbtHex).slice(2) is used to get a non-0x-prefixed hex string. A more direct and readable approach is to use ccc.bytesTo(ccc.bytesFrom(psbtHex), "hex"). This avoids the intermediate step of potentially adding and then immediately removing the "0x" prefix.
| return this.provider.pushPsbt(ccc.hexFrom(psbtHex).slice(2)); | |
| return this.provider.pushPsbt(ccc.bytesTo(ccc.bytesFrom(psbtHex), "hex")); |
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -127,4 +127,32 @@ export class SignerBtc extends ccc.SignerBtc { | |||||||||
| this.accountCache ?? (await this.getBtcAccount()), | ||||||||||
| ); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| /** | ||||||||||
| * Signs a PSBT using UTXO Global wallet. | ||||||||||
| * | ||||||||||
| * @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 (without 0x prefix) | ||||||||||
| */ | ||||||||||
| async signPsbt( | ||||||||||
| _psbtHex: ccc.HexLike, | ||||||||||
| _options?: ccc.SignPsbtOptions, | ||||||||||
| ): Promise<string> { | ||||||||||
| 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) | ||||||||||
| * @todo Implement PSBT broadcasting with UTXO Global | ||||||||||
| */ | ||||||||||
| async broadcastPsbt( | ||||||||||
| _: ccc.HexLike, | ||||||||||
| __?: ccc.SignPsbtOptions, | ||||||||||
|
||||||||||
| _: ccc.HexLike, | |
| __?: ccc.SignPsbtOptions, | |
| _psbtHex: ccc.HexLike, | |
| _options?: ccc.SignPsbtOptions, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suggest having
SignPsbtOptionsLikeandToSignInputLiketypes to simplify the type conversion tasks needed for developers, just like other CCC types.BTW, I'm curious about why it is
ToSignInputinstead ofInputToSign?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good suggestion. It does help keep the API style consistent. Modified.
I copied it from the wallet API docs without second thought. I’ve switched it to
InputToSignas well since it reads more clearly.