Skip to content

Commit d4a3188

Browse files
committed
feat(core): multisig signer now detects mustMatch public keys
1 parent 30b1a01 commit d4a3188

6 files changed

Lines changed: 445 additions & 57 deletions

File tree

packages/core/src/hasher/hasherCkb.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,7 @@ export function hashCkb(...data: BytesLike[]): Hex {
8181
data.forEach((d) => hasher.update(d));
8282
return hasher.digest();
8383
}
84+
85+
export function hashCkbShort(...data: BytesLike[]): Hex {
86+
return hashCkb(...data).slice(0, HASH_CKB_SHORT_LENGTH * 2 + 2) as Hex;
87+
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ 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 { HASH_CKB_SHORT_LENGTH, hashCkb } from "../../hasher/index.js";
5+
import { hashCkbShort } from "../../hasher/index.js";
66
import { Hex, HexLike, hexFrom } from "../../hex/index.js";
77
import { Signer, SignerSignType, SignerType } from "../signer/index.js";
88
import { SECP256K1_SIGNATURE_LENGTH } from "./secp256k1Signing.js";
@@ -48,7 +48,7 @@ export class SignerCkbPublicKey extends Signer {
4848
return Address.fromKnownScript(
4949
this.client,
5050
KnownScript.Secp256k1Blake160,
51-
bytesFrom(hashCkb(this.publicKey)).slice(0, HASH_CKB_SHORT_LENGTH),
51+
hashCkbShort(this.publicKey),
5252
);
5353
}
5454

packages/core/src/signer/ckb/signerMultisigCkb.test.ts

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
22
import { ccc } from "../../index.js";
33

44
const client = new ccc.ClientPublicTestnet();
5+
const ZERO_HASH =
6+
"0x0000000000000000000000000000000000000000000000000000000000000000";
57

68
describe("MultisigCkbWitness", () => {
79
it("should encode and decode correctly", () => {
@@ -57,6 +59,52 @@ describe("MultisigCkbWitness", () => {
5759
"0x6418f118e94d8dff7d9b0b59a4d837c4e201c5a9",
5860
);
5961
});
62+
63+
describe("signature matching logic", () => {
64+
const privKey1 =
65+
"0x0000000000000000000000000000000000000000000000000000000000000001";
66+
const privKey2 =
67+
"0x0000000000000000000000000000000000000000000000000000000000000002";
68+
const privKey3 =
69+
"0x0000000000000000000000000000000000000000000000000000000000000003";
70+
const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1);
71+
const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2);
72+
const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3);
73+
74+
const message = ccc.hashCkb("0x0123456789abcdef");
75+
76+
it("should count signatures when required signers signed", async () => {
77+
const sig1 = await signer1._signMessage(message);
78+
const sig2 = await signer2._signMessage(message);
79+
80+
const witness = ccc.MultisigCkbWitness.from({
81+
publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey],
82+
threshold: 2,
83+
mustMatch: 1, // signer1 is required
84+
signatures: [sig1, sig2],
85+
});
86+
87+
const counts = witness.calcMatchedSignaturesCount(message);
88+
expect(counts.required).toBe(1);
89+
expect(counts.flexible).toBe(1);
90+
});
91+
92+
it("should count signatures when only flexible signers signed", async () => {
93+
const sig2 = await signer2._signMessage(message);
94+
const sig3 = await signer3._signMessage(message);
95+
96+
const witness = ccc.MultisigCkbWitness.from({
97+
publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey],
98+
threshold: 2,
99+
mustMatch: 1,
100+
signatures: [sig2, sig3],
101+
});
102+
103+
const counts = witness.calcMatchedSignaturesCount(message);
104+
expect(counts.required).toBe(0);
105+
expect(counts.flexible).toBe(2);
106+
});
107+
});
60108
});
61109

