From 4c70ff5d20a827665c065966f6eb3e7e8d5be1d4 Mon Sep 17 00:00:00 2001 From: fgh Date: Wed, 23 Sep 2026 15:20:24 +0800 Subject: [PATCH 1/2] 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/2] 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: {}