-
Notifications
You must be signed in to change notification settings - Fork 35
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: releases/next
Are you sure you want to change the base?
Changes from all commits
5d2725f
6e1ff95
014fcf2
a27fb48
14decce
b133cc0
870db02
8c3a4a4
aca2112
979c1dc
1778940
b810d80
a21379b
c63e538
a803d5f
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 |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| --- | ||
| "@ckb-ccc/core": minor | ||
| "@ckb-ccc/joy-id": patch | ||
| "@ckb-ccc/okx": patch | ||
| "@ckb-ccc/uni-sat": patch | ||
| "@ckb-ccc/utxo-global": patch | ||
| "@ckb-ccc/xverse": patch | ||
| --- | ||
|
|
||
| feat(core): add BTC PSBT signing support | ||
|
|
||
| - Add `SignerBtc.signPsbt()`, `signAndBroadcastPsbt()`, and `broadcastPsbt()` for signing and broadcasting PSBTs | ||
| - Add `SignPsbtOptions` and `InputToSign` for configuring PSBT signing |
| 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,98 @@ | ||
| import { Hex, HexLike, hexFrom } from "../../hex/index.js"; | ||
|
|
||
| /** | ||
| * Options for signing a PSBT (Partially Signed Bitcoin Transaction) | ||
| */ | ||
| export type SignPsbtOptionsLike = { | ||
| /** | ||
| * Whether to finalize the PSBT after signing. | ||
| * Default is true. | ||
| */ | ||
| autoFinalized?: boolean; | ||
| /** | ||
| * Array of inputs to sign | ||
| */ | ||
| inputsToSign?: InputToSignLike[]; | ||
| }; | ||
|
|
||
| export class SignPsbtOptions { | ||
| constructor( | ||
| public autoFinalized: boolean, | ||
| public inputsToSign: InputToSign[], | ||
| ) {} | ||
|
|
||
| static from(options?: SignPsbtOptionsLike): SignPsbtOptions { | ||
| if (options instanceof SignPsbtOptions) { | ||
| return options; | ||
| } | ||
| return new SignPsbtOptions( | ||
| options?.autoFinalized ?? true, | ||
| options?.inputsToSign?.map((i) => InputToSign.from(i)) ?? [], | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Specification for an input to sign in a PSBT. | ||
| * Must specify at least one of: address or pubkey. | ||
| */ | ||
| export type InputToSignLike = { | ||
| /** | ||
| * 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?: HexLike; | ||
| } | ||
| | { | ||
| /** | ||
| * The address whose corresponding private key to use for signing. | ||
| */ | ||
| address?: string; | ||
| /** | ||
| * The public key whose corresponding private key to use for signing. | ||
| */ | ||
| publicKey: HexLike; | ||
| } | ||
| ); | ||
|
|
||
| export class InputToSign { | ||
| constructor( | ||
| public index: number, | ||
| public sighashTypes?: number[], | ||
| public disableTweakSigner?: boolean, | ||
| public address?: string, | ||
| public publicKey?: Hex, | ||
| ) {} | ||
|
|
||
| static from(input: InputToSignLike): InputToSign { | ||
| if (input instanceof InputToSign) { | ||
| return input; | ||
| } | ||
| return new InputToSign( | ||
| input.index, | ||
| input.sighashTypes, | ||
| input.disableTweakSigner, | ||
| input.address, | ||
| input.publicKey ? hexFrom(input.publicKey) : undefined, | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,7 @@ | ||
| import { ccc } from "@ckb-ccc/ccc"; | ||
| import * as bitcoinLib from "bitcoinjs-lib"; | ||
|
|
||
| export function render(tx: ccc.Transaction): Promise<void>; | ||
| export const signer: ccc.Signer; | ||
| export const client: ccc.Client; | ||
| export const bitcoin: typeof bitcoinLib; |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,103 @@ | ||||||||||||||||||||||||||
| import { ccc } from "@ckb-ccc/ccc"; | ||||||||||||||||||||||||||
| import { bitcoin, signer } from "@ckb-ccc/playground"; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Supported wallets: Unisat, JoyID, Xverse | ||||||||||||||||||||||||||
| // Check if the current signer is also a Bitcoin signer | ||||||||||||||||||||||||||
| if (!(signer instanceof ccc.SignerBtc)) { | ||||||||||||||||||||||||||
| throw new Error("Signer is not a Bitcoin signer"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Only support testnet for safety | ||||||||||||||||||||||||||
| if (signer.client.addressPrefix !== "ckt") { | ||||||||||||||||||||||||||
| throw new Error("Only supported on testnet"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Xverse has deprecated Testnet3 support, so we default to Signet. Make sure to switch to Signet in Xverse's network settings. | ||||||||||||||||||||||||||
| const isXverse = signer instanceof ccc.Xverse.Signer; | ||||||||||||||||||||||||||
| const btcTestnetName = isXverse ? "signet" : "testnet"; | ||||||||||||||||||||||||||
|
Member
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. This looks quite interesting because we are actually requiring devs to detect the network manually. I was wondering if we can avoid this by adding a new method the ccc/packages/uni-sat/src/signer.ts Lines 17 to 28 in 66ae81b
Anyway, I think we should have a separate PR for this. Just a note here. |
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const btcAddress = await signer.getBtcAccount(); | ||||||||||||||||||||||||||
| // Fetch UTXOs from mempool.space API | ||||||||||||||||||||||||||
| const utxos = (await fetch( | ||||||||||||||||||||||||||
| `https://mempool.space/${btcTestnetName}/api/address/${btcAddress}/utxo`, | ||||||||||||||||||||||||||
| ).then((res) => { | ||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||
| throw new Error(`Failed to fetch UTXOs: ${res.status} ${res.statusText}`); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return res.json(); | ||||||||||||||||||||||||||
| })) as { value: number; txid: string; vout: number }[]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const DUST_LIMIT = 546; | ||||||||||||||||||||||||||
| const FEE_SATS = 200; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Select a UTXO above the 546 sat dust threshold | ||||||||||||||||||||||||||
| const selectedUtxo = utxos.find((utxo) => utxo.value > DUST_LIMIT + FEE_SATS); | ||||||||||||||||||||||||||
| if (!selectedUtxo) { | ||||||||||||||||||||||||||
| throw new Error("No UTXO available"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Fetch the full transaction to get the scriptpubkey | ||||||||||||||||||||||||||
| const btcTx = (await fetch( | ||||||||||||||||||||||||||
| `https://mempool.space/${btcTestnetName}/api/tx/${selectedUtxo.txid}`, | ||||||||||||||||||||||||||
| ).then((res) => { | ||||||||||||||||||||||||||
| if (!res.ok) { | ||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||
| `Failed to fetch transaction: ${res.status} ${res.statusText}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| return res.json(); | ||||||||||||||||||||||||||
| })) as { | ||||||||||||||||||||||||||
| vout: { | ||||||||||||||||||||||||||
| value: number; | ||||||||||||||||||||||||||
| scriptpubkey: string; | ||||||||||||||||||||||||||
| scriptpubkey_type: string; | ||||||||||||||||||||||||||
| }[]; | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| const vout = btcTx.vout[selectedUtxo.vout]; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (!vout || !vout.scriptpubkey) { | ||||||||||||||||||||||||||
| throw new Error("Invalid vout data"); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Build PSBT with the selected UTXO as input | ||||||||||||||||||||||||||
| const psbt = new bitcoin.Psbt({ | ||||||||||||||||||||||||||
| network: isXverse ? bitcoin.networks.testnet : bitcoin.networks.testnet, | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
| const input: { | ||||||||||||||||||||||||||
| hash: string; | ||||||||||||||||||||||||||
| index: number; | ||||||||||||||||||||||||||
| witnessUtxo: { | ||||||||||||||||||||||||||
| script: Uint8Array; | ||||||||||||||||||||||||||
| value: bigint; | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
| tapInternalKey?: Uint8Array; | ||||||||||||||||||||||||||
| } = { | ||||||||||||||||||||||||||
| hash: selectedUtxo.txid, | ||||||||||||||||||||||||||
| index: selectedUtxo.vout, | ||||||||||||||||||||||||||
| witnessUtxo: { | ||||||||||||||||||||||||||
| script: ccc.bytesFrom(vout.scriptpubkey), | ||||||||||||||||||||||||||
| value: BigInt(vout.value), | ||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Handle Taproot (P2TR) specific input fields | ||||||||||||||||||||||||||
| if ( | ||||||||||||||||||||||||||
| vout.scriptpubkey_type === "v1_p2tr" || | ||||||||||||||||||||||||||
| vout.scriptpubkey_type === "witness_v1_taproot" | ||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||
| input.tapInternalKey = ccc.bytesFrom(await signer.getBtcPublicKey()).slice(1); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| psbt.addInput(input); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Add a single output back to the same address minus a hardcoded 200 sat fee | ||||||||||||||||||||||||||
| psbt.addOutput({ | ||||||||||||||||||||||||||
| address: btcAddress, | ||||||||||||||||||||||||||
| value: BigInt(vout.value) - BigInt(FEE_SATS), | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| // Sign and broadcast the transaction | ||||||||||||||||||||||||||
| const txId = await signer.signAndBroadcastPsbt(psbt.toHex()); | ||||||||||||||||||||||||||
| console.log( | ||||||||||||||||||||||||||
| `View transaction: https://mempool.space/${btcTestnetName}/tx/${txId.slice(2)}`, | ||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||
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.
Optional: add a
instanceofcheck to avoid convertingSignPsbtOptionstoSignPsbtOptions. Same for theInputToSign.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 point. Done!