diff --git a/.changeset/little-zebras-pump.md b/.changeset/little-zebras-pump.md new file mode 100644 index 00000000..a0c05a28 --- /dev/null +++ b/.changeset/little-zebras-pump.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): multisig Signers + \ No newline at end of file diff --git a/packages/core/src/client/clientPublicMainnet.advanced.ts b/packages/core/src/client/clientPublicMainnet.advanced.ts index 19c9556c..35847fbe 100644 --- a/packages/core/src/client/clientPublicMainnet.advanced.ts +++ b/packages/core/src/client/clientPublicMainnet.advanced.ts @@ -54,6 +54,23 @@ export const MAINNET_SCRIPTS: Record = }, ], }, + [KnownScript.Secp256k1MultisigV2Beta]: { + codeHash: + "0xd1a9f877aed3f5e07cb9c52b61ab96d06f250ae6883cc7f0a2423db0976fc821", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0x44be4f4feda80c0e41783ab10e191df3b2bb5c3731b0970c916dbec385dcdc60", + index: 0, + }, + depType: "depGroup", + }, + }, + ], + }, [KnownScript.Secp256k1MultisigV2]: { codeHash: "0x36c971b8d41fbd94aabca77dc75e826729ac98447b46f91e00796155dddb0d29", diff --git a/packages/core/src/client/clientPublicTestnet.advanced.ts b/packages/core/src/client/clientPublicTestnet.advanced.ts index 9453d252..fe1e8455 100644 --- a/packages/core/src/client/clientPublicTestnet.advanced.ts +++ b/packages/core/src/client/clientPublicTestnet.advanced.ts @@ -54,6 +54,23 @@ export const TESTNET_SCRIPTS: Record = }, ], }, + [KnownScript.Secp256k1MultisigV2Beta]: { + codeHash: + "0x765b3ed6ae264b335d07e73ac332bf2c0f38f8d3340ed521cb447b4c42dd5f09", + hashType: "type", + cellDeps: [ + { + cellDep: { + outPoint: { + txHash: + "0xf2013f123b2cb745e3fdf5c935a3925647496f88090503eef58332a9245b4172", + index: 0, + }, + depType: "depGroup", + }, + }, + ], + }, [KnownScript.Secp256k1MultisigV2]: { codeHash: "0x36c971b8d41fbd94aabca77dc75e826729ac98447b46f91e00796155dddb0d29", diff --git a/packages/core/src/client/knownScript.ts b/packages/core/src/client/knownScript.ts index 90a1546f..491da998 100644 --- a/packages/core/src/client/knownScript.ts +++ b/packages/core/src/client/knownScript.ts @@ -5,6 +5,7 @@ export enum KnownScript { NervosDao = "NervosDao", Secp256k1Blake160 = "Secp256k1Blake160", Secp256k1Multisig = "Secp256k1Multisig", + Secp256k1MultisigV2Beta = "Secp256k1MultisigV2Beta", // Fix rare failing case (https://github.com/nervosnetwork/ckb-system-scripts/pull/98) Secp256k1MultisigV2 = "Secp256k1MultisigV2", // Enhanced since handling (https://github.com/nervosnetwork/ckb-system-scripts/pull/99) AnyoneCanPay = "AnyoneCanPay", TypeId = "TypeId", diff --git a/packages/core/src/signer/ckb/index.ts b/packages/core/src/signer/ckb/index.ts index d36c3d38..5a9dc928 100644 --- a/packages/core/src/signer/ckb/index.ts +++ b/packages/core/src/signer/ckb/index.ts @@ -2,4 +2,6 @@ export * from "./secp256k1Signing.js"; export * from "./signerCkbPrivateKey.js"; export * from "./signerCkbPublicKey.js"; export * from "./signerCkbScriptReadonly.js"; +export * from "./signerMultisigCkbPrivateKey.js"; +export * from "./signerMultisigCkbReadonly.js"; export * from "./verifyJoyId.js"; diff --git a/packages/core/src/signer/ckb/signerMultisigCkb.test.ts b/packages/core/src/signer/ckb/signerMultisigCkb.test.ts new file mode 100644 index 00000000..9819cc45 --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigCkb.test.ts @@ -0,0 +1,410 @@ +import { describe, expect, it } from "vitest"; +import { ccc } from "../../index.js"; + +const client = new ccc.ClientPublicTestnet(); +const ZERO_HASH = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + +describe("MultisigCkbWitness", () => { + it("should encode and decode correctly", () => { + const witness: ccc.MultisigCkbWitnessLike = { + publicKeys: [ + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa80", + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa81", + ], + threshold: 1, + mustMatch: 0, + signatures: [], + }; + + const encoded = ccc.MultisigCkbWitness.from(witness).toBytes(); + const decoded = ccc.MultisigCkbWitness.decode(encoded); + + expect(decoded.threshold).toBe(witness.threshold); + expect(decoded.mustMatch).toBe(witness.mustMatch); + expect(decoded.publicKeyHashes.length).toBe(witness.publicKeys.length); + }); + + it("should throw error for invalid threshold", () => { + expect(() => { + new ccc.MultisigCkbWitness([], 0, 0, []); + }).toThrow("threshold should be in range from 1 to public keys length"); + + expect(() => { + new ccc.MultisigCkbWitness([], 1, 0, []); + }).toThrow("threshold should be in range from 1 to public keys length"); + }); + + it("should throw error for invalid mustMatch", () => { + expect(() => { + new ccc.MultisigCkbWitness(["0x00"], 1, 2, []); + }).toThrow( + "mustMatch should be in range from 0 to min(public keys length, threshold)", + ); + }); + + it("should calculate scriptArgs correctly", () => { + const witness: ccc.MultisigCkbWitnessLike = { + publicKeys: [ + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa80", + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa81", + ], + threshold: 1, + mustMatch: 0, + }; + const multisigWitness = ccc.MultisigCkbWitness.from(witness); + const args = multisigWitness.scriptArgs(); + expect(args).toBeInstanceOf(Uint8Array); + expect(ccc.hexFrom(args)).toBe( + "0x6418f118e94d8dff7d9b0b59a4d837c4e201c5a9", + ); + }); + + describe("signature matching logic", () => { + const privKey1 = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + const privKey2 = + "0x0000000000000000000000000000000000000000000000000000000000000002"; + const privKey3 = + "0x0000000000000000000000000000000000000000000000000000000000000003"; + const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1); + const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2); + const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3); + + const message = ccc.hashCkb("0x0123456789abcdef"); + + it("should count signatures when required signers signed", async () => { + const sig1 = await signer1._signMessage(message); + const sig2 = await signer2._signMessage(message); + + const witness = ccc.MultisigCkbWitness.from({ + publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey], + threshold: 2, + mustMatch: 1, // signer1 is required + signatures: [sig1, sig2], + }); + + const counts = witness.calcMatchedSignaturesCount(message); + expect(counts.required).toBe(1); + expect(counts.flexible).toBe(1); + }); + + it("should count signatures when only flexible signers signed", async () => { + const sig2 = await signer2._signMessage(message); + const sig3 = await signer3._signMessage(message); + + const witness = ccc.MultisigCkbWitness.from({ + publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey], + threshold: 2, + mustMatch: 1, + signatures: [sig2, sig3], + }); + + const counts = witness.calcMatchedSignaturesCount(message); + expect(counts.required).toBe(0); + expect(counts.flexible).toBe(2); + }); + }); + + it("should derive the same scriptArgs regardless of attached signatures", async () => { + const privKey1 = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + const privKey2 = + "0x0000000000000000000000000000000000000000000000000000000000000002"; + const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1); + const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2); + const message = ccc.hashCkb("0x0123456789abcdef"); + const sig1 = await signer1._signMessage(message); + const sig2 = await signer2._signMessage(message); + + const baseWitness = ccc.MultisigCkbWitness.from({ + publicKeys: [signer1.publicKey, signer2.publicKey], + threshold: 2, + mustMatch: 1, + }); + const signedWitness = ccc.MultisigCkbWitness.from({ + publicKeys: [signer1.publicKey, signer2.publicKey], + threshold: 2, + mustMatch: 1, + signatures: [sig1, sig2], + }); + + expect(ccc.hexFrom(signedWitness.scriptArgs())).toBe( + ccc.hexFrom(baseWitness.scriptArgs()), + ); + }); +}); + +describe("SignerMultisigCkbReadonly", () => { + it("should initialize correctly", async () => { + const witness: ccc.MultisigCkbWitnessLike = { + publicKeys: [ + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa80", + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa81", + ], + threshold: 1, + mustMatch: 0, + }; + + const signer = new ccc.SignerMultisigCkbReadonly(client, witness); + + expect(await signer.getMemberCount()).toBe(2); + expect(await signer.getMemberThreshold()).toBe(1); + expect(await signer.getMemberRequiredCount()).toBe(0); + }); + + describe("getSignaturesCount with mustMatch", () => { + const privKey1 = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + const privKey2 = + "0x0000000000000000000000000000000000000000000000000000000000000002"; + const privKey3 = + "0x0000000000000000000000000000000000000000000000000000000000000003"; + const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1); + const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2); + const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3); + + const multisigSigner = new ccc.SignerMultisigCkbReadonly(client, { + publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey], + threshold: 2, + mustMatch: 1, // signer1 is required + }); + + const message = ccc.hashCkb("0x0123456789abcdef"); + multisigSigner.getSignInfo = async () => ({ + message: message, + position: 0, + }); + + const getTx = (signatures: string[]) => + ccc.Transaction.from({ + inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }], + witnesses: [ + ccc.WitnessArgs.from({ + lock: ccc.MultisigCkbWitness.from({ + publicKeyHashes: multisigSigner.multisigInfo.publicKeyHashes, + threshold: 2, + mustMatch: 1, + signatures, + }).toBytes(), + }).toBytes(), + ], + }); + + it("should return 1 when only required signer signed", async () => { + const sig1 = await signer1._signMessage(message); + const tx = getTx([sig1]); + expect(await multisigSigner.getSignaturesCount(tx)).toBe(1); + expect(await multisigSigner.needMoreSignatures(tx)).toBe(true); + }); + + it("should return 1 when only flexible signers signed", async () => { + const sig2 = await signer2._signMessage(message); + const sig3 = await signer3._signMessage(message); + const tx = getTx([sig2, sig3]); + expect(await multisigSigner.getSignaturesCount(tx)).toBe(1); + expect(await multisigSigner.needMoreSignatures(tx)).toBe(true); + }); + + it("should return 2 when required and flexible signers signed", async () => { + const sig1 = await signer1._signMessage(message); + const sig2 = await signer2._signMessage(message); + const tx = getTx([sig1, sig2]); + expect(await multisigSigner.getSignaturesCount(tx)).toBe(2); + expect(await multisigSigner.needMoreSignatures(tx)).toBe(false); + }); + }); + + describe("aggregateTransactions with mustMatch", () => { + const privKey1 = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + const privKey2 = + "0x0000000000000000000000000000000000000000000000000000000000000002"; + const privKey3 = + "0x0000000000000000000000000000000000000000000000000000000000000003"; + const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1); + const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2); + const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3); + + const multisigSigner = new ccc.SignerMultisigCkbReadonly(client, { + publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey], + threshold: 2, + mustMatch: 1, // signer1 is required + }); + + const message = ccc.hashCkb("0x0123456789abcdef"); + multisigSigner.getSignInfo = async () => ({ + message: message, + position: 0, + }); + + it("should aggregate required signatures from different transactions", async () => { + const sig1 = await signer1._signMessage(message); + const sig2 = await signer2._signMessage(message); + const sig3 = await signer3._signMessage(message); + + const tx1 = ccc.Transaction.from({ + inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }], + witnesses: [ + ccc.WitnessArgs.from({ + lock: ccc.MultisigCkbWitness.from({ + publicKeyHashes: multisigSigner.multisigInfo.publicKeyHashes, + threshold: 2, + mustMatch: 1, + signatures: [sig2, sig3], // Missing required + }).toBytes(), + }).toBytes(), + ], + }); + + const tx2 = ccc.Transaction.from({ + inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }], + witnesses: [ + ccc.WitnessArgs.from({ + lock: ccc.MultisigCkbWitness.from({ + publicKeyHashes: multisigSigner.multisigInfo.publicKeyHashes, + threshold: 2, + mustMatch: 1, + signatures: [sig1], // Contains required + }).toBytes(), + }).toBytes(), + ], + }); + + const aggregatedTx = await multisigSigner.aggregateTransactions([ + tx1, + tx2, + ]); + const decodedWitness = multisigSigner.decodeWitnessArgsAt( + aggregatedTx, + 0, + )!; + + const { required, flexible } = + decodedWitness.calcMatchedSignaturesCount(message); + expect(required).toBe(1); + expect(flexible).toBe(1); + expect(decodedWitness.signatures.length).toBe(2); + }); + }); + + it("should throw a dedicated error when sending without enough signatures", async () => { + const witness: ccc.MultisigCkbWitnessLike = { + publicKeys: [ + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa80", + "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa81", + ], + threshold: 2, + mustMatch: 0, + }; + const signer = new ccc.SignerMultisigCkbReadonly(client, witness); + const tx = ccc.Transaction.from({ + inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }], + }); + + signer.signTransaction = async () => tx; + signer.needMoreSignatures = async () => true; + signer.getSignaturesCount = async () => 1; + signer.getMemberThreshold = async () => 2; + + await expect(signer.sendTransaction(tx)).rejects.toMatchObject( + new ccc.SignerMultisigNotEnoughSignaturesError(1, 2), + ); + }); +}); + +describe("SignerMultisigCkbPrivateKey", () => { + const privKey1 = + "0x0000000000000000000000000000000000000000000000000000000000000001"; + const privKey2 = + "0x0000000000000000000000000000000000000000000000000000000000000002"; + const privKey3 = + "0x0000000000000000000000000000000000000000000000000000000000000003"; + const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1); + const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2); + const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3); + + const multisigWitness: ccc.MultisigCkbWitnessLike = { + publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey], + threshold: 2, + mustMatch: 1, // signer1 is required + }; + + const message = ccc.hashCkb("0x0123456789abcdef"); + + it("should replace a flexible signature with a required one if threshold reached", async () => { + const sig2 = await signer2._signMessage(message); + const sig3 = await signer3._signMessage(message); + + const multisigSigner1 = new ccc.SignerMultisigCkbPrivateKey( + client, + privKey1, + multisigWitness, + ); + multisigSigner1.getSignInfo = async () => ({ + message: message, + position: 0, + }); + + const tx = ccc.Transaction.from({ + inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }], + witnesses: [ + ccc.WitnessArgs.from({ + lock: ccc.MultisigCkbWitness.from({ + publicKeyHashes: multisigSigner1.multisigInfo.publicKeyHashes, + threshold: 2, + mustMatch: 1, + signatures: [sig2, sig3], + }).toBytes(), + }).toBytes(), + ], + }); + + const signedTx = await multisigSigner1.signOnlyTransaction(tx); + const decodedWitness = multisigSigner1.decodeWitnessArgsAt(signedTx, 0)!; + + const { required, flexible } = + decodedWitness.calcMatchedSignaturesCount(message); + expect(required).toBe(1); + expect(flexible).toBe(1); + expect(decodedWitness.signatures.length).toBe(2); + }); + + it("should preserve an existing flexible co-signature when the required signer re-signs", async () => { + const sig1 = await signer1._signMessage(message); + const sig2 = await signer2._signMessage(message); + + const multisigSigner1 = new ccc.SignerMultisigCkbPrivateKey( + client, + privKey1, + multisigWitness, + ); + multisigSigner1.getSignInfo = async () => ({ + message: message, + position: 0, + }); + + const tx = ccc.Transaction.from({ + inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }], + witnesses: [ + ccc.WitnessArgs.from({ + lock: ccc.MultisigCkbWitness.from({ + publicKeyHashes: multisigSigner1.multisigInfo.publicKeyHashes, + threshold: 2, + mustMatch: 1, + signatures: [sig1, sig2], + }).toBytes(), + }).toBytes(), + ], + }); + + const signedTx = await multisigSigner1.signOnlyTransaction(tx); + const decodedWitness = multisigSigner1.decodeWitnessArgsAt(signedTx, 0)!; + + const { required, flexible } = + decodedWitness.calcMatchedSignaturesCount(message); + expect(required).toBe(1); + expect(flexible).toBe(1); + expect(decodedWitness.signatures).toEqual([sig1, sig2]); + }); +}); diff --git a/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts b/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts new file mode 100644 index 00000000..03202c7c --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts @@ -0,0 +1,129 @@ +import { SinceLike, Transaction, TransactionLike } from "../../ckb/index.js"; +import { Client, KnownScript, ScriptInfoLike } from "../../client/index.js"; +import { hashCkbShort } from "../../hasher/index.js"; +import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { signMessageSecp256k1 } from "./secp256k1Signing.js"; +import { SignerCkbPrivateKey } from "./signerCkbPrivateKey.js"; +import { + MultisigCkbWitnessLike, + SignerMultisigCkbReadonly, +} from "./signerMultisigCkbReadonly.js"; + +/** + * A class extending Signer that provides access to a CKB multisig script and supports signing operations. + * @public + */ +export class SignerMultisigCkbPrivateKey extends SignerMultisigCkbReadonly { + private readonly privateKey: Hex; + private readonly signer: SignerCkbPrivateKey; + + /** + * Creates an instance of SignerMultisigCkbPrivateKey. + * + * @param client - The client instance. + * @param privateKey - The private key. + * @param multisigInfo - The multisig information. + * @param options - The options. + */ + constructor( + client: Client, + privateKey: HexLike, + multisigInfo: MultisigCkbWitnessLike, + options?: { + since?: SinceLike | null; + scriptInfos?: (KnownScript | ScriptInfoLike)[] | null; + } | null, + ) { + super(client, multisigInfo, options); + + this.privateKey = hexFrom(privateKey); + this.signer = new SignerCkbPrivateKey(client, this.privateKey); + } + + /** + * Sign a transaction only (without preparing). + * + * @param txLike - The transaction to sign. + * @returns The signed transaction. + */ + async signOnlyTransaction(txLike: TransactionLike): Promise { + let tx = Transaction.from(txLike); + + const thisPubkeyHash = hashCkbShort(this.signer.publicKey); + + const index = this.multisigInfo.publicKeyHashes.indexOf(thisPubkeyHash); + if (index === -1) { + return tx; + } + const isSelfRequired = index < this.multisigInfo.mustMatch; + + for (const { script } of await this.scriptInfos) { + const info = await this.getSignInfo(tx, script); + if (!info) { + continue; + } + + // === Find a position for the signature === + tx = await this.prepareWitnessArgsAt( + tx, + info.position, + async (witness) => { + // We re-evaluate the signatures to filter invalid / excessive signatures + const signatures: Hex[] = []; + let requiredCount = 0; + let isSignNeeded = true; + + for (const { + pubkeyHash, + signature, + isRequired, + } of witness.generatePublicKeyHashesFromSignatures(info.message)) { + if (pubkeyHash === thisPubkeyHash) { + if (!isSignNeeded) { + // Has signed and added to the signatures list already. We will not add it again. + continue; + } + isSignNeeded = false; + } + + if (isRequired) { + requiredCount += 1; + } else if ( + signatures.length - requiredCount >= + this.multisigInfo.flexibleThreshold + ) { + // Too many flexible signatures + continue; + } + + signatures.push(signature); + if (signatures.length >= this.multisigInfo.threshold) { + // We have got enough signatures + isSignNeeded = false; + break; + } + } + + if ( + isSignNeeded && + (isSelfRequired || + signatures.length - requiredCount < + this.multisigInfo.flexibleThreshold) + ) { + // Add the signature from this signer only when + // 1. The signature is needed + // 2. It's required or... + // 3. We haven't got enough flexible signatures + signatures.push( + signMessageSecp256k1(info.message, this.privateKey), + ); + } + witness.signatures = signatures; + return witness; + }, + ); + } + + return tx; + } +} diff --git a/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts b/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts new file mode 100644 index 00000000..d6400996 --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts @@ -0,0 +1,713 @@ +import { Address } from "../../address/index.js"; +import { Bytes, bytesConcat, bytesFrom, BytesLike } from "../../bytes/index.js"; +import { + Script, + ScriptLike, + Since, + SinceLike, + Transaction, + TransactionLike, + WitnessArgs, + WitnessArgsLike, +} from "../../ckb/index.js"; +import { + CellDepInfo, + CellDepInfoLike, + Client, + KnownScript, + ScriptInfo, + ScriptInfoLike, +} from "../../client/index.js"; +import { codec, Entity } from "../../codec/index.js"; +import { HASH_CKB_SHORT_LENGTH, hashCkbShort } from "../../hasher/index.js"; +import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { numFrom, NumLike, numToBytes } from "../../num/index.js"; +import { apply, reduceAsync } from "../../utils/index.js"; +import { SignerMultisig, SignerSignType, SignerType } from "../signer/index.js"; +import { + recoverMessageSecp256k1, + SECP256K1_SIGNATURE_LENGTH, +} from "./secp256k1Signing.js"; + +export type MultisigCkbWitnessLike = ( + | { + publicKeyHashes: HexLike[]; + publicKeys?: undefined | null; + } + | { + publicKeyHashes?: undefined | null; + publicKeys: HexLike[]; + } +) & { + threshold: NumLike; + mustMatch?: NumLike | null; + signatures?: HexLike[] | null; +}; + +/** + * A class representing multisig information, holding information ingredients and containing utilities. + * @public + */ +@codec({ + encode: (encodable: MultisigCkbWitness) => { + const { publicKeyHashes, threshold, mustMatch, signatures } = + MultisigCkbWitness.from(encodable); + + if ( + signatures.some((s) => s.length !== SECP256K1_SIGNATURE_LENGTH * 2 + 2) + ) { + throw Error("MultisigCkbWitness: invalid signature length"); + } + if ( + publicKeyHashes.some((s) => s.length !== HASH_CKB_SHORT_LENGTH * 2 + 2) + ) { + throw Error("MultisigCkbWitness: invalid public key hash length"); + } + + return bytesConcat( + "0x00", + numToBytes(mustMatch ?? 0), + numToBytes(threshold), + numToBytes(publicKeyHashes.length), + ...publicKeyHashes, + ...signatures, + ); + }, + decode: (raw: Bytes) => { + if (raw.length < 4) { + throw Error("MultisigCkbWitness: data length too short"); + } + + const [ + _reserved, + mustMatch, + threshold, + publicKeyHashesLength, + ...rawKeyAndSignatures + ] = raw; + + if ( + rawKeyAndSignatures.length < + publicKeyHashesLength * HASH_CKB_SHORT_LENGTH + ) { + throw Error("MultisigCkbWitness: invalid public key hashes length"); + } + + const signatures = rawKeyAndSignatures.slice( + publicKeyHashesLength * HASH_CKB_SHORT_LENGTH, + ); + + return MultisigCkbWitness.from({ + publicKeyHashes: Array.from(new Array(publicKeyHashesLength), (_, i) => + hexFrom( + rawKeyAndSignatures.slice( + i * HASH_CKB_SHORT_LENGTH, + (i + 1) * HASH_CKB_SHORT_LENGTH, + ), + ), + ), + threshold: numFrom(threshold), + mustMatch: numFrom(mustMatch), + signatures: Array.from( + new Array(Math.floor(signatures.length / SECP256K1_SIGNATURE_LENGTH)), + (_, i) => + hexFrom( + signatures.slice( + i * SECP256K1_SIGNATURE_LENGTH, + (i + 1) * SECP256K1_SIGNATURE_LENGTH, + ), + ), + ), + }); + }, +}) +export class MultisigCkbWitness extends Entity.Base< + MultisigCkbWitnessLike, + MultisigCkbWitness +>() { + /** + * @param publicKeyHashes - The public key hashes. + * @param threshold - The threshold. + * @param mustMatch - The number of signatures that must match. + * @param signatures - The signatures. + */ + constructor( + public publicKeyHashes: Hex[], + public threshold: number, + public mustMatch: number, + public signatures: Hex[], + ) { + super(); + + const keysLength = publicKeyHashes.length; + + if (threshold <= 0 || threshold > keysLength) { + throw new Error( + "threshold should be in range from 1 to public keys length", + ); + } + if (mustMatch < 0 || mustMatch > Math.min(keysLength, threshold)) { + throw new Error( + "mustMatch should be in range from 0 to min(public keys length, threshold)", + ); + } + if (keysLength > 255) { + throw new Error("public keys length should be less than 256"); + } + } + + /** + * Create a MultisigCkbWitness from a MultisigCkbWitnessLike. + * + * @param witness - The witness like object. + * @returns The MultisigCkbWitness. + */ + static from(witness: MultisigCkbWitnessLike): MultisigCkbWitness { + const publicKeyHashes = (() => { + if (witness.publicKeyHashes) { + return witness.publicKeyHashes; + } + return witness.publicKeys.map((k) => hashCkbShort(k)); + })(); + + return new MultisigCkbWitness( + publicKeyHashes.map(hexFrom), + Number(numFrom(witness.threshold)), + Number(numFrom(witness.mustMatch ?? 0)), + witness.signatures?.map(hexFrom) ?? [], + ); + } + + /** + * Get the threshold of flexible signatures. + */ + get flexibleThreshold() { + return this.threshold - this.mustMatch; + } + + /** + * Get the script args of the multisig script. + * + * @param since - The since value. + * @returns The script args. + */ + scriptArgs(since?: SinceLike | null): Bytes { + const hash = hashCkbShort( + MultisigCkbWitness.from({ ...this, signatures: [] }).toBytes(), + ); + + if (since != null) { + return bytesConcat(hash, Since.from(since).toBytes()); + } + + return bytesFrom(hash); + } + + /** + * Check if the multisig info is equal to another. + * + * @param otherLike - The other multisig info. + * @returns True if the multisig info is equal, false otherwise. + */ + eqInfo(otherLike: MultisigCkbWitnessLike): boolean { + const other = MultisigCkbWitness.from(otherLike); + return ( + this.publicKeyHashes.length === other.publicKeyHashes.length && + this.publicKeyHashes.every((h, i) => h === other.publicKeyHashes[i]) && + this.threshold === other.threshold && + this.mustMatch === other.mustMatch + ); + } + + /** + * Generate valid public key hashes and their signatures from the witness. + * This method filters out invalid signatures, duplicate signatures, and signatures not in the multisig script. + * + * @param message - The message signed. + * @returns A generator of public key hashes, signatures, and whether the signature is required. + */ + *generatePublicKeyHashesFromSignatures(message: BytesLike): Generator<{ + pubkeyHash: Hex; + signature: Hex; + isRequired: boolean; + }> { + const publicKeyHashesFromSignature = new Set(); + + for (const signature of this.signatures.filter( + (sig) => sig !== SignerMultisigCkbReadonly.EmptySignature, + )) { + const pubkey = (() => { + try { + return recoverMessageSecp256k1(message, signature); + } catch (_) { + // Ignore invalid signature + return; + } + })(); + if (pubkey === undefined) { + continue; + } + + const pubkeyHash = hashCkbShort(pubkey); + if (publicKeyHashesFromSignature.has(pubkeyHash)) { + continue; + } + + const index = this.publicKeyHashes.indexOf(pubkeyHash); + if (index === -1) { + continue; + } + publicKeyHashesFromSignature.add(pubkeyHash); + const isRequired = index < this.mustMatch; + + yield { + pubkeyHash, + signature, + isRequired, + }; + } + } + + /** + * Calculate the number of matched signatures in the witness. + * + * @param message - The message signed. + * @returns The number of required and flexible signatures. + */ + calcMatchedSignaturesCount(message: BytesLike): { + required: number; + flexible: number; + } { + let required = 0; + let flexible = 0; + + for (const { isRequired } of this.generatePublicKeyHashesFromSignatures( + message, + )) { + if (isRequired) { + required += 1; + } else { + flexible += 1; + } + } + + return { required, flexible }; + } +} + +/** + * A class extending Signer that provides access to a CKB multisig script. + * This class does not support signing operations. + * @public + */ +export class SignerMultisigCkbReadonly extends SignerMultisig { + static EmptySignature = hexFrom("00".repeat(SECP256K1_SIGNATURE_LENGTH)); + + get type(): SignerType { + return SignerType.CKB; + } + + get signType(): SignerSignType { + return SignerSignType.Unknown; + } + + public readonly multisigInfo: MultisigCkbWitness; + + public readonly since?: Since; + public readonly scriptInfos: Promise< + { + script: Script; + cellDeps: CellDepInfo[]; + }[] + >; + + /** + * Creates an instance of SignerMultisigCkbReadonly. + * + * @param client - The client instance. + * @param multisigInfoLike - The multisig information. + * @param options - The options. + */ + constructor( + client: Client, + multisigInfoLike: MultisigCkbWitnessLike, + options?: { + since?: SinceLike | null; + scriptInfos?: (KnownScript | ScriptInfoLike)[] | null; + } | null, + ) { + super(client); + + this.multisigInfo = MultisigCkbWitness.from(multisigInfoLike); + this.since = apply(Since.from, options?.since); + + const args = this.multisigInfo.scriptArgs(this.since); + this.scriptInfos = Promise.all( + ( + options?.scriptInfos ?? [ + KnownScript.Secp256k1MultisigV2, + KnownScript.Secp256k1MultisigV2Beta, + KnownScript.Secp256k1Multisig, + ] + ).map(async (v) => { + if (typeof v !== "string") { + return ScriptInfo.from(v); + } + + try { + return await client.getKnownScript(v); + } catch (_) { + return undefined; + } + }), + ).then((infos) => + infos + .filter((s) => s !== undefined) + .map((i) => ({ + script: Script.from({ ...i, args }), + cellDeps: i.cellDeps, + })), + ); + } + + /** + * Get the number of members in the multisig script. + * + * @returns The number of members. + */ + async getMemberCount() { + return this.multisigInfo.publicKeyHashes.length; + } + + /** + * Get the threshold of the multisig script. + * + * @returns The threshold. + */ + async getMemberThreshold() { + return this.multisigInfo.threshold; + } + + /** + * Get the count of required member of the multisig script. + * + * @returns The must match count. + */ + async getMemberRequiredCount() { + return this.multisigInfo.mustMatch; + } + + async connect(): Promise {} + + async isConnected(): Promise { + return true; + } + + async getInternalAddress(): Promise { + return this.getRecommendedAddress(); + } + + async getAddressObjs(): Promise { + return (await this.scriptInfos).map(({ script }) => + Address.fromScript(script, this.client), + ); + } + + /** + * Decode the witness args at a specific index. + * + * @param txLike - The transaction. + * @param index - The index of the witness args. + * @returns The decoded MultisigCkbWitness. + */ + decodeWitnessArgsAt( + txLike: TransactionLike, + index: number, + ): MultisigCkbWitness | undefined { + const tx = Transaction.from(txLike); + + return this.decodeWitnessArgs(tx.getWitnessArgsAt(index)); + } + + /** + * Decode the witness args. + * + * @param witnessLike - The witness args like object. + * @returns The decoded MultisigCkbWitness. + */ + decodeWitnessArgs( + witnessLike?: WitnessArgsLike | null, + ): MultisigCkbWitness | undefined { + if (!witnessLike) { + return; + } + const witness = WitnessArgs.from(witnessLike); + + if (witness.lock == null) { + return; + } + + try { + const decoded = MultisigCkbWitness.decode(witness.lock); + if (decoded.eqInfo(this.multisigInfo)) { + return decoded; + } + } catch (_) { + // Returns undefined for invalid data + } + } + + /** + * Prepare the witness args at a specific index. + * + * @param txLike - The transaction. + * @param index - The index of the witness args. + * @param transformer - The transformer function. + * @returns The prepared transaction. + */ + async prepareWitnessArgsAt( + txLike: TransactionLike, + index: number, + transformer?: + | (( + witness: MultisigCkbWitness, + witnessArgs: WitnessArgs, + ) => + | MultisigCkbWitnessLike + | undefined + | null + | void + | Promise) + | null, + ): Promise { + const tx = Transaction.from(txLike); + + const witnessArgs = tx.getWitnessArgsAt(index) ?? WitnessArgs.from({}); + const multisigWitness = + this.decodeWitnessArgs(witnessArgs) ?? this.multisigInfo.clone(); + + const transformed = MultisigCkbWitness.from( + (await transformer?.(multisigWitness, witnessArgs)) ?? multisigWitness, + ); + + transformed.signatures = transformed.signatures.slice( + 0, + this.multisigInfo.threshold, + ); + transformed.signatures.push( + ...Array.from( + new Array(this.multisigInfo.threshold - transformed.signatures.length), + () => SignerMultisigCkbReadonly.EmptySignature, + ), + ); + + witnessArgs.lock = transformed.toHex(); + tx.setWitnessArgsAt(index, witnessArgs); + + return tx; + } + + /** + * Prepare multisig witness, if the existence of multisig witness is detected, nothing happens + * + * @param txLike - The transaction to prepare. + * @param scriptLike - The script to prepare. + * @returns A promise that resolves to the prepared transaction + */ + async prepareTransactionOneScript( + txLike: TransactionLike, + script: ScriptLike, + cellDeps: CellDepInfoLike[], + ) { + const tx = Transaction.from(txLike); + const position = await tx.findInputIndexByLock(script, this.client); + if (position === undefined) { + return tx; + } + + await tx.addCellDepInfos(this.client, cellDeps); + return this.prepareWitnessArgsAt(tx, position); + } + + /** + * Prepare transaction for multisig witness and adding related cell deps + * + * @param txLike - The transaction to prepare. + * @returns A promise that resolves to the prepared transaction + */ + async prepareTransaction(txLike: TransactionLike): Promise { + return await reduceAsync( + await this.scriptInfos, + (tx, { script, cellDeps }) => + this.prepareTransactionOneScript(tx, script, cellDeps), + Transaction.from(txLike), + ); + } + + /** + * Get the number of valid signatures for matching multisig inputs in the transaction. + * + * @remarks + * Returns `undefined` when the transaction has no inputs locked by any multisig address + * supported by this signer. This method only counts signatures for matching multisig inputs + * and does not imply that the transaction should be signed by this multisig. + * + * @param txLike - The transaction. + * @returns The matched multisig signature count, or `undefined` when the transaction is unrelated to any multisig address supported by this signer. + */ + async getSignaturesCount( + txLike: TransactionLike, + ): Promise { + const tx = Transaction.from(txLike); + let minSignaturesCount = undefined; + + for (const { script } of await this.scriptInfos) { + const info = await this.getSignInfo(tx, script); + if (info === undefined) { + continue; + } + + const multisigWitness = this.decodeWitnessArgsAt(tx, info.position); + if (!multisigWitness) { + minSignaturesCount = 0; + continue; + } + + const { required, flexible } = multisigWitness.calcMatchedSignaturesCount( + info.message, + ); + + minSignaturesCount = Math.min( + minSignaturesCount ?? 256, + required + Math.min(flexible, this.multisigInfo.flexibleThreshold), + ); + } + + return minSignaturesCount; + } + + /** + * Check if related multisig inputs in the transaction need more signatures. + * + * @remarks + * Returns `false` when the transaction has no inputs locked by any multisig address + * supported by this signer. + * A `false` result therefore means either the related multisig inputs are already fulfilled, + * or the transaction is unrelated to all multisig addresses supported by this signer. + * + * @param txLike - The transaction to check. + * @returns A promise that resolves to `true` when related multisig inputs still need signatures, and `false` otherwise. + */ + async needMoreSignatures(txLike: TransactionLike): Promise { + const count = await this.getSignaturesCount(txLike); + if (count == null) { + return false; + } + return count < (await this.getMemberThreshold()); + } + + /** + * Get the sign info for a script. + * + * @param txLike - The transaction. + * @param script - The script. + * @returns The sign info. + */ + async getSignInfo( + txLike: TransactionLike, + script: ScriptLike, + ): Promise<{ message: Hex; position: number } | undefined> { + const tx = Transaction.from(txLike); + + const position = await tx.findInputIndexByLock(script, this.client); + if (position == null) { + return; + } + + // === Replace the witness with a dummy one === + const witness = tx.getWitnessArgsAt(position) ?? WitnessArgs.from({}); + witness.lock = MultisigCkbWitness.from({ + ...this.multisigInfo, + signatures: Array.from( + new Array(this.multisigInfo.threshold), + () => SignerMultisigCkbReadonly.EmptySignature, + ), + }).toHex(); + + const clonedTx = tx.clone(); + clonedTx.setWitnessArgsAt(position, witness); + // === Replace the witness with a dummy one === + + return clonedTx.getSignHashInfo(script, this.client); + } + + /** + * Aggregate transactions. + * + * @param txs - The transactions to aggregate. + * @returns The aggregated transaction. + */ + async aggregateTransactions(txs: TransactionLike[]): Promise { + if (txs.length === 0) { + throw Error("No transaction to aggregate"); + } + + let res = Transaction.from(txs[0]); + + for (const { script } of await this.scriptInfos) { + const info = await this.getSignInfo(res, script); + if (info === undefined) { + continue; + } + + const signatures = new Map(); + let requiredCount = 0; + for (const txLike of txs) { + const tx = Transaction.from(txLike); + const multisigWitness = this.decodeWitnessArgsAt(tx, info.position); + + if (!multisigWitness) { + continue; + } + + for (const { + pubkeyHash, + signature, + isRequired, + } of multisigWitness.generatePublicKeyHashesFromSignatures( + info.message, + )) { + if (signatures.has(pubkeyHash)) { + continue; + } + + if (isRequired) { + // A required public key + requiredCount += 1; + } else if ( + signatures.size - requiredCount >= + this.multisigInfo.flexibleThreshold + ) { + // Not a required public key, and we have too many optional public key + continue; + } + + signatures.set(pubkeyHash, signature); + if (signatures.size >= this.multisigInfo.threshold) { + break; + } + } + + if (signatures.size >= this.multisigInfo.threshold) { + break; + } + } + + res = await this.prepareWitnessArgsAt(res, info.position, (witness) => { + witness.signatures = Array.from(signatures.values()); + }); + } + + return res; + } +} diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index ff2a19d1..b65994ed 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -491,6 +491,105 @@ export abstract class Signer { } } +/** + * An abstract class representing a multisig signer. + * @public + */ +export abstract class SignerMultisig extends Signer { + /** + * Get the number of members in the multisig script. + * @returns The number of members. + */ + abstract getMemberCount(): Promise; + + /** + * Get the threshold of the multisig script. + * @returns The threshold. + */ + abstract getMemberThreshold(): Promise; + + /** + * Get the count of required member of the multisig script. + * @returns The must match count. + */ + abstract getMemberRequiredCount(): Promise; + + /** + * Get the number of valid signatures for matching multisig inputs in the transaction. + * + * @remarks + * Returns `undefined` when the transaction has no inputs locked by any multisig address + * supported by this signer. This method only evaluates matching multisig inputs and does + * not indicate whether the transaction itself is expected to be signed by this multisig. + * + * @param _ - The transaction. + * @returns The matched multisig signature count, or `undefined` when the transaction is unrelated to any multisig address supported by this signer. + */ + abstract getSignaturesCount(_: TransactionLike): Promise; + + /** + * Check if related multisig inputs in the transaction need more signatures. + * + * @remarks + * Returns `false` when the transaction has no inputs locked by any multisig address + * supported by this signer. + * A `false` result therefore means either the related multisig inputs are already fulfilled, + * or the transaction is unrelated to all multisig addresses supported by this signer. + * + * @param txLike - The transaction to check. + * @returns A promise that resolves to `true` when related multisig inputs still need signatures, and `false` otherwise. + */ + abstract needMoreSignatures(_: TransactionLike): Promise; + + /** + * Aggregate transactions. + * @param _ - The transactions to aggregate. + * @returns The aggregated transaction. + */ + abstract aggregateTransactions(_: TransactionLike[]): Promise; + + /** + * Send a transaction. + * + * @remarks + * This method rejects the transaction only when related multisig inputs still need signatures. + * It does not verify that the transaction actually contains inputs locked by any multisig + * address supported by this signer, so transactions unrelated to those multisig addresses + * are not rejected by this check. + * + * @param tx - The transaction to send. + * @returns The transaction hash. + */ + async sendTransaction(tx: TransactionLike): Promise { + const signedTx = await this.signTransaction(tx); + if (await this.needMoreSignatures(signedTx)) { + const count = (await this.getSignaturesCount(signedTx)) ?? 0; + const threshold = await this.getMemberThreshold(); + throw new SignerMultisigNotEnoughSignaturesError(count, threshold); + } + + return this.client.sendTransaction(signedTx); + } +} + +/** + * Thrown when a multisig transaction is sent before enough signatures are collected. + * + * @public + */ +export class SignerMultisigNotEnoughSignaturesError extends Error { + readonly signaturesCount: number; + readonly threshold: number; + + constructor(signaturesCount: number, threshold: number) { + const message = `Not enough signatures: got ${signaturesCount}, need ${threshold}`; + super(message); + this.name = "SignerMultisigNotEnoughSignaturesError"; + this.signaturesCount = signaturesCount; + this.threshold = threshold; + } +} + /** * A class representing information about a signer, including its type and the signer instance. * @public diff --git a/packages/examples/src/transferFromMultisig.ts b/packages/examples/src/transferFromMultisig.ts new file mode 100644 index 00000000..30d68079 --- /dev/null +++ b/packages/examples/src/transferFromMultisig.ts @@ -0,0 +1,56 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// === Prepare multisig signer === + +const signers = [ + "0x2c56a92a03d767542222432e4f2a0584f01e516311f705041d86b1af7573751f", + "0x3bc65932a75f76c5b6a04660e4d0b85c2d9b5114efa78e6e5cf7ad0588ca09c8", + "0xbe06025fbd8c74f65a513a28e62ac56f3227fcb307307a0f2a0ef34d4a66e81f", +].map((key) => new ccc.SignerCkbPrivateKey(signer.client, key)); + +const publicKeys = signers.map((signer) => signer.publicKey); + +const multisigSigners = signers.map( + (signer) => + new ccc.SignerMultisigCkbPrivateKey(signer.client, signer.privateKey, { + publicKeys: publicKeys, + threshold: 2, + mustMatch: 0, + }), +); + +// === Transfer From Multisig === + +const { script: lock } = await signer.getRecommendedAddressObj(); +let tx = ccc.Transaction.from({ + outputs: [{ capacity: ccc.fixedPointFrom(200), lock }], +}); +await tx.completeFeeBy(multisigSigners[0]); +await render(tx); + +for (const multisigSigner of multisigSigners) { + if (!(await multisigSigner.needMoreSignatures(tx))) { + break; + } + + tx = await multisigSigner.signTransaction(tx); + + const signaturesCount = await multisigSigner.getSignaturesCount(tx); + if (signaturesCount == null) { + console.log( + `Need ${await multisigSigner.getMemberThreshold()} signatures, ${await multisigSigner.getMemberCount()} members in total`, + ); + } else { + console.log( + `${signaturesCount}/${await multisigSigner.getMemberCount()} signers signed, need ${(await multisigSigner.getMemberThreshold()) - signaturesCount} more`, + ); + } +} + +if (await multisigSigners[0].needMoreSignatures(tx)) { + console.log("No enough signatures"); +} else { + const txHash = await signer.client.sendTransaction(tx); + console.log(`Transaction ${txHash} sent`); +} diff --git a/packages/examples/src/transferFromMultisigAggregateTxs.ts b/packages/examples/src/transferFromMultisigAggregateTxs.ts new file mode 100644 index 00000000..e7f47ad0 --- /dev/null +++ b/packages/examples/src/transferFromMultisigAggregateTxs.ts @@ -0,0 +1,44 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// === Prepare multisig signer === + +const signers = [ + "0x2c56a92a03d767542222432e4f2a0584f01e516311f705041d86b1af7573751f", + "0x3bc65932a75f76c5b6a04660e4d0b85c2d9b5114efa78e6e5cf7ad0588ca09c8", + "0xbe06025fbd8c74f65a513a28e62ac56f3227fcb307307a0f2a0ef34d4a66e81f", +].map((key) => new ccc.SignerCkbPrivateKey(signer.client, key)); + +const publicKeys = signers.map((signer) => signer.publicKey); + +const multisigSigners = signers.map( + (signer) => + new ccc.SignerMultisigCkbPrivateKey(signer.client, signer.privateKey, { + publicKeys: publicKeys, + threshold: 2, + mustMatch: 0, + }), +); + +// === Sign Transactions === + +const { script: lock } = await signer.getRecommendedAddressObj(); +const tx = ccc.Transaction.from({ + outputs: [{ capacity: ccc.fixedPointFrom(200), lock }], +}); +await tx.completeFeeBy(multisigSigners[0]); +await render(tx); + +const collectedTxs = []; +for (const multisigSigner of multisigSigners) { + collectedTxs.push(await multisigSigner.signTransaction(tx.clone())); +} + +const aggregatedTx = + await multisigSigners[0].aggregateTransactions(collectedTxs); +console.log( + `${await multisigSigners[0].getSignaturesCount(aggregatedTx)} signatures aggregated`, +); + +const txHash = await signer.client.sendTransaction(aggregatedTx); +console.log(`Transaction ${txHash} sent`); diff --git a/packages/examples/src/transferToMultisig.ts b/packages/examples/src/transferToMultisig.ts new file mode 100644 index 00000000..71c9c02c --- /dev/null +++ b/packages/examples/src/transferToMultisig.ts @@ -0,0 +1,35 @@ +import { ccc } from "@ckb-ccc/ccc"; +import { render, signer } from "@ckb-ccc/playground"; + +// === Prepare multisig signer === + +const signers = [ + "0x2c56a92a03d767542222432e4f2a0584f01e516311f705041d86b1af7573751f", + "0x3bc65932a75f76c5b6a04660e4d0b85c2d9b5114efa78e6e5cf7ad0588ca09c8", + "0xbe06025fbd8c74f65a513a28e62ac56f3227fcb307307a0f2a0ef34d4a66e81f", +].map((key) => new ccc.SignerCkbPrivateKey(signer.client, key)); + +const publicKeys = signers.map((signer) => signer.publicKey); +const multisigSigner = new ccc.SignerMultisigCkbReadonly(signer.client, { + publicKeys: publicKeys, + threshold: 2, + mustMatch: 0, +}); + +// === Transfer To Multisig === + +// Check the multisig address +const multisigAddress = await multisigSigner.getRecommendedAddressObj(); +console.log("Multisig address:", multisigAddress.toString()); + +// Create a transaction to transfer 1000 CKB to the multisig address +const tx = ccc.Transaction.from({ + outputs: [ + { capacity: ccc.fixedPointFrom(1000), lock: multisigAddress.script }, + ], +}); +await tx.completeFeeBy(signer); +await render(tx); + +const txHash = await signer.sendTransaction(tx); +console.log(`Transaction ${txHash} sent`);