62110
describe("SignerMultisigCkbReadonly", () => {
@@ -74,5 +122,199 @@ describe("SignerMultisigCkbReadonly", () => {
74122

75123
expect(await signer.getMemberCount()).toBe(2);
76124
expect(await signer.getMemberThreshold()).toBe(1);
125+
expect(await signer.getMemberRequiredCount()).toBe(0);
126+
});
127+
128+
describe("getSignaturesCount with mustMatch", () => {
129+
const privKey1 =
130+
"0x0000000000000000000000000000000000000000000000000000000000000001";
131+
const privKey2 =
132+
"0x0000000000000000000000000000000000000000000000000000000000000002";
133+
const privKey3 =
134+
"0x0000000000000000000000000000000000000000000000000000000000000003";
135+
const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1);
136+
const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2);
137+
const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3);
138+
139+
const multisigSigner = new ccc.SignerMultisigCkbReadonly(client, {
140+
publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey],
141+
threshold: 2,
142+
mustMatch: 1, // signer1 is required
143+
});
144+
145+
const message = ccc.hashCkb("0x0123456789abcdef");
146+
multisigSigner.getSignInfo = async () => ({
147+
message: message,
148+
position: 0,
149+
});
150+
151+
const getTx = (signatures: string[]) =>
152+
ccc.Transaction.from({
153+
inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }],
154+
witnesses: [
155+
ccc.WitnessArgs.from({
156+
lock: ccc.MultisigCkbWitness.from({
157+
publicKeyHashes: multisigSigner.multisigInfo.publicKeyHashes,
158+
threshold: 2,
159+
mustMatch: 1,
160+
signatures,
161+
}).toBytes(),
162+
}).toBytes(),
163+
],
164+
});
165+
166+
it("should return 1 when only required signer signed", async () => {
167+
const sig1 = await signer1._signMessage(message);
168+
const tx = getTx([sig1]);
169+
expect(await multisigSigner.getSignaturesCount(tx)).toBe(1);
170+
expect(await multisigSigner.needMoreSignatures(tx)).toBe(true);
171+
});
172+
173+
it("should return 1 when only flexible signers signed", async () => {
174+
const sig2 = await signer2._signMessage(message);
175+
const sig3 = await signer3._signMessage(message);
176+
const tx = getTx([sig2, sig3]);
177+
expect(await multisigSigner.getSignaturesCount(tx)).toBe(1);
178+
expect(await multisigSigner.needMoreSignatures(tx)).toBe(true);
179+
});
180+
181+
it("should return 2 when required and flexible signers signed", async () => {
182+
const sig1 = await signer1._signMessage(message);
183+
const sig2 = await signer2._signMessage(message);
184+
const tx = getTx([sig1, sig2]);
185+
expect(await multisigSigner.getSignaturesCount(tx)).toBe(2);
186+
expect(await multisigSigner.needMoreSignatures(tx)).toBe(false);
187+
});
188+
});
189+
190+
describe("aggregateTransactions with mustMatch", () => {
191+
const privKey1 =
192+
"0x0000000000000000000000000000000000000000000000000000000000000001";
193+
const privKey2 =
194+
"0x0000000000000000000000000000000000000000000000000000000000000002";
195+
const privKey3 =
196+
"0x0000000000000000000000000000000000000000000000000000000000000003";
197+
const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1);
198+
const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2);
199+
const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3);
200+
201+
const multisigSigner = new ccc.SignerMultisigCkbReadonly(client, {
202+
publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey],
203+
threshold: 2,
204+
mustMatch: 1, // signer1 is required
205+
});
206+
207+
const message = ccc.hashCkb("0x0123456789abcdef");
208+
multisigSigner.getSignInfo = async () => ({
209+
message: message,
210+
position: 0,
211+
});
212+
213+
it("should aggregate required signatures from different transactions", async () => {
214+
const sig1 = await signer1._signMessage(message);
215+
const sig2 = await signer2._signMessage(message);
216+
const sig3 = await signer3._signMessage(message);
217+
218+
const tx1 = ccc.Transaction.from({
219+
inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }],
220+
witnesses: [
221+
ccc.WitnessArgs.from({
222+
lock: ccc.MultisigCkbWitness.from({
223+
publicKeyHashes: multisigSigner.multisigInfo.publicKeyHashes,
224+
threshold: 2,
225+
mustMatch: 1,
226+
signatures: [sig2, sig3], // Missing required
227+
}).toBytes(),
228+
}).toBytes(),
229+
],
230+
});
231+
232+
const tx2 = ccc.Transaction.from({
233+
inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }],
234+
witnesses: [
235+
ccc.WitnessArgs.from({
236+
lock: ccc.MultisigCkbWitness.from({
237+
publicKeyHashes: multisigSigner.multisigInfo.publicKeyHashes,
238+
threshold: 2,
239+
mustMatch: 1,
240+
signatures: [sig1], // Contains required
241+
}).toBytes(),
242+
}).toBytes(),
243+
],
244+
});
245+
246+
const aggregatedTx = await multisigSigner.aggregateTransactions([
247+
tx1,
248+
tx2,
249+
]);
250+
const decodedWitness = multisigSigner.decodeWitnessArgsAt(
251+
aggregatedTx,
252+
0,
253+
)!;
254+
255+
const { required, flexible } =
256+
decodedWitness.calcMatchedSignaturesCount(message);
257+
expect(required).toBe(1);
258+
expect(flexible).toBe(1);
259+
expect(decodedWitness.signatures.length).toBe(2);
260+
});
261+
});
262+
});
263+
264+
describe("SignerMultisigCkbPrivateKey", () => {
265+
const privKey1 =
266+
"0x0000000000000000000000000000000000000000000000000000000000000001";
267+
const privKey2 =
268+
"0x0000000000000000000000000000000000000000000000000000000000000002";
269+
const privKey3 =
270+
"0x0000000000000000000000000000000000000000000000000000000000000003";
271+
const signer1 = new ccc.SignerCkbPrivateKey(client, privKey1);
272+
const signer2 = new ccc.SignerCkbPrivateKey(client, privKey2);
273+
const signer3 = new ccc.SignerCkbPrivateKey(client, privKey3);
274+
275+
const multisigWitness: ccc.MultisigCkbWitnessLike = {
276+
publicKeys: [signer1.publicKey, signer2.publicKey, signer3.publicKey],
277+
threshold: 2,
278+
mustMatch: 1, // signer1 is required
279+
};
280+
281+
const message = ccc.hashCkb("0x0123456789abcdef");
282+
283+
it("should replace a flexible signature with a required one if threshold reached", async () => {
284+
const sig2 = await signer2._signMessage(message);
285+
const sig3 = await signer3._signMessage(message);
286+
287+
const multisigSigner1 = new ccc.SignerMultisigCkbPrivateKey(
288+
client,
289+
privKey1,
290+
multisigWitness,
291+
);
292+
multisigSigner1.getSignInfo = async () => ({
293+
message: message,
294+
position: 0,
295+
});
296+
297+
const tx = ccc.Transaction.from({
298+
inputs: [{ previousOutput: { txHash: ZERO_HASH, index: 0 }, since: 0 }],
299+
witnesses: [
300+
ccc.WitnessArgs.from({
301+
lock: ccc.MultisigCkbWitness.from({
302+
publicKeyHashes: multisigSigner1.multisigInfo.publicKeyHashes,
303+
threshold: 2,
304+
mustMatch: 1,
305+
signatures: [sig2, sig3],
306+
}).toBytes(),
307+
}).toBytes(),
308+
],
309+
});
310+
311+
const signedTx = await multisigSigner1.signOnlyTransaction(tx);
312+
const decodedWitness = multisigSigner1.decodeWitnessArgsAt(signedTx, 0)!;
313+
314+
const { required, flexible } =
315+
decodedWitness.calcMatchedSignaturesCount(message);
316+
expect(required).toBe(1);
317+
expect(flexible).toBe(1);
318+
expect(decodedWitness.signatures.length).toBe(2);
77319
});
78320
});

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

