Skip to content

Commit 44cd2d9

Browse files
authored
Merge pull request #346 from fghdotio/feat/btc-psbt-2master
feat(btc): add PSBT signing and broadcasting support
2 parents 0e18748 + a803d5f commit 44cd2d9

20 files changed

Lines changed: 719 additions & 6 deletions

File tree

.changeset/cuddly-lands-build.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@ckb-ccc/core": minor
3+
"@ckb-ccc/joy-id": patch
4+
"@ckb-ccc/okx": patch
5+
"@ckb-ccc/uni-sat": patch
6+
"@ckb-ccc/utxo-global": patch
7+
"@ckb-ccc/xverse": patch
8+
---
9+
10+
feat(core): add BTC PSBT signing support
11+
12+
- Add `SignerBtc.signPsbt()`, `signAndBroadcastPsbt()`, and `broadcastPsbt()` for signing and broadcasting PSBTs
13+
- Add `SignPsbtOptions` and `InputToSign` for configuring PSBT signing
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from "./psbt.js";
12
export * from "./signerBtc.js";
23
export * from "./signerBtcPublicKeyReadonly.js";
34
export * from "./verify.js";
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { Hex, HexLike, hexFrom } from "../../hex/index.js";
2+
3+
/**
4+
* Options for signing a PSBT (Partially Signed Bitcoin Transaction)
5+
*/
6+
export type SignPsbtOptionsLike = {
7+
/**
8+
* Whether to finalize the PSBT after signing.
9+
* Default is true.
10+
*/
11+
autoFinalized?: boolean;
12+
/**
13+
* Array of inputs to sign
14+
*/
15+
inputsToSign?: InputToSignLike[];
16+
};
17+
18+
export class SignPsbtOptions {
19+
constructor(
20+
public autoFinalized: boolean,
21+
public inputsToSign: InputToSign[],
22+
) {}
23+
24+
static from(options?: SignPsbtOptionsLike): SignPsbtOptions {
25+
if (options instanceof SignPsbtOptions) {
26+
return options;
27+
}
28+
return new SignPsbtOptions(
29+
options?.autoFinalized ?? true,
30+
options?.inputsToSign?.map((i) => InputToSign.from(i)) ?? [],
31+
);
32+
}
33+
}
34+
35+
/**
36+
* Specification for an input to sign in a PSBT.
37+
* Must specify at least one of: address or pubkey.
38+
*/
39+
export type InputToSignLike = {
40+
/**
41+
* Which input to sign (index in the PSBT inputs array)
42+
*/
43+
index: number;
44+
/**
45+
* (Optional) Sighash types to use for signing.
46+
*/
47+
sighashTypes?: number[];
48+
/**
49+
* (Optional) When signing and unlocking Taproot addresses, the tweakSigner is used by default
50+
* for signature generation. Setting this to true allows for signing with the original private key.
51+
* Default value is false.
52+
*/
53+
disableTweakSigner?: boolean;
54+
} & (
55+
| {
56+
/**
57+
* The address whose corresponding private key to use for signing.
58+
*/
59+
address: string;
60+
/**
61+
* The public key whose corresponding private key to use for signing.
62+
*/
63+
publicKey?: HexLike;
64+
}
65+
| {
66+
/**
67+
* The address whose corresponding private key to use for signing.
68+
*/
69+
address?: string;
70+
/**
71+
* The public key whose corresponding private key to use for signing.
72+
*/
73+
publicKey: HexLike;
74+
}
75+
);
76+
77+
export class InputToSign {
78+
constructor(
79+
public index: number,
80+
public sighashTypes?: number[],
81+
public disableTweakSigner?: boolean,
82+
public address?: string,
83+
public publicKey?: Hex,
84+
) {}
85+
86+
static from(input: InputToSignLike): InputToSign {
87+
if (input instanceof InputToSign) {
88+
return input;
89+
}
90+
return new InputToSign(
91+
input.index,
92+
input.sighashTypes,
93+
input.disableTweakSigner,
94+
input.address,
95+
input.publicKey ? hexFrom(input.publicKey) : undefined,
96+
);
97+
}
98+
}

