From 4c70ff5d20a827665c065966f6eb3e7e8d5be1d4 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 23 Sep 2026 15:20:24 +0800 Subject: [PATCH 1/5] fix(joy-id): always return a PSBT from signPsbt --- .changeset/joy-id-sign-psbt-return-psbt.md | 10 + packages/joy-id/package.json | 5 +- packages/joy-id/src/btc/index.test.ts | 292 +++++++++++++++++++-- packages/joy-id/src/btc/index.ts | 33 ++- packages/joy-id/src/btc/psbt.ts | 130 +++++++++ pnpm-lock.yaml | 9 + 6 files changed, 455 insertions(+), 24 deletions(-) create mode 100644 .changeset/joy-id-sign-psbt-return-psbt.md create mode 100644 packages/joy-id/src/btc/psbt.ts diff --git a/.changeset/joy-id-sign-psbt-return-psbt.md b/.changeset/joy-id-sign-psbt-return-psbt.md new file mode 100644 index 000000000..cc7b81ea7 --- /dev/null +++ b/.changeset/joy-id-sign-psbt-return-psbt.md @@ -0,0 +1,10 @@ +--- +"@ckb-ccc/joy-id": patch +--- + +`signPsbt` now always returns a PSBT. Previously JoyID returned a raw +transaction when `autoFinalized` was true, and failed on PSBTs with inputs it +couldn't sign. + +If you broadcast the result of `signPsbt` directly, extract the transaction +from the PSBT or use `signAndBroadcastPsbt` instead. diff --git a/packages/joy-id/package.json b/packages/joy-id/package.json index f47719232..1f85b435b 100644 --- a/packages/joy-id/package.json +++ b/packages/joy-id/package.json @@ -40,6 +40,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@noble/curves": "^2.2.0", "eslint": "^10.5.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.6", @@ -55,9 +56,11 @@ "access": "public" }, "dependencies": { + "@bitcoinerlab/secp256k1": "^2.0.0", "@ckb-ccc/core": "workspace:*", "@joyid/ckb": "^1.1.4", - "@joyid/common": "^0.2.2" + "@joyid/common": "^0.2.2", + "bitcoinjs-lib": "^7.0.1" }, "packageManager": "pnpm@11.8.0", "types": "./dist.commonjs/index.d.ts" diff --git a/packages/joy-id/src/btc/index.test.ts b/packages/joy-id/src/btc/index.test.ts index 96333e1f9..9b248ff31 100644 --- a/packages/joy-id/src/btc/index.test.ts +++ b/packages/joy-id/src/btc/index.test.ts @@ -1,5 +1,7 @@ import { ccc } from "@ckb-ccc/core"; import { decodeSearch } from "@joyid/common"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { networks, payments, Psbt, script, Transaction } from "bitcoinjs-lib"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPopup } from "../common/index.js"; import { @@ -13,7 +15,7 @@ vi.mock("../common/index.js", () => ({ createPopup: vi.fn(), })); -const ADDRESS = "tb1qcqza4qj68la40xfcjg8kdaks7uvjxqhtkh3apm"; +const NETWORK = networks.testnet; const CLIENT = ccc.ClientPublicTestnet.new({ transport: { request: async () => { @@ -22,6 +24,29 @@ const CLIENT = ccc.ClientPublicTestnet.new({ }, }); +function createKey(seed: number) { + const privateKey = new Uint8Array(32).fill(seed); + const publicKey = secp256k1.getPublicKey(privateKey, true); + const { address, output } = payments.p2wpkh({ + pubkey: publicKey, + network: NETWORK, + }); + return { + address: address!, + publicKey, + output: output!, + signer: { + publicKey, + sign: (hash: Uint8Array) => secp256k1.sign(hash, privateKey), + }, + }; +} + +const JOYID = createKey(1); +const OTHER = createKey(2); +// Key-path finalization only needs a valid x-only key. +const TAPROOT_OUTPUT = ccc.bytesConcat([0x51, 0x20], JOYID.publicKey.slice(1)); + class ConnectionsRepoMemory implements ConnectionsRepo { constructor(public connection?: Connection) {} @@ -46,61 +71,292 @@ function createSigner() { "p2wpkh", undefined, new ConnectionsRepoMemory({ - address: ADDRESS, - publicKey: `0x${"02".repeat(33)}`, + address: JOYID.address, + publicKey: ccc.hexFrom(JOYID.publicKey), keyType: "main_key", }), "btcTestnet", ); } +function createPsbt( + outputs: Uint8Array[], + presign: (psbt: Psbt) => void = () => {}, +): ccc.Hex { + const psbt = new Psbt({ network: NETWORK }); + outputs.forEach((output, i) => { + psbt.addInput({ + hash: new Uint8Array(32).fill(i + 1), + index: 0, + witnessUtxo: { script: output, value: 10000n }, + }); + }); + psbt.addOutput({ address: JOYID.address, value: 9000n }); + presign(psbt); + return ccc.hexFrom(psbt.toBuffer()); +} + +// A JoyID signature over a different message. +function staleJoyIdSignature() { + return { + pubkey: JOYID.publicKey, + signature: script.signature.encode( + JOYID.signer.sign(new Uint8Array(32).fill(9)), + Transaction.SIGHASH_ALL, + ), + }; +} + +function parse(psbtHex: ccc.Hex) { + return Psbt.fromBuffer(ccc.bytesFrom(psbtHex), { network: NETWORK }); +} + function requestSentTo(popup: string) { const data = new URL(popup).searchParams.get("_data_"); return decodeSearch(data!) as Record; } +// Signs every JoyID input, replacing any existing JoyID signature. +function mockJoyIdSigning( + sign: (psbt: Psbt) => void = (psbt) => + psbt.data.inputs.forEach((input, index) => { + if ( + ccc.hexFrom(input.witnessUtxo!.script) !== ccc.hexFrom(JOYID.output) + ) { + return; + } + input.partialSig = input.partialSig?.filter( + ({ pubkey }) => ccc.hexFrom(pubkey) !== ccc.hexFrom(JOYID.publicKey), + ); + if (!input.partialSig?.length) { + delete input.partialSig; + } + psbt.signInput(index, JOYID.signer); + }), +) { + vi.mocked(createPopup).mockImplementation(async (popup) => { + const psbt = Psbt.fromHex(requestSentTo(popup).tx as string, { + network: NETWORK, + }); + sign(psbt); + return { tx: psbt.toHex() }; + }); +} + describe("BitcoinSigner.signPsbt", () => { beforeEach(() => { vi.stubGlobal("location", { href: "https://dapp.test/" }); vi.mocked(createPopup).mockReset(); - vi.mocked(createPopup).mockResolvedValue({ tx: "70736274ff" }); }); - it("should send the default options in JoyID's shape", async () => { - const signed = await createSigner().signPsbt("0x70736274ff"); + it("should ask JoyID not to finalize and finalize locally by default", async () => { + mockJoyIdSigning(); + const psbtHex = createPsbt([JOYID.output]); + + const signed = await createSigner().signPsbt(psbtHex); - expect(signed).toBe("0x70736274ff"); const request = requestSentTo(vi.mocked(createPopup).mock.calls[0][0]); expect(request).toMatchObject({ - tx: "70736274ff", - signerAddress: ADDRESS, - autoFinalized: true, - options: { autoFinalized: true }, + tx: psbtHex.slice(2), + signerAddress: JOYID.address, + autoFinalized: false, + options: { autoFinalized: false }, }); expect(request.options).not.toHaveProperty("toSignInputs"); + + const [input] = parse(signed).data.inputs; + expect(input.finalScriptWitness).toBeDefined(); + expect(input.partialSig).toBeUndefined(); + }); + + it("should return the partially signed PSBT when autoFinalized is false", async () => { + mockJoyIdSigning(); + + const signed = await createSigner().signPsbt(createPsbt([JOYID.output]), { + autoFinalized: false, + }); + + const [input] = parse(signed).data.inputs; + expect(input.finalScriptWitness).toBeUndefined(); + expect(input.partialSig).toHaveLength(1); }); it("should send inputsToSign as toSignInputs", async () => { - await createSigner().signPsbt("0x70736274ff", { + mockJoyIdSigning(); + + await createSigner().signPsbt(createPsbt([JOYID.output, OTHER.output]), { autoFinalized: false, inputsToSign: [ - { index: 0, address: ADDRESS }, + { index: 0, address: JOYID.address }, { index: 1, publicKey: "0xabcd", sighashTypes: [1] }, ], }); - const request = requestSentTo(vi.mocked(createPopup).mock.calls[0][0]); - expect(request).toMatchObject({ + expect( + requestSentTo(vi.mocked(createPopup).mock.calls[0][0]), + ).toMatchObject({ autoFinalized: false, options: { autoFinalized: false, toSignInputs: [ - { index: 0, address: ADDRESS }, + { index: 0, address: JOYID.address }, { index: 1, publicKey: "abcd", sighashTypes: [1] }, ], }, }); }); + + it("should only finalize the inputs JoyID signed", async () => { + mockJoyIdSigning(); + + const signed = await createSigner().signPsbt( + createPsbt([JOYID.output, OTHER.output]), + ); + + const [mine, theirs] = parse(signed).data.inputs; + expect(mine.finalScriptWitness).toBeDefined(); + expect(theirs.finalScriptWitness).toBeUndefined(); + expect(theirs.partialSig).toBeUndefined(); + }); + + it("should finalize Taproot key-path inputs", async () => { + mockJoyIdSigning((psbt) => + psbt.updateInput(0, { tapKeySig: new Uint8Array(64).fill(1) }), + ); + + const signed = await createSigner().signPsbt(createPsbt([TAPROOT_OUTPUT])); + + const [input] = parse(signed).data.inputs; + expect(input.finalScriptWitness).toBeDefined(); + expect(input.tapKeySig).toBeUndefined(); + }); + + it("should finalize an input whose signature JoyID replaced", async () => { + mockJoyIdSigning(); + const psbtHex = createPsbt([JOYID.output], (psbt) => + psbt.updateInput(0, { partialSig: [staleJoyIdSignature()] }), + ); + + const signed = await createSigner().signPsbt(psbtHex); + + expect(parse(signed).data.inputs[0].finalScriptWitness).toBeDefined(); + }); + + it("should not finalize inputs signed only by other signers", async () => { + mockJoyIdSigning(); + const psbtHex = createPsbt([JOYID.output, OTHER.output], (psbt) => + psbt.signInput(1, OTHER.signer), + ); + + const signed = await createSigner().signPsbt(psbtHex); + + const [mine, theirs] = parse(signed).data.inputs; + expect(mine.finalScriptWitness).toBeDefined(); + expect(theirs.finalScriptWitness).toBeUndefined(); + expect(theirs.partialSig).toHaveLength(1); + }); + + it("should throw when JoyID skips a requested input signed by others", async () => { + mockJoyIdSigning(); + const psbtHex = createPsbt([JOYID.output, OTHER.output], (psbt) => + psbt.signInput(1, OTHER.signer), + ); + + await expect( + createSigner().signPsbt(psbtHex, { + inputsToSign: [ + { index: 0, address: JOYID.address }, + { index: 1, address: OTHER.address }, + ], + }), + ).rejects.toThrow( + "JoyID did not add a signature to the requested input #1", + ); + }); + + it("should throw when JoyID skips an unsigned requested input", async () => { + mockJoyIdSigning(); + + await expect( + createSigner().signPsbt(createPsbt([JOYID.output, OTHER.output]), { + inputsToSign: [{ index: 1, address: OTHER.address }], + }), + ).rejects.toThrow( + "JoyID did not add a signature to the requested input #1", + ); + }); + + it("should suggest autoFinalized false when finalizing fails", async () => { + mockJoyIdSigning((psbt) => + psbt.updateInput(0, { partialSig: [staleJoyIdSignature()] }), + ); + + await expect( + createSigner().signPsbt(createPsbt([TAPROOT_OUTPUT])), + ).rejects.toThrow("Use { autoFinalized: false }"); + }); + + it.each([true, false])( + "should reject a raw transaction (autoFinalized: %s)", + async (autoFinalized) => { + vi.mocked(createPopup).mockResolvedValue({ tx: "02000000000101aabb" }); + + await expect( + createSigner().signPsbt(createPsbt([JOYID.output]), { autoFinalized }), + ).rejects.toThrow("JoyID did not return a PSBT"); + }, + ); + + it.each([true, false])( + "should reject a corrupted PSBT (autoFinalized: %s)", + async (autoFinalized) => { + vi.mocked(createPopup).mockResolvedValue({ tx: "70736274ff0100aabb" }); + + await expect( + createSigner().signPsbt(createPsbt([JOYID.output]), { autoFinalized }), + ).rejects.toThrow("JoyID returned an invalid PSBT"); + }, + ); + + it.each([true, false])( + "should reject a PSBT for a different transaction (autoFinalized: %s)", + async (autoFinalized) => { + vi.mocked(createPopup).mockResolvedValue({ + tx: createPsbt([OTHER.output, OTHER.output]).slice(2), + }); + + await expect( + createSigner().signPsbt(createPsbt([JOYID.output]), { autoFinalized }), + ).rejects.toThrow("different transaction"); + }, + ); + + describe("inputs already signed by JoyID (unsupported)", () => { + const presigned = () => + createPsbt([JOYID.output], (psbt) => psbt.signInput(0, JOYID.signer)); + + it("should leave the input unfinalized by default", async () => { + mockJoyIdSigning(); + + const signed = await createSigner().signPsbt(presigned()); + + const [input] = parse(signed).data.inputs; + expect(input.finalScriptWitness).toBeUndefined(); + expect(input.partialSig).toHaveLength(1); + }); + + it("should report the requested input as not signed", async () => { + mockJoyIdSigning(); + + await expect( + createSigner().signPsbt(presigned(), { + inputsToSign: [{ index: 0, address: JOYID.address }], + }), + ).rejects.toThrow( + "JoyID did not add a signature to the requested input #0", + ); + }); + }); }); describe("BitcoinSigner.signAndBroadcastPsbt", () => { @@ -113,7 +369,7 @@ describe("BitcoinSigner.signAndBroadcastPsbt", () => { it("should force autoFinalized on and request broadcasting", async () => { const txid = await createSigner().signAndBroadcastPsbt("0x70736274ff", { autoFinalized: false, - inputsToSign: [{ index: 0, address: ADDRESS }], + inputsToSign: [{ index: 0, address: JOYID.address }], }); expect(txid).toBe(`0x${"ab".repeat(32)}`); @@ -124,7 +380,7 @@ describe("BitcoinSigner.signAndBroadcastPsbt", () => { autoFinalized: true, options: { autoFinalized: true, - toSignInputs: [{ index: 0, address: ADDRESS }], + toSignInputs: [{ index: 0, address: JOYID.address }], }, }); }); diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index 1705c25bf..f8ba8e838 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -6,6 +6,7 @@ import { ConnectionsRepo, ConnectionsRepoLocalStorage, } from "../connectionsStorage/index.js"; +import { finalizeSignedInputs, parseSignedPsbt } from "./psbt.js"; /** * Converts CCC-level sign PSBT options into the shape expected by JoyID, @@ -208,7 +209,14 @@ export class BitcoinSigner extends ccc.SignerBtc { * Signs a PSBT using JoyID 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 as a Hex string. + * + * @remarks + * JoyID returns a raw transaction when asked to finalize, and rejects PSBTs + * with inputs it can't sign. So JoyID is always asked for an unfinalized + * PSBT, and `autoFinalized` is handled here. Inputs that already have a + * JoyID signature are not finalized. */ async signPsbt( psbtHex: ccc.HexLike, @@ -216,16 +224,19 @@ export class BitcoinSigner extends ccc.SignerBtc { ): Promise { const { address } = await this.assertConnection(); const formattedOptions = ccc.SignPsbtOptions.from(options); + const unsignedPsbtHex = ccc.hexFrom(psbtHex); const config = this.getConfig(); - const { tx: signedPsbtHex } = await createPopup( + const { tx } = await createPopup( buildJoyIDURL( { ...config, - tx: ccc.hexFrom(psbtHex).slice(2), - options: toJoyIdSignPsbtOptions(formattedOptions), + tx: unsignedPsbtHex.slice(2), + options: toJoyIdSignPsbtOptions( + new ccc.SignPsbtOptions(false, formattedOptions.inputsToSign), + ), signerAddress: address, - autoFinalized: formattedOptions.autoFinalized, + autoFinalized: false, }, "popup", "/sign-psbt", @@ -233,7 +244,19 @@ export class BitcoinSigner extends ccc.SignerBtc { { ...config, type: DappRequestType.SignPsbt }, ); - return ccc.hexFrom(signedPsbtHex); + const signedPsbtHex = ccc.hexFrom(tx); + const { unsigned, signed } = parseSignedPsbt( + unsignedPsbtHex, + signedPsbtHex, + ); + if (!formattedOptions.autoFinalized) { + return signedPsbtHex; + } + return finalizeSignedInputs( + unsigned, + signed, + formattedOptions.inputsToSign, + ); } /** diff --git a/packages/joy-id/src/btc/psbt.ts b/packages/joy-id/src/btc/psbt.ts new file mode 100644 index 000000000..b4d87ea71 --- /dev/null +++ b/packages/joy-id/src/btc/psbt.ts @@ -0,0 +1,130 @@ +import * as ecc from "@bitcoinerlab/secp256k1"; +import { ccc } from "@ckb-ccc/core"; +import { initEccLib, Psbt } from "bitcoinjs-lib"; + +type PsbtInput = Psbt["data"]["inputs"][number]; + +let isEccLibInitialized = false; + +// Required by bitcoinjs-lib to finalize Taproot inputs. +function ensureEccLib() { + if (!isEccLibInitialized) { + initEccLib(ecc); + isEccLibInitialized = true; + } +} + +function isFinalized(input: PsbtInput) { + return !!(input.finalScriptSig || input.finalScriptWitness); +} + +// Compare by content so a replaced signature counts as new. +function signatureEntries(input: PsbtInput | undefined): Set { + const entries = new Set(); + input?.partialSig?.forEach(({ pubkey, signature }) => + entries.add(`partial:${ccc.hexFrom(pubkey)}:${ccc.hexFrom(signature)}`), + ); + if (input?.tapKeySig) { + entries.add(`tapKey:${ccc.hexFrom(input.tapKeySig)}`); + } + input?.tapScriptSig?.forEach(({ pubkey, leafHash, signature }) => + entries.add( + `tapScript:${ccc.hexFrom(pubkey)}:${ccc.hexFrom(leafHash)}:${ccc.hexFrom(signature)}`, + ), + ); + return entries; +} + +function hasNewSignature( + before: PsbtInput | undefined, + after: PsbtInput, +): boolean { + const existing = signatureEntries(before); + return [...signatureEntries(after)].some((entry) => !existing.has(entry)); +} + +function finalizeInputs(psbt: Psbt, indexes: Set): ccc.Hex { + ensureEccLib(); + try { + for (const index of indexes) { + psbt.finalizeInput(index); + } + } catch (error) { + throw new Error( + "Failed to finalize the PSBT signed by JoyID. " + + "Use { autoFinalized: false } for partial or multisig signing.", + { cause: error }, + ); + } + + return ccc.hexFrom(psbt.toBuffer()); +} + +/** + * Checks that JoyID returned a PSBT of the same transaction. + */ +export function parseSignedPsbt( + unsignedPsbtHex: ccc.Hex, + signedPsbtHex: ccc.Hex, +): { unsigned: Psbt; signed: Psbt } { + if (!signedPsbtHex.startsWith("0x70736274ff")) { + throw new Error("JoyID did not return a PSBT."); + } + + const unsigned = Psbt.fromHex(unsignedPsbtHex.slice(2)); + let signed: Psbt; + try { + signed = Psbt.fromHex(signedPsbtHex.slice(2)); + } catch (error) { + throw new Error("JoyID returned an invalid PSBT.", { cause: error }); + } + + if ( + ccc.hexFrom(unsigned.data.getTransaction()) !== + ccc.hexFrom(signed.data.getTransaction()) + ) { + throw new Error("JoyID returned a PSBT for a different transaction."); + } + + return { unsigned, signed }; +} + +/** + * Finalizes the inputs JoyID signed. Inputs signed only by others are skipped, + * and every requested input must be signed by JoyID. + * + * Inputs that already had a JoyID signature are not supported, as re-signing + * produces the same bytes. + */ +export function finalizeSignedInputs( + unsigned: Psbt, + signed: Psbt, + inputsToSign: ccc.InputToSign[], +): ccc.Hex { + const signedIndexes = new Set( + signed.data.inputs.flatMap((input, index) => + !isFinalized(input) && hasNewSignature(unsigned.data.inputs[index], input) + ? [index] + : [], + ), + ); + + if (inputsToSign.length === 0) { + return finalizeInputs(signed, signedIndexes); + } + + const requestedIndexes = new Set(); + for (const { index } of inputsToSign) { + const input = signed.data.inputs[index]; + if (input && isFinalized(input)) { + continue; + } + if (!signedIndexes.has(index)) { + throw new Error( + `JoyID did not add a signature to the requested input #${index}.`, + ); + } + requestedIndexes.add(index); + } + return finalizeInputs(signed, requestedIndexes); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ca71dc18..9fe3f77e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -781,6 +781,9 @@ importers: packages/joy-id: dependencies: + '@bitcoinerlab/secp256k1': + specifier: ^2.0.0 + version: 2.0.0 '@ckb-ccc/core': specifier: workspace:* version: link:../core @@ -790,10 +793,16 @@ importers: '@joyid/common': specifier: ^0.2.2 version: 0.2.2(@typescript/typescript6@6.0.2) + bitcoinjs-lib: + specifier: ^7.0.1 + version: 7.0.2(@typescript/typescript6@6.0.2) devDependencies: '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.10.0(jiti@2.7.0)) + '@noble/curves': + specifier: ^2.2.0 + version: 2.4.0 '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 From dcaac2eb7549f82887bdeac3ede8b556ba9ba3d2 Mon Sep 17 00:00:00 2001 From: fgh Date: Tue, 29 Sep 2026 19:20:48 +0800 Subject: [PATCH 2/5] refactor(btc): replace bitcoinjs-lib with @scure/btc-signer --- .changeset/scure-btc-signer.md | 7 + packages/examples/package.json | 3 +- packages/examples/src/playground/index.d.ts | 2 - packages/examples/src/transferBtc.ts | 58 +++-- packages/joy-id/package.json | 14 +- packages/joy-id/src/btc/index.test.ts | 205 +++++++++++------- packages/joy-id/src/btc/psbt.ts | 85 +++++--- packages/joy-id/tsdown.config.mts | 12 +- packages/playground/package.json | 5 +- .../playground/src/app/components/Editor.tsx | 26 ++- packages/playground/src/app/execute/index.tsx | 22 +- packages/xverse/package.json | 11 +- packages/xverse/src/signer.test.ts | 89 +++++--- packages/xverse/src/signer.ts | 54 ++++- packages/xverse/tsdown.config.mts | 12 +- pnpm-lock.yaml | 103 +++------ 16 files changed, 439 insertions(+), 269 deletions(-) create mode 100644 .changeset/scure-btc-signer.md diff --git a/.changeset/scure-btc-signer.md b/.changeset/scure-btc-signer.md new file mode 100644 index 000000000..1f7c6f59c --- /dev/null +++ b/.changeset/scure-btc-signer.md @@ -0,0 +1,7 @@ +--- +"@ckb-ccc/joy-id": patch +"@ckb-ccc/xverse": patch +--- + +Use `@scure/btc-signer` instead of `bitcoinjs-lib` for PSBTs. JoyID no longer +calls `initEccLib`, which set global state. diff --git a/packages/examples/package.json b/packages/examples/package.json index 09df2b0b7..780b30574 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -34,7 +34,8 @@ "@ckb-ccc/ccc": "workspace:*", "@ckb-ccc/playground": "file:src/playground", "@noble/curves": "^2.2.0", - "@noble/hashes": "^2.2.0" + "@noble/hashes": "^2.2.0", + "@scure/btc-signer": "2.4.1" }, "packageManager": "pnpm@11.8.0" } diff --git a/packages/examples/src/playground/index.d.ts b/packages/examples/src/playground/index.d.ts index 7a7873c29..fefc0e139 100644 --- a/packages/examples/src/playground/index.d.ts +++ b/packages/examples/src/playground/index.d.ts @@ -1,7 +1,5 @@ import { ccc } from "@ckb-ccc/ccc"; -import * as bitcoinLib from "bitcoinjs-lib"; export function render(tx: ccc.Transaction): Promise; export const signer: ccc.Signer; export const client: ccc.Client; -export const bitcoin: typeof bitcoinLib; diff --git a/packages/examples/src/transferBtc.ts b/packages/examples/src/transferBtc.ts index 1af442e66..6488c1549 100644 --- a/packages/examples/src/transferBtc.ts +++ b/packages/examples/src/transferBtc.ts @@ -1,5 +1,6 @@ import { ccc } from "@ckb-ccc/ccc"; -import { bitcoin, signer } from "@ckb-ccc/playground"; +import { signer } from "@ckb-ccc/playground"; +import * as btc from "@scure/btc-signer"; // Supported wallets: Unisat, JoyID, Xverse // Check if the current signer is also a Bitcoin signer @@ -60,46 +61,37 @@ if (!vout || !vout.scriptpubkey) { } // 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, +const tx = new btc.Transaction(); +const isTaproot = + vout.scriptpubkey_type === "v1_p2tr" || + vout.scriptpubkey_type === "witness_v1_taproot"; +tx.addInput({ + txid: selectedUtxo.txid, index: selectedUtxo.vout, witnessUtxo: { script: ccc.bytesFrom(vout.scriptpubkey), - value: BigInt(vout.value), + amount: 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()) - .subarray(1); -} - -psbt.addInput(input); + // Taproot inputs need the internal key + ...(isTaproot + ? { + tapInternalKey: ccc + .bytesFrom(await signer.getBtcPublicKey()) + .subarray(1), + } + : {}), +}); // 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), -}); +// Signet uses the testnet address format +tx.addOutputAddress( + btcAddress, + BigInt(vout.value) - BigInt(FEE_SATS), + btc.TEST_NETWORK, +); // Sign and broadcast the transaction -const txId = await signer.signAndBroadcastPsbt(psbt.toHex()); +const txId = await signer.signAndBroadcastPsbt(tx.toPSBT()); console.log( `View transaction: https://mempool.space/${btcTestnetName}/tx/${txId.slice(2)}`, ); diff --git a/packages/joy-id/package.json b/packages/joy-id/package.json index 1f85b435b..89e0ac744 100644 --- a/packages/joy-id/package.json +++ b/packages/joy-id/package.json @@ -56,12 +56,18 @@ "access": "public" }, "dependencies": { - "@bitcoinerlab/secp256k1": "^2.0.0", "@ckb-ccc/core": "workspace:*", + "@scure/btc-signer": "2.4.1", "@joyid/ckb": "^1.1.4", - "@joyid/common": "^0.2.2", - "bitcoinjs-lib": "^7.0.1" + "@joyid/common": "^0.2.2" }, "packageManager": "pnpm@11.8.0", - "types": "./dist.commonjs/index.d.ts" + "types": "./dist.commonjs/index.d.ts", + "inlinedDependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0", + "@scure/btc-signer": "2.4.1", + "micro-packed": "0.11.1" + } } diff --git a/packages/joy-id/src/btc/index.test.ts b/packages/joy-id/src/btc/index.test.ts index 9b248ff31..28948d77a 100644 --- a/packages/joy-id/src/btc/index.test.ts +++ b/packages/joy-id/src/btc/index.test.ts @@ -1,7 +1,7 @@ import { ccc } from "@ckb-ccc/core"; import { decodeSearch } from "@joyid/common"; import { secp256k1 } from "@noble/curves/secp256k1.js"; -import { networks, payments, Psbt, script, Transaction } from "bitcoinjs-lib"; +import * as btc from "@scure/btc-signer"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPopup } from "../common/index.js"; import { @@ -15,7 +15,6 @@ vi.mock("../common/index.js", () => ({ createPopup: vi.fn(), })); -const NETWORK = networks.testnet; const CLIENT = ccc.ClientPublicTestnet.new({ transport: { request: async () => { @@ -27,19 +26,8 @@ const CLIENT = ccc.ClientPublicTestnet.new({ function createKey(seed: number) { const privateKey = new Uint8Array(32).fill(seed); const publicKey = secp256k1.getPublicKey(privateKey, true); - const { address, output } = payments.p2wpkh({ - pubkey: publicKey, - network: NETWORK, - }); - return { - address: address!, - publicKey, - output: output!, - signer: { - publicKey, - sign: (hash: Uint8Array) => secp256k1.sign(hash, privateKey), - }, - }; + const { address, script } = btc.p2wpkh(publicKey, btc.TEST_NETWORK); + return { privateKey, publicKey, address, script }; } const JOYID = createKey(1); @@ -80,35 +68,37 @@ function createSigner() { } function createPsbt( - outputs: Uint8Array[], - presign: (psbt: Psbt) => void = () => {}, + scripts: Uint8Array[], + presign: (tx: btc.Transaction) => void = () => {}, ): ccc.Hex { - const psbt = new Psbt({ network: NETWORK }); - outputs.forEach((output, i) => { - psbt.addInput({ - hash: new Uint8Array(32).fill(i + 1), + const tx = new btc.Transaction({ unknown: "ignore" }); + scripts.forEach((script, i) => { + tx.addInput({ + txid: new Uint8Array(32).fill(i + 1), index: 0, - witnessUtxo: { script: output, value: 10000n }, + witnessUtxo: { script, amount: 10000n }, }); }); - psbt.addOutput({ address: JOYID.address, value: 9000n }); - presign(psbt); - return ccc.hexFrom(psbt.toBuffer()); + tx.addOutputAddress(JOYID.address, 9000n, btc.TEST_NETWORK); + presign(tx); + return ccc.hexFrom(tx.toPSBT()); } // A JoyID signature over a different message. -function staleJoyIdSignature() { - return { - pubkey: JOYID.publicKey, - signature: script.signature.encode( - JOYID.signer.sign(new Uint8Array(32).fill(9)), - Transaction.SIGHASH_ALL, - ), - }; +function staleJoyIdSignature(): [Uint8Array, Uint8Array] { + const signature = secp256k1.sign( + new Uint8Array(32).fill(9), + JOYID.privateKey, + { format: "der" }, + ); + return [JOYID.publicKey, ccc.bytesConcat(signature, [btc.SigHash.ALL])]; } -function parse(psbtHex: ccc.Hex) { - return Psbt.fromBuffer(ccc.bytesFrom(psbtHex), { network: NETWORK }); +function inputsOf(psbtHex: ccc.Hex) { + const tx = btc.Transaction.fromPSBT(ccc.bytesFrom(psbtHex), { + unknown: "ignore", + }); + return Array.from({ length: tx.inputsLength }, (_, i) => tx.getInput(i)); } function requestSentTo(popup: string) { @@ -118,28 +108,33 @@ function requestSentTo(popup: string) { // Signs every JoyID input, replacing any existing JoyID signature. function mockJoyIdSigning( - sign: (psbt: Psbt) => void = (psbt) => - psbt.data.inputs.forEach((input, index) => { + sign: (tx: btc.Transaction) => void = (tx) => { + for (let i = 0; i < tx.inputsLength; i++) { + const { witnessUtxo, partialSig } = tx.getInput(i); if ( - ccc.hexFrom(input.witnessUtxo!.script) !== ccc.hexFrom(JOYID.output) + !witnessUtxo || + ccc.hexFrom(witnessUtxo.script) !== ccc.hexFrom(JOYID.script) ) { - return; + continue; } - input.partialSig = input.partialSig?.filter( - ({ pubkey }) => ccc.hexFrom(pubkey) !== ccc.hexFrom(JOYID.publicKey), + const others = partialSig?.filter( + ([pubkey]) => ccc.hexFrom(pubkey) !== ccc.hexFrom(JOYID.publicKey), ); - if (!input.partialSig?.length) { - delete input.partialSig; + tx.updateInput(i, { partialSig: undefined }); + if (others?.length) { + tx.updateInput(i, { partialSig: others }); } - psbt.signInput(index, JOYID.signer); - }), + tx.signIdx(JOYID.privateKey, i); + } + }, ) { vi.mocked(createPopup).mockImplementation(async (popup) => { - const psbt = Psbt.fromHex(requestSentTo(popup).tx as string, { - network: NETWORK, - }); - sign(psbt); - return { tx: psbt.toHex() }; + const tx = btc.Transaction.fromPSBT( + ccc.bytesFrom(requestSentTo(popup).tx as string), + { unknown: "ignore" }, + ); + sign(tx); + return { tx: ccc.hexFrom(tx.toPSBT()).slice(2) }; }); } @@ -151,7 +146,7 @@ describe("BitcoinSigner.signPsbt", () => { it("should ask JoyID not to finalize and finalize locally by default", async () => { mockJoyIdSigning(); - const psbtHex = createPsbt([JOYID.output]); + const psbtHex = createPsbt([JOYID.script]); const signed = await createSigner().signPsbt(psbtHex); @@ -164,7 +159,7 @@ describe("BitcoinSigner.signPsbt", () => { }); expect(request.options).not.toHaveProperty("toSignInputs"); - const [input] = parse(signed).data.inputs; + const [input] = inputsOf(signed); expect(input.finalScriptWitness).toBeDefined(); expect(input.partialSig).toBeUndefined(); }); @@ -172,11 +167,11 @@ describe("BitcoinSigner.signPsbt", () => { it("should return the partially signed PSBT when autoFinalized is false", async () => { mockJoyIdSigning(); - const signed = await createSigner().signPsbt(createPsbt([JOYID.output]), { + const signed = await createSigner().signPsbt(createPsbt([JOYID.script]), { autoFinalized: false, }); - const [input] = parse(signed).data.inputs; + const [input] = inputsOf(signed); expect(input.finalScriptWitness).toBeUndefined(); expect(input.partialSig).toHaveLength(1); }); @@ -184,7 +179,7 @@ describe("BitcoinSigner.signPsbt", () => { it("should send inputsToSign as toSignInputs", async () => { mockJoyIdSigning(); - await createSigner().signPsbt(createPsbt([JOYID.output, OTHER.output]), { + await createSigner().signPsbt(createPsbt([JOYID.script, OTHER.script]), { autoFinalized: false, inputsToSign: [ { index: 0, address: JOYID.address }, @@ -210,47 +205,103 @@ describe("BitcoinSigner.signPsbt", () => { mockJoyIdSigning(); const signed = await createSigner().signPsbt( - createPsbt([JOYID.output, OTHER.output]), + createPsbt([JOYID.script, OTHER.script]), ); - const [mine, theirs] = parse(signed).data.inputs; + const [mine, theirs] = inputsOf(signed); expect(mine.finalScriptWitness).toBeDefined(); expect(theirs.finalScriptWitness).toBeUndefined(); expect(theirs.partialSig).toBeUndefined(); }); + it("should finalize without UTXO info on other signers' inputs", async () => { + mockJoyIdSigning(); + const tx = new btc.Transaction(); + tx.addInput({ + txid: new Uint8Array(32).fill(1), + index: 0, + witnessUtxo: { script: JOYID.script, amount: 10000n }, + }); + tx.addInput({ txid: new Uint8Array(32).fill(2), index: 0 }); + tx.addOutputAddress(JOYID.address, 9000n, btc.TEST_NETWORK); + + const signed = await createSigner().signPsbt(ccc.hexFrom(tx.toPSBT())); + + const [mine, theirs] = inputsOf(signed); + expect(mine.finalScriptWitness).toBeDefined(); + expect(theirs.finalScriptWitness).toBeUndefined(); + }); + + it("should finalize P2SH-P2WPKH inputs", async () => { + const nested = btc.p2sh(btc.p2wpkh(JOYID.publicKey), btc.TEST_NETWORK); + mockJoyIdSigning((tx) => tx.signIdx(JOYID.privateKey, 0)); + const tx = new btc.Transaction(); + tx.addInput({ + txid: new Uint8Array(32).fill(1), + index: 0, + witnessUtxo: { script: nested.script, amount: 10000n }, + redeemScript: nested.redeemScript, + }); + tx.addOutputAddress(JOYID.address, 9000n, btc.TEST_NETWORK); + + const signed = await createSigner().signPsbt(ccc.hexFrom(tx.toPSBT())); + + const [input] = inputsOf(signed); + expect(input.finalScriptSig).toBeDefined(); + expect(input.finalScriptWitness).toBeDefined(); + expect(input.redeemScript).toBeUndefined(); + expect(input.partialSig).toBeUndefined(); + }); + + it("should keep PSBT fields it does not know", async () => { + mockJoyIdSigning(); + const psbtHex = createPsbt([JOYID.script], (tx) => + tx.updateInput(0, { + unknown: [ + [{ type: 0xee, key: new Uint8Array([1]) }, new Uint8Array([7])], + ], + }), + ); + + const signed = await createSigner().signPsbt(psbtHex); + + const [input] = inputsOf(signed); + expect(input.finalScriptWitness).toBeDefined(); + expect(input.unknown).toHaveLength(1); + }); + it("should finalize Taproot key-path inputs", async () => { - mockJoyIdSigning((psbt) => - psbt.updateInput(0, { tapKeySig: new Uint8Array(64).fill(1) }), + mockJoyIdSigning((tx) => + tx.updateInput(0, { tapKeySig: new Uint8Array(64).fill(1) }), ); const signed = await createSigner().signPsbt(createPsbt([TAPROOT_OUTPUT])); - const [input] = parse(signed).data.inputs; + const [input] = inputsOf(signed); expect(input.finalScriptWitness).toBeDefined(); expect(input.tapKeySig).toBeUndefined(); }); it("should finalize an input whose signature JoyID replaced", async () => { mockJoyIdSigning(); - const psbtHex = createPsbt([JOYID.output], (psbt) => - psbt.updateInput(0, { partialSig: [staleJoyIdSignature()] }), + const psbtHex = createPsbt([JOYID.script], (tx) => + tx.updateInput(0, { partialSig: [staleJoyIdSignature()] }), ); const signed = await createSigner().signPsbt(psbtHex); - expect(parse(signed).data.inputs[0].finalScriptWitness).toBeDefined(); + expect(inputsOf(signed)[0].finalScriptWitness).toBeDefined(); }); it("should not finalize inputs signed only by other signers", async () => { mockJoyIdSigning(); - const psbtHex = createPsbt([JOYID.output, OTHER.output], (psbt) => - psbt.signInput(1, OTHER.signer), + const psbtHex = createPsbt([JOYID.script, OTHER.script], (tx) => + tx.signIdx(OTHER.privateKey, 1), ); const signed = await createSigner().signPsbt(psbtHex); - const [mine, theirs] = parse(signed).data.inputs; + const [mine, theirs] = inputsOf(signed); expect(mine.finalScriptWitness).toBeDefined(); expect(theirs.finalScriptWitness).toBeUndefined(); expect(theirs.partialSig).toHaveLength(1); @@ -258,8 +309,8 @@ describe("BitcoinSigner.signPsbt", () => { it("should throw when JoyID skips a requested input signed by others", async () => { mockJoyIdSigning(); - const psbtHex = createPsbt([JOYID.output, OTHER.output], (psbt) => - psbt.signInput(1, OTHER.signer), + const psbtHex = createPsbt([JOYID.script, OTHER.script], (tx) => + tx.signIdx(OTHER.privateKey, 1), ); await expect( @@ -278,7 +329,7 @@ describe("BitcoinSigner.signPsbt", () => { mockJoyIdSigning(); await expect( - createSigner().signPsbt(createPsbt([JOYID.output, OTHER.output]), { + createSigner().signPsbt(createPsbt([JOYID.script, OTHER.script]), { inputsToSign: [{ index: 1, address: OTHER.address }], }), ).rejects.toThrow( @@ -287,8 +338,8 @@ describe("BitcoinSigner.signPsbt", () => { }); it("should suggest autoFinalized false when finalizing fails", async () => { - mockJoyIdSigning((psbt) => - psbt.updateInput(0, { partialSig: [staleJoyIdSignature()] }), + mockJoyIdSigning((tx) => + tx.updateInput(0, { partialSig: [staleJoyIdSignature()] }), ); await expect( @@ -302,7 +353,7 @@ describe("BitcoinSigner.signPsbt", () => { vi.mocked(createPopup).mockResolvedValue({ tx: "02000000000101aabb" }); await expect( - createSigner().signPsbt(createPsbt([JOYID.output]), { autoFinalized }), + createSigner().signPsbt(createPsbt([JOYID.script]), { autoFinalized }), ).rejects.toThrow("JoyID did not return a PSBT"); }, ); @@ -313,7 +364,7 @@ describe("BitcoinSigner.signPsbt", () => { vi.mocked(createPopup).mockResolvedValue({ tx: "70736274ff0100aabb" }); await expect( - createSigner().signPsbt(createPsbt([JOYID.output]), { autoFinalized }), + createSigner().signPsbt(createPsbt([JOYID.script]), { autoFinalized }), ).rejects.toThrow("JoyID returned an invalid PSBT"); }, ); @@ -322,25 +373,25 @@ describe("BitcoinSigner.signPsbt", () => { "should reject a PSBT for a different transaction (autoFinalized: %s)", async (autoFinalized) => { vi.mocked(createPopup).mockResolvedValue({ - tx: createPsbt([OTHER.output, OTHER.output]).slice(2), + tx: createPsbt([OTHER.script, OTHER.script]).slice(2), }); await expect( - createSigner().signPsbt(createPsbt([JOYID.output]), { autoFinalized }), + createSigner().signPsbt(createPsbt([JOYID.script]), { autoFinalized }), ).rejects.toThrow("different transaction"); }, ); describe("inputs already signed by JoyID (unsupported)", () => { const presigned = () => - createPsbt([JOYID.output], (psbt) => psbt.signInput(0, JOYID.signer)); + createPsbt([JOYID.script], (tx) => tx.signIdx(JOYID.privateKey, 0)); it("should leave the input unfinalized by default", async () => { mockJoyIdSigning(); const signed = await createSigner().signPsbt(presigned()); - const [input] = parse(signed).data.inputs; + const [input] = inputsOf(signed); expect(input.finalScriptWitness).toBeUndefined(); expect(input.partialSig).toHaveLength(1); }); diff --git a/packages/joy-id/src/btc/psbt.ts b/packages/joy-id/src/btc/psbt.ts index b4d87ea71..32f15b2ae 100644 --- a/packages/joy-id/src/btc/psbt.ts +++ b/packages/joy-id/src/btc/psbt.ts @@ -1,17 +1,41 @@ -import * as ecc from "@bitcoinerlab/secp256k1"; import { ccc } from "@ckb-ccc/core"; -import { initEccLib, Psbt } from "bitcoinjs-lib"; +import { Transaction, type TxOpts } from "@scure/btc-signer"; -type PsbtInput = Psbt["data"]["inputs"][number]; +type PsbtInput = ReturnType; +type PsbtInputUpdate = Parameters[1]; -let isEccLibInitialized = false; +// scure drops unknown PSBT fields by default. +const PSBT_OPTIONS: TxOpts = { unknown: "ignore", proprietary: "ignore" }; -// Required by bitcoinjs-lib to finalize Taproot inputs. -function ensureEccLib() { - if (!isEccLibInitialized) { - initEccLib(ecc); - isEccLibInitialized = true; - } +// finalizeIdx needs every input's UTXO to check the fee, so finalize the input +// alone. +function finalizeInput(tx: Transaction, index: number) { + const input = tx.getInput(index); + const scratch = new Transaction(PSBT_OPTIONS); + scratch.addInput(input); + scratch.finalizeIdx(0); + const finalized = scratch.getInput(0); + const cleared = Object.fromEntries( + Object.keys(input) + .filter((key) => !(key in finalized)) + .map((key) => [key, undefined]), + ) as PsbtInputUpdate; + + // Fields a signature commits to can't be cleared while it exists. + tx.updateInput(index, { + partialSig: undefined, + tapKeySig: undefined, + tapScriptSig: undefined, + }); + tx.updateInput(index, { + ...cleared, + finalScriptSig: finalized.finalScriptSig, + finalScriptWitness: finalized.finalScriptWitness, + }); +} + +function inputsOf(tx: Transaction): PsbtInput[] { + return Array.from({ length: tx.inputsLength }, (_, i) => tx.getInput(i)); } function isFinalized(input: PsbtInput) { @@ -21,15 +45,15 @@ function isFinalized(input: PsbtInput) { // Compare by content so a replaced signature counts as new. function signatureEntries(input: PsbtInput | undefined): Set { const entries = new Set(); - input?.partialSig?.forEach(({ pubkey, signature }) => + input?.partialSig?.forEach(([pubkey, signature]) => entries.add(`partial:${ccc.hexFrom(pubkey)}:${ccc.hexFrom(signature)}`), ); if (input?.tapKeySig) { entries.add(`tapKey:${ccc.hexFrom(input.tapKeySig)}`); } - input?.tapScriptSig?.forEach(({ pubkey, leafHash, signature }) => + input?.tapScriptSig?.forEach(([{ pubKey, leafHash }, signature]) => entries.add( - `tapScript:${ccc.hexFrom(pubkey)}:${ccc.hexFrom(leafHash)}:${ccc.hexFrom(signature)}`, + `tapScript:${ccc.hexFrom(pubKey)}:${ccc.hexFrom(leafHash)}:${ccc.hexFrom(signature)}`, ), ); return entries; @@ -43,11 +67,10 @@ function hasNewSignature( return [...signatureEntries(after)].some((entry) => !existing.has(entry)); } -function finalizeInputs(psbt: Psbt, indexes: Set): ccc.Hex { - ensureEccLib(); +function finalizeInputs(tx: Transaction, indexes: Set): ccc.Hex { try { for (const index of indexes) { - psbt.finalizeInput(index); + finalizeInput(tx, index); } } catch (error) { throw new Error( @@ -57,7 +80,7 @@ function finalizeInputs(psbt: Psbt, indexes: Set): ccc.Hex { ); } - return ccc.hexFrom(psbt.toBuffer()); + return ccc.hexFrom(tx.toPSBT()); } /** @@ -66,23 +89,23 @@ function finalizeInputs(psbt: Psbt, indexes: Set): ccc.Hex { export function parseSignedPsbt( unsignedPsbtHex: ccc.Hex, signedPsbtHex: ccc.Hex, -): { unsigned: Psbt; signed: Psbt } { +): { unsigned: Transaction; signed: Transaction } { if (!signedPsbtHex.startsWith("0x70736274ff")) { throw new Error("JoyID did not return a PSBT."); } - const unsigned = Psbt.fromHex(unsignedPsbtHex.slice(2)); - let signed: Psbt; + const unsigned = Transaction.fromPSBT( + ccc.bytesFrom(unsignedPsbtHex), + PSBT_OPTIONS, + ); + let signed: Transaction; try { - signed = Psbt.fromHex(signedPsbtHex.slice(2)); + signed = Transaction.fromPSBT(ccc.bytesFrom(signedPsbtHex), PSBT_OPTIONS); } catch (error) { throw new Error("JoyID returned an invalid PSBT.", { cause: error }); } - if ( - ccc.hexFrom(unsigned.data.getTransaction()) !== - ccc.hexFrom(signed.data.getTransaction()) - ) { + if (ccc.hexFrom(unsigned.unsignedTx) !== ccc.hexFrom(signed.unsignedTx)) { throw new Error("JoyID returned a PSBT for a different transaction."); } @@ -97,13 +120,15 @@ export function parseSignedPsbt( * produces the same bytes. */ export function finalizeSignedInputs( - unsigned: Psbt, - signed: Psbt, + unsigned: Transaction, + signed: Transaction, inputsToSign: ccc.InputToSign[], ): ccc.Hex { + const before = inputsOf(unsigned); + const after = inputsOf(signed); const signedIndexes = new Set( - signed.data.inputs.flatMap((input, index) => - !isFinalized(input) && hasNewSignature(unsigned.data.inputs[index], input) + after.flatMap((input, index) => + !isFinalized(input) && hasNewSignature(before[index], input) ? [index] : [], ), @@ -115,7 +140,7 @@ export function finalizeSignedInputs( const requestedIndexes = new Set(); for (const { index } of inputsToSign) { - const input = signed.data.inputs[index]; + const input = after[index]; if (input && isFinalized(input)) { continue; } diff --git a/packages/joy-id/tsdown.config.mts b/packages/joy-id/tsdown.config.mts index 4951574d0..e80e9e21d 100644 --- a/packages/joy-id/tsdown.config.mts +++ b/packages/joy-id/tsdown.config.mts @@ -15,7 +15,17 @@ const entry = { advancedBarrel: "src/advancedBarrel.ts", } as const; -const bundleDeps: string[] = []; +// ESM-only, so bundled for CommonJS. +const bundleDeps: string[] = [ + "@scure/btc-signer", + "@scure/btc-signer/*", + "@scure/base", + "micro-packed", + "@noble/curves", + "@noble/curves/*", + "@noble/hashes", + "@noble/hashes/*", +]; export default defineConfig( ( diff --git a/packages/playground/package.json b/packages/playground/package.json index f353cec06..b38256af3 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -11,7 +11,6 @@ "format": "prettier --write ./src && eslint --fix ./src" }, "dependencies": { - "@bitcoinerlab/secp256k1": "^2.0.0", "@ckb-ccc/ccc": "workspace:*", "@ckb-ccc/connector-react": "workspace:*", "@ckb-ccc/spore": "workspace:*", @@ -20,12 +19,14 @@ "@next/third-parties": "16.3.5", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", + "@scure/base": "2.4.0", + "@scure/btc-signer": "2.4.1", "@shikijs/monaco": "^4.3.0", "axios": "^1.11.0", "bech32": "^2.0.0", - "bitcoinjs-lib": "^7.0.0", "isomorphic-ws": "^5.0.0", "lucide-react": "^1.21.0", + "micro-packed": "0.11.1", "monaco-editor": "^0.56.0", "next": "16.3.5", "prettier": "^3.9.1", diff --git a/packages/playground/src/app/components/Editor.tsx b/packages/playground/src/app/components/Editor.tsx index ce32a2989..941e8721c 100644 --- a/packages/playground/src/app/components/Editor.tsx +++ b/packages/playground/src/app/components/Editor.tsx @@ -51,6 +51,30 @@ const EXTRA_SOURCES = [ ), name: "@noble/curves", }, + { + files: webpackRequire.context( + "../../../node_modules/@scure/btc-signer", + true, + COMMON_REGEX, + ), + name: "@scure/btc-signer", + }, + { + files: webpackRequire.context( + "../../../node_modules/@scure/base", + true, + COMMON_REGEX, + ), + name: "@scure/base", + }, + { + files: webpackRequire.context( + "../../../node_modules/micro-packed", + true, + COMMON_REGEX, + ), + name: "micro-packed", + }, ]; export function Editor({ @@ -162,7 +186,7 @@ export function Editor({ }); monaco.languages.typescript.typescriptDefaults.addExtraLib( - "import { ccc } from '@ckb-ccc/core'; import * as bitcoin from 'bitcoinjs-lib'; export { bitcoin }; export function render(...msgs: unknown[]): Promise; export const signer: ccc.Signer; export const client: ccc.Client;", + "import { ccc } from '@ckb-ccc/core'; export function render(...msgs: unknown[]): Promise; export const signer: ccc.Signer; export const client: ccc.Client; /** @deprecated Removed. Use `import * as btc from \"@scure/btc-signer\"`. */ export const bitcoin: never;", "file:///node_modules/@ckb-ccc/playground/index.d.ts", ); monaco.languages.typescript.typescriptDefaults.addExtraLib( diff --git a/packages/playground/src/app/execute/index.tsx b/packages/playground/src/app/execute/index.tsx index f20a55ed1..440caeda5 100644 --- a/packages/playground/src/app/execute/index.tsx +++ b/packages/playground/src/app/execute/index.tsx @@ -1,12 +1,5 @@ -import * as ecc from "@bitcoinerlab/secp256k1"; import { ccc } from "@ckb-ccc/connector-react"; -import * as bitcoin from "bitcoinjs-lib"; import * as React from "react"; - -// Initialize the ECC library for bitcoinjs-lib to support Schnorr signatures (Taproot). -// This must be done once globally before any PSBT operations. -bitcoin.initEccLib(ecc); - import ts from "typescript"; import { formatTimestamp } from "../utils"; import { vlqDecode } from "./vlq"; @@ -17,6 +10,7 @@ const LIBS = await Promise.all( [ ["@noble/curves/secp256k1.js"], ["@noble/hashes/sha2.js"], + ["@scure/btc-signer"], ["@ckb-ccc/ccc", "@ckb-ccc/core"], ["@ckb-ccc/ccc/advanced", "@ckb-ccc/core/advanced"], ["@ckb-ccc/spore"], @@ -28,6 +22,18 @@ const LIBS = await Promise.all( }), ).then(() => LIBS_MAP_); +// Old saved scripts may still use `bitcoin` (bitcoinjs-lib). +const removedBitcoin = new Proxy( + {}, + { + get() { + throw new Error( + 'The playground no longer provides "bitcoin" (bitcoinjs-lib). Use `import * as btc from "@scure/btc-signer"` instead.', + ); + }, + }, +); + function findSourcePos( sourceMap: string | undefined, row: number, @@ -115,7 +121,7 @@ export async function execute( }, signer, client: signer.client, - bitcoin, + bitcoin: removedBitcoin, }; } diff --git a/packages/xverse/package.json b/packages/xverse/package.json index cb4f02ea9..0a5cc15b5 100644 --- a/packages/xverse/package.json +++ b/packages/xverse/package.json @@ -57,9 +57,16 @@ }, "dependencies": { "@ckb-ccc/core": "workspace:*", - "bitcoinjs-lib": "^7.0.1", + "@scure/btc-signer": "2.4.1", "valibot": "^1.4.1" }, "packageManager": "pnpm@11.8.0", - "types": "./dist.commonjs/index.d.ts" + "types": "./dist.commonjs/index.d.ts", + "inlinedDependencies": { + "@noble/curves": "2.4.0", + "@noble/hashes": "2.4.0", + "@scure/base": "2.4.0", + "@scure/btc-signer": "2.4.1", + "micro-packed": "0.11.1" + } } diff --git a/packages/xverse/src/signer.test.ts b/packages/xverse/src/signer.test.ts index 73a1cce0b..9e2d84676 100644 --- a/packages/xverse/src/signer.test.ts +++ b/packages/xverse/src/signer.test.ts @@ -1,6 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { secp256k1 } from "@noble/curves/secp256k1.js"; -import { networks, payments, Psbt } from "bitcoinjs-lib"; +import * as btc from "@scure/btc-signer"; import { describe, expect, it, vi } from "vitest"; import { AddressPurpose, @@ -10,7 +10,6 @@ import { } from "./advancedBarrel.js"; import { Signer } from "./signer.js"; -const NETWORK = networks.testnet; const CLIENT = ccc.ClientPublicTestnet.new({ transport: { request: async () => { @@ -22,47 +21,38 @@ const CLIENT = ccc.ClientPublicTestnet.new({ function createKey(seed: number) { const privateKey = new Uint8Array(32).fill(seed); const publicKey = secp256k1.getPublicKey(privateKey, true); - const { address, output } = payments.p2wpkh({ - pubkey: publicKey, - network: NETWORK, - }); - return { - address: address!, - publicKey, - output: output!, - signer: { - publicKey, - sign: (hash: Uint8Array) => secp256k1.sign(hash, privateKey), - }, - }; + const { address, script } = btc.p2wpkh(publicKey, btc.TEST_NETWORK); + return { privateKey, publicKey, address, script }; } const WALLET = createKey(1); const OTHER = createKey(2); -function createPsbt(...inputs: { output: Uint8Array }[]): ccc.Hex { - const psbt = new Psbt({ network: NETWORK }); - inputs.forEach(({ output }, i) => { - psbt.addInput({ - hash: new Uint8Array(32).fill(i + 1), +function createPsbt(...inputs: { script: Uint8Array }[]): ccc.Hex { + const tx = new btc.Transaction({ unknown: "ignore" }); + inputs.forEach(({ script }, i) => { + tx.addInput({ + txid: new Uint8Array(32).fill(i + 1), index: 0, - witnessUtxo: { script: output, value: 10000n }, + witnessUtxo: { script, amount: 10000n }, }); }); - psbt.addOutput({ address: WALLET.address, value: 9000n }); - return ccc.hexFrom(psbt.toBuffer()); + tx.addOutputAddress(WALLET.address, 9000n, btc.TEST_NETWORK); + return ccc.hexFrom(tx.toPSBT()); } function createSigner({ sign = true } = {}) { const signPsbt = vi.fn((params: SignPsbtParams) => { - const psbt = Psbt.fromBase64(params.psbt, { network: NETWORK }); + const tx = btc.Transaction.fromPSBT(ccc.bytesFrom(params.psbt, "base64"), { + unknown: "ignore", + }); if (sign) { Object.entries(params.signInputs).forEach(([address, indexes]) => { expect(address).toBe(WALLET.address); - indexes.forEach((index) => psbt.signInput(index, WALLET.signer)); + indexes.forEach((index) => tx.signIdx(WALLET.privateKey, index)); }); } - return { psbt: psbt.toBase64() }; + return { psbt: ccc.bytesTo(tx.toPSBT(), "base64") }; }); const provider = { @@ -94,8 +84,11 @@ function createSigner({ sign = true } = {}) { return { signer: new Signer(CLIENT, provider), signPsbt }; } -function parseSigned(signed: ccc.Hex) { - return Psbt.fromBuffer(ccc.bytesFrom(signed), { network: NETWORK }); +function inputsOf(signed: ccc.Hex) { + const tx = btc.Transaction.fromPSBT(ccc.bytesFrom(signed), { + unknown: "ignore", + }); + return Array.from({ length: tx.inputsLength }, (_, i) => tx.getInput(i)); } describe("Signer.signPsbt", () => { @@ -110,7 +103,7 @@ describe("Signer.signPsbt", () => { broadcast: false, }), ); - const [input] = parseSigned(signed).data.inputs; + const [input] = inputsOf(signed); expect(input.finalScriptWitness).toBeDefined(); expect(input.partialSig).toBeUndefined(); }); @@ -122,7 +115,7 @@ describe("Signer.signPsbt", () => { autoFinalized: false, }); - const [input] = parseSigned(signed).data.inputs; + const [input] = inputsOf(signed); expect(input.finalScriptWitness).toBeUndefined(); expect(input.partialSig).toHaveLength(1); }); @@ -137,7 +130,7 @@ describe("Signer.signPsbt", () => { expect(signPsbt).toHaveBeenCalledWith( expect.objectContaining({ signInputs: { [WALLET.address]: [0] } }), ); - const [mine, theirs] = parseSigned(signed).data.inputs; + const [mine, theirs] = inputsOf(signed); expect(mine.finalScriptWitness).toBeDefined(); expect(theirs.finalScriptWitness).toBeUndefined(); expect(theirs.partialSig).toBeUndefined(); @@ -151,6 +144,40 @@ describe("Signer.signPsbt", () => { ); }); + it("should finalize without UTXO info on other signers' inputs", async () => { + const { signer } = createSigner(); + const tx = btc.Transaction.fromPSBT(ccc.bytesFrom(createPsbt(WALLET)), { + unknown: "ignore", + }); + tx.addInput({ txid: new Uint8Array(32).fill(9), index: 0 }); + + const signed = await signer.signPsbt(ccc.hexFrom(tx.toPSBT()), { + inputsToSign: [{ index: 0, address: WALLET.address }], + }); + + const [mine, theirs] = inputsOf(signed); + expect(mine.finalScriptWitness).toBeDefined(); + expect(theirs.finalScriptWitness).toBeUndefined(); + }); + + it("should keep PSBT fields it does not know", async () => { + const { signer } = createSigner(); + const tx = btc.Transaction.fromPSBT(ccc.bytesFrom(createPsbt(WALLET)), { + unknown: "ignore", + }); + tx.updateInput(0, { + unknown: [ + [{ type: 0xee, key: new Uint8Array([1]) }, new Uint8Array([7])], + ], + }); + + const signed = await signer.signPsbt(ccc.hexFrom(tx.toPSBT())); + + const [input] = inputsOf(signed); + expect(input.finalScriptWitness).toBeDefined(); + expect(input.unknown).toHaveLength(1); + }); + it("should require an address in inputsToSign", async () => { const { signer } = createSigner(); diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index c92d3eb11..72848da79 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -1,5 +1,5 @@ import { ccc } from "@ckb-ccc/core"; -import { Psbt } from "bitcoinjs-lib"; +import { Transaction, type TxOpts } from "@scure/btc-signer"; import * as v from "valibot"; import { Address, @@ -15,6 +15,38 @@ import { rpcSuccessResponseMessageSchema, } from "./advancedBarrel.js"; +// scure drops unknown PSBT fields by default. +const PSBT_OPTIONS: TxOpts = { unknown: "ignore", proprietary: "ignore" }; + +type PsbtInputUpdate = Parameters[1]; + +// finalizeIdx needs every input's UTXO to check the fee, so finalize the input +// alone. +function finalizeInput(tx: Transaction, index: number) { + const input = tx.getInput(index); + const scratch = new Transaction(PSBT_OPTIONS); + scratch.addInput(input); + scratch.finalizeIdx(0); + const finalized = scratch.getInput(0); + const cleared = Object.fromEntries( + Object.keys(input) + .filter((key) => !(key in finalized)) + .map((key) => [key, undefined]), + ) as PsbtInputUpdate; + + // Fields a signature commits to can't be cleared while it exists. + tx.updateInput(index, { + partialSig: undefined, + tapKeySig: undefined, + tapScriptSig: undefined, + }); + tx.updateInput(index, { + ...cleared, + finalScriptSig: finalized.finalScriptSig, + finalScriptWitness: finalized.finalScriptWitness, + }); +} + async function checkResponse( response: Promise>, ): Promise> { @@ -206,8 +238,9 @@ export class Signer extends ccc.SignerBtc { try { // Collect all unsigned inputs - const psbt = Psbt.fromHex(psbtHex.slice(2)); - psbt.data.inputs.forEach((input, index) => { + const tx = Transaction.fromPSBT(ccc.bytesFrom(psbtHex), PSBT_OPTIONS); + for (let index = 0; index < tx.inputsLength; index++) { + const input = tx.getInput(index); const isSigned = input.finalScriptSig || input.finalScriptWitness || @@ -218,7 +251,7 @@ export class Signer extends ccc.SignerBtc { if (!isSigned) { inputsToSign.push(ccc.InputToSign.from({ index, address })); } - }); + } // If no unsigned inputs found, the PSBT is already fully signed // Let the wallet handle this case (likely a no-op or error) @@ -278,18 +311,18 @@ export class Signer extends ccc.SignerBtc { * leaving inputs owned by other signers untouched. */ private finalizeSignedInputs( - psbt: Psbt, + tx: Transaction, signInputs: Record, ): void { const indexes = new Set(Object.values(signInputs).flat()); try { for (const index of indexes) { - const input = psbt.data.inputs[index]; + const input = index < tx.inputsLength ? tx.getInput(index) : undefined; if (input?.finalScriptSig || input?.finalScriptWitness) { continue; } - psbt.finalizeInput(index); + finalizeInput(tx, index); } } catch (error) { throw new Error( @@ -348,9 +381,12 @@ export class Signer extends ccc.SignerBtc { return ccc.hexFrom(ccc.bytesFrom(signedPsbtBase64, "base64")); } - const signedPsbt = Psbt.fromBase64(signedPsbtBase64); + const signedPsbt = Transaction.fromPSBT( + ccc.bytesFrom(signedPsbtBase64, "base64"), + PSBT_OPTIONS, + ); this.finalizeSignedInputs(signedPsbt, signInputs); - return ccc.hexFrom(signedPsbt.toBuffer()); + return ccc.hexFrom(signedPsbt.toPSBT()); } /** diff --git a/packages/xverse/tsdown.config.mts b/packages/xverse/tsdown.config.mts index 4951574d0..e80e9e21d 100644 --- a/packages/xverse/tsdown.config.mts +++ b/packages/xverse/tsdown.config.mts @@ -15,7 +15,17 @@ const entry = { advancedBarrel: "src/advancedBarrel.ts", } as const; -const bundleDeps: string[] = []; +// ESM-only, so bundled for CommonJS. +const bundleDeps: string[] = [ + "@scure/btc-signer", + "@scure/btc-signer/*", + "@scure/base", + "micro-packed", + "@noble/curves", + "@noble/curves/*", + "@noble/hashes", + "@noble/hashes/*", +]; export default defineConfig( ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fe3f77e4..19c8a4981 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -632,6 +632,9 @@ importers: '@noble/hashes': specifier: ^2.2.0 version: 2.4.0 + '@scure/btc-signer': + specifier: 2.4.1 + version: 2.4.1 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -781,9 +784,6 @@ importers: packages/joy-id: dependencies: - '@bitcoinerlab/secp256k1': - specifier: ^2.0.0 - version: 2.0.0 '@ckb-ccc/core': specifier: workspace:* version: link:../core @@ -793,9 +793,9 @@ importers: '@joyid/common': specifier: ^0.2.2 version: 0.2.2(@typescript/typescript6@6.0.2) - bitcoinjs-lib: - specifier: ^7.0.1 - version: 7.0.2(@typescript/typescript6@6.0.2) + '@scure/btc-signer': + specifier: 2.4.1 + version: 2.4.1 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -1029,9 +1029,6 @@ importers: packages/playground: dependencies: - '@bitcoinerlab/secp256k1': - specifier: ^2.0.0 - version: 2.0.0 '@ckb-ccc/ccc': specifier: workspace:* version: link:../ccc @@ -1056,6 +1053,12 @@ importers: '@noble/hashes': specifier: ^2.2.0 version: 2.4.0 + '@scure/base': + specifier: 2.4.0 + version: 2.4.0 + '@scure/btc-signer': + specifier: 2.4.1 + version: 2.4.1 '@shikijs/monaco': specifier: ^4.3.0 version: 4.4.3 @@ -1065,15 +1068,15 @@ importers: bech32: specifier: ^2.0.0 version: 2.0.0 - bitcoinjs-lib: - specifier: ^7.0.0 - version: 7.0.2(@typescript/typescript6@6.0.2) isomorphic-ws: specifier: ^5.0.0 version: 5.0.0(ws@8.21.3) lucide-react: specifier: ^1.21.0 version: 1.47.0(react@19.3.0) + micro-packed: + specifier: 0.11.1 + version: 0.11.1 monaco-editor: specifier: ^0.56.0 version: 0.56.0 @@ -1537,9 +1540,9 @@ importers: '@ckb-ccc/core': specifier: workspace:* version: link:../core - bitcoinjs-lib: - specifier: ^7.0.1 - version: 7.0.2(@typescript/typescript6@6.0.2) + '@scure/btc-signer': + specifier: 2.4.1 + version: 2.4.1 valibot: specifier: ^1.4.1 version: 1.5.0(@typescript/typescript6@6.0.2) @@ -1852,10 +1855,6 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@bitcoinerlab/secp256k1@2.0.0': - resolution: {integrity: sha512-l8wO4Hx7fovtcDVU5xYEa7yfP9DokC8QxYlJoaP1zi2/iX+c6NNSvMzq+oCZ8ikqvrXTuhSQ6iP1IRx08kWZgw==} - engines: {node: '>=20.19.0'} - '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -4280,6 +4279,9 @@ packages: '@scure/bip39@2.4.0': resolution: {integrity: sha512-82dxFbZUYboyOf0AXiydsQrFQ5Q4h9mX+O2UkE91ROYmsc0BKMGZLwDmy96Jpa2+vrtoxomjUhy1RPIgH/r2nA==} + '@scure/btc-signer@2.4.1': + resolution: {integrity: sha512-ch6h66o9nPhEoe/KciMMUJeH2MBPB8UAJI1if35Fxl6885lKTsc9YoCRqTW0/RjI0Vv9GSg1tmKtHHODyAw81g==} + '@shikijs/core@4.4.3': resolution: {integrity: sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==} engines: {node: '>=20'} @@ -5695,14 +5697,6 @@ packages: big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} - bip174@3.0.1: - resolution: {integrity: sha512-avOnOoLOwdjw1MebWKh3bHqWOH558Q9VpqmRKUv2S63vjw0pnX7Y+yRihxIibgP55twhc7+YfC0IAH43IF5FqQ==} - engines: {node: '>=18.0.0'} - - bitcoinjs-lib@7.0.2: - resolution: {integrity: sha512-ugLk9SR+2gvhpNA7kqQL1eVDKyBNo/rbr+df72nrZj4OOdZvQRHDVtyogPGXAU2MLQimLL7EbUdz95QGh6Th5Q==} - engines: {node: '>=18.0.0'} - blake2b-wasm@2.4.0: resolution: {integrity: sha512-S1kwmW2ZhZFFFOghcx73+ZajEfKBqhP82JMssxtLVMxlaPea1p9uoLiUZ5WYyHn0KddwbLc+0vh4wR0KBNoT5w==} @@ -8428,6 +8422,10 @@ packages: engines: {node: ^22.13.0 || ^24.3.0 || >= 26.0.0} hasBin: true + micro-packed@0.11.1: + resolution: {integrity: sha512-agQGBnBCw8OoKQZMUxcaQPaOowdfxyEmBsq4CNAXAXCyB8KxlRG0lTl5GADJIsMEDaFb0trhXBHKYsjTWa5kuw==} + engines: {node: '>= 20.19.0'} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -10250,14 +10248,6 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} - uint8array-tools@0.0.10: - resolution: {integrity: sha512-sSbrZqlr04w4p8KKpHw0ME/R4JzQaadum+BWhMlk2M7LrDyXWTfJOco3d1jNMdQNxgk5DDm88R3IWYH0nR+tzQ==} - engines: {node: '>=14.0.0'} - - uint8array-tools@0.0.9: - resolution: {integrity: sha512-9vqDWmoSXOoi+K14zNaf6LBV51Q8MayF0/IiQs3GlygIKUYtog603e6virExkjjFosfJUBI4LhbQK1iq8IG11A==} - engines: {node: '>=14.0.0'} - uint8arraylist@2.4.9: resolution: {integrity: sha512-KxWjyEFzchzik3aoQlK66oaoxIReoMo5bQRm1fcjBUZvE8xv/tyR3CTKhjh6K/faV8VaF6hd5pjr45CzbwuwkA==} @@ -10414,9 +10404,6 @@ packages: resolution: {integrity: sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==} engines: {node: '>= 0.10'} - varuint-bitcoin@2.0.1: - resolution: {integrity: sha512-TTl5qSPJ3FzjuTHo0Ex9WUE3jTxnUsQHR38vIDhHApoVstzJK4gE20WqmTWOT5oo7ilvOSNejXt4CJrfDcqm5A==} - vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -11082,10 +11069,6 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@bitcoinerlab/secp256k1@2.0.0': - dependencies: - '@noble/curves': 2.4.0 - '@borewit/text-codec@0.2.2': {} '@braintree/sanitize-url@7.1.2': {} @@ -13652,6 +13635,13 @@ snapshots: dependencies: '@noble/hashes': 2.4.0 + '@scure/btc-signer@2.4.1': + dependencies: + '@noble/curves': 2.4.0 + '@noble/hashes': 2.4.0 + '@scure/base': 2.4.0 + micro-packed: 0.11.1 + '@shikijs/core@4.4.3': dependencies: '@shikijs/primitive': 4.4.3 @@ -15034,23 +15024,6 @@ snapshots: big.js@5.2.2: {} - bip174@3.0.1: - dependencies: - uint8array-tools: 0.0.10 - varuint-bitcoin: 2.0.1 - - bitcoinjs-lib@7.0.2(@typescript/typescript6@6.0.2): - dependencies: - '@noble/hashes': 1.8.0 - bech32: 2.0.0 - bip174: 3.0.1 - bs58check: 4.0.0 - uint8array-tools: 0.0.9 - valibot: 1.5.0(@typescript/typescript6@6.0.2) - varuint-bitcoin: 2.0.1 - transitivePeerDependencies: - - typescript - blake2b-wasm@2.4.0: dependencies: b4a: 1.6.7 @@ -18647,6 +18620,10 @@ snapshots: - supports-color - utf-8-validate + micro-packed@0.11.1: + dependencies: + '@scure/base': 2.4.0 + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -20788,10 +20765,6 @@ snapshots: uint8array-extras@1.5.0: {} - uint8array-tools@0.0.10: {} - - uint8array-tools@0.0.9: {} - uint8arraylist@2.4.9: dependencies: uint8arrays: 5.1.1 @@ -20975,10 +20948,6 @@ snapshots: validator@13.15.35: {} - varuint-bitcoin@2.0.1: - dependencies: - uint8array-tools: 0.0.10 - vary@1.1.2: {} verkit@0.4.0: {} From 7eb17099597090725eece39b8414f807130c7d62 Mon Sep 17 00:00:00 2001 From: Hanssen's Alter Ego Date: Sun, 18 Oct 2026 04:03:51 +0800 Subject: [PATCH 3/5] fix(btc): validate wallet PSBTs through a shared boundary Bind wallet responses to the requested transaction and signature policy, including finalizer public-key binding. Share encapsulated stock-scure compatibility and finalization across both adapters while preserving their transport and input-selection policies. Add core regression coverage. --- packages/btc-psbt/eslint.config.mjs | 63 +++ packages/btc-psbt/package.json | 32 ++ packages/btc-psbt/prettier.config.cjs | 11 + packages/btc-psbt/src/index.test.ts | 658 ++++++++++++++++++++++++++ packages/btc-psbt/src/index.ts | 526 ++++++++++++++++++++ packages/btc-psbt/tsconfig.json | 25 + packages/btc-psbt/vitest.config.mts | 11 + packages/joy-id/package.json | 3 +- packages/joy-id/src/btc/index.test.ts | 11 +- packages/joy-id/src/btc/index.ts | 12 +- packages/joy-id/src/btc/psbt.ts | 107 +---- packages/joy-id/tsdown.config.mts | 6 +- packages/xverse/package.json | 3 +- packages/xverse/src/signer.ts | 72 +-- packages/xverse/tsdown.config.mts | 6 +- pnpm-lock.yaml | 48 ++ pnpm-workspace.yaml | 6 +- vitest.config.mts | 1 + 18 files changed, 1453 insertions(+), 148 deletions(-) create mode 100644 packages/btc-psbt/eslint.config.mjs create mode 100644 packages/btc-psbt/package.json create mode 100644 packages/btc-psbt/prettier.config.cjs create mode 100644 packages/btc-psbt/src/index.test.ts create mode 100644 packages/btc-psbt/src/index.ts create mode 100644 packages/btc-psbt/tsconfig.json create mode 100644 packages/btc-psbt/vitest.config.mts diff --git a/packages/btc-psbt/eslint.config.mjs b/packages/btc-psbt/eslint.config.mjs new file mode 100644 index 000000000..3b2fac7c4 --- /dev/null +++ b/packages/btc-psbt/eslint.config.mjs @@ -0,0 +1,63 @@ +// @ts-check + +import eslint from "@eslint/js"; +import eslintPluginPrettierRecommended from "eslint-plugin-prettier/recommended"; +import tseslint from "typescript-eslint"; + +import { dirname } from "path"; +import { fileURLToPath } from "url"; + +export default [ + ...tseslint.config({ + files: ["**/*.ts"], + extends: [ + eslint.configs.recommended, + ...tseslint.configs.recommendedTypeChecked, + ], + rules: { + "@typescript-eslint/no-unused-vars": [ + "error", + { + args: "all", + argsIgnorePattern: "^_", + caughtErrors: "all", + caughtErrorsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + varsIgnorePattern: "^_", + ignoreRestSiblings: true, + }, + ], + "@typescript-eslint/unbound-method": ["error", { ignoreStatic: true }], + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/require-await": "off", + "@typescript-eslint/only-throw-error": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + allowRethrowing: true, + }, + ], + "@typescript-eslint/prefer-promise-reject-errors": [ + "error", + { + allowThrowingAny: true, + allowThrowingUnknown: true, + }, + ], + "no-empty": "off", + "prefer-const": [ + "error", + { ignoreReadBeforeAssign: true, destructuring: "all" }, + ], + }, + languageOptions: { + parserOptions: { + project: true, + tsconfigRootDir: dirname(fileURLToPath(import.meta.url)), + }, + }, + }), + eslintPluginPrettierRecommended, + { rules: { curly: ["error", "all"] } }, +]; diff --git a/packages/btc-psbt/package.json b/packages/btc-psbt/package.json new file mode 100644 index 000000000..661b21132 --- /dev/null +++ b/packages/btc-psbt/package.json @@ -0,0 +1,32 @@ +{ + "name": "@ckb-ccc/btc-psbt", + "version": "0.0.0", + "description": "Private PSBT validation and finalization shared by wallet adapters", + "private": true, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "lint": "tsc --noEmit && eslint ./src", + "format": "prettier --write . && eslint --fix ./src", + "test": "vitest" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@noble/curves": "^2.2.0", + "eslint": "^10.5.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.6", + "prettier": "^3.8.4", + "prettier-plugin-organize-imports": "^4.3.0", + "@typescript/native": "npm:typescript@^7.0.2", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "typescript-eslint": "^8.61.1", + "vitest": "^4.1.9", + "@ckb-ccc/core": "workspace:*", + "@scure/btc-signer": "2.4.1" + }, + "packageManager": "pnpm@11.8.0" +} diff --git a/packages/btc-psbt/prettier.config.cjs b/packages/btc-psbt/prettier.config.cjs new file mode 100644 index 000000000..5e1810363 --- /dev/null +++ b/packages/btc-psbt/prettier.config.cjs @@ -0,0 +1,11 @@ +/** + * @see https://prettier.io/docs/configuration + * @type {import("prettier").Config} + */ +const config = { + singleQuote: false, + trailingComma: "all", + plugins: [require.resolve("prettier-plugin-organize-imports")], +}; + +module.exports = config; diff --git a/packages/btc-psbt/src/index.test.ts b/packages/btc-psbt/src/index.test.ts new file mode 100644 index 000000000..6ea944950 --- /dev/null +++ b/packages/btc-psbt/src/index.test.ts @@ -0,0 +1,658 @@ +import { ccc } from "@ckb-ccc/core"; +import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js"; +import * as btc from "@scure/btc-signer"; +import { RawPSBTV0, RawPSBTV2 } from "@scure/btc-signer/psbt.js"; +import { getInputType } from "@scure/btc-signer/transaction.js"; +import { sha256x2 } from "@scure/btc-signer/utils.js"; +import { describe, expect, it } from "vitest"; +import { + PSBT_OPTIONS, + WalletPsbt, + parseAndValidateSignedPsbt, + validateInputSelection, + verifyFinalizerPubkey, + verifyTaprootKeySignature, +} from "./index.js"; + +// Test-only builders model wallet PSBTs that stock scure refuses to construct. +// Production parsing/finalization always goes through WalletPsbt. +const FIXTURE_OPTIONS = { ...PSBT_OPTIONS, disableScriptCheck: true }; + +const KEY = new Uint8Array(32).fill(1); +const OTHER = new Uint8Array(32).fill(2); +const PUB = secp256k1.getPublicKey(KEY, true); +const OTHER_PUB = secp256k1.getPublicKey(OTHER, true); +const PAY = btc.p2wpkh(PUB); +const MODES = [1, 2, 3, 0x81, 0x82, 0x83]; +type Raw = ReturnType; + +function hex(tx: Pick) { + return ccc.hexFrom(tx.toPSBT()); +} +function txFor(script = PAY.script, count = 1, version = 0) { + const tx = new btc.Transaction({ ...FIXTURE_OPTIONS, PSBTVersion: version }); + for (let i = 0; i < count; i++) { + tx.addInput({ + txid: new Uint8Array(32).fill(i + 1), + index: 0, + witnessUtxo: { amount: 10000n, script }, + }); + tx.addOutput({ script: PAY.script, amount: 9000n }); + } + return tx; +} +function mutate( + psbt: ccc.Hex, + change: (raw: Raw) => void, + version = 0, +): ccc.Hex { + const coder = version === 2 ? RawPSBTV2 : RawPSBTV0; + const raw = coder.decode(ccc.bytesFrom(psbt)); + change(raw); + return ccc.hexFrom(coder.encode(raw)); +} +function policy(mode: number, index = 0) { + return [ + ccc.InputToSign.from({ index, publicKey: PUB, sighashTypes: [mode] }), + ]; +} +function signEcdsa(tx: btc.Transaction, mode = 1, index = 0) { + tx.updateInput(index, { sighashType: mode }); + tx.signIdx(KEY, index, [mode]); + return hex(tx); +} +function signedTaproot(mode = 0, count = 1, index = 0) { + const payment = btc.p2tr(schnorr.getPublicKey(KEY)); + const tx = txFor(payment.script, count); + for (let i = 0; i < count; i++) { + tx.updateInput(i, { tapInternalKey: payment.tapInternalKey }); + } + tx.updateInput(index, { sighashType: mode }); + const before = hex(tx); + tx.signIdx(KEY, index, [mode]); + return { before, after: hex(tx), tx }; +} + +const SINGLE_KEY_PAYMENTS = [ + ["wpkh", btc.p2wpkh(PUB)], + ["sh-wpkh", btc.p2sh(btc.p2wpkh(PUB))], + ["pkh", btc.p2pkh(PUB)], + ["sh-pkh", btc.p2sh(btc.p2pkh(PUB))], + ["wsh-pkh", btc.p2wsh(btc.p2pkh(PUB))], + ["sh-wsh-pkh", btc.p2sh(btc.p2wsh(btc.p2pkh(PUB)))], + ["pk", btc.p2pk(PUB)], + ["sh-pk", btc.p2sh(btc.p2pk(PUB))], + ["wsh-pk", btc.p2wsh(btc.p2pk(PUB))], + ["sh-wsh-pk", btc.p2sh(btc.p2wsh(btc.p2pk(PUB)))], +] as const; + +function singleKeyTx(payment: { + script: Uint8Array; + redeemScript?: Uint8Array; + witnessScript?: Uint8Array; +}) { + const previous = new btc.Transaction(FIXTURE_OPTIONS); + previous.addInput({ txid: new Uint8Array(32).fill(7), index: 0 }); + previous.addOutput({ script: payment.script, amount: 10000n }); + const tx = new btc.Transaction(FIXTURE_OPTIONS); + tx.addInput({ + txid: previous.id, + index: 0, + nonWitnessUtxo: previous.toBytes(true, true), + ...(payment.redeemScript ? { redeemScript: payment.redeemScript } : {}), + ...(payment.witnessScript ? { witnessScript: payment.witnessScript } : {}), + }); + tx.addOutput({ script: PAY.script, amount: 9000n }); + return tx; +} + +// These fixtures have one input and use SIGHASH_ALL. Keep their legacy digest +// construction on public coders rather than scure's private signing helpers. +function legacySighashAll(tx: btc.Transaction, scriptCode: Uint8Array) { + expect(tx.inputsLength).toBe(1); + const raw = btc.RawTx.decode(tx.unsignedTx); + raw.inputs[0].finalScriptSig = scriptCode; + return sha256x2(btc.RawTx.encode(raw), new Uint8Array([1, 0, 0, 0])); +} + +describe("wallet PSBT response validation", () => { + it.each(SINGLE_KEY_PAYMENTS)( + "binds the finalizer public key to the %s script", + (_name, payment) => { + const tx = singleKeyTx(payment); + const inputType = getInputType(tx.getInput(0)); + const scriptCode = + inputType.last.type === "wpkh" + ? btc.p2pkh(PUB).script + : inputType.lastScript; + const digest = + inputType.txType === "segwit" + ? tx.preimageWitnessV0(0, scriptCode, 1, 10000n) + : legacySighashAll(tx, scriptCode); + const signature = secp256k1.sign(digest, OTHER, { + format: "der", + prehash: false, + }); + expect( + secp256k1.verify(signature, digest, OTHER_PUB, { + format: "der", + prehash: false, + }), + ).toBe(true); + tx.updateInput(0, { + partialSig: [[OTHER_PUB, ccc.bytesConcat(signature, [1])]], + }); + const before = hex(tx); + const guarded = WalletPsbt.fromPSBT(tx.toPSBT()); + expect(() => guarded.finalizeInput(0)).toThrow(/public key.*script/); + expect(hex(guarded)).toBe(before); + + const correct = singleKeyTx(payment); + correct.signIdx(KEY, 0); + expect( + secp256k1.verify( + correct.getInput(0).partialSig![0][1].subarray(0, -1), + digest, + PUB, + { format: "der", prehash: false }, + ), + ).toBe(true); + expect(() => verifyFinalizerPubkey(correct, 0)).not.toThrow(); + const finalized = WalletPsbt.fromPSBT(correct.toPSBT()); + finalized.finalizeInput(0); + expect(finalized.isFinal).toBe(true); + }, + ); + it("accepts equivalent compressed and uncompressed public keys for P2PK", () => { + const tx = singleKeyTx(btc.p2pk(secp256k1.getPublicKey(KEY, false))); + const digest = legacySighashAll( + tx, + getInputType(tx.getInput(0)).lastScript, + ); + const signature = secp256k1.sign(digest, KEY, { + format: "der", + prehash: false, + }); + tx.updateInput(0, { partialSig: [[PUB, ccc.bytesConcat(signature, [1])]] }); + expect(() => verifyFinalizerPubkey(tx, 0)).not.toThrow(); + const finalized = WalletPsbt.fromPSBT(tx.toPSBT()); + finalized.finalizeInput(0); + expect(finalized.isFinal).toBe(true); + }); + it.each(MODES)("accepts explicit ECDSA sighash %i", (mode) => { + const tx = txFor(); + tx.updateInput(0, { sighashType: mode }); + const before = hex(tx); + const { signed } = parseAndValidateSignedPsbt( + before, + signEcdsa(tx, mode), + policy(mode), + ); + signed.finalizeInput(0); + expect(signed.getInput(0).finalScriptWitness).toBeDefined(); + }); + it.each([0, ...MODES])("accepts explicit Schnorr sighash %i", (mode) => { + const { before, after } = signedTaproot(mode); + expect( + parseAndValidateSignedPsbt(before, after, policy(mode)).signed.getInput(0) + .tapKeySig, + ).toBeDefined(); + }); + it.each([0, ...MODES])( + "accepts explicit Taproot script-path sighash %i", + (mode) => { + const payment = btc.p2tr(schnorr.getPublicKey(KEY), { + script: btc.p2tr_pk(schnorr.getPublicKey(OTHER)).script, + }); + const tx = txFor(payment.script); + tx.updateInput(0, { + tapInternalKey: payment.tapInternalKey, + tapMerkleRoot: payment.tapMerkleRoot, + tapLeafScript: payment.tapLeafScript, + sighashType: mode, + }); + const before = hex(tx); + tx.signIdx(OTHER, 0, [mode]); + const after = hex(tx); + expect( + parseAndValidateSignedPsbt(before, after, policy(mode)).signed.getInput( + 0, + ).tapScriptSig, + ).toHaveLength(1); + expect(() => + parseAndValidateSignedPsbt(before, after, policy(mode === 1 ? 2 : 1)), + ).toThrow(/disallowed/); + }, + ); + it.each(["keep", "delete", "rewrite"])( + "rejects actual signature mode despite a %s declaration", + (action) => { + const request = txFor(); + request.updateInput(0, { sighashType: 1 }); + const response = signEcdsa(txFor(), 2); + const after = mutate(response, (raw) => { + if (action === "delete") { + delete raw.inputs[0].sighashType; + } else { + raw.inputs[0].sighashType = action === "keep" ? 1 : 2; + } + }); + expect(() => parseAndValidateSignedPsbt(hex(request), after)).toThrow( + /sighash/i, + ); + }, + ); + it("restores an omitted declaration but still validates actual signature bytes", () => { + const tx = txFor(); + tx.updateInput(0, { sighashType: 2 }); + const before = hex(tx); + const after = mutate(signEcdsa(tx, 2), (raw) => { + delete raw.inputs[0].sighashType; + }); + expect( + parseAndValidateSignedPsbt(before, after).signed.getInput(0).sighashType, + ).toBe(2); + }); + it("does not invent an ALL policy when no mode was declared", () => { + const before = hex(txFor()); + const after = mutate(signEcdsa(txFor(), 2), (raw) => { + delete raw.inputs[0].sighashType; + }); + expect( + parseAndValidateSignedPsbt(before, after).signed.getInput(0).partialSig, + ).toHaveLength(1); + }); + it("rejects caller-disallowed new and replaced signatures", () => { + const before = hex(txFor()); + const after = signEcdsa(txFor(), 2); + expect(() => parseAndValidateSignedPsbt(before, after, policy(1))).toThrow( + /disallowed/, + ); + const old = mutate(signEcdsa(txFor(), 1), (raw) => { + delete raw.inputs[0].sighashType; + }); + const replacement = mutate(after, (raw) => { + delete raw.inputs[0].sighashType; + }); + expect(() => + parseAndValidateSignedPsbt(old, replacement, policy(1)), + ).toThrow(/disallowed/); + }); + it("does not apply a caller allowlist to another participant's existing signature", () => { + const payment = btc.p2wsh(btc.p2ms(2, [PUB, OTHER_PUB])); + const tx = txFor(payment.script); + tx.updateInput(0, { witnessScript: payment.witnessScript, sighashType: 2 }); + tx.signIdx(OTHER, 0, [2]); + const before = mutate(hex(tx), (raw) => { + delete raw.inputs[0].sighashType; + }); + const response = btc.Transaction.fromPSBT( + ccc.bytesFrom(before), + FIXTURE_OPTIONS, + ); + response.signIdx(KEY, 0, [1]); + expect( + parseAndValidateSignedPsbt( + before, + hex(response), + policy(1), + ).signed.getInput(0).partialSig, + ).toHaveLength(2); + }); + it.each(["partialSig", "tapKeySig", "tapScriptSig"] as const)( + "rejects removal of an existing %s", + (field) => { + let before: ccc.Hex; + if (field === "partialSig") { + before = signEcdsa(txFor()); + } else if (field === "tapKeySig") { + before = signedTaproot().after; + } else { + const payment = btc.p2tr(schnorr.getPublicKey(KEY), { + script: btc.p2tr_pk(schnorr.getPublicKey(OTHER)).script, + }); + const tx = txFor(payment.script); + tx.updateInput(0, { + tapInternalKey: payment.tapInternalKey, + tapMerkleRoot: payment.tapMerkleRoot, + tapLeafScript: payment.tapLeafScript, + }); + tx.signIdx(OTHER, 0); + before = hex(tx); + } + const after = mutate(before, (raw) => { + expect(raw.inputs[0][field]).toBeDefined(); + delete raw.inputs[0][field]; + }); + expect(() => parseAndValidateSignedPsbt(before, after)).toThrow( + /removed an existing signature/, + ); + }, + ); + it("rejects a multisig response that drops another participant's signature", () => { + const payment = btc.p2wsh(btc.p2ms(2, [PUB, OTHER_PUB])); + const tx = txFor(payment.script); + tx.updateInput(0, { witnessScript: payment.witnessScript }); + tx.signIdx(OTHER, 0); + const before = hex(tx); + tx.signIdx(KEY, 0); + const after = mutate(hex(tx), (raw) => { + const signatures = raw.inputs[0].partialSig!; + const index = signatures.findIndex( + ([pubkey]) => ccc.hexFrom(pubkey) === ccc.hexFrom(OTHER_PUB), + ); + expect(index).toBeGreaterThanOrEqual(0); + signatures.splice(index, 1); + }); + expect(() => parseAndValidateSignedPsbt(before, after)).toThrow( + /removed an existing signature/, + ); + }); + it.each(["version", "locktime", "outpoint", "sequence", "output", "order"])( + "rejects changed transaction %s", + (field) => { + const before = hex(txFor(PAY.script, 2)); + const after = mutate(before, (raw) => { + const tx = raw.global.unsignedTx!; + if (field === "version") { + tx.version++; + } + if (field === "locktime") { + tx.lockTime++; + } + if (field === "outpoint") { + tx.inputs[0].index++; + } + if (field === "sequence") { + tx.inputs[0].sequence--; + } + if (field === "output") { + tx.outputs[0].amount--; + } + if (field === "order") { + tx.inputs[0].txid = new Uint8Array(32).fill(2); + tx.inputs[1].txid = new Uint8Array(32).fill(1); + } + }); + expect(() => parseAndValidateSignedPsbt(before, after)).toThrow( + /different transaction/, + ); + }, + ); + it("rejects PSBTv2 locktime changes caused by restored constraints", () => { + const tx = txFor(PAY.script, 2, 2); + for (let index = 0; index < 2; index++) { + tx.updateInput(index, { + sequence: 0xfffffffe, + requiredTimeLocktime: 500000100, + }); + } + tx.updateInput(0, { requiredHeightLocktime: 100 }); + const before = hex(tx); + const after = mutate( + before, + (raw) => { + delete raw.inputs[0].requiredHeightLocktime; + raw.inputs[1].requiredHeightLocktime = 99; + }, + 2, + ); + const response = btc.Transaction.fromPSBT( + ccc.bytesFrom(after), + FIXTURE_OPTIONS, + ); + expect(ccc.hexFrom(response.unsignedTx)).toBe(ccc.hexFrom(tx.unsignedTx)); + expect(() => parseAndValidateSignedPsbt(before, after)).toThrow( + /different transaction/, + ); + }); + it.each(["amount", "script"])( + "rejects changed previous output %s", + (field) => { + const before = hex(txFor()); + const after = mutate(before, (raw) => { + if (field === "amount") { + raw.inputs[0].witnessUtxo!.amount++; + } else { + raw.inputs[0].witnessUtxo!.script = btc.p2wpkh(OTHER_PUB).script; + } + }); + expect(() => parseAndValidateSignedPsbt(before, after)).toThrow( + /witnessUtxo/, + ); + }, + ); + it("restores omitted UTXO data without restoring replaced signatures", () => { + const tx = txFor(); + const before = hex(tx); + const after = mutate(signEcdsa(tx), (raw) => { + delete raw.inputs[0].witnessUtxo; + }); + expect( + parseAndValidateSignedPsbt(before, after).signed.getInput(0).witnessUtxo, + ).toBeDefined(); + }); + it("rejects added final scripts and preserves unchanged finalized inputs", () => { + const tx = txFor(); + const before = hex(tx); + signEcdsa(tx); + tx.finalizeIdx(0); + expect(() => parseAndValidateSignedPsbt(before, hex(tx))).toThrow( + /finalized scripts/, + ); + expect( + parseAndValidateSignedPsbt(hex(tx), hex(tx)).signed.getInput(0) + .finalScriptWitness, + ).toBeDefined(); + }); + it("preserves unchanged finalized witness inputs with an explicit empty scriptSig", () => { + const tx = txFor(); + signEcdsa(tx); + tx.finalizeIdx(0); + const finalized = mutate(hex(tx), (raw) => { + raw.inputs[0].finalScriptSig = new Uint8Array(); + }); + const result = parseAndValidateSignedPsbt(finalized, finalized).signed; + expect(result.getInput(0).finalScriptWitness).toEqual( + tx.getInput(0).finalScriptWitness, + ); + }); + it.each([0, 2])( + "preserves version %i and unknown/proprietary fields", + (version) => { + const tx = txFor(PAY.script, 1, version); + tx.updateInput(0, { + unknown: [ + [{ type: 0xee, key: new Uint8Array([1]) }, new Uint8Array([2])], + ], + proprietary: [[new Uint8Array([1, 1, 0]), new Uint8Array([3])]], + }); + const before = hex(tx); + const after = signEcdsa(tx); + const result = parseAndValidateSignedPsbt(before, after).signed; + expect(result.getInput(0).unknown).toEqual(tx.getInput(0).unknown); + expect(result.getInput(0).proprietary).toEqual( + tx.getInput(0).proprietary, + ); + const raw = (version === 2 ? RawPSBTV2 : RawPSBTV0).decode( + result.toPSBT(), + ); + expect(raw.global.version ?? 0).toBe(version); + }, + ); + it.each([-1, 0.5, 1, NaN])("rejects invalid requested index %s", (index) => { + expect(() => + validateInputSelection( + [ccc.InputToSign.from({ index, publicKey: PUB })], + 1, + ), + ).toThrow(/index/i); + }); + it("deduplicates identical selections and rejects conflicting options", () => { + expect( + validateInputSelection([...policy(1), ...policy(1)], 1), + ).toHaveLength(1); + expect(() => + validateInputSelection([...policy(1), ...policy(2)], 1), + ).toThrow(/Conflicting/); + }); + it("rejects malformed ECDSA DER", () => { + const before = hex(txFor()); + const after = mutate(before, (raw) => { + raw.inputs[0].partialSig = [[PUB, new Uint8Array([0x30, 0, 1])]]; + }); + expect(() => parseAndValidateSignedPsbt(before, after)).toThrow(); + }); + it("rejects invalid ECDSA hash-mode encoding", () => { + const before = hex(txFor()); + const after = mutate(signEcdsa(txFor()), (raw) => { + raw.inputs[0].partialSig![0][1][ + raw.inputs[0].partialSig![0][1].length - 1 + ] = 0; + }); + expect(() => parseAndValidateSignedPsbt(before, after)).toThrow(/sighash/); + }); + it.each([0, 4, 0x80])("rejects explicit 65-byte Schnorr mode %i", (mode) => { + const { before, after } = signedTaproot(); + const invalid = mutate(after, (raw) => { + raw.inputs[0].tapKeySig = Uint8Array.from( + ccc.bytesConcat(raw.inputs[0].tapKeySig!, [mode]), + ); + }); + expect(() => parseAndValidateSignedPsbt(before, invalid)).toThrow(); + }); + it("rejects an invalid output-key signature", () => { + const { before, after } = signedTaproot(); + const invalid = mutate(after, (raw) => { + raw.inputs[0].tapKeySig![0] ^= 1; + }); + expect(() => parseAndValidateSignedPsbt(before, invalid)).toThrow( + /signature/i, + ); + }); + it("verifies SINGLE at the original nonzero input index", () => { + const { before, after, tx } = signedTaproot(3, 2, 1); + expect( + parseAndValidateSignedPsbt(before, after).signed.getInput(1).tapKeySig, + ).toBeDefined(); + expect(() => verifyTaprootKeySignature(tx, 1)).not.toThrow(); + }); + it("requires all committed prevouts for a non-ANYONECANPAY signature", () => { + const { before, after } = signedTaproot(1, 2, 1); + const strip = (psbt: ccc.Hex) => + mutate(psbt, (raw) => { + delete raw.inputs[0].witnessUtxo; + delete raw.inputs[0].tapInternalKey; + }); + expect(() => + parseAndValidateSignedPsbt(strip(before), strip(after)), + ).toThrow(/Missing previous output/); + }); + it("permits missing unrelated prevouts with ANYONECANPAY", () => { + const { before, after } = signedTaproot(0x83, 2, 1); + const strip = (psbt: ccc.Hex) => + mutate(psbt, (raw) => { + delete raw.inputs[0].witnessUtxo; + delete raw.inputs[0].tapInternalKey; + }); + expect( + parseAndValidateSignedPsbt(strip(before), strip(after)).signed.getInput(1) + .tapKeySig, + ).toBeDefined(); + }); + it("restores omitted Taproot tree metadata and accepts initially unknown roots", () => { + const payment = btc.p2tr(schnorr.getPublicKey(KEY), { + script: btc.p2tr_pk(schnorr.getPublicKey(OTHER)).script, + }); + const tx = txFor(payment.script); + tx.updateInput(0, { + tapInternalKey: payment.tapInternalKey, + tapMerkleRoot: payment.tapMerkleRoot, + tapLeafScript: payment.tapLeafScript, + }); + const before = hex(tx); + tx.signIdx(KEY, 0); + const complete = hex(tx); + const strip = (psbt: ccc.Hex) => + mutate(psbt, (raw) => { + delete raw.inputs[0].tapMerkleRoot; + delete raw.inputs[0].tapLeafScript; + }); + const restored = parseAndValidateSignedPsbt(before, strip(complete)).signed; + expect(restored.getInput(0).tapMerkleRoot).toEqual(payment.tapMerkleRoot); + expect( + parseAndValidateSignedPsbt( + strip(before), + strip(complete), + ).signed.getInput(0).tapKeySig, + ).toBeDefined(); + const incomplete = btc.Transaction.fromPSBT( + ccc.bytesFrom(strip(before)), + FIXTURE_OPTIONS, + ); + // Stock scure may sign using an assumed empty root. The wallet boundary must + // reject that signature against the actual tree-bearing previous output. + incomplete.signIdx(KEY, 0); + expect(() => + parseAndValidateSignedPsbt(strip(before), hex(incomplete)), + ).toThrow(/Invalid Taproot key-path signature/); + }); + it("still rejects conflicting Taproot roots and control-block parity", () => { + const payment = btc.p2tr(schnorr.getPublicKey(KEY), { + script: btc.p2tr_pk(schnorr.getPublicKey(OTHER)).script, + }); + const tx = txFor(payment.script); + tx.updateInput(0, { + tapInternalKey: payment.tapInternalKey, + tapMerkleRoot: payment.tapMerkleRoot, + tapLeafScript: payment.tapLeafScript, + }); + const before = hex(tx); + const root = mutate(before, (raw) => { + raw.inputs[0].tapMerkleRoot![0] ^= 1; + }); + expect(() => parseAndValidateSignedPsbt(before, root)).toThrow(); + const parity = mutate(before, (raw) => { + raw.inputs[0].tapLeafScript![0][0].version ^= 1; + }); + expect(() => parseAndValidateSignedPsbt(before, parity)).toThrow(/parity/); + }); + it.each([btc.p2pk(OTHER_PUB).script, btc.p2ms(1, [OTHER_PUB]).script])( + "parses an unrelated bare input and retains wrapped-script checks", + (script) => { + const tx = txFor(); + tx.addInput({ + txid: new Uint8Array(32).fill(9), + index: 0, + witnessUtxo: { amount: 10000n, script }, + }); + const before = hex(tx); + const after = signEcdsa(tx); + expect( + parseAndValidateSignedPsbt(before, after).signed.inputsLength, + ).toBe(2); + const bad = mutate(before, (raw) => { + raw.inputs[0].redeemScript = script; + }); + expect(() => parseAndValidateSignedPsbt(before, bad)).toThrow( + /redeemScript/, + ); + }, + ); + it("rejects actual-signature mismatch at the wallet boundary before cleanup", () => { + const signed = signEcdsa(txFor(), 2); + const mismatched = mutate(signed, (raw) => { + raw.inputs[0].sighashType = 1; + }); + const tx = btc.Transaction.fromPSBT( + ccc.bytesFrom(mismatched), + FIXTURE_OPTIONS, + ); + expect(() => parseAndValidateSignedPsbt(mismatched, mismatched)).toThrow( + /sighash/, + ); + expect(tx.getInput(0).sighashType).toBe(1); + expect(tx.getInput(0).partialSig).toHaveLength(1); + expect(tx.getInput(0).finalScriptWitness).toBeUndefined(); + }); +}); diff --git a/packages/btc-psbt/src/index.ts b/packages/btc-psbt/src/index.ts new file mode 100644 index 000000000..e04899852 --- /dev/null +++ b/packages/btc-psbt/src/index.ts @@ -0,0 +1,526 @@ +import { ccc } from "@ckb-ccc/core"; +import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js"; +import { OutScript, Transaction, type TxOpts } from "@scure/btc-signer"; +import { RawPSBTV0, RawPSBTV2 } from "@scure/btc-signer/psbt.js"; +import { + getInputType, + getPrevOut, + normalizeInput, +} from "@scure/btc-signer/transaction.js"; +import { hash160 } from "@scure/btc-signer/utils.js"; + +export const PSBT_OPTIONS: Readonly = Object.freeze({ + unknown: "ignore", + proprietary: "ignore", +}); +type Input = ReturnType; +type TransactionView = Pick< + Transaction, + "getInput" | "inputsLength" | "opts" | "preimageWitnessV1" +>; +type RawPsbt = ReturnType; +const HASH_MODES = [1, 2, 3, 0x81, 0x82, 0x83]; + +function same(a: unknown, b: unknown): boolean { + if (a instanceof Uint8Array && b instanceof Uint8Array) { + return ccc.hexFrom(a) === ccc.hexFrom(b); + } + if (Array.isArray(a) && Array.isArray(b)) { + return ( + a.length === b.length && a.every((value, index) => same(value, b[index])) + ); + } + if (a && b && typeof a === "object" && typeof b === "object") { + const left = Object.entries(a); + const right = Object.entries(b); + return ( + left.length === right.length && + left.every(([key, value]) => + right.some( + ([otherKey, otherValue]) => + key === otherKey && same(value, otherValue), + ), + ) + ); + } + return a === b; +} + +export function validateInputSelection( + inputs: ccc.InputToSign[], + count: number, +): ccc.InputToSign[] { + const selected = new Map(); + for (const input of inputs) { + if ( + !Number.isSafeInteger(input.index) || + input.index < 0 || + input.index >= count + ) { + throw new Error(`Invalid PSBT input index #${input.index}.`); + } + if ( + input.sighashTypes?.some( + (mode) => mode !== 0 && !HASH_MODES.includes(mode), + ) + ) { + throw new Error(`Invalid sighashTypes for input #${input.index}.`); + } + const previous = selected.get(input.index); + if (previous && !same(previous, input)) { + throw new Error(`Conflicting signing options for input #${input.index}.`); + } + selected.set(input.index, input); + } + return [...selected.values()]; +} + +function decodePsbt(bytes: Uint8Array): RawPsbt { + try { + return RawPSBTV0.decode(bytes); + } catch (error) { + try { + return RawPSBTV2.decode(bytes); + } catch { + throw error; + } + } +} + +function encodePsbt(raw: RawPsbt): Uint8Array { + return raw.global.version === 2 + ? RawPSBTV2.encode(raw) + : RawPSBTV0.encode(raw); +} + +export function isFinalized(input: Input): boolean { + return !!(input.finalScriptSig?.length || input.finalScriptWitness?.length); +} + +export function signatureEntries( + input: Input, +): Map { + const entries = new Map< + string, + { signature: Uint8Array; taproot: boolean } + >(); + input.partialSig?.forEach(([key, signature]) => + entries.set(`ecdsa:${ccc.hexFrom(key)}`, { signature, taproot: false }), + ); + if (input.tapKeySig) { + entries.set("tapKey", { signature: input.tapKeySig, taproot: true }); + } + input.tapScriptSig?.forEach(([{ pubKey, leafHash }, signature]) => + entries.set(`tapScript:${ccc.hexFrom(pubKey)}:${ccc.hexFrom(leafHash)}`, { + signature, + taproot: true, + }), + ); + return entries; +} + +export function hasNewSignature(before: Input, after: Input): boolean { + const existing = signatureEntries(before); + return [...signatureEntries(after)].some( + ([key, { signature }]) => !same(existing.get(key)?.signature, signature), + ); +} + +function signatureMode(signature: Uint8Array, taproot: boolean): number { + if (taproot) { + if (signature.length === 64) { + return 0; + } + if (signature.length === 65 && HASH_MODES.includes(signature[64])) { + return signature[64]; + } + throw new Error("Invalid Schnorr signature encoding or sighash."); + } + secp256k1.Signature.fromBytes(signature.subarray(0, -1), "der"); + const mode = signature[signature.length - 1]; + if (!HASH_MODES.includes(mode)) { + throw new Error("Invalid ECDSA signature sighash."); + } + return mode; +} + +/** Check the ECDSA candidate scure will use for a single-key satisfaction. */ +export function verifyFinalizerPubkey( + tx: TransactionView, + index: number, +): void { + const input = tx.getInput(index); + const pubkey = input.partialSig?.[0]?.[0]; + if (!pubkey) { + return; + } + // Inputs have already passed script-commitment checks. Resolve P2SH/P2WSH + // wrappers exactly as the finalizer does, without changing signature selection. + const { last } = getInputType(input, tx.opts.allowLegacyWitnessUtxo); + const matches = + last.type === "pkh" || last.type === "wpkh" + ? same(hash160(pubkey), last.hash) + : last.type === "pk" + ? secp256k1.Point.fromBytes(pubkey).equals( + secp256k1.Point.fromBytes(last.pubkey), + ) + : true; + if (!matches) { + throw new Error( + `Signature public key does not match input #${index} script.`, + ); + } +} + +// Verify in the complete transaction and at its original index, never the scratch finalizer. +export function verifyTaprootKeySignature( + tx: TransactionView, + index: number, +): void { + const input = tx.getInput(index); + if (!input.tapKeySig) { + return; + } + const mode = signatureMode(input.tapKeySig, true); + const output = OutScript.decode(getPrevOut(input).script); + if (output.type !== "tr") { + throw new Error("Taproot signature without a Taproot previous output."); + } + const prevouts = Array.from({ length: tx.inputsLength }, (_, i) => { + // ANYONECANPAY does not commit to other prevouts. Placeholders are never hashed. + if (i !== index && mode & 0x80) { + return { amount: 0n, script: new Uint8Array() }; + } + try { + return getPrevOut(tx.getInput(i)); + } catch (error) { + throw new Error( + `Missing previous output required to verify Taproot input #${index}.`, + { cause: error }, + ); + } + }); + const digest = tx.preimageWitnessV1( + index, + prevouts.map(({ script }) => script), + mode, + prevouts.map(({ amount }) => amount), + ); + if (!schnorr.verify(input.tapKeySig.subarray(0, 64), digest, output.pubkey)) { + throw new Error(`Invalid Taproot key-path signature for input #${index}.`); + } +} + +/** Bind a wallet response to the request before restoring omitted, trusted metadata. */ +export function parseAndValidateSignedPsbt( + unsignedPsbtHex: ccc.Hex, + signedPsbtHex: ccc.Hex, + inputsToSign: ccc.InputToSign[] = [], + wallet = "Wallet", +): { unsigned: WalletPsbt; signed: WalletPsbt } { + if (!signedPsbtHex.startsWith("0x70736274ff")) { + throw new Error(`${wallet} did not return a PSBT.`); + } + const unsigned = WalletPsbt.fromPSBT(ccc.bytesFrom(unsignedPsbtHex)); + const selected = validateInputSelection(inputsToSign, unsigned.inputsLength); + let raw: RawPsbt; + try { + raw = decodePsbt(ccc.bytesFrom(signedPsbtHex)); + } catch (error) { + throw new Error(`${wallet} returned an invalid PSBT.`, { cause: error }); + } + // Parse only the transaction framing first. After binding the effective transaction, + // restore trusted metadata and validate every input through WalletPsbt below. + const framing = { + ...raw, + inputs: raw.inputs.map((input) => + Object.fromEntries( + Object.entries(input).filter(([key]) => + [ + "txid", + "index", + "sequence", + "requiredHeightLocktime", + "requiredTimeLocktime", + ].includes(key), + ), + ), + ), + }; + const responseTx = Transaction.fromPSBT(encodePsbt(framing), PSBT_OPTIONS); + if (!same(unsigned.unsignedTx, responseTx.unsignedTx)) { + throw new Error(`${wallet} returned a PSBT for a different transaction.`); + } + const restoreFields = [ + "nonWitnessUtxo", + "witnessUtxo", + "redeemScript", + "witnessScript", + "sighashType", + "tapInternalKey", + "tapMerkleRoot", + "requiredHeightLocktime", + "requiredTimeLocktime", + ] as const; + for (let index = 0; index < unsigned.inputsLength; index++) { + const before = unsigned.getInput(index); + const after = raw.inputs[index]; + // Match the parser's normalization: empty final fields do not finalize an input. + if (!after.finalScriptSig?.length) { + delete after.finalScriptSig; + } + if (!after.finalScriptWitness?.length) { + delete after.finalScriptWitness; + } + if ( + !same(before.finalScriptSig, after.finalScriptSig) || + !same(before.finalScriptWitness, after.finalScriptWitness) + ) { + throw new Error( + `${wallet} changed finalized scripts for input #${index}. Request an unfinalized PSBT.`, + ); + } + for (const field of restoreFields) { + const original = before[field]; + if (original === undefined) { + continue; + } + if (after[field] !== undefined && !same(original, after[field])) { + throw new Error(`${wallet} changed ${field} for input #${index}.`); + } + Object.assign(after, { [field]: original }); + } + // Restore known leaves by control block; never restore old signatures or overwrite conflicts. + if (before.tapLeafScript) { + const leaves = after.tapLeafScript ?? []; + for (const original of before.tapLeafScript) { + const found = leaves.find(([control]) => same(control, original[0])); + if (found && !same(found[1], original[1])) { + throw new Error( + `${wallet} changed tapLeafScript for input #${index}.`, + ); + } + if (!found) { + leaves.push(original); + } + } + after.tapLeafScript = leaves; + } + } + const signed = WalletPsbt.fromPSBT(encodePsbt(raw)); + // Restored PSBTv2 constraints can change the effective locktime. Bind again + // after metadata restoration so the returned transaction remains unchanged. + if (!same(unsigned.unsignedTx, signed.unsignedTx)) { + throw new Error(`${wallet} returned a PSBT for a different transaction.`); + } + for (let index = 0; index < unsigned.inputsLength; index++) { + const before = unsigned.getInput(index); + const after = signed.getInput(index); + const existing = signatureEntries(before); + const returned = signatureEntries(after); + for (const key of existing.keys()) { + if (!returned.has(key)) { + throw new Error( + `${wallet} removed an existing signature for input #${index}.`, + ); + } + } + const allowed = selected.find( + (input) => input.index === index, + )?.sighashTypes; + for (const [key, { signature, taproot }] of returned) { + const mode = signatureMode(signature, taproot); + if (after.sighashType !== undefined && mode !== after.sighashType) { + throw new Error( + `${wallet} returned a signature with a conflicting sighash for input #${index}.`, + ); + } + const added = !same(existing.get(key)?.signature, signature); + if (added && allowed && !allowed.includes(mode)) { + throw new Error( + `${wallet} returned a disallowed signature sighash for input #${index}.`, + ); + } + if (added && key === "tapKey") { + verifyTaprootKeySignature(signed, index); + } + } + } + return { unsigned, signed }; +} + +/** Validate using public scure APIs; return whether the input needs compatibility. */ +function validateInput(input: Input): boolean { + const normalize = (value: Input, disableScriptCheck = false) => + normalizeInput( + value, + undefined, + undefined, + disableScriptCheck, + "ignore", + "ignore", + ); + // Validate field encodings/public keys, txids, redundant UTXOs and locktimes + // BEFORE omitting anything from the validation-only projection below. + const checked = normalize(input, true); + if ( + checked.witnessUtxo === undefined && + checked.nonWitnessUtxo === undefined + ) { + normalize(checked); + return false; + } + const output = OutScript.decode(getPrevOut(checked).script); + if (output.type === "pk" || output.type === "ms") { + // Bare scripts have no wrapper or Taproot commitments to check. Only scure's + // rejection of these recognized bare script types is bypassed. + if (checked.redeemScript !== undefined) { + throw new Error("checkScript: redeemScript without P2SH"); + } + if (checked.witnessScript !== undefined) { + throw new Error("checkScript: witnessScript without P2WSH"); + } + if ( + checked.tapInternalKey !== undefined || + checked.tapMerkleRoot !== undefined || + checked.tapLeafScript?.length + ) { + throw new Error( + "validateInput: Taproot metadata without P2TR previous output", + ); + } + return true; + } + if ( + output.type === "tr" && + checked.tapInternalKey !== undefined && + checked.tapMerkleRoot === undefined && + !checked.tapLeafScript?.length + ) { + const projection = { ...checked }; + delete projection.tapInternalKey; + // No root/leaves means unknown, not an empty tree. Keep the original key on + // the transaction, and retain scure's wrapper/raw-witness-program checks. + normalize(projection); + return true; + } + normalize(checked); // Explicit roots/leaves and all other scripts stay strict. + return false; +} + +function validateInputs(tx: Transaction): void { + for (let index = 0; index < tx.inputsLength; index++) { + validateInput(tx.getInput(index)); + } +} + +/** + * Private wallet boundary around stock scure. No mutable Transaction escapes: + * reads return copies, finalization is the only mutation, and every mutation or + * serialization rechecks all inputs, including unrelated inputs. + */ +export class WalletPsbt { + #tx: Transaction; + + private constructor(bytes: Uint8Array) { + // Own the input bytes even if the decoder retains views into its buffer. + const owned = Uint8Array.from(bytes); + try { + this.#tx = Transaction.fromPSBT(owned, PSBT_OPTIONS); + } catch (strictError) { + const tx = Transaction.fromPSBT(owned, { + ...PSBT_OPTIONS, + disableScriptCheck: true, + }); + let compatible = false; + for (let index = 0; index < tx.inputsLength; index++) { + compatible = validateInput(tx.getInput(index)) || compatible; + } + if (!compatible) { + throw strictError; + } + this.#tx = tx; + } + } + + static fromPSBT(bytes: Uint8Array): WalletPsbt { + return new WalletPsbt(bytes); + } + + getInput(index: number): Input { + return this.#tx.getInput(index); + } + + get inputsLength(): number { + return this.#tx.inputsLength; + } + + get unsignedTx(): Uint8Array { + return this.#tx.unsignedTx; + } + + get opts(): Transaction["opts"] { + return this.#tx.opts; + } + + get isFinal(): boolean { + return this.#tx.isFinal; + } + + preimageWitnessV1( + ...args: Parameters + ): Uint8Array { + return this.#tx.preimageWitnessV1(...args); + } + + toPSBT(): Uint8Array { + validateInputs(this.#tx); + return this.#tx.toPSBT(); + } + + finalizeInput(index: number): void { + validateInputs(this.#tx); + const input = this.#tx.getInput(index); + // Response validation enforces caller policy. Also guard this sole mutation + // entry point against malformed encodings/declarations on a parsed PSBT. + for (const { signature, taproot } of signatureEntries(input).values()) { + const mode = signatureMode(signature, taproot); + if (input.sighashType !== undefined && mode !== input.sighashType) { + throw new Error(`Signature sighash does not match input #${index}.`); + } + } + verifyFinalizerPubkey(this.#tx, index); + verifyTaprootKeySignature(this.#tx, index); + // Verify signatures above in the complete transaction at the original index. + // Scure's finalizer needs every UTXO for its fee check, so satisfy one input alone. + const scratch = new Transaction({ + ...PSBT_OPTIONS, + disableScriptCheck: validateInput(input), + }); + scratch.addInput(input); + scratch.finalizeIdx(0); + const finalized = scratch.getInput(0); + validateInput(finalized); + const cleared = Object.fromEntries( + Object.keys(input) + .filter((key) => !(key in finalized)) + .map((key) => [key, undefined]), + ) as Parameters[1]; + + // Stage the two cleanup updates privately; a failure must not remove existing + // signatures or expose an unchecked intermediate state on this facade. + const candidate = this.#tx.clone(); + candidate.updateInput(index, { + partialSig: undefined, + tapKeySig: undefined, + tapScriptSig: undefined, + }); + candidate.updateInput(index, { + ...cleared, + finalScriptSig: finalized.finalScriptSig, + finalScriptWitness: finalized.finalScriptWitness, + }); + validateInputs(candidate); + this.#tx = candidate; + } +} diff --git a/packages/btc-psbt/tsconfig.json b/packages/btc-psbt/tsconfig.json new file mode 100644 index 000000000..3399c6f67 --- /dev/null +++ b/packages/btc-psbt/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "es2022", + "incremental": true, + "allowJs": true, + "importHelpers": false, + "declaration": true, + "declarationMap": true, + "experimentalDecorators": true, + "useDefineForClassFields": false, + "esModuleInterop": true, + "strict": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "strictNullChecks": true, + "alwaysStrict": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/btc-psbt/vitest.config.mts b/packages/btc-psbt/vitest.config.mts new file mode 100644 index 000000000..ca2dc9bfc --- /dev/null +++ b/packages/btc-psbt/vitest.config.mts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: import.meta.dirname, + test: { + include: ["src/**/*.test.ts"], + coverage: { + include: ["src/**/*.ts"], + }, + }, +}); diff --git a/packages/joy-id/package.json b/packages/joy-id/package.json index 89e0ac744..4fff28894 100644 --- a/packages/joy-id/package.json +++ b/packages/joy-id/package.json @@ -50,7 +50,8 @@ "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-eslint": "^8.61.1", - "vitest": "^4.1.9" + "vitest": "^4.1.9", + "@ckb-ccc/btc-psbt": "workspace:*" }, "publishConfig": { "access": "public" diff --git a/packages/joy-id/src/btc/index.test.ts b/packages/joy-id/src/btc/index.test.ts index 28948d77a..88830bdfe 100644 --- a/packages/joy-id/src/btc/index.test.ts +++ b/packages/joy-id/src/btc/index.test.ts @@ -1,6 +1,6 @@ import { ccc } from "@ckb-ccc/core"; import { decodeSearch } from "@joyid/common"; -import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js"; import * as btc from "@scure/btc-signer"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { createPopup } from "../common/index.js"; @@ -32,7 +32,7 @@ function createKey(seed: number) { const JOYID = createKey(1); const OTHER = createKey(2); -// Key-path finalization only needs a valid x-only key. +// Key-path responses are verified against the actual output key. const TAPROOT_OUTPUT = ccc.bytesConcat([0x51, 0x20], JOYID.publicKey.slice(1)); class ConnectionsRepoMemory implements ConnectionsRepo { @@ -272,7 +272,12 @@ describe("BitcoinSigner.signPsbt", () => { it("should finalize Taproot key-path inputs", async () => { mockJoyIdSigning((tx) => - tx.updateInput(0, { tapKeySig: new Uint8Array(64).fill(1) }), + tx.updateInput(0, { + tapKeySig: schnorr.sign( + tx.preimageWitnessV1(0, [TAPROOT_OUTPUT], 0, [10000n]), + JOYID.privateKey, + ), + }), ); const signed = await createSigner().signPsbt(createPsbt([TAPROOT_OUTPUT])); diff --git a/packages/joy-id/src/btc/index.ts b/packages/joy-id/src/btc/index.ts index f8ba8e838..42ca725af 100644 --- a/packages/joy-id/src/btc/index.ts +++ b/packages/joy-id/src/btc/index.ts @@ -1,3 +1,4 @@ +import { WalletPsbt, validateInputSelection } from "@ckb-ccc/btc-psbt"; import { ccc } from "@ckb-ccc/core"; import { DappRequestType, buildJoyIDURL } from "@joyid/common"; import { createPopup } from "../common/index.js"; @@ -216,7 +217,9 @@ export class BitcoinSigner extends ccc.SignerBtc { * JoyID returns a raw transaction when asked to finalize, and rejects PSBTs * with inputs it can't sign. So JoyID is always asked for an unfinalized * PSBT, and `autoFinalized` is handled here. Inputs that already have a - * JoyID signature are not finalized. + * JoyID signature are not finalized. Transaction, prevout, and signature + * policy checks also run with `autoFinalized: false`. The wallet must not + * add or replace finalized scripts. */ async signPsbt( psbtHex: ccc.HexLike, @@ -225,6 +228,10 @@ export class BitcoinSigner extends ccc.SignerBtc { const { address } = await this.assertConnection(); const formattedOptions = ccc.SignPsbtOptions.from(options); const unsignedPsbtHex = ccc.hexFrom(psbtHex); + formattedOptions.inputsToSign = validateInputSelection( + formattedOptions.inputsToSign, + WalletPsbt.fromPSBT(ccc.bytesFrom(unsignedPsbtHex)).inputsLength, + ); const config = this.getConfig(); const { tx } = await createPopup( @@ -248,9 +255,10 @@ export class BitcoinSigner extends ccc.SignerBtc { const { unsigned, signed } = parseSignedPsbt( unsignedPsbtHex, signedPsbtHex, + formattedOptions.inputsToSign, ); if (!formattedOptions.autoFinalized) { - return signedPsbtHex; + return ccc.hexFrom(signed.toPSBT()); } return finalizeSignedInputs( unsigned, diff --git a/packages/joy-id/src/btc/psbt.ts b/packages/joy-id/src/btc/psbt.ts index 32f15b2ae..0f480856b 100644 --- a/packages/joy-id/src/btc/psbt.ts +++ b/packages/joy-id/src/btc/psbt.ts @@ -1,76 +1,20 @@ +import { + WalletPsbt, + hasNewSignature, + isFinalized, + parseAndValidateSignedPsbt, +} from "@ckb-ccc/btc-psbt"; import { ccc } from "@ckb-ccc/core"; -import { Transaction, type TxOpts } from "@scure/btc-signer"; -type PsbtInput = ReturnType; -type PsbtInputUpdate = Parameters[1]; - -// scure drops unknown PSBT fields by default. -const PSBT_OPTIONS: TxOpts = { unknown: "ignore", proprietary: "ignore" }; - -// finalizeIdx needs every input's UTXO to check the fee, so finalize the input -// alone. -function finalizeInput(tx: Transaction, index: number) { - const input = tx.getInput(index); - const scratch = new Transaction(PSBT_OPTIONS); - scratch.addInput(input); - scratch.finalizeIdx(0); - const finalized = scratch.getInput(0); - const cleared = Object.fromEntries( - Object.keys(input) - .filter((key) => !(key in finalized)) - .map((key) => [key, undefined]), - ) as PsbtInputUpdate; - - // Fields a signature commits to can't be cleared while it exists. - tx.updateInput(index, { - partialSig: undefined, - tapKeySig: undefined, - tapScriptSig: undefined, - }); - tx.updateInput(index, { - ...cleared, - finalScriptSig: finalized.finalScriptSig, - finalScriptWitness: finalized.finalScriptWitness, - }); -} - -function inputsOf(tx: Transaction): PsbtInput[] { +type PsbtInput = ReturnType; +function inputsOf(tx: WalletPsbt): PsbtInput[] { return Array.from({ length: tx.inputsLength }, (_, i) => tx.getInput(i)); } -function isFinalized(input: PsbtInput) { - return !!(input.finalScriptSig || input.finalScriptWitness); -} - -// Compare by content so a replaced signature counts as new. -function signatureEntries(input: PsbtInput | undefined): Set { - const entries = new Set(); - input?.partialSig?.forEach(([pubkey, signature]) => - entries.add(`partial:${ccc.hexFrom(pubkey)}:${ccc.hexFrom(signature)}`), - ); - if (input?.tapKeySig) { - entries.add(`tapKey:${ccc.hexFrom(input.tapKeySig)}`); - } - input?.tapScriptSig?.forEach(([{ pubKey, leafHash }, signature]) => - entries.add( - `tapScript:${ccc.hexFrom(pubKey)}:${ccc.hexFrom(leafHash)}:${ccc.hexFrom(signature)}`, - ), - ); - return entries; -} - -function hasNewSignature( - before: PsbtInput | undefined, - after: PsbtInput, -): boolean { - const existing = signatureEntries(before); - return [...signatureEntries(after)].some((entry) => !existing.has(entry)); -} - -function finalizeInputs(tx: Transaction, indexes: Set): ccc.Hex { +function finalizeInputs(tx: WalletPsbt, indexes: Set): ccc.Hex { try { for (const index of indexes) { - finalizeInput(tx, index); + tx.finalizeInput(index); } } catch (error) { throw new Error( @@ -89,27 +33,14 @@ function finalizeInputs(tx: Transaction, indexes: Set): ccc.Hex { export function parseSignedPsbt( unsignedPsbtHex: ccc.Hex, signedPsbtHex: ccc.Hex, -): { unsigned: Transaction; signed: Transaction } { - if (!signedPsbtHex.startsWith("0x70736274ff")) { - throw new Error("JoyID did not return a PSBT."); - } - - const unsigned = Transaction.fromPSBT( - ccc.bytesFrom(unsignedPsbtHex), - PSBT_OPTIONS, + inputsToSign: ccc.InputToSign[] = [], +): { unsigned: WalletPsbt; signed: WalletPsbt } { + return parseAndValidateSignedPsbt( + unsignedPsbtHex, + signedPsbtHex, + inputsToSign, + "JoyID", ); - let signed: Transaction; - try { - signed = Transaction.fromPSBT(ccc.bytesFrom(signedPsbtHex), PSBT_OPTIONS); - } catch (error) { - throw new Error("JoyID returned an invalid PSBT.", { cause: error }); - } - - if (ccc.hexFrom(unsigned.unsignedTx) !== ccc.hexFrom(signed.unsignedTx)) { - throw new Error("JoyID returned a PSBT for a different transaction."); - } - - return { unsigned, signed }; } /** @@ -120,8 +51,8 @@ export function parseSignedPsbt( * produces the same bytes. */ export function finalizeSignedInputs( - unsigned: Transaction, - signed: Transaction, + unsigned: WalletPsbt, + signed: WalletPsbt, inputsToSign: ccc.InputToSign[], ): ccc.Hex { const before = inputsOf(unsigned); diff --git a/packages/joy-id/tsdown.config.mts b/packages/joy-id/tsdown.config.mts index e80e9e21d..8ae204d10 100644 --- a/packages/joy-id/tsdown.config.mts +++ b/packages/joy-id/tsdown.config.mts @@ -15,8 +15,9 @@ const entry = { advancedBarrel: "src/advancedBarrel.ts", } as const; -// ESM-only, so bundled for CommonJS. +// Inline the private PSBT boundary and ESM dependencies in both formats. const bundleDeps: string[] = [ + "@ckb-ccc/btc-psbt", "@scure/btc-signer", "@scure/btc-signer/*", "@scure/base", @@ -33,7 +34,8 @@ export default defineConfig( { entry, deps: { - onlyBundle: [] as string[], + alwaysBundle: bundleDeps, + onlyBundle: bundleDeps, }, format: "esm", copy: "./misc/basedirs/dist/*", diff --git a/packages/xverse/package.json b/packages/xverse/package.json index 0a5cc15b5..de6645566 100644 --- a/packages/xverse/package.json +++ b/packages/xverse/package.json @@ -50,7 +50,8 @@ "@typescript/native": "npm:typescript@^7.0.2", "typescript": "npm:@typescript/typescript6@^6.0.2", "typescript-eslint": "^8.61.1", - "vitest": "^4.1.9" + "vitest": "^4.1.9", + "@ckb-ccc/btc-psbt": "workspace:*" }, "publishConfig": { "access": "public" diff --git a/packages/xverse/src/signer.ts b/packages/xverse/src/signer.ts index 72848da79..b3980d763 100644 --- a/packages/xverse/src/signer.ts +++ b/packages/xverse/src/signer.ts @@ -1,5 +1,9 @@ +import { + WalletPsbt, + parseAndValidateSignedPsbt, + validateInputSelection, +} from "@ckb-ccc/btc-psbt"; import { ccc } from "@ckb-ccc/core"; -import { Transaction, type TxOpts } from "@scure/btc-signer"; import * as v from "valibot"; import { Address, @@ -15,38 +19,6 @@ import { rpcSuccessResponseMessageSchema, } from "./advancedBarrel.js"; -// scure drops unknown PSBT fields by default. -const PSBT_OPTIONS: TxOpts = { unknown: "ignore", proprietary: "ignore" }; - -type PsbtInputUpdate = Parameters[1]; - -// finalizeIdx needs every input's UTXO to check the fee, so finalize the input -// alone. -function finalizeInput(tx: Transaction, index: number) { - const input = tx.getInput(index); - const scratch = new Transaction(PSBT_OPTIONS); - scratch.addInput(input); - scratch.finalizeIdx(0); - const finalized = scratch.getInput(0); - const cleared = Object.fromEntries( - Object.keys(input) - .filter((key) => !(key in finalized)) - .map((key) => [key, undefined]), - ) as PsbtInputUpdate; - - // Fields a signature commits to can't be cleared while it exists. - tx.updateInput(index, { - partialSig: undefined, - tapKeySig: undefined, - tapScriptSig: undefined, - }); - tx.updateInput(index, { - ...cleared, - finalScriptSig: finalized.finalScriptSig, - finalScriptWitness: finalized.finalScriptWitness, - }); -} - async function checkResponse( response: Promise>, ): Promise> { @@ -238,7 +210,7 @@ export class Signer extends ccc.SignerBtc { try { // Collect all unsigned inputs - const tx = Transaction.fromPSBT(ccc.bytesFrom(psbtHex), PSBT_OPTIONS); + const tx = WalletPsbt.fromPSBT(ccc.bytesFrom(psbtHex)); for (let index = 0; index < tx.inputsLength; index++) { const input = tx.getInput(index); const isSigned = @@ -274,13 +246,18 @@ export class Signer extends ccc.SignerBtc { psbtBase64: string; signInputs: Record; }> { - let inputsToSign = options.inputsToSign; + const unsigned = WalletPsbt.fromPSBT(ccc.bytesFrom(psbtHex)); + let inputsToSign = validateInputSelection( + options.inputsToSign, + unsigned.inputsLength, + ); if (!inputsToSign.length) { const address = await this.getBtcAccount(); inputsToSign = this.buildDefaultinputsToSign(psbtHex, address); } + inputsToSign = validateInputSelection(inputsToSign, unsigned.inputsLength); const psbtBase64 = ccc.bytesTo(psbtHex, "base64"); const signInputs = inputsToSign.reduce( @@ -311,7 +288,7 @@ export class Signer extends ccc.SignerBtc { * leaving inputs owned by other signers untouched. */ private finalizeSignedInputs( - tx: Transaction, + tx: WalletPsbt, signInputs: Record, ): void { const indexes = new Set(Object.values(signInputs).flat()); @@ -322,7 +299,7 @@ export class Signer extends ccc.SignerBtc { if (input?.finalScriptSig || input?.finalScriptWitness) { continue; } - finalizeInput(tx, index); + tx.finalizeInput(index); } } catch (error) { throw new Error( @@ -351,9 +328,11 @@ export class Signer extends ccc.SignerBtc { * - psbt: The base64 encoded signed PSBT, which is not finalized * * Xverse has no equivalent of `autoFinalized`, so when it is enabled the - * requested inputs are finalized locally after signing. `sighashTypes` and - * `disableTweakSigner` in `inputsToSign` are not supported by Xverse and - * are ignored. + * requested inputs are finalized locally after signing. `sighashTypes` is + * checked locally against new or replaced signatures; the RPC cannot pass + * it to Xverse. `disableTweakSigner` is not supported and is ignored. + * Transaction, prevout, and signature-policy checks also run when + * `autoFinalized` is false. Wallet-added final scripts are rejected. * * @see https://docs.xverse.app/sats-connect/bitcoin-methods/signpsbt */ @@ -377,14 +356,15 @@ export class Signer extends ccc.SignerBtc { ) ).psbt; + const { signed: signedPsbt } = parseAndValidateSignedPsbt( + ccc.hexFrom(psbtHex), + ccc.hexFrom(ccc.bytesFrom(signedPsbtBase64, "base64")), + normalized.inputsToSign, + "Xverse", + ); if (!normalized.autoFinalized) { - return ccc.hexFrom(ccc.bytesFrom(signedPsbtBase64, "base64")); + return ccc.hexFrom(signedPsbt.toPSBT()); } - - const signedPsbt = Transaction.fromPSBT( - ccc.bytesFrom(signedPsbtBase64, "base64"), - PSBT_OPTIONS, - ); this.finalizeSignedInputs(signedPsbt, signInputs); return ccc.hexFrom(signedPsbt.toPSBT()); } diff --git a/packages/xverse/tsdown.config.mts b/packages/xverse/tsdown.config.mts index e80e9e21d..8ae204d10 100644 --- a/packages/xverse/tsdown.config.mts +++ b/packages/xverse/tsdown.config.mts @@ -15,8 +15,9 @@ const entry = { advancedBarrel: "src/advancedBarrel.ts", } as const; -// ESM-only, so bundled for CommonJS. +// Inline the private PSBT boundary and ESM dependencies in both formats. const bundleDeps: string[] = [ + "@ckb-ccc/btc-psbt", "@scure/btc-signer", "@scure/btc-signer/*", "@scure/base", @@ -33,7 +34,8 @@ export default defineConfig( { entry, deps: { - onlyBundle: [] as string[], + alwaysBundle: bundleDeps, + onlyBundle: bundleDeps, }, format: "esm", copy: "./misc/basedirs/dist/*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71584a884..1f919099c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -178,6 +178,48 @@ importers: specifier: npm:@typescript/typescript6@^6.0.2 version: '@typescript/typescript6@6.0.2' + packages/btc-psbt: + devDependencies: + '@ckb-ccc/core': + specifier: workspace:* + version: link:../core + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.12.0(jiti@2.7.0)) + '@noble/curves': + specifier: ^2.2.0 + version: 2.4.0 + '@scure/btc-signer': + specifier: 2.4.1 + version: 2.4.1 + '@typescript/native': + specifier: npm:typescript@^7.0.2 + version: typescript@7.0.2 + eslint: + specifier: ^10.5.0 + version: 10.12.0(jiti@2.7.0) + eslint-config-prettier: + specifier: ^10.1.8 + version: 10.1.8(eslint@10.12.0(jiti@2.7.0)) + eslint-plugin-prettier: + specifier: ^5.5.6 + version: 5.5.7(@types/eslint@9.6.1)(eslint-config-prettier@10.1.8(eslint@10.12.0(jiti@2.7.0)))(eslint@10.12.0(jiti@2.7.0))(prettier@3.9.9) + prettier: + specifier: ^3.8.4 + version: 3.9.9 + prettier-plugin-organize-imports: + specifier: ^4.3.0 + version: 4.3.0(@typescript/typescript6@6.0.2)(prettier@3.9.9) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + typescript-eslint: + specifier: ^8.61.1 + version: 8.71.2(@typescript/typescript6@6.0.2)(eslint@10.12.0(jiti@2.7.0)) + vitest: + specifier: ^4.1.9 + version: 4.1.11(@types/node@26.6.4)(@vitest/coverage-v8@4.1.11)(vite@7.3.7(@types/node@26.6.5)(jiti@2.7.0)(lightningcss@1.32.0)(terser@5.51.2)(tsx@4.23.15)(yaml@2.9.1)) + packages/ccc: dependencies: '@ckb-ccc/eip6963': @@ -800,6 +842,9 @@ importers: specifier: 2.4.1 version: 2.4.1 devDependencies: + '@ckb-ccc/btc-psbt': + specifier: workspace:* + version: link:../btc-psbt '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.12.0(jiti@2.7.0)) @@ -1550,6 +1595,9 @@ importers: specifier: ^1.4.1 version: 1.5.0(@typescript/typescript6@6.0.2) devDependencies: + '@ckb-ccc/btc-psbt': + specifier: workspace:* + version: link:../btc-psbt '@eslint/js': specifier: ^10.0.1 version: 10.0.1(eslint@10.12.0(jiti@2.7.0)) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fba67cac3..a0e026d07 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,9 +2,9 @@ packages: - "packages/*" minimumReleaseAge: 1440 allowBuilds: - '@nestjs/core': true - '@parcel/watcher': true - '@tailwindcss/oxide': true + "@nestjs/core": true + "@parcel/watcher": true + "@tailwindcss/oxide": true core-js: true core-js-pure: true esbuild: true diff --git a/vitest.config.mts b/vitest.config.mts index 832387f33..58c1af598 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,6 +1,7 @@ import { defineConfig, coverageConfigDefaults } from "vitest/config"; const packages = [ + "packages/btc-psbt", "packages/connector", "packages/core", "packages/did-ckb", From 2e825b09de6aa581316550693d39a118a72290e6 Mon Sep 17 00:00:00 2001 From: Hanssen's Alter Ego Date: Sun, 18 Oct 2026 04:03:57 +0800 Subject: [PATCH 4/5] test(btc): cover PSBT compatibility and published artifacts Add parser acceptance and lifecycle regressions plus ESM/CommonJS artifact checks for both adapters. Cover wrong-key rejection, partial returns, wrapper types, and PSBT v0/v2 with nonzero input indexes. Run the artifact harness from the repository CI test command. --- package.json | 5 +- packages/btc-psbt/src/compatibility.test.ts | 358 +++++++++++++++ packages/btc-psbt/src/index.test.ts | 137 ++++++ scripts/test-psbt-artifacts.mjs | 473 ++++++++++++++++++++ 4 files changed, 971 insertions(+), 2 deletions(-) create mode 100644 packages/btc-psbt/src/compatibility.test.ts create mode 100644 scripts/test-psbt-artifacts.mjs diff --git a/package.json b/package.json index 63b2c881e..8d27d1765 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ ], "scripts": { "test": "pnpm run -C packages/tests test && vitest", - "test:ci": "pnpm run -C packages/tests test && vitest run", + "test:ci": "pnpm run -C packages/tests test && vitest run && pnpm test:psbt:artifacts", "test:cov": "vitest run --coverage", "build:prepare": "pnpm -r --filter !./packages/tests --filter !./packages/app --filter !./packages/faucet --filter !./packages/examples --filter !./packages/playground --filter !./packages/docs --filter !. install", "build": "pnpm -r --filter !./packages/tests --filter !./packages/app --filter !./packages/faucet --filter !./packages/examples --filter !./packages/playground --filter !./packages/docs run build", @@ -13,7 +13,8 @@ "lint": "pnpm -r run lint", "format": "pnpm -r run format", "sync:config": "pnpm -r --filter !./packages/app --filter !./packages/faucet --filter !./packages/playground --filter !./packages/docs -c exec \"cp ../../config/* . && cp ../../config/.* .\"", - "docs": "typedoc" + "docs": "typedoc", + "test:psbt:artifacts": "node scripts/test-psbt-artifacts.mjs" }, "devDependencies": { "@changesets/changelog-github": "^1.0.0", diff --git a/packages/btc-psbt/src/compatibility.test.ts b/packages/btc-psbt/src/compatibility.test.ts new file mode 100644 index 000000000..8e63e5b44 --- /dev/null +++ b/packages/btc-psbt/src/compatibility.test.ts @@ -0,0 +1,358 @@ +import { schnorr, secp256k1 } from "@noble/curves/secp256k1.js"; +import * as btc from "@scure/btc-signer"; +import { RawPSBTV0, RawPSBTV2 } from "@scure/btc-signer/psbt.js"; +import { describe, expect, it } from "vitest"; +import { PSBT_OPTIONS, WalletPsbt } from "./index.js"; + +// Only fixture construction bypasses scure's checks. Every tested production +// operation uses the encapsulated wallet boundary with stock scure. +const FIXTURE_OPTIONS = { ...PSBT_OPTIONS, disableScriptCheck: true }; +const KEY = new Uint8Array(32).fill(1); +const OTHER = new Uint8Array(32).fill(2); +const PUB = secp256k1.getPublicKey(KEY); +const XPUB = schnorr.getPublicKey(KEY); +const PAY = btc.p2wpkh(PUB); +const TREE = btc.p2tr(XPUB, { + script: btc.p2tr_pk(schnorr.getPublicKey(OTHER)).script, +}); +type Input = ReturnType; +type Raw = ReturnType; +type Vector = { name: string; bytes: Uint8Array; accepted: boolean }; + +function fixture( + version: number, + script: Uint8Array = PAY.script, + fields: Partial = {}, +) { + const tx = new btc.Transaction({ ...FIXTURE_OPTIONS, PSBTVersion: version }); + tx.addInput({ + txid: new Uint8Array(32).fill(1), + index: 0, + witnessUtxo: { amount: 10000n, script }, + ...fields, + }); + tx.addOutput({ script: PAY.script, amount: 9000n }); + return tx; +} +function mutate( + bytes: Uint8Array, + version: number, + change: (raw: Raw) => void, +) { + const coder = version === 2 ? RawPSBTV2 : RawPSBTV0; + const raw = coder.decode(Uint8Array.from(bytes)); + change(raw); + return coder.encode(raw); +} +function legacyFixture(version: number) { + const previous = new btc.Transaction(FIXTURE_OPTIONS); + previous.addInput({ txid: new Uint8Array(32).fill(3), index: 0 }); + previous.addOutput({ script: btc.p2pk(PUB).script, amount: 10000n }); + const tx = new btc.Transaction({ ...FIXTURE_OPTIONS, PSBTVersion: version }); + tx.addInput({ + txid: previous.id, + index: 0, + nonWitnessUtxo: previous.toBytes(true, true), + }); + tx.addOutput({ script: PAY.script, amount: 9000n }); + return tx; +} + +// Acceptance contracts were compared against the original parser patch. Keep +// explicit expectations so this suite needs neither a patched oracle nor internals. +function vectors(version: number): Vector[] { + const result: Vector[] = []; + const scripts: [string, Uint8Array, Partial][] = [ + ["wpkh", PAY.script, {}], + ["bare pk", btc.p2pk(PUB).script, {}], + ["bare ms", btc.p2ms(1, [PUB]).script, {}], + ["unknown root", TREE.script, { tapInternalKey: TREE.tapInternalKey }], + [ + "tree", + TREE.script, + { + tapInternalKey: TREE.tapInternalKey, + tapMerkleRoot: TREE.tapMerkleRoot, + tapLeafScript: TREE.tapLeafScript, + }, + ], + ]; + for (const [name, script, fields] of scripts) { + const bytes = fixture(version, script, fields).toPSBT(); + result.push({ name, bytes, accepted: true }); + const updates: [string, Partial, boolean][] = [ + ["empty redeem", { redeemScript: new Uint8Array() }, false], + ["empty witness", { witnessScript: new Uint8Array() }, false], + ["redeem", { redeemScript: PAY.script }, false], + ["witness", { witnessScript: PAY.script }, false], + [ + "internal key", + { tapInternalKey: schnorr.getPublicKey(OTHER) }, + name === "unknown root", + ], + ["root", { tapMerkleRoot: new Uint8Array(32).fill(7) }, false], + [ + "leaves", + { tapLeafScript: TREE.tapLeafScript }, + name === "tree" || name === "unknown root", + ], + ]; + for (const [field, update, accepted] of updates) { + result.push({ + name: `${name} with ${field}`, + bytes: mutate(bytes, version, (raw) => + Object.assign(raw.inputs[0], update), + ), + accepted, + }); + } + } + for (const spend of [ + btc.p2sh(PAY), + btc.p2wsh(btc.p2ms(1, [PUB])), + btc.p2sh(btc.p2wsh(btc.p2ms(1, [PUB]))), + ]) { + const fields: Partial = {}; + if ("redeemScript" in spend) { + fields.redeemScript = spend.redeemScript; + } + if ("witnessScript" in spend) { + fields.witnessScript = spend.witnessScript; + } + const bytes = fixture(version, spend.script, fields).toPSBT(); + result.push({ + name: `valid ${spend.type} ${Object.keys(fields).join("/")}`, + bytes, + accepted: true, + }); + for (const field of ["redeemScript", "witnessScript"] as const) { + if (!fields[field]) { + continue; + } + result.push({ + name: `${spend.type} wrong ${field}`, + bytes: mutate(bytes, version, (raw) => { + raw.inputs[0][field]![0] ^= 1; + }), + accepted: false, + }); + } + } + const full = fixture(version, TREE.script, { + tapInternalKey: TREE.tapInternalKey, + tapMerkleRoot: TREE.tapMerkleRoot, + tapLeafScript: TREE.tapLeafScript, + }).toPSBT(); + for (const field of ["root", "parity", "internal", "leaf"] as const) { + result.push({ + name: `wrong Taproot ${field}`, + bytes: mutate(full, version, (raw) => { + const input = raw.inputs[0]; + if (field === "root") { + input.tapMerkleRoot![0] ^= 1; + } + if (field === "parity") { + input.tapLeafScript![0][0].version ^= 1; + } + if (field === "internal") { + input.tapInternalKey = schnorr.getPublicKey(OTHER); + } + if (field === "leaf") { + input.tapLeafScript![0][1][1] ^= 1; + } + }), + accepted: false, + }); + } + const unknown = fixture(version, TREE.script, { + tapInternalKey: TREE.tapInternalKey, + }).toPSBT(); + result.push({ + name: "noncanonical Taproot program", + bytes: mutate(unknown, version, (raw) => { + raw.inputs[0].witnessUtxo!.script = Uint8Array.from([ + 0x51, + 0x4c, + 0x20, + ...TREE.script.subarray(2), + ]); + }), + accepted: false, + }); + const mixed = fixture(version, btc.p2pk(PUB).script); + mixed.addInput({ + txid: new Uint8Array(32).fill(2), + index: 0, + witnessUtxo: { amount: 10000n, script: PAY.script }, + }); + result.push({ + name: "unrelated input with invalid wrapper", + bytes: mutate(mixed.toPSBT(), version, (raw) => { + raw.inputs[1].redeemScript = PAY.script; + }), + accepted: false, + }); + const legacy = legacyFixture(version).toPSBT(); + result.push({ name: "bare nonWitnessUtxo", bytes: legacy, accepted: true }); + result.push({ + name: "bare wrong txid", + bytes: mutate(legacy, version, (raw) => { + (version === 0 + ? raw.global.unsignedTx!.inputs[0] + : raw.inputs[0] + ).txid![0] ^= 1; + }), + accepted: false, + }); + result.push({ + name: "bare wrong amount", + bytes: mutate(legacy, version, (raw) => { + raw.inputs[0].witnessUtxo = { amount: 1n, script: btc.p2pk(PUB).script }; + }), + accepted: false, + }); + result.push({ + name: "bare out-of-range prevout", + bytes: mutate(legacy, version, (raw) => { + (version === 0 ? raw.global.unsignedTx!.inputs[0] : raw.inputs[0]).index = + 1; + }), + accepted: false, + }); + result.push({ + name: "nonminimal bare P2PK push", + bytes: fixture( + version, + Uint8Array.from([0x4c, 0x21, ...PUB, 0xac]), + ).toPSBT(), + accepted: true, + }); + result.push({ + name: "unknown script passthrough", + bytes: fixture(version, new Uint8Array([0x6a])).toPSBT(), + accepted: true, + }); + result.push({ + name: "root without internal key", + bytes: fixture(version, TREE.script, { + tapMerkleRoot: TREE.tapMerkleRoot, + }).toPSBT(), + accepted: true, + }); + result.push({ + name: "leaves without root or separate internal key", + bytes: fixture(version, TREE.script, { + tapLeafScript: TREE.tapLeafScript, + }).toPSBT(), + accepted: true, + }); + const missing = new btc.Transaction({ + ...FIXTURE_OPTIONS, + PSBTVersion: version, + }); + missing.addInput({ txid: new Uint8Array(32).fill(4), index: 0 }); + missing.addOutput({ script: PAY.script, amount: 9000n }); + result.push({ + name: "missing unrelated prevout", + bytes: missing.toPSBT(), + accepted: true, + }); + return result; +} + +describe.each([0, 2])("stock-scure compatibility v%i", (version) => { + it.each(vectors(version))( + "$name (accepted=$accepted)", + ({ bytes, accepted }) => { + if (!accepted) { + expect(() => WalletPsbt.fromPSBT(bytes)).toThrow(); + return; + } + const tx = WalletPsbt.fromPSBT(bytes); + expect(tx.toPSBT()).toEqual(bytes); + }, + ); + + it("finalizes a bare input and preserves its PSBT version", () => { + const source = legacyFixture(version); + source.signIdx(KEY, 0); + const tx = WalletPsbt.fromPSBT(source.toPSBT()); + tx.finalizeInput(0); + expect(tx.isFinal).toBe(true); + expect(tx.getInput(0).finalScriptSig?.length).toBeGreaterThan(0); + expect( + (version === 2 ? RawPSBTV2 : RawPSBTV0).decode(tx.toPSBT()).global + .version ?? 0, + ).toBe(version); + }); + + it("closes the unrelated-input mutation/serialization bypass", () => { + const source = fixture(version, btc.p2pk(PUB).script); + source.addInput({ + txid: new Uint8Array(32).fill(2), + index: 0, + witnessUtxo: { amount: 10000n, script: PAY.script }, + }); + // An exception at input 0 must not disable checks on ordinary input 1. + const unchecked = btc.Transaction.fromPSBT( + source.toPSBT(), + FIXTURE_OPTIONS, + ); + unchecked.updateInput(1, { redeemScript: PAY.script }); + expect(() => unchecked.toPSBT()).not.toThrow(); + expect(() => WalletPsbt.fromPSBT(unchecked.toPSBT())).toThrow( + /redeemScript/, + ); + + source.signIdx(KEY, 1); + const bytes = source.toPSBT(); + const tx = WalletPsbt.fromPSBT(bytes); + expect("updateInput" in tx).toBe(false); + expect("addInput" in tx).toBe(false); + expect("updateOutput" in tx).toBe(false); + expect("clone" in tx).toBe(false); + expect(Reflect.ownKeys(tx)).toHaveLength(0); + const unrelated = tx.getInput(1); + unrelated.redeemScript = PAY.script; + unrelated.witnessUtxo!.script[0] ^= 1; + bytes.fill(0); // Input byte storage must be owned too. + expect(tx.getInput(1).redeemScript).toBeUndefined(); + tx.finalizeInput(1); + const result = WalletPsbt.fromPSBT(tx.toPSBT()); + expect(result.getInput(0).witnessUtxo?.script).toEqual( + btc.p2pk(PUB).script, + ); + expect(result.getInput(0).finalScriptSig).toBeUndefined(); + expect(result.getInput(1).finalScriptWitness).toHaveLength(2); + }); + + it.each(["length", "point"])( + "rejects malformed omitted-root internal-key %s", + (kind) => { + const bytes = fixture(version, TREE.script, { + tapInternalKey: TREE.tapInternalKey, + }).toPSBT(); + const position = bytes.findIndex((_, i) => + XPUB.every((byte, j) => bytes[i + j] === byte), + ); + expect(position).toBeGreaterThan(0); + if (kind === "length") { + bytes[position - 1] = 31; + } else { + bytes.fill(0, position, position + 32); + } + expect(() => WalletPsbt.fromPSBT(bytes)).toThrow(); + }, + ); + + it("leaves state intact when finalization rejects signature policy", () => { + const source = fixture(version); + source.signIdx(KEY, 0); + const bytes = mutate(source.toPSBT(), version, (raw) => { + raw.inputs[0].sighashType = 2; + }); + const tx = WalletPsbt.fromPSBT(bytes); + expect(() => tx.finalizeInput(0)).toThrow(/sighash/); + expect(tx.toPSBT()).toEqual(bytes); + }); +}); diff --git a/packages/btc-psbt/src/index.test.ts b/packages/btc-psbt/src/index.test.ts index 6ea944950..0cf22e9d1 100644 --- a/packages/btc-psbt/src/index.test.ts +++ b/packages/btc-psbt/src/index.test.ts @@ -656,3 +656,140 @@ describe("wallet PSBT response validation", () => { expect(tx.getInput(0).finalScriptWitness).toBeUndefined(); }); }); + +describe.each([0, 2])("parser compatibility in PSBTv%i", (version) => { + it.each([btc.p2pk(PUB).script, btc.p2ms(1, [PUB]).script])( + "accepts bare inputs while rejecting stray wrapper and Taproot metadata", + (script) => { + const before = hex(txFor(script, 1, version)); + const parsed = parseAndValidateSignedPsbt(before, before).signed; + expect("updateInput" in parsed).toBe(false); + expect(parsed.getInput(0).witnessUtxo?.script).toEqual(script); + for (const field of ["redeemScript", "witnessScript"] as const) { + const invalid = mutate( + before, + (raw) => { + raw.inputs[0][field] = script; + }, + version, + ); + expect(() => parseAndValidateSignedPsbt(invalid, invalid)).toThrow( + field, + ); + } + for (const field of ["tapInternalKey", "tapMerkleRoot"] as const) { + const invalid = mutate( + before, + (raw) => { + raw.inputs[0][field] = schnorr.getPublicKey(KEY); + }, + version, + ); + expect(() => parseAndValidateSignedPsbt(invalid, invalid)).toThrow( + /Taproot metadata/, + ); + } + }, + ); + + it("still validates explicit roots and leaves without trusted request metadata", () => { + const payment = btc.p2tr(schnorr.getPublicKey(KEY), { + script: btc.p2tr_pk(schnorr.getPublicKey(OTHER)).script, + }); + const tx = txFor(payment.script, 1, version); + tx.updateInput(0, { + tapInternalKey: payment.tapInternalKey, + tapMerkleRoot: payment.tapMerkleRoot, + tapLeafScript: payment.tapLeafScript, + }); + const complete = hex(tx); + for (const field of ["root", "parity", "internalKey", "leaf"] as const) { + const invalid = mutate( + complete, + (raw) => { + const input = raw.inputs[0]; + if (field === "root") { + delete input.tapLeafScript; + input.tapMerkleRoot![0] ^= 1; + } else if (field === "parity") { + delete input.tapMerkleRoot; + input.tapLeafScript![0][0].version ^= 1; + } else if (field === "internalKey") { + delete input.tapMerkleRoot; + input.tapInternalKey = schnorr.getPublicKey(OTHER); + } else { + input.tapLeafScript![0][1][1] ^= 1; + } + }, + version, + ); + expect(() => parseAndValidateSignedPsbt(invalid, invalid)).toThrow(); + } + const leavesOnly = mutate( + complete, + (raw) => { + delete raw.inputs[0].tapMerkleRoot; + }, + version, + ); + expect( + parseAndValidateSignedPsbt(leavesOnly, leavesOnly).signed.getInput(0) + .tapLeafScript, + ).toEqual(payment.tapLeafScript); + }); + + it("validates wrapped script commitments independently of transaction binding", () => { + for (const payment of [btc.p2sh(PAY), btc.p2wsh(btc.p2ms(1, [PUB]))]) { + const tx = txFor(payment.script, 1, version); + const field = payment.type === "sh" ? "redeemScript" : "witnessScript"; + tx.updateInput( + 0, + payment.type === "sh" + ? { redeemScript: payment.redeemScript } + : { witnessScript: payment.witnessScript }, + ); + const invalid = mutate( + hex(tx), + (raw) => { + raw.inputs[0][field]![0] ^= 1; + }, + version, + ); + expect(() => parseAndValidateSignedPsbt(invalid, invalid)).toThrow( + /wrong.*hash/, + ); + } + }); + + it("checks nonWitnessUtxo txids and redundant prevouts on bare inputs", () => { + const tx = singleKeyTx(btc.p2pk(PUB)); + const before = ccc.hexFrom(tx.toPSBT(version)); + const wrongTxid = mutate( + before, + (raw) => { + if (version === 0) { + raw.global.unsignedTx!.inputs[0].txid[0] ^= 1; + } else { + raw.inputs[0].txid![0] ^= 1; + } + }, + version, + ); + expect(() => parseAndValidateSignedPsbt(wrongTxid, wrongTxid)).toThrow( + /wrong txid/, + ); + const wrongAmount = mutate( + before, + (raw) => { + raw.inputs[0].witnessUtxo = { + script: btc.p2pk(PUB).script, + amount: 1n, + }; + }, + version, + ); + expect(() => parseAndValidateSignedPsbt(wrongAmount, wrongAmount)).toThrow( + /different from nonWitnessUtxo/, + ); + }); +}); diff --git a/scripts/test-psbt-artifacts.mjs b/scripts/test-psbt-artifacts.mjs new file mode 100644 index 000000000..1b2e99ffa --- /dev/null +++ b/scripts/test-psbt-artifacts.mjs @@ -0,0 +1,473 @@ +import assert from "node:assert/strict"; +import { readFile, readdir, realpath } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const root = new URL("../", import.meta.url); +const xrequire = createRequire( + await realpath(new URL("packages/xverse/package.json", root)), +); +const jrequire = createRequire( + await realpath(new URL("packages/joy-id/package.json", root)), +); +const fixtureRequire = createRequire(xrequire.resolve("@scure/btc-signer")); +const btc = await import(pathToFileURL(xrequire.resolve("@scure/btc-signer"))); +const { RawPSBTV0, RawPSBTV2 } = await import( + pathToFileURL(xrequire.resolve("@scure/btc-signer/psbt.js")) +); +const { getInputType } = await import( + pathToFileURL(xrequire.resolve("@scure/btc-signer/transaction.js")) +); +const { ccc } = xrequire("@ckb-ccc/core"); +const { schnorr, secp256k1 } = await import( + pathToFileURL(fixtureRequire.resolve("@noble/curves/secp256k1.js")) +); +const { DappRequestType } = jrequire("@joyid/common"); +const key = new Uint8Array(32).fill(1); +const pub = secp256k1.getPublicKey(key, true); +const payment = btc.p2wpkh(pub, btc.TEST_NETWORK); +const client = ccc.ClientPublicTestnet.new({ + transport: { + request: async () => { + throw new Error("Unexpected network request"); + }, + }, +}); +// Fixture construction/readback only; adapter code enforces its own boundary. +const options = { + unknown: "ignore", + proprietary: "ignore", + disableScriptCheck: true, +}; +const hex = (tx) => ccc.hexFrom(tx.toPSBT()); +const rawChange = (psbt, change, version = 0) => { + const coder = version === 2 ? RawPSBTV2 : RawPSBTV0; + const raw = coder.decode(ccc.bytesFrom(psbt)); + change(raw); + return ccc.hexFrom(coder.encode(raw)); +}; +function transaction(script = payment.script) { + const tx = new btc.Transaction(options); + tx.addInput({ + txid: new Uint8Array(32).fill(1), + index: 0, + witnessUtxo: { amount: 10000n, script }, + }); + tx.addOutput({ script: payment.script, amount: 9000n }); + return tx; +} +const singleKeyPayments = [ + ["wpkh", btc.p2wpkh(pub)], + ["sh-wpkh", btc.p2sh(btc.p2wpkh(pub))], + ["pkh", btc.p2pkh(pub)], + ["sh-pkh", btc.p2sh(btc.p2pkh(pub))], + ["wsh-pkh", btc.p2wsh(btc.p2pkh(pub))], + ["sh-wsh-pkh", btc.p2sh(btc.p2wsh(btc.p2pkh(pub)))], + ["pk", btc.p2pk(pub)], + ["sh-pk", btc.p2sh(btc.p2pk(pub))], + ["wsh-pk", btc.p2wsh(btc.p2pk(pub))], + ["sh-wsh-pk", btc.p2sh(btc.p2wsh(btc.p2pk(pub)))], +]; +function singleKeyTransaction(spend) { + const previous = new btc.Transaction(options); + previous.addInput({ txid: new Uint8Array(32).fill(7), index: 0 }); + previous.addOutput({ script: spend.script, amount: 10000n }); + const tx = new btc.Transaction(options); + tx.addInput({ + txid: previous.id, + index: 0, + nonWitnessUtxo: previous.toBytes(true, true), + ...(spend.redeemScript ? { redeemScript: spend.redeemScript } : {}), + ...(spend.witnessScript ? { witnessScript: spend.witnessScript } : {}), + }); + tx.addOutput({ script: payment.script, amount: 9000n }); + return tx; +} +function createSigner(wallet, api, response) { + if (wallet === "xverse") { + const provider = { + request: async (method) => { + assert.equal(method, "signPsbt"); + return { + jsonrpc: "2.0", + id: "1", + result: { psbt: ccc.bytesTo(response(), "base64") }, + }; + }, + }; + return new api.Xverse.Signer(client, provider); + } + globalThis.location = { href: "https://dapp.test/" }; + const popup = { + location: {}, + closed: false, + close() { + this.closed = true; + }, + }; + globalThis.window = { + screenX: 0, + screenY: 0, + innerWidth: 1200, + innerHeight: 800, + open: () => popup, + navigator: {}, + matchMedia: () => ({ matches: false }), + removeEventListener: () => {}, + addEventListener: (_name, listener) => + queueMicrotask(() => + listener({ + origin: "https://testnet.joyid.dev", + data: { + type: DappRequestType.SignPsbt, + data: { tx: response().slice(2) }, + }, + }), + ), + }; + return new api.JoyId.BitcoinSigner( + client, + "Test", + "", + undefined, + "p2wpkh", + undefined, + { + get: async () => ({ + address: payment.address, + publicKey: ccc.hexFrom(pub), + keyType: "main_key", + }), + set: async () => {}, + }, + "btcTestnet", + ); +} +let passed = 0; +const selection = [{ index: 0, address: payment.address }]; +try { + for (const wallet of ["xverse", "joy-id"]) { + const manifest = JSON.parse( + await readFile(new URL(`packages/${wallet}/package.json`, root), "utf8"), + ); + for (const field of [ + "dependencies", + "optionalDependencies", + "peerDependencies", + ]) { + assert.equal(manifest[field]?.["@ckb-ccc/btc-psbt"], undefined); + passed++; + } + for (const format of ["esm", "cjs"]) { + const directory = new URL( + `packages/${wallet}/${format === "esm" ? "dist" : "dist.commonjs"}/`, + root, + ); + const files = (await readdir(directory)).filter((name) => + /\.(?:mjs|js|d\.mts|d\.ts)$/.test(name), + ); + const code = ( + await Promise.all( + files.map((name) => readFile(new URL(name, directory), "utf8")), + ) + ).join("\n"); + assert.doesNotMatch( + code, + /(?:from\s*|import\s*\(|require\s*\()["']@scure\/btc-signer/, + "Published output must bundle the signer for both module formats", + ); + passed++; + assert.doesNotMatch( + code, + /@ckb-ccc\/btc-psbt/, + "Published code and declarations must inline the private PSBT helper", + ); + passed++; + const entry = new URL( + format === "esm" ? "index.mjs" : "index.js", + directory, + ); + const api = + format === "esm" + ? await import(entry) + : createRequire(import.meta.url)(fileURLToPath(entry)); + for (const [type, spend] of singleKeyPayments) { + const candidate = singleKeyTransaction(spend); + const request = hex(candidate); + const inputType = getInputType(candidate.getInput(0)); + const scriptCode = + inputType.last.type === "wpkh" + ? btc.p2pkh(pub).script + : inputType.lastScript; + const digest = + inputType.txType === "segwit" + ? candidate.preimageWitnessV0(0, scriptCode, 1, 10000n) + : candidate.preimageLegacy(0, scriptCode, 1); + const unrelatedKey = new Uint8Array(32).fill(2); + const unrelatedPub = secp256k1.getPublicKey(unrelatedKey, true); + const der = secp256k1.sign(digest, unrelatedKey, { + format: "der", + prehash: false, + }); + assert.ok( + secp256k1.verify(der, digest, unrelatedPub, { + format: "der", + prehash: false, + }), + ); + const wrongKey = rawChange(request, (raw) => { + raw.inputs[0].partialSig = [ + [unrelatedPub, ccc.bytesConcat(der, [1])], + ]; + }); + const wrongSigner = createSigner(wallet, api, () => wrongKey); + await assert.rejects( + wrongSigner.signPsbt(request, { inputsToSign: selection }), + (error) => + error.cause instanceof Error && + /public key.*script/.test(error.cause.message), + `${wallet} ${format} ${type}: reject a signature key outside the committed script`, + ); + passed++; + const partial = await wrongSigner.signPsbt(request, { + inputsToSign: selection, + autoFinalized: false, + }); + assert.ok( + btc.Transaction.fromPSBT(ccc.bytesFrom(partial), options).getInput(0) + .partialSig, + ); + passed++; + candidate.signIdx(key, 0); + const goodSigner = createSigner(wallet, api, () => hex(candidate)); + const result = await goodSigner.signPsbt(request, { + inputsToSign: selection, + }); + assert.ok( + btc.Transaction.fromPSBT(ccc.bytesFrom(result), options).isFinal, + ); + passed++; + } + const tx = transaction(); + tx.addInput({ + txid: new Uint8Array(32).fill(2), + index: 0, + witnessUtxo: { amount: 10000n, script: btc.p2pk(pub).script }, + }); + const request = hex(tx); + tx.signIdx(key, 0); + let response = hex(tx); + let signer = createSigner(wallet, api, () => response); + const result = btc.Transaction.fromPSBT( + ccc.bytesFrom( + await signer.signPsbt(request, { inputsToSign: selection }), + ), + options, + ); + assert.ok(result.getInput(0).finalScriptWitness); + assert.equal(result.getInput(1).finalScriptWitness, undefined); + passed++; + // A bare input must not suppress commitment checks on another input. + const invalidWrapper = rawChange(request, (raw) => { + raw.inputs[0].redeemScript = payment.script; + }); + for (const autoFinalized of [false, true]) { + signer = createSigner(wallet, api, () => invalidWrapper); + await assert.rejects( + signer.signPsbt(invalidWrapper, { + autoFinalized, + inputsToSign: selection, + }), + /redeemScript/, + ); + passed++; + } + const declared = transaction(); + declared.updateInput(0, { sighashType: 1 }); + const wrong = transaction(); + wrong.updateInput(0, { sighashType: 2 }); + wrong.signIdx(key, 0, [2]); + response = rawChange(hex(wrong), (raw) => { + delete raw.inputs[0].sighashType; + }); + for (const autoFinalized of [false, true]) { + signer = createSigner(wallet, api, () => response); + await assert.rejects( + signer.signPsbt(hex(declared), { + autoFinalized, + inputsToSign: selection, + }), + /sighash/i, + ); + passed++; + } + const locked = new btc.Transaction({ ...options, PSBTVersion: 2 }); + for (let index = 0; index < 2; index++) { + locked.addInput({ + txid: new Uint8Array(32).fill(index + 1), + index: 0, + sequence: 0xfffffffe, + requiredTimeLocktime: 500000100, + witnessUtxo: { amount: 10000n, script: payment.script }, + }); + } + locked.updateInput(0, { requiredHeightLocktime: 100 }); + locked.addOutput({ script: payment.script, amount: 9000n }); + const locktimeRaw = RawPSBTV2.decode(locked.toPSBT()); + delete locktimeRaw.inputs[0].requiredHeightLocktime; + locktimeRaw.inputs[1].requiredHeightLocktime = 99; + response = ccc.hexFrom(RawPSBTV2.encode(locktimeRaw)); + for (const autoFinalized of [false, true]) { + signer = createSigner(wallet, api, () => response); + await assert.rejects( + signer.signPsbt(hex(locked), { + autoFinalized, + inputsToSign: selection, + }), + /different transaction/, + ); + passed++; + } + const presigned = transaction(); + presigned.signIdx(key, 0); + response = rawChange(hex(presigned), (raw) => { + delete raw.inputs[0].partialSig; + }); + for (const autoFinalized of [false, true]) { + signer = createSigner(wallet, api, () => response); + await assert.rejects( + signer.signPsbt(hex(presigned), { + autoFinalized, + inputsToSign: selection, + }), + /removed an existing signature/, + ); + passed++; + } + presigned.finalizeIdx(0); + response = rawChange(hex(presigned), (raw) => { + raw.inputs[0].finalScriptSig = new Uint8Array(); + }); + signer = createSigner(wallet, api, () => response); + const preserved = btc.Transaction.fromPSBT( + ccc.bytesFrom( + await signer.signPsbt(response, { inputsToSign: selection }), + ), + options, + ); + assert.deepEqual( + preserved.getInput(0).finalScriptWitness, + presigned.getInput(0).finalScriptWitness, + ); + passed++; + const tree = btc.p2tr(schnorr.getPublicKey(key), { + script: btc.p2tr_pk(schnorr.getPublicKey(new Uint8Array(32).fill(2))) + .script, + }); + const tap = transaction(tree.script); + tap.updateInput(0, { + tapInternalKey: tree.tapInternalKey, + tapMerkleRoot: tree.tapMerkleRoot, + tapLeafScript: tree.tapLeafScript, + }); + const strip = (value) => + rawChange(value, (raw) => { + delete raw.inputs[0].tapMerkleRoot; + delete raw.inputs[0].tapLeafScript; + }); + const incomplete = strip(hex(tap)); + tap.signIdx(key, 0); + response = strip(hex(tap)); + signer = createSigner(wallet, api, () => response); + const finalized = btc.Transaction.fromPSBT( + ccc.bytesFrom( + await signer.signPsbt(incomplete, { inputsToSign: selection }), + ), + options, + ); + assert.equal(finalized.getInput(0).finalScriptWitness?.length, 1); + passed++; + // Scure's private-key signer assumes an empty tree when the root is absent. + // Adapters never call it, and must reject such a wallet response themselves. + const emptyTree = btc.Transaction.fromPSBT( + ccc.bytesFrom(incomplete), + options, + ); + emptyTree.signIdx(key, 0); + response = hex(emptyTree); + for (const autoFinalized of [false, true]) { + signer = createSigner(wallet, api, () => response); + await assert.rejects( + signer.signPsbt(incomplete, { + autoFinalized, + inputsToSign: selection, + }), + /Invalid Taproot key-path signature/, + ); + passed++; + } + for (const version of [0, 2]) { + for (const mode of [3, 0x83]) { + const nonzero = new btc.Transaction({ + ...options, + PSBTVersion: version, + }); + for (let index = 0; index < 2; index++) { + nonzero.addInput({ + txid: new Uint8Array(32).fill(index + 1), + index: 0, + witnessUtxo: { amount: 10000n, script: tree.script }, + tapInternalKey: tree.tapInternalKey, + tapMerkleRoot: tree.tapMerkleRoot, + tapLeafScript: tree.tapLeafScript, + }); + nonzero.addOutput({ amount: 9000n, script: payment.script }); + } + nonzero.updateInput(1, { sighashType: mode }); + const omitTree = (value) => + rawChange( + value, + (raw) => { + for (const input of raw.inputs) { + delete input.tapMerkleRoot; + delete input.tapLeafScript; + } + }, + version, + ); + const nonzeroRequest = omitTree(hex(nonzero)); + nonzero.signIdx(key, 1, [mode]); + response = omitTree(hex(nonzero)); + for (const autoFinalized of [false, true]) { + signer = createSigner(wallet, api, () => response); + const returned = btc.Transaction.fromPSBT( + ccc.bytesFrom( + await signer.signPsbt(nonzeroRequest, { + autoFinalized, + inputsToSign: [ + { ...selection[0], index: 1, sighashTypes: [mode] }, + ], + }), + ), + options, + ); + assert.equal(returned.getInput(0).finalScriptWitness, undefined); + assert.ok( + autoFinalized + ? returned.getInput(1).finalScriptWitness + : returned.getInput(1).tapKeySig, + ); + passed++; + } + } + } + console.log( + `${wallet} ${format}: bundled PSBT boundary, transaction binding, signature preservation/policy, finalization, and optional-tree signing flow passed`, + ); + } + } + console.log(`${passed} artifact assertions passed`); +} finally { + delete globalThis.window; + delete globalThis.location; +} From 654aa2b7a16460043e6575f97ed8afc381142014 Mon Sep 17 00:00:00 2001 From: Hanssen's Alter Ego Date: Sun, 18 Oct 2026 04:04:00 +0800 Subject: [PATCH 5/5] docs(btc): document wallet PSBT validation and finalization Explain the shared private helper, stock-scure compatibility, signature validation, and adapter-specific finalization policies. Add the wallet validation changeset. --- .changeset/secure-wallet-psbt-responses.md | 12 +++++++ packages/btc-psbt/README.md | 37 ++++++++++++++++++++++ packages/joy-id/README.md | 37 ++++++++++++++++++++++ packages/xverse/README.md | 35 ++++++++++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 .changeset/secure-wallet-psbt-responses.md create mode 100644 packages/btc-psbt/README.md diff --git a/.changeset/secure-wallet-psbt-responses.md b/.changeset/secure-wallet-psbt-responses.md new file mode 100644 index 000000000..486561bfa --- /dev/null +++ b/.changeset/secure-wallet-psbt-responses.md @@ -0,0 +1,12 @@ +--- +"@ckb-ccc/xverse": patch +"@ckb-ccc/joy-id": patch +--- + +Bind signed Bitcoin PSBT responses to the original transaction and prevouts, check actual signature sighash modes and caller policies before finalization, and reject wallet-added final scripts. These checks also apply when automatic finalization is disabled. + +Preserve partial/multiparty signing, unrelated bare inputs, and optional Taproot tree metadata while retaining commitment checks. Verify Taproot key-path signatures against the actual output key, rejecting incorrect empty-tree signatures. Use stock scure through a shared private compatibility, validation, and finalization boundary bundled into ESM and CommonJS builds. + +Recheck the effective PSBTv2 transaction after metadata restoration, reject removed signature entries, and preserve finalized witness inputs with an explicit empty scriptSig. + +Check single-key ECDSA public-key/script binding before automatic finalization, including wrapped payment scripts. diff --git a/packages/btc-psbt/README.md b/packages/btc-psbt/README.md new file mode 100644 index 000000000..16ab12e70 --- /dev/null +++ b/packages/btc-psbt/README.md @@ -0,0 +1,37 @@ +# Private wallet PSBT helpers + +This source-only workspace package shares PSBT response validation and per-input +finalization between Xverse and JoyID. It is a development dependency bundled by +both adapters' tsdown configurations, with no independent build or publication. +Core does not depend on it. Wallet communication, input selection, and which inputs +to finalize remain adapter-specific. + +`WalletPsbt` owns its scure transaction and input bytes. It exposes copied reads, +guarded serialization, and per-input finalization; no generic mutation or raw +transaction accessor is exposed. Finalization stages updates privately and checks +all inputs before and after mutation. `parseAndValidateSignedPsbt` enforces wallet +response binding, signature encoding, and caller sighash policy. Finalization also +checks encodings/declarations, verifies Taproot key signatures in the full +transaction at the original index, and binds ECDSA keys to single-key scripts. + +Stock scure 2.4.1 is used without dependency patches. Parsing first tries its strict +public API. A fallback delegates structural and commitment validation to public +`normalizeInput`, `getPrevOut`, and `OutScript` APIs, with exactly two exceptions: + +- Recognized bare P2PK/multisig inputs may omit wrappers; any redeem/witness script + or Taproot commitment metadata on them is rejected. +- A Taproot internal key without a root or leaves describes an unknown tree. + Its encoding is checked first, then only this key is omitted from a temporary + validation projection. The original metadata is retained. Explicit roots, + leaves, wrapper hashes, and canonical witness programs remain checked by scure. + +Every fallback input is checked, including unrelated inputs, and every +serialization repeats these checks. The compatibility suite records acceptance +contracts from the former parser patch and tests malformed metadata, alias +isolation, atomic finalization, and the unrelated-input bypass of raw unchecked +transactions. No upstream private-key signing or finalizer implementation is copied. + +Run the common suite with `pnpm --filter @ckb-ccc/btc-psbt test run`, and both +adapter suites with `pnpm --filter @ckb-ccc/xverse --filter @ckb-ccc/joy-id test run`. +After building the adapters, `pnpm test:psbt:artifacts` exercises their published +ESM/CommonJS entry points and checks that this private package is fully bundled. diff --git a/packages/joy-id/README.md b/packages/joy-id/README.md index 6c0fc48e3..ce3a79635 100644 --- a/packages/joy-id/README.md +++ b/packages/joy-id/README.md @@ -41,3 +41,40 @@ Check the [package documentation](https://docs.ckbccc.com/docs/packages/wallet-i

