diff --git a/.changeset/large-icons-dream.md b/.changeset/large-icons-dream.md new file mode 100644 index 000000000..051d984dd --- /dev/null +++ b/.changeset/large-icons-dream.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +Add SignerCkbMultisig module for simplifying multisig transaction construction + \ No newline at end of file diff --git a/.changeset/stale-clowns-kiss.md b/.changeset/stale-clowns-kiss.md new file mode 100644 index 000000000..fd52f8cc1 --- /dev/null +++ b/.changeset/stale-clowns-kiss.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/examples": patch +--- + +Add examples for new signer of SignerCkbMultisig + \ No newline at end of file diff --git a/packages/core/src/ckb/script.ts b/packages/core/src/ckb/script.ts index 1de3c7ac0..6890f8308 100644 --- a/packages/core/src/ckb/script.ts +++ b/packages/core/src/ckb/script.ts @@ -96,7 +96,6 @@ export function hashTypeToBytes(hashType: HashTypeLike): Bytes { export function hashTypeFromBytes(bytes: BytesLike): HashType { return NUM_TO_HASH_TYPE[bytesFrom(bytes)[0]]; } - /** * @public */ @@ -223,12 +222,6 @@ export class Script extends mol.Entity.Base() { const script = await client.getKnownScript(knownScript); return new Script(script.codeHash, script.hashType, hexFrom(args)); } - - /** - * Converts the Script instance to molecule data format. - * - * @returns An object representing the script in molecule data format. - */ } export const ScriptOpt = mol.option(Script); diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index 87de6a687..48e6f0f42 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -52,6 +52,10 @@ export abstract class Client { abstract getKnownScript(script: KnownScript): Promise; + abstract findKnownScript( + scriptLike: ScriptLike, + ): Promise; + abstract getFeeRateStatistics( blockRange?: NumLike, ): Promise<{ mean?: Num; median?: Num }>; diff --git a/packages/core/src/client/clientPublicMainnet.ts b/packages/core/src/client/clientPublicMainnet.ts index 27a3271e8..4dc94b8cb 100644 --- a/packages/core/src/client/clientPublicMainnet.ts +++ b/packages/core/src/client/clientPublicMainnet.ts @@ -1,4 +1,5 @@ import WebSocket from "isomorphic-ws"; +import { Script, ScriptLike } from "../index.js"; import { MAINNET_SCRIPTS } from "./clientPublicMainnet.advanced.js"; import { ScriptInfo, ScriptInfoLike } from "./clientTypes.js"; import { ClientJsonRpc, ClientJsonRpcConfig } from "./jsonRpc/index.js"; @@ -52,4 +53,16 @@ export class ClientPublicMainnet extends ClientJsonRpc { } return ScriptInfo.from(found); } + + async findKnownScript( + scriptLike: ScriptLike, + ): Promise { + const script = Script.from(scriptLike); + const scriptInfo = Object.values(this.scripts).find( + (scriptInfo) => + scriptInfo?.codeHash === script.codeHash && + scriptInfo.hashType === script.hashType, + ); + return scriptInfo ? ScriptInfo.from(scriptInfo) : undefined; + } } diff --git a/packages/core/src/client/clientPublicTestnet.ts b/packages/core/src/client/clientPublicTestnet.ts index 3e3aed174..49d5cd30b 100644 --- a/packages/core/src/client/clientPublicTestnet.ts +++ b/packages/core/src/client/clientPublicTestnet.ts @@ -1,4 +1,5 @@ import WebSocket from "isomorphic-ws"; +import { Script, ScriptLike } from "../index.js"; import { TESTNET_SCRIPTS } from "./clientPublicTestnet.advanced.js"; import { ScriptInfo, ScriptInfoLike } from "./clientTypes.js"; import { ClientJsonRpc, ClientJsonRpcConfig } from "./jsonRpc/index.js"; @@ -52,4 +53,16 @@ export class ClientPublicTestnet extends ClientJsonRpc { } return ScriptInfo.from(found); } + + async findKnownScript( + scriptLike: ScriptLike, + ): Promise { + const script = Script.from(scriptLike); + const scriptInfo = Object.values(this.scripts).find( + (scriptInfo) => + scriptInfo?.codeHash === script.codeHash && + scriptInfo.hashType === script.hashType, + ); + return scriptInfo ? ScriptInfo.from(scriptInfo) : undefined; + } } diff --git a/packages/core/src/hex/index.ts b/packages/core/src/hex/index.ts index 41df43d76..ef4dc93e2 100644 --- a/packages/core/src/hex/index.ts +++ b/packages/core/src/hex/index.ts @@ -1,4 +1,4 @@ -import { bytesFrom, BytesLike, bytesTo } from "../bytes/index.js"; +import { bytesConcat, bytesFrom, BytesLike, bytesTo } from "../bytes/index.js"; /** * Represents a hexadecimal string prefixed with "0x". @@ -28,3 +28,19 @@ export type HexLike = BytesLike; export function hexFrom(hex: HexLike): Hex { return `0x${bytesTo(bytesFrom(hex), "hex")}`; } + +/** + * Concatenates multiple HexLike values into a single Hex string. + * @public + * + * @param hexLikes - The HexLike values to concatenate. + * @returns A Hex string representing the concatenated values. + * + * @example + * ```typescript + * const hexString = hexConcat("0x68656c6c6f", "0x776f726c64"); // Outputs "0x68656c6c6f776f726c64" + * ``` + */ +export function hexConcat(...hexLikes: HexLike[]): Hex { + return hexFrom(bytesConcat(...hexLikes)); +} diff --git a/packages/core/src/signer/ckb/index.ts b/packages/core/src/signer/ckb/index.ts index 61cec4a3a..9bedc28a1 100644 --- a/packages/core/src/signer/ckb/index.ts +++ b/packages/core/src/signer/ckb/index.ts @@ -1,3 +1,5 @@ +export * from "./signerCkbMultisig.js"; +export * from "./signerCkbMultisigReadonly.js"; export * from "./signerCkbPrivateKey.js"; export * from "./signerCkbPublicKey.js"; export * from "./signerCkbScriptReadonly.js"; diff --git a/packages/core/src/signer/ckb/signerCkbMultisig.ts b/packages/core/src/signer/ckb/signerCkbMultisig.ts new file mode 100644 index 000000000..e1c950ed3 --- /dev/null +++ b/packages/core/src/signer/ckb/signerCkbMultisig.ts @@ -0,0 +1,92 @@ +import { Transaction, TransactionLike } from "../../ckb/index.js"; +import { Client } from "../../client/index.js"; +import { hexConcat, hexFrom, HexLike } from "../../hex/index.js"; +import { + MultisigInfoLike, + SignerCkbMultisigReadonly, +} from "./signerCkbMultisigReadonly.js"; +import { SignerCkbPrivateKey } from "./signerCkbPrivateKey.js"; + +/** + * A class extending Signer that provides access to a CKB multisig script and supports signing operations. + * @public + */ +export class SignerCkbMultisig extends SignerCkbMultisigReadonly { + public readonly signer: SignerCkbPrivateKey; + + constructor( + client: Client, + privateKey: HexLike, + multisigInfoLike: MultisigInfoLike, + ) { + super(client, multisigInfoLike); + this.signer = new SignerCkbPrivateKey(client, privateKey); + } + + async signOnlyTransaction(txLike: TransactionLike): Promise { + let lastIndex = -1; + let tx = Transaction.from(txLike); + const emptySignature = hexFrom(Array.from(new Array(65), () => 0)); + + for (const { script } of await this.getRelatedScripts(tx)) { + const index = await tx.findInputIndexByLock(script, this.client); + if (index === undefined) { + return tx; + } + if (index === lastIndex) { + continue; + } else { + lastIndex = index; + } + + let witness = tx.getWitnessArgsAt(index); + if (!witness || !witness.lock?.startsWith(this.multisigInfo.metadata)) { + tx = await this.prepareTransaction(tx); + } + + witness = tx.getWitnessArgsAt(index); + if (!witness || !witness.lock) { + throw new Error("Multisig witness not prepared"); + } + + // Signatures array is placed after the multisig metadata, in 65 bytes per signature + const signatures = + witness.lock + .slice(this.multisigInfo.metadata.length) + .match(/.{1,130}/g) + ?.map(hexFrom) || []; + const insertIndex = signatures.findIndex((sig) => sig === emptySignature); + if (signatures.length !== this.multisigInfo.threshold) { + throw new Error( + `Not enough signature slots to threshold (${signatures.length}/${this.multisigInfo.threshold})`, + ); + } + if (insertIndex === -1) { + // Signatures have been filled + continue; + } + + // Empty multisig witness for current signing + witness.lock = hexConcat( + this.multisigInfo.metadata, + ...Array.from( + new Array(this.multisigInfo.threshold), + () => emptySignature, + ), + ); + tx.setWitnessArgsAt(index, witness); + const info = await tx.getSignHashInfo(script, this.client); + if (!info) { + continue; + } + + const signature = await this.signer._signMessage(info.message); + signatures[insertIndex] = signature; + + witness.lock = hexConcat(this.multisigInfo.metadata, ...signatures); + tx.setWitnessArgsAt(index, witness); + } + + return tx; + } +} diff --git a/packages/core/src/signer/ckb/signerCkbMultisigReadonly.ts b/packages/core/src/signer/ckb/signerCkbMultisigReadonly.ts new file mode 100644 index 000000000..0c7b4a3df --- /dev/null +++ b/packages/core/src/signer/ckb/signerCkbMultisigReadonly.ts @@ -0,0 +1,353 @@ +import { Address } from "../../address/index.js"; +import { + Script, + SinceLike, + Transaction, + TransactionLike, +} from "../../ckb/index.js"; +import { CellDepInfo, Client, KnownScript } from "../../client/index.js"; +import { Hex, hexConcat, hexFrom, HexLike } from "../../hex/index.js"; +import { + hashCkb, + numToBytes, + ScriptInfoLike, + ScriptLike, + Since, + WitnessArgs, +} from "../../index.js"; +import { Signer, SignerSignType, SignerType } from "../signer/index.js"; + +export type MultisigInfoLike = { + pubkeys: HexLike[]; + threshold: number; + mustMatch: number; + since?: SinceLike; + multisigScript?: + | ScriptInfoLike + | KnownScript.Secp256k1Multisig + | KnownScript.Secp256k1MultisigV2; +}; + +/** + * A class representing multisig information, holding information ingredients and containing utilities. + * @public + */ +export class MultisigInfo { + public readonly pubkeys: Hex[]; + public readonly threshold: number; + public readonly mustMatch: number; + public readonly since?: Since; + public readonly knownMultisigScript: + | ScriptInfoLike + | KnownScript.Secp256k1MultisigV2 + | KnownScript.Secp256k1Multisig; + + public readonly pubkeyBlake160Hashes: Hex[]; + public readonly metadata: Hex; + + private constructor(multisig: MultisigInfoLike) { + this.pubkeys = multisig.pubkeys.map(hexFrom); + this.threshold = multisig.threshold; + this.mustMatch = multisig.mustMatch; + this.since = multisig.since ? Since.from(multisig.since) : undefined; + this.knownMultisigScript = + multisig.multisigScript ?? KnownScript.Secp256k1MultisigV2; + if ( + typeof this.knownMultisigScript === "string" && + this.knownMultisigScript !== KnownScript.Secp256k1MultisigV2 + ) { + console.warn( + `Multisig script '${this.knownMultisigScript}' is marked as **Deprecated**, please using '${KnownScript.Secp256k1MultisigV2}' instead`, + ); + } + if (this.threshold < 0 || this.threshold > 255) { + throw new Error("`threshold` must be positive and less than 256!"); + } + if (this.mustMatch < 0 || this.mustMatch > 255) { + throw new Error("`mustMatch` must be positive and less than 256!"); + } + if (this.mustMatch > this.threshold) { + throw new Error("`mustMatch` must be less than or equal to `threshold`!"); + } + if ( + this.pubkeys.length < this.mustMatch || + this.pubkeys.length < this.threshold || + this.pubkeys.length > 255 + ) { + throw new Error( + "length of `pubkeys` must be greater than or equal to `mustMatch` and `threshold` and less than 256!", + ); + } + this.pubkeyBlake160Hashes = this.pubkeys.map( + (pubkey) => hashCkb(hexFrom(pubkey)).slice(0, 42) as Hex, + ); + this.metadata = hexConcat( + "0x00", + hexFrom(numToBytes(this.mustMatch)), + hexFrom(numToBytes(this.threshold)), + hexFrom(numToBytes(this.pubkeyBlake160Hashes.length)), + ...this.pubkeyBlake160Hashes, + ); + } + + static from(multisig: MultisigInfoLike): MultisigInfo { + return new MultisigInfo(multisig); + } + + multisigScriptArgs(): Hex { + const metadataBlake160Hash = hashCkb(this.metadata).slice(0, 42) as Hex; + if (this.since) { + const sinceBytes = this.since.toBytes(); + return hexConcat(metadataBlake160Hash, hexFrom(sinceBytes)); + } else { + return metadataBlake160Hash; + } + } + + async defaultMultisigScript(client: Client): Promise