packages/core/src/signer/btc/signerBtc.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Address } from "../../address/index.js";
22
import { bytesConcat, bytesFrom } from "../../bytes/index.js";
33
import { Transaction, TransactionLike, WitnessArgs } from "../../ckb/index.js";
44
import { KnownScript } from "../../client/index.js";
5-
import { HexLike, hexFrom } from "../../hex/index.js";
5+
import { Hex, HexLike, hexFrom } from "../../hex/index.js";
66
import { numToBytes } from "../../num/index.js";
77
import { Signer, SignerSignType, SignerType } from "../signer/index.js";
8+
import { SignPsbtOptionsLike } from "./psbt.js";
89
import { btcEcdsaPublicKeyHash } from "./verify.js";
910

1011
/**
@@ -22,6 +23,21 @@ export abstract class SignerBtc extends Signer {
2223
return SignerSignType.BtcEcdsa;
2324
}
2425

26+
/**
27+
* Sign and broadcast a PSBT.
28+
*
29+
* @param psbtHex - The hex string of PSBT to sign and broadcast.
30+
* @param options - Options for signing the PSBT.
31+
* @returns A promise that resolves to the transaction ID as a Hex string.
32+
*/
33+
async signAndBroadcastPsbt(
34+
psbtHex: HexLike,
35+
options?: SignPsbtOptionsLike,
36+
): Promise<Hex> {
37+
const signedPsbt = await this.signPsbt(psbtHex, options);
38+
return this.broadcastPsbt(signedPsbt, options);
39+
}
40+
2541
/**
2642
* Gets the Bitcoin account associated with the signer.
2743
*
@@ -123,4 +139,28 @@ export abstract class SignerBtc extends Signer {
123139
tx.setWitnessArgsAt(info.position, witness);
124140
return tx;
125141
}
142+
143+
/**
144+
* Signs a Partially Signed Bitcoin Transaction (PSBT).
145+
*
146+
* @param psbtHex - The hex string of PSBT to sign.
147+
* @param options - Options for signing the PSBT
148+
* @returns A promise that resolves to the signed PSBT as a Hex string.
149+
*/
150+
abstract signPsbt(
151+
psbtHex: HexLike,
152+
options?: SignPsbtOptionsLike,
153+
): Promise<Hex>;
154+
155+
/**
156+
* Broadcasts a PSBT to the Bitcoin network.
157+
*
158+
* @param psbtHex - The hex string of the PSBT to broadcast.
159+
* @param options - Options for broadcasting the PSBT.
160+
* @returns A promise that resolves to the transaction ID as a Hex string.
161+
*/
162+
abstract broadcastPsbt(
163+
psbtHex: HexLike,
164+
options?: SignPsbtOptionsLike,
165+
): Promise<Hex>;
126166
}

packages/core/src/signer/btc/signerBtcPublicKeyReadonly.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Client } from "../../client/index.js";
22
import { Hex, HexLike, hexFrom } from "../../hex/index.js";
3+
import { SignPsbtOptionsLike } from "./psbt.js";
34
import { SignerBtc } from "./signerBtc.js";
45