Read more about CCC on our website or GitHub Repo.

+ +## Bitcoin PSBT response validation + +`signPsbt` binds the wallet response to the requested transaction, input order, +outputs, locktime, and previously supplied UTXO data. Actual ECDSA and Schnorr +signature hash modes must match the input declaration and any caller allowlist +for new or replaced signatures. These checks also run with `autoFinalized: false`. +Other participants' unchanged signatures are not subject to the caller allowlist. +Responses that remove an existing signature are rejected. +Explicit non-default hash modes remain supported. + +Wallets must return an unfinalized PSBT. Existing, unchanged finalized inputs are +preserved, but newly added or changed final scripts are rejected. Missing trusted +UTXO/script/Taproot metadata is restored after transaction binding, with conflicts +rejected. Omitted Taproot roots/leaves do not assert an empty tree. New key-path +signatures, and signatures about to be finalized, are verified against the actual +output key in the full transaction context. All committed prevouts are required, +except unrelated prevouts excluded by `ANYONECANPAY`. + +Automatic finalization checks that a single-key ECDSA signature's public key +matches the committed script, including P2SH/P2WSH wrappers. +It supports ordinary payment scripts, wrapped multisig, +Taproot key-path, and scure's `tr_ns`/`tr_ms` script templates. Unrelated bare +P2PK/multisig inputs can be carried through while retaining commitment checks. +Arbitrary CLTV/CSV or inscription scripts are not automatically solved. Use +`autoFinalized: false` for partial/multiparty signing or application-managed +finalization; `broadcastPsbt` remains unsupported. + +Both adapters bundle a shared private PSBT boundary using stock scure public APIs. +It encapsulates compatibility for bare inputs and omitted Taproot tree metadata, +validates all inputs before finalization and serialization, and never exposes an +unchecked mutable transaction. ESM and CommonJS consumers receive the same checks. + +JoyID's default selection finalizes inputs with new or replaced signature bytes. +It does not infer ownership from those bytes. An already present identical JoyID +signature is not considered newly added; explicitly requesting that input still +reports that JoyID did not add a signature. diff --git a/packages/xverse/README.md b/packages/xverse/README.md index 2b0a3231f..408f03e45 100644 --- a/packages/xverse/README.md +++ b/packages/xverse/README.md @@ -41,3 +41,38 @@ Check the [package documentation](https://docs.ckbccc.com/docs/packages/wallet-i

