Skip to content
6 changes: 6 additions & 0 deletions .changeset/large-icons-dream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ckb-ccc/core": patch
---

Add SignerCkbMultisig module for simplifying multisig transaction construction

6 changes: 6 additions & 0 deletions .changeset/stale-clowns-kiss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@ckb-ccc/examples": patch
---

Add examples for new signer of SignerCkbMultisig

7 changes: 0 additions & 7 deletions packages/core/src/ckb/script.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -223,12 +222,6 @@ export class Script extends mol.Entity.Base<ScriptLike, Script>() {
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);
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ export abstract class Client {

abstract getKnownScript(script: KnownScript): Promise<ScriptInfo>;

abstract findKnownScript(
scriptLike: ScriptLike,
): Promise<ScriptInfo | undefined>;

abstract getFeeRateStatistics(
blockRange?: NumLike,
): Promise<{ mean?: Num; median?: Num }>;
Expand Down
13 changes: 13 additions & 0 deletions packages/core/src/client/clientPublicMainnet.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -52,4 +53,16 @@ export class ClientPublicMainnet extends ClientJsonRpc {
}
return ScriptInfo.from(found);
}

async findKnownScript(
scriptLike: ScriptLike,
): Promise<ScriptInfo | undefined> {
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;
}
}
13 changes: 13 additions & 0 deletions packages/core/src/client/clientPublicTestnet.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -52,4 +53,16 @@ export class ClientPublicTestnet extends ClientJsonRpc {
}
return ScriptInfo.from(found);
}

async findKnownScript(
scriptLike: ScriptLike,
): Promise<ScriptInfo | undefined> {
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;
}
}
18 changes: 17 additions & 1 deletion packages/core/src/hex/index.ts
Original file line number Diff line number Diff line change
@@ -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".
Expand Down Expand Up @@ -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));
}
2 changes: 2 additions & 0 deletions packages/core/src/signer/ckb/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export * from "./signerCkbMultisig.js";
export * from "./signerCkbMultisigReadonly.js";
export * from "./signerCkbPrivateKey.js";
export * from "./signerCkbPublicKey.js";
export * from "./signerCkbScriptReadonly.js";
Expand Down
92 changes: 92 additions & 0 deletions packages/core/src/signer/ckb/signerCkbMultisig.ts
Original file line number Diff line number Diff line change
@@ -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<Transaction> {
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;
}
}
Loading