Skip to content

Commit 525e81b

Browse files
committed
feat(core): multisig Signers
1 parent 3c14ec6 commit 525e81b

16 files changed

Lines changed: 1098 additions & 13 deletions

.changeset/little-zebras-pump.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@ckb-ccc/core": minor
3+
---
4+
5+
feat(core): multisig Signers
6+

packages/core/src/client/clientPublicMainnet.advanced.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ export const MAINNET_SCRIPTS: Record<KnownScript, ScriptInfoLike | undefined> =
5454
},
5555
],
5656
},
57+
[KnownScript.Secp256k1MultisigV2Beta]: {
58+
codeHash:
59+
"0xd1a9f877aed3f5e07cb9c52b61ab96d06f250ae6883cc7f0a2423db0976fc821",
60+
hashType: "type",
61+
cellDeps: [
62+
{
63+
cellDep: {
64+
outPoint: {
65+
txHash:
66+
"0x44be4f4feda80c0e41783ab10e191df3b2bb5c3731b0970c916dbec385dcdc60",
67+
index: 0,
68+
},
69+
depType: "depGroup",
70+
},
71+
},
72+
],
73+
},
5774
[KnownScript.Secp256k1MultisigV2]: {
5875
codeHash:
5976
"0x36c971b8d41fbd94aabca77dc75e826729ac98447b46f91e00796155dddb0d29",

packages/core/src/client/clientPublicTestnet.advanced.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,23 @@ export const TESTNET_SCRIPTS: Record<KnownScript, ScriptInfoLike> =
5454
},
5555
],
5656
},
57+
[KnownScript.Secp256k1MultisigV2Beta]: {
58+
codeHash:
59+
"0x765b3ed6ae264b335d07e73ac332bf2c0f38f8d3340ed521cb447b4c42dd5f09",
60+
hashType: "type",
61+
cellDeps: [
62+
{
63+
cellDep: {
64+
outPoint: {
65+
txHash:
66+
"0xf2013f123b2cb745e3fdf5c935a3925647496f88090503eef58332a9245b4172",
67+
index: 0,
68+
},
69+
depType: "depGroup",
70+
},
71+
},
72+
],
73+
},
5774
[KnownScript.Secp256k1MultisigV2]: {
5875
codeHash:
5976
"0x36c971b8d41fbd94aabca77dc75e826729ac98447b46f91e00796155dddb0d29",

packages/core/src/client/knownScript.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export enum KnownScript {
55
NervosDao = "NervosDao",
66
Secp256k1Blake160 = "Secp256k1Blake160",
77
Secp256k1Multisig = "Secp256k1Multisig",
8+
Secp256k1MultisigV2Beta = "Secp256k1MultisigV2Beta", // Fix rare failing case (https://github.com/nervosnetwork/ckb-system-scripts/pull/98)
89
Secp256k1MultisigV2 = "Secp256k1MultisigV2", // Enhanced since handling (https://github.com/nervosnetwork/ckb-system-scripts/pull/99)
910
AnyoneCanPay = "AnyoneCanPay",
1011
TypeId = "TypeId",

packages/core/src/hasher/hasherCkb.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ import { Hex, hexFrom } from "../hex/index.js";
44
import { CKB_BLAKE2B_PERSONAL } from "./advanced.js";
55
import { Hasher } from "./hasher.js";
66

7+
export const HASH_CKB_LENGTH = 32;
8+
export const HASH_CKB_SHORT_LENGTH = 20;
9+
710
/**
811
* @public
912
*/
@@ -73,7 +76,6 @@ export class HasherCkb implements Hasher {
7376
* const hash = hashCkb("some data"); // Outputs something like "0x..."
7477
* ```
7578
*/
76-
7779
export function hashCkb(...data: BytesLike[]): Hex {
7880
const hasher = new HasherCkb();
7981
data.forEach((d) => hasher.update(d));
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
export * from "./signerCkbPrivateKey.js";
22
export * from "./signerCkbPublicKey.js";
33
export * from "./signerCkbScriptReadonly.js";
4+
export * from "./signerMultisigCkbPrivateKey.js";
5+
export * from "./signerMultisigCkbReadonly.js";
46
export * from "./verifyCkbSecp256k1.js";
57
export * from "./verifyJoyId.js";
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { secp256k1 } from "@noble/curves/secp256k1.js";
2+
import { describe, expect, it } from "vitest";
3+
import { ccc } from "../../index.js";
4+
import {
5+
recoverMessageSecp256k1,
6+
signMessageSecp256k1,
7+
verifyMessageSecp256k1,
8+
} from "./signerCkbPrivateKey.js";
9+
10+
describe("Secp256k1 Helpers", () => {
11+
const privateKey =
12+
"0x0123456789012345678901234567890123456789012345678901234567890123";
13+
const publicKey = ccc.hexFrom(
14+
secp256k1.getPublicKey(ccc.bytesFrom(privateKey), true),
15+
);
16+
const messageHash =
17+
"0x1234567890123456789012345678901234567890123456789012345678901234";
18+
19+
it("should verifies a message", () => {
20+
const isValid = verifyMessageSecp256k1(
21+
messageHash,
22+
"0xf71fd3e5b90289fa939bd3f3c0e263e8ea8e37550417344e58c9b1675084be456c506a30789a6ec98919e5458b3898199b560a41d5262cb18db37058cff339a300",
23+
publicKey,
24+
);
25+
expect(isValid).toBe(true);
26+
});
27+
28+
it("should sign and verify a message hash", () => {
29+
const signature = signMessageSecp256k1(messageHash, privateKey);
30+
const isValid = verifyMessageSecp256k1(messageHash, signature, publicKey);
31+
expect(isValid).toBe(true);
32+
});
33+
34+
it("should recover the public key from the signature", () => {
35+
const signature = signMessageSecp256k1(messageHash, privateKey);
36+
const recovered = recoverMessageSecp256k1(messageHash, signature);
37+
expect(recovered).toBe(publicKey);
38+
});
39+
40+
it("should fail verification with wrong message", () => {
41+
const signature = signMessageSecp256k1(messageHash, privateKey);
42+
const wrongMessage =
43+
"0x0000000000000000000000000000000000000000000000000000000000000000";
44+
const isValid = verifyMessageSecp256k1(wrongMessage, signature, publicKey);
45+
expect(isValid).toBe(false);
46+
});
47+
});

packages/core/src/signer/ckb/signerCkbPrivateKey.ts

Lines changed: 67 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,72 @@ import { Hex, hexFrom, HexLike } from "../../hex/index.js";
66
import { SignerCkbPublicKey } from "./signerCkbPublicKey.js";
77
import { messageHashCkbSecp256k1 } from "./verifyCkbSecp256k1.js";
88

9+
export const SECP256K1_SIGNATURE_LENGTH = 65;
10+
11+
/**
12+
* Sign a message using Secp256k1.
13+
*
14+
* @param message - The message to sign.
15+
* @param privateKey - The private key.
16+
* @returns The signature.
17+
* @public
18+
*/
19+
export function signMessageSecp256k1(
20+
message: HexLike,
21+
privateKey: BytesLike,
22+
): Hex {
23+
const signature = secp256k1.sign(bytesFrom(message), bytesFrom(privateKey), {
24+
format: "recovered",
25+
prehash: false,
26+
});
27+
return hexFrom(bytesConcat(signature.slice(1), signature.slice(0, 1)));
28+
}
29+
30+
/**
31+
* Verify a message using Secp256k1.
32+
*
33+
* @param message - The message to verify.
34+
* @param signature - The signature.
35+
* @param publicKey - The public key.
36+
* @returns True if the signature is valid, false otherwise.
37+
* @public
38+
*/
39+
export function verifyMessageSecp256k1(
40+
message: string | BytesLike,
41+
signature: BytesLike,
42+
publicKey: BytesLike,
43+
): boolean {
44+
const signatureBytes = bytesFrom(signature);
45+
return secp256k1.verify(
46+
bytesConcat(signatureBytes.slice(64), signatureBytes.slice(0, 64)),
47+
bytesFrom(message),
48+
bytesFrom(publicKey),
49+
{ format: "recovered", prehash: false },
50+
);
51+
}
52+
53+
/**
54+
* Recover the public key from a Secp256k1 signature.
55+
*
56+
* @param message - The message.
57+
* @param signature - The signature.
58+
* @returns The recovered public key.
59+
* @public
60+
*/
61+
export function recoverMessageSecp256k1(
62+
message: string | BytesLike,
63+
signature: BytesLike,
64+
): Hex {
65+
const signatureBytes = bytesFrom(signature);
66+
return hexFrom(
67+
secp256k1.recoverPublicKey(
68+
bytesConcat(signatureBytes.slice(64), signatureBytes.slice(0, 64)),
69+
bytesFrom(message),
70+
{ prehash: false },
71+
),
72+
);
73+
}
74+
975
/**
1076
* @public
1177
*/
@@ -23,15 +89,7 @@ export class SignerCkbPrivateKey extends SignerCkbPublicKey {
2389
}
2490

2591
async _signMessage(message: HexLike): Promise<Hex> {
26-
const signature = secp256k1.sign(
27-
bytesFrom(message),
28-
bytesFrom(this.privateKey),
29-
{
30-
format: "recovered",
31-
prehash: false,
32-
},
33-
);
34-
return hexFrom(bytesConcat(signature.slice(1), signature.slice(0, 1)));
92+
return signMessageSecp256k1(message, this.privateKey);
3593
}
3694

3795
async signMessageRaw(message: string | BytesLike): Promise<Hex> {

packages/core/src/signer/ckb/signerCkbPublicKey.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { Address } from "../../address/index.js";
22
import { bytesFrom } from "../../bytes/index.js";
33
import { Script, Transaction, TransactionLike } from "../../ckb/index.js";
44
import { CellDepInfo, Client, KnownScript } from "../../client/index.js";
5-
import { hashCkb } from "../../hasher/index.js";
5+
import { HASH_CKB_SHORT_LENGTH, hashCkb } from "../../hasher/index.js";
66
import { Hex, HexLike, hexFrom } from "../../hex/index.js";
77
import { Signer, SignerSignType, SignerType } from "../signer/index.js";
8+
import { SECP256K1_SIGNATURE_LENGTH } from "./signerCkbPrivateKey.js";
89

910
/**
1011
* @public
@@ -47,7 +48,7 @@ export class SignerCkbPublicKey extends Signer {
4748
return Address.fromKnownScript(
4849
this.client,
4950
KnownScript.Secp256k1Blake160,
50-
bytesFrom(hashCkb(this.publicKey)).slice(0, 20),
51+
bytesFrom(hashCkb(this.publicKey)).slice(0, HASH_CKB_SHORT_LENGTH),
5152
);
5253
}
5354

@@ -140,7 +141,11 @@ export class SignerCkbPublicKey extends Signer {
140141

141142
await Promise.all(
142143
(await this.getRelatedScripts(tx)).map(async ({ script, cellDeps }) => {
143-
await tx.prepareSighashAllWitness(script, 65, this.client);
144+
await tx.prepareSighashAllWitness(
145+
script,
146+
SECP256K1_SIGNATURE_LENGTH,
147+
this.client,
148+
);
144149
await tx.addCellDepInfos(this.client, cellDeps);
145150
}),
146151
);
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { describe, expect, it } from "vitest";
2+
import { ccc } from "../../index.js";
3+
4+
const client = new ccc.ClientPublicTestnet();
5+
6+
describe("MultisigCkbWitness", () => {
7+
it("should encode and decode correctly", () => {
8+
const witness: ccc.MultisigCkbWitnessLike = {
9+
publicKeys: [
10+
"0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa80",
11+
"0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa81",
12+
],
13+
threshold: 1,
14+
mustMatch: 0,
15+
signatures: [],
16+
};
17+
18+
const encoded = ccc.MultisigCkbWitness.from(witness).toBytes();
19+
const decoded = ccc.MultisigCkbWitness.decode(encoded);
20+
21+
expect(decoded.threshold).toBe(witness.threshold);
22+
expect(decoded.mustMatch).toBe(witness.mustMatch);
23+
expect(decoded.publicKeyHashes.length).toBe(witness.publicKeys.length);
24+
});
25+
26+
it("should throw error for invalid threshold", () => {
27+
expect(() => {
28+
new ccc.MultisigCkbWitness([], 0, 0, []);
29+
}).toThrow("threshold should be in range from 1 to public keys length");
30+
31+
expect(() => {
32+
new ccc.MultisigCkbWitness([], 1, 0, []);
33+
}).toThrow("threshold should be in range from 1 to public keys length");
34+
});
35+
36+
it("should throw error for invalid mustMatch", () => {
37+
expect(() => {
38+
new ccc.MultisigCkbWitness(["0x00"], 1, 2, []);
39+
}).toThrow(
40+
"mustMatch should be in range from 0 to min(public keys length, threshold)",
41+
);
42+
});
43+
44+
it("should calculate scriptArgs correctly", () => {
45+
const witness: ccc.MultisigCkbWitnessLike = {
46+
publicKeys: [
47+
"0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa80",
48+
"0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa81",
49+
],
50+
threshold: 1,
51+
mustMatch: 0,
52+
};
53+
const multisigWitness = ccc.MultisigCkbWitness.from(witness);
54+
const args = multisigWitness.scriptArgs();
55+
expect(args).toBeInstanceOf(Uint8Array);
56+
expect(ccc.hexFrom(args)).toBe(
57+
"0x6418f118e94d8dff7d9b0b59a4d837c4e201c5a9",
58+
);
59+
});
60+
});
61+
62+
describe("SignerMultisigCkbReadonly", () => {
63+
it("should initialize correctly", async () => {
64+
const witness: ccc.MultisigCkbWitnessLike = {
65+
publicKeys: [
66+
"0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa80",
67+
"0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ece3d8d3c80fa81",
68+
],
69+
threshold: 1,
70+
mustMatch: 0,
71+
};
72+
73+
const signer = new ccc.SignerMultisigCkbReadonly(client, witness);
74+
75+
expect(await signer.getMemberCount()).toBe(2);
76+
expect(await signer.getMemberThreshold()).toBe(1);
77+
});
78+
});

0 commit comments

Comments
 (0)