Read more about CCC on our website or GitHub Repo.

+ +## Bitcoin PSBT response validation + +`signPsbt` binds the wallet response to the requested transaction, input order, +outputs, locktime, and previously supplied UTXO data. Actual ECDSA and Schnorr +signature hash modes must match the input declaration and any caller allowlist +for new or replaced signatures. These checks also run with `autoFinalized: false`. +Other participants' unchanged signatures are not subject to the caller allowlist. +Responses that remove an existing signature are rejected. +Explicit non-default hash modes remain supported. + +Wallets must return an unfinalized PSBT. Existing, unchanged finalized inputs are +preserved, but newly added or changed final scripts are rejected. Missing trusted +UTXO/script/Taproot metadata is restored after transaction binding, with conflicts +rejected. Omitted Taproot roots/leaves do not assert an empty tree. New key-path +signatures, and signatures about to be finalized, are verified against the actual +output key in the full transaction context. All committed prevouts are required, +except unrelated prevouts excluded by `ANYONECANPAY`. + +Automatic finalization checks that a single-key ECDSA signature's public key +matches the committed script, including P2SH/P2WSH wrappers. +It supports ordinary payment scripts, wrapped multisig, +Taproot key-path, and scure's `tr_ns`/`tr_ms` script templates. Unrelated bare +P2PK/multisig inputs can be carried through while retaining commitment checks. +Arbitrary CLTV/CSV or inscription scripts are not automatically solved. Use +`autoFinalized: false` for partial/multiparty signing or application-managed +finalization; `broadcastPsbt` remains unsupported. + +Both adapters bundle a shared private PSBT boundary using stock scure public APIs. +It encapsulates compatibility for bare inputs and omitted Taproot tree metadata, +validates all inputs before finalization and serialization, and never exposes an +unchecked mutable transaction. ESM and CommonJS consumers receive the same checks. + +Xverse's RPC cannot forward `inputsToSign[].sighashTypes`; CCC enforces it locally +on returned signatures. `disableTweakSigner` remains unsupported by Xverse.