Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/cuddly-lands-build.md
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
1 change: 1 addition & 0 deletions packages/core/src/signer/btc/index.ts
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";
98 changes: 98 additions & 0 deletions packages/core/src/signer/btc/psbt.ts
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 {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: add a instanceof check to avoid converting SignPsbtOptions to SignPsbtOptions. Same for the InputToSign.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. Done!

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,
);
}
}
42 changes: 41 additions & 1 deletion packages/core/src/signer/btc/signerBtc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ 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 { SignPsbtOptionsLike } from "./psbt.js";
import { btcEcdsaPublicKeyHash } from "./verify.js";

/**
Expand All @@ -22,6 +23,21 @@ export abstract class SignerBtc extends Signer {
return SignerSignType.BtcEcdsa;
}

/**
* Sign and broadcast a PSBT.
*
* @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 as a Hex string.
*/
async signAndBroadcastPsbt(
psbtHex: HexLike,
options?: SignPsbtOptionsLike,
): Promise<Hex> {
const signedPsbt = await this.signPsbt(psbtHex, options);
return this.broadcastPsbt(signedPsbt, options);
}

/**
* Gets the Bitcoin account associated with the signer.
*
Expand Down Expand Up @@ -123,4 +139,28 @@ 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 as a Hex string.
*/
abstract signPsbt(
psbtHex: HexLike,
options?: SignPsbtOptionsLike,
): Promise<Hex>;

/**
* Broadcasts a PSBT to the Bitcoin network.
*
* @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 as a Hex string.
*/
abstract broadcastPsbt(
psbtHex: HexLike,
options?: SignPsbtOptionsLike,
): Promise<Hex>;
}
15 changes: 15 additions & 0 deletions packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts
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 { SignPsbtOptionsLike } from "./psbt.js";
import { SignerBtc } from "./signerBtc.js";

/**
Expand Down Expand Up @@ -70,4 +71,18 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc {
async getBtcPublicKey(): Promise<Hex> {
return this.publicKey;
}

async signPsbt(
_psbtHex: HexLike,
_options?: SignPsbtOptionsLike,
): Promise<Hex> {
throw new Error("Read-only signer does not support signPsbt");
}

async broadcastPsbt(
_psbtHex: HexLike,
_options?: SignPsbtOptionsLike,
): Promise<Hex> {
throw new Error("Read-only signer does not support broadcastPsbt");
}
}
7 changes: 6 additions & 1 deletion packages/docs/docs/code-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,4 +28,9 @@ That's it! The transaction is sent.
- [Use all supported wallets in custom UI.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/customUiWithController.ts)
- [Sign and verify any message.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/sign.ts)
- [Transfer all native CKB token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferAll.ts)
- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts)
- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts)


CCC also supports Bitcoin! You can now build Bitcoin transactions and sign them using supported Bitcoin wallets.

- [Transfer Bitcoin.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferBtc.ts)
2 changes: 2 additions & 0 deletions packages/examples/src/playground/index.d.ts
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;
103 changes: 103 additions & 0 deletions packages/examples/src/transferBtc.ts
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";
Copy link
Member

Choose a reason for hiding this comment

The 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 SignerBtc like getBtcNetwork, since we do need this feature. See:

private readonly preferredNetworks: ccc.NetworkPreference[] = [
{
addressPrefix: "ckb",
signerType: ccc.SignerType.BTC,
network: "btc",
},
{
addressPrefix: "ckt",
signerType: ccc.SignerType.BTC,
network: "btcTestnet",
},
],

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)}`,
);
Loading