Lines changed: 48 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import { SinceLike, Transaction, TransactionLike } from "../../ckb/index.js";
22
import { Client, KnownScript, ScriptInfoLike } from "../../client/index.js";
3+
import { hashCkbShort } from "../../hasher/index.js";
34
import { Hex, hexFrom, HexLike } from "../../hex/index.js";
4-
import {
5-
signMessageSecp256k1,
6-
verifyMessageSecp256k1,
7-
} from "./secp256k1Signing.js";
5+
import { signMessageSecp256k1 } from "./secp256k1Signing.js";
86
import { SignerCkbPrivateKey } from "./signerCkbPrivateKey.js";
97
import {
108
MultisigCkbWitnessLike,
@@ -51,6 +49,14 @@ export class SignerMultisigCkbPrivateKey extends SignerMultisigCkbReadonly {
5149
async signOnlyTransaction(txLike: TransactionLike): Promise<Transaction> {
5250
let tx = Transaction.from(txLike);
5351

52+
const thisPubkeyHash = hashCkbShort(this.signer.publicKey);
53+
54+
const index = this.multisigInfo.publicKeyHashes.indexOf(thisPubkeyHash);
55+
if (index === -1) {
56+
return tx;
57+
}
58+
const isSelfRequired = index < this.multisigInfo.mustMatch;
59+
5460
for (const { script } of await this.scriptInfos) {
5561
const info = await this.getSignInfo(tx, script);
5662
if (!info) {
@@ -62,31 +68,47 @@ export class SignerMultisigCkbPrivateKey extends SignerMultisigCkbReadonly {
6268
tx,
6369
info.position,
6470
async (witness) => {
65-
if (
66-
witness.signatures.some(
67-
(sig) =>
68-
sig !== SignerMultisigCkbPrivateKey.EmptySignature &&
69-
verifyMessageSecp256k1(
70-
info.message,
71-
sig,
72-
this.signer.publicKey,
73-
),
74-
)
75-
) {
76-
// Has signed
77-
return;
78-
}
71+
// We re-evaluate the signatures to filter invalid / excessive signatures
72+
const signatures: Hex[] = [];
73+
let requiredCount = 0;
7974

80-
const empty = witness.signatures.findIndex(
81-
(sig) => sig === SignerMultisigCkbPrivateKey.EmptySignature,
82-
);
83-
if (empty === -1) {
84-
return;
85-
}
75+
// === Returns if: ===
76+
// === 1. This signer already signed the transaction ===
77+
// === 2. The transaction already has enough flexible signatures, and this signer is not required ===
78+
// === 3. `mustMatch` equals to `threshold` but we have more this signer is not required.
79+
for (const {
80+
pubkeyHash,
81+
signature,
82+
isRequired,
83+
} of witness.generatePublicKeyHashesFromSignatures(info.message)) {
84+
if (pubkeyHash === thisPubkeyHash) {
85+
// Has signed
86+
return;
87+
}
8688

87-
const signature = signMessageSecp256k1(info.message, this.privateKey);
89+
if (isRequired) {
90+
requiredCount += 1;
91+
} else if (
92+
signatures.length - requiredCount >=
93+
this.multisigInfo.flexibleThreshold
94+
) {
95+
// Too many flexible signatures
96+
if (!isSelfRequired) {
97+
return;
98+
}
99+
continue;
100+
}
101+
102+
signatures.push(signature);
103+
if (signatures.length >= this.multisigInfo.threshold) {
104+
// Too many signatures and the flexible detect doesn't work. Only happens if all signatures are required.
105+
return;
106+
}
107+
}
108+
// === This signature is required or it's flexible but we don't have enough flexible signatures ===
88109

89-
witness.signatures[empty] = signature;
110+
signatures.push(signMessageSecp256k1(info.message, this.privateKey));
111+
witness.signatures = signatures;
90112
},
91113
);
92114
}

0 commit comments

Comments
 (0)