56
/**
@@ -70,4 +71,18 @@ export class SignerBtcPublicKeyReadonly extends SignerBtc {
7071
async getBtcPublicKey(): Promise<Hex> {
7172
return this.publicKey;
7273
}
74+
75+
async signPsbt(
76+
_psbtHex: HexLike,
77+
_options?: SignPsbtOptionsLike,
78+
): Promise<Hex> {
79+
throw new Error("Read-only signer does not support signPsbt");
80+
}
81+
82+
async broadcastPsbt(
83+
_psbtHex: HexLike,
84+
_options?: SignPsbtOptionsLike,
85+
): Promise<Hex> {
86+
throw new Error("Read-only signer does not support broadcastPsbt");
87+
}
7388
}

packages/docs/docs/code-examples.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,4 +28,9 @@ That's it! The transaction is sent.
2828
- [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)
2929
- [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)
3030
- [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)
31-
- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts)
31+
- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts)
32+
33+
34+
CCC also supports Bitcoin! You can now build Bitcoin transactions and sign them using supported Bitcoin wallets.
35+
36+
- [Transfer Bitcoin.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferBtc.ts)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { ccc } from "@ckb-ccc/ccc";
2+
import * as bitcoinLib from "bitcoinjs-lib";
23

34
export function render(tx: ccc.Transaction): Promise<void>;
45
export const signer: ccc.Signer;
56
export const client: ccc.Client;
7+
export const bitcoin: typeof bitcoinLib;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { ccc } from "@ckb-ccc/ccc";
2+
import { bitcoin, signer } from "@ckb-ccc/playground";
3+
4+
// Supported wallets: Unisat, JoyID, Xverse
5+
// Check if the current signer is also a Bitcoin signer
6+
if (!(signer instanceof ccc.SignerBtc)) {
7+
throw new Error("Signer is not a Bitcoin signer");
8+
}
9+
10+
// Only support testnet for safety
11+
if (signer.client.addressPrefix !== "ckt") {
12+
throw new Error("Only supported on testnet");
13+
}
14+
15+
// Xverse has deprecated Testnet3 support, so we default to Signet. Make sure to switch to Signet in Xverse's network settings.
16+
const isXverse = signer instanceof ccc.Xverse.Signer;
17+
const btcTestnetName = isXverse ? "signet" : "testnet";
18+
19+
const btcAddress = await signer.getBtcAccount();
20+
// Fetch UTXOs from mempool.space API
21+
const utxos = (await fetch(
22+
`https://mempool.space/${btcTestnetName}/api/address/${btcAddress}/utxo`,
23+
).then((res) => {
24+
if (!res.ok) {
25+
throw new Error(`Failed to fetch UTXOs: ${res.status} ${res.statusText}`);
26+
}
27+
return res.json();
28+
})) as { value: number; txid: string; vout: number }[];
29+
30+
const DUST_LIMIT = 546;
31+
const FEE_SATS = 200;
32+
33+
// Select a UTXO above the 546 sat dust threshold
34+
const selectedUtxo = utxos.find((utxo) => utxo.value > DUST_LIMIT + FEE_SATS);
35+
if (!selectedUtxo) {
36+
throw new Error("No UTXO available");
37+
}
38+
39+
// Fetch the full transaction to get the scriptpubkey
40+
const btcTx = (await fetch(
41+
`https://mempool.space/${btcTestnetName}/api/tx/${selectedUtxo.txid}`,
42+
).then((res) => {
43+
if (!res.ok) {
44+
throw new Error(
45+
`Failed to fetch transaction: ${res.status} ${res.statusText}`,
46+
);
47+
}
48+
return res.json();
49+
})) as {
50+
vout: {
51+
value: number;
52+
scriptpubkey: string;
53+
scriptpubkey_type: string;
54+
}[];
55+
};
56+
const vout = btcTx.vout[selectedUtxo.vout];
57+
58+
if (!vout || !vout.scriptpubkey) {
59+
throw new Error("Invalid vout data");
60+
}
61+
62+
// Build PSBT with the selected UTXO as input
63+
const psbt = new bitcoin.Psbt({
64+
network: isXverse ? bitcoin.networks.testnet : bitcoin.networks.testnet,
65+
});
66+
const input: {
67+
hash: string;
68+
index: number;
69+
witnessUtxo: {
70+
script: Uint8Array;
71+
value: bigint;
72+
};
73+
tapInternalKey?: Uint8Array;
74+
} = {
75+
hash: selectedUtxo.txid,
76+
index: selectedUtxo.vout,
77+
witnessUtxo: {
78+
script: ccc.bytesFrom(vout.scriptpubkey),
79+
value: BigInt(vout.value),
80+
},
81+
};
82+
83+
// Handle Taproot (P2TR) specific input fields
84+
if (
85+
vout.scriptpubkey_type === "v1_p2tr" ||
86+
vout.scriptpubkey_type === "witness_v1_taproot"
87+
) {
88+
input.tapInternalKey = ccc.bytesFrom(await signer.getBtcPublicKey()).slice(1);
89+
}
90+
91+
psbt.addInput(input);
92+
93+
// Add a single output back to the same address minus a hardcoded 200 sat fee
94+
psbt.addOutput({
95+
address: btcAddress,
96+
value: BigInt(vout.value) - BigInt(FEE_SATS),
97+
});
98+
99+
// Sign and broadcast the transaction
100+
const txId = await signer.signAndBroadcastPsbt(psbt.toHex());
101+
console.log(
102+
`View transaction: https://mempool.space/${btcTestnetName}/tx/${txId.slice(2)}`,
103+
);

0 commit comments

Comments
 (0)