From 823e2d3fecd621c3fa508031a1729d612ee7feab Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sat, 10 Jan 2026 01:37:34 +0800 Subject: [PATCH 1/6] feat(core): `Transaction.getWitnessArgsAtUnsafe` --- .changeset/orange-goats-float.md | 6 ++++++ packages/core/src/ckb/transaction.ts | 21 ++++++++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) create mode 100644 .changeset/orange-goats-float.md diff --git a/.changeset/orange-goats-float.md b/.changeset/orange-goats-float.md new file mode 100644 index 00000000..0398b482 --- /dev/null +++ b/.changeset/orange-goats-float.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): `Transaction.getWitnessArgsAtUnsafe` + \ No newline at end of file diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 2cb60e90..bf11bb66 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -1768,10 +1768,29 @@ export class Transaction extends Entity.Base() { * * @example * ```typescript - * const witnessArgs = await tx.getWitnessArgsAt(0); + * const witnessArgs = tx.getWitnessArgsAt(0); * ``` */ getWitnessArgsAt(index: number): WitnessArgs | undefined { + try { + return this.getWitnessArgsAtUnsafe(index); + } catch (_) { + return undefined; + } + } + + /** + * Get witness at index as WitnessArgs, throw if failed to decode + * + * @param index - The index of the witness. + * @returns The witness parsed as WitnessArgs. + * + * @example + * ```typescript + * const witnessArgs = tx.getWitnessArgsAtUnsafe(0); + * ``` + */ + getWitnessArgsAtUnsafe(index: number): WitnessArgs | undefined { const rawWitness = this.witnesses[index]; return (rawWitness ?? "0x") !== "0x" ? WitnessArgs.fromBytes(rawWitness) From 26180adda49a9ba3f2445d9c482234d52d93d3ef Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sat, 10 Jan 2026 01:41:11 +0800 Subject: [PATCH 2/6] feat(core): relax `@ccc.codec`'s type restriction --- .changeset/fuzzy-years-add.md | 6 ++++++ packages/core/src/codec/entity.ts | 16 +++++++++++----- 2 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 .changeset/fuzzy-years-add.md diff --git a/.changeset/fuzzy-years-add.md b/.changeset/fuzzy-years-add.md new file mode 100644 index 00000000..29371a80 --- /dev/null +++ b/.changeset/fuzzy-years-add.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): relax `@ccc.codec`'s type restriction + \ No newline at end of file diff --git a/packages/core/src/codec/entity.ts b/packages/core/src/codec/entity.ts index d62a7a53..c44bcaf0 100644 --- a/packages/core/src/codec/entity.ts +++ b/packages/core/src/codec/entity.ts @@ -1,8 +1,7 @@ -import { Bytes, bytesEq, BytesLike } from "../bytes/index.js"; +import { Bytes, bytesEq, bytesFrom, BytesLike } from "../bytes/index.js"; import { hashCkb } from "../hasher/index.js"; import { Hex, hexFrom } from "../hex/index.js"; import { Constructor } from "../utils/index.js"; -import { Codec } from "./codec.js"; /** * The base class of CCC to create a serializable instance. This should be used with the {@link codec} decorator. @@ -172,7 +171,14 @@ export function codec< Encodable, TypeLike extends Encodable, Decoded extends TypeLike, ->(codec: Codec) { +>(codec: { + encode: (encodable: Encodable) => Bytes; + decode: ( + decodable: Bytes, + config?: { isExtraFieldIgnored?: boolean }, + ) => Decoded; + byteLength?: number; +}) { return function < Type extends TypeLike, ConstructorType extends Constructor & { @@ -191,12 +197,12 @@ export function codec< } if (Constructor.decode === undefined) { Constructor.decode = function (bytesLike: BytesLike) { - return Constructor.from(codec.decode(bytesLike)); + return Constructor.from(codec.decode(bytesFrom(bytesLike))); }; } if (Constructor.fromBytes === undefined) { Constructor.fromBytes = function (bytes: BytesLike) { - return Constructor.from(codec.decode(bytes)); + return Constructor.from(codec.decode(bytesFrom(bytes))); }; } From 3c14ec67e41e3ae93556b961c5256e37ca6ff0d7 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sat, 10 Jan 2026 03:49:42 +0800 Subject: [PATCH 3/6] feat(core): bump @noble packages --- .changeset/vast-trees-film.md | 6 + packages/core/package.json | 39 +++---- packages/core/src/hasher/advanced.ts | 4 +- packages/core/src/hasher/hasherCkb.ts | 2 +- packages/core/src/hasher/hasherKeecak256.ts | 2 +- packages/core/src/keystore/index.ts | 8 +- packages/core/src/molecule/codec.ts | 20 ++-- packages/core/src/signer/btc/verify.ts | 3 +- .../src/signer/ckb/signerCkbPrivateKey.ts | 17 +-- .../core/src/signer/ckb/verifyCkbSecp256k1.ts | 16 +-- .../src/signer/doge/signerDogePrivateKey.ts | 9 +- packages/core/src/signer/doge/verify.ts | 15 ++- .../src/signer/nostr/signerNostrPrivateKey.ts | 17 +-- packages/core/src/signer/nostr/verify.ts | 4 +- packages/core/tsdown.config.mts | 38 +++++++ packages/examples/package.json | 4 +- packages/examples/src/createDidWithLocalId.ts | 11 +- packages/playground/next.config.mjs | 4 + packages/playground/package.json | 4 +- .../playground/src/app/components/Editor.tsx | 14 ++- packages/playground/src/app/execute/index.tsx | 4 +- packages/tests/package.json | 4 +- packages/tests/tests/esm.test.mts | 3 +- pnpm-lock.yaml | 107 ++++++++++-------- 24 files changed, 206 insertions(+), 149 deletions(-) create mode 100644 .changeset/vast-trees-film.md create mode 100644 packages/core/tsdown.config.mts diff --git a/.changeset/vast-trees-film.md b/.changeset/vast-trees-film.md new file mode 100644 index 00000000..a2bd05a6 --- /dev/null +++ b/.changeset/vast-trees-film.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": minor +--- + +feat(core): bump @noble packages + \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 56c3b8cd..8b5e5a0b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,46 +12,42 @@ }, "sideEffects": false, "main": "./dist.commonjs/index.js", - "module": "./dist/index.js", + "module": "./dist/index.mjs", "exports": { ".": { - "import": "./dist/index.js", "require": "./dist.commonjs/index.js", - "default": "./dist.commonjs/index.js" + "import": "./dist/index.mjs" }, - "./barrel": { - "import": "./dist/barrel.js", - "require": "./dist.commonjs/barrel.js", - "default": "./dist.commonjs/barrel.js" + "./advanced": { + "require": "./dist.commonjs/advanced.js", + "import": "./dist/advanced.mjs" }, "./advancedBarrel": { - "import": "./dist/advancedBarrel.js", "require": "./dist.commonjs/advancedBarrel.js", - "default": "./dist.commonjs/advancedBarrel.js" + "import": "./dist/advancedBarrel.mjs" }, - "./advanced": { - "import": "./dist/advanced.js", - "require": "./dist.commonjs/advanced.js", - "default": "./dist.commonjs/advanced.js" - } + "./barrel": { + "require": "./dist.commonjs/barrel.js", + "import": "./dist/barrel.mjs" + }, + "./package.json": "./package.json" }, "scripts": { "test": "vitest", "test:ci": "vitest run", - "build": "rimraf ./dist && rimraf ./dist.commonjs && tsc && tsc --project tsconfig.commonjs.json && copyfiles -u 2 misc/basedirs/**/* .", + "build": "tsdown", "lint": "eslint ./src", "format": "prettier --write . && eslint --fix ./src" }, "devDependencies": { "@eslint/js": "^9.34.0", "@types/ws": "^8.18.1", - "copyfiles": "^2.4.1", "eslint": "^9.34.0", "eslint-config-prettier": "^10.1.8", "eslint-plugin-prettier": "^5.5.4", "prettier": "^3.6.2", "prettier-plugin-organize-imports": "^4.2.0", - "rimraf": "^6.0.1", + "tsdown": "0.19.0-beta.3", "typescript": "^5.9.2", "typescript-eslint": "^8.41.0", "vitest": "^3.2.4" @@ -61,9 +57,9 @@ }, "dependencies": { "@joyid/ckb": "^1.1.2", - "@noble/ciphers": "^0.5.3", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0", + "@noble/ciphers": "^2.1.1", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "bech32": "^2.0.0", "bs58check": "^4.0.0", "buffer": "^6.0.3", @@ -71,5 +67,6 @@ "isomorphic-ws": "^5.0.0", "ws": "^8.18.3" }, - "packageManager": "pnpm@10.8.1" + "packageManager": "pnpm@10.8.1", + "types": "./dist.commonjs/index.d.ts" } diff --git a/packages/core/src/hasher/advanced.ts b/packages/core/src/hasher/advanced.ts index f3f4359b..1344b964 100644 --- a/packages/core/src/hasher/advanced.ts +++ b/packages/core/src/hasher/advanced.ts @@ -1 +1,3 @@ -export const CKB_BLAKE2B_PERSONAL = "ckb-default-hash"; +import { bytesFrom } from "../bytes"; + +export const CKB_BLAKE2B_PERSONAL = bytesFrom("ckb-default-hash", "utf8"); diff --git a/packages/core/src/hasher/hasherCkb.ts b/packages/core/src/hasher/hasherCkb.ts index c32ec569..fbcddd7f 100644 --- a/packages/core/src/hasher/hasherCkb.ts +++ b/packages/core/src/hasher/hasherCkb.ts @@ -1,4 +1,4 @@ -import { blake2b } from "@noble/hashes/blake2b"; +import { blake2b } from "@noble/hashes/blake2.js"; import { BytesLike, bytesFrom } from "../bytes/index.js"; import { Hex, hexFrom } from "../hex/index.js"; import { CKB_BLAKE2B_PERSONAL } from "./advanced.js"; diff --git a/packages/core/src/hasher/hasherKeecak256.ts b/packages/core/src/hasher/hasherKeecak256.ts index 88beef6e..4660fbb3 100644 --- a/packages/core/src/hasher/hasherKeecak256.ts +++ b/packages/core/src/hasher/hasherKeecak256.ts @@ -1,4 +1,4 @@ -import { keccak_256 } from "@noble/hashes/sha3"; +import { keccak_256 } from "@noble/hashes/sha3.js"; import { BytesLike, bytesFrom } from "../bytes/index.js"; import { Hex, hexFrom } from "../hex/index.js"; import { Hasher } from "./hasher.js"; diff --git a/packages/core/src/keystore/index.ts b/packages/core/src/keystore/index.ts index bd963af3..3ce3e042 100644 --- a/packages/core/src/keystore/index.ts +++ b/packages/core/src/keystore/index.ts @@ -1,7 +1,7 @@ -import { ctr } from "@noble/ciphers/aes"; -import { scryptAsync } from "@noble/hashes/scrypt"; -import { keccak_256 } from "@noble/hashes/sha3"; -import { randomBytes } from "@noble/hashes/utils"; +import { ctr } from "@noble/ciphers/aes.js"; +import { scryptAsync } from "@noble/hashes/scrypt.js"; +import { keccak_256 } from "@noble/hashes/sha3.js"; +import { randomBytes } from "@noble/hashes/utils.js"; import { Bytes, BytesLike, bytesConcat, bytesFrom } from "../bytes/index.js"; import { hexFrom } from "../hex/index.js"; diff --git a/packages/core/src/molecule/codec.ts b/packages/core/src/molecule/codec.ts index ff09cab9..8ae90fe6 100644 --- a/packages/core/src/molecule/codec.ts +++ b/packages/core/src/molecule/codec.ts @@ -20,25 +20,25 @@ export { */ Codec, /** - * @deprecated Use ccc.CodecLike instead + * @deprecated Use ccc.codecUint instead */ - CodecLike, + codecUint as uint, /** - * @deprecated Use ccc.DecodedType instead + * @deprecated Use ccc.codecUintNumber instead */ - DecodedType, + codecUintNumber as uintNumber, /** - * @deprecated Use ccc.EncodableType instead + * @deprecated Use ccc.CodecLike instead */ - EncodableType, + type CodecLike, /** - * @deprecated Use ccc.codecUint instead + * @deprecated Use ccc.DecodedType instead */ - codecUint as uint, + type DecodedType, /** - * @deprecated Use ccc.codecUintNumber instead + * @deprecated Use ccc.EncodableType instead */ - codecUintNumber as uintNumber, + type EncodableType, } from "../codec/index.js"; function uint32To(numLike: NumLike) { diff --git a/packages/core/src/signer/btc/verify.ts b/packages/core/src/signer/btc/verify.ts index 2d7f7fb1..2907560a 100644 --- a/packages/core/src/signer/btc/verify.ts +++ b/packages/core/src/signer/btc/verify.ts @@ -1,4 +1,4 @@ -import { secp256k1 } from "@noble/curves/secp256k1"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; import { ripemd160 } from "@noble/hashes/legacy.js"; import { sha256 } from "@noble/hashes/sha2.js"; import bs58check from "bs58check"; @@ -98,5 +98,6 @@ export function verifyMessageBtcEcdsa( bytesFrom(rawSign), messageHashBtcEcdsa(challenge), bytesFrom(publicKey), + { prehash: false }, ); } diff --git a/packages/core/src/signer/ckb/signerCkbPrivateKey.ts b/packages/core/src/signer/ckb/signerCkbPrivateKey.ts index 31354a93..41a507d3 100644 --- a/packages/core/src/signer/ckb/signerCkbPrivateKey.ts +++ b/packages/core/src/signer/ckb/signerCkbPrivateKey.ts @@ -1,9 +1,8 @@ -import { secp256k1 } from "@noble/curves/secp256k1"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; import { bytesConcat, bytesFrom, BytesLike } from "../../bytes/index.js"; import { Transaction, TransactionLike, WitnessArgs } from "../../ckb/index.js"; import { Client } from "../../client/index.js"; import { Hex, hexFrom, HexLike } from "../../hex/index.js"; -import { numBeToBytes } from "../../num/index.js"; import { SignerCkbPublicKey } from "./signerCkbPublicKey.js"; import { messageHashCkbSecp256k1 } from "./verifyCkbSecp256k1.js"; @@ -27,16 +26,12 @@ export class SignerCkbPrivateKey extends SignerCkbPublicKey { const signature = secp256k1.sign( bytesFrom(message), bytesFrom(this.privateKey), + { + format: "recovered", + prehash: false, + }, ); - const { r, s, recovery } = signature; - - return hexFrom( - bytesConcat( - numBeToBytes(r, 32), - numBeToBytes(s, 32), - numBeToBytes(recovery, 1), - ), - ); + return hexFrom(bytesConcat(signature.slice(1), signature.slice(0, 1))); } async signMessageRaw(message: string | BytesLike): Promise { diff --git a/packages/core/src/signer/ckb/verifyCkbSecp256k1.ts b/packages/core/src/signer/ckb/verifyCkbSecp256k1.ts index c5e268e5..28244e1b 100644 --- a/packages/core/src/signer/ckb/verifyCkbSecp256k1.ts +++ b/packages/core/src/signer/ckb/verifyCkbSecp256k1.ts @@ -1,8 +1,7 @@ -import { secp256k1 } from "@noble/curves/secp256k1"; -import { BytesLike, bytesFrom } from "../../bytes/index.js"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesConcat, bytesFrom, BytesLike } from "../../bytes/index.js"; import { hashCkb } from "../../hasher/index.js"; import { Hex, hexFrom } from "../../hex/index.js"; -import { numFrom } from "../../num/index.js"; /** * @public @@ -21,15 +20,12 @@ export function verifyMessageCkbSecp256k1( signature: string, publicKey: string, ): boolean { - const signatureBytes = bytesFrom(signature); + const raw = bytesFrom(signature); + return secp256k1.verify( - new secp256k1.Signature( - numFrom(signatureBytes.slice(0, 32)), - numFrom(signatureBytes.slice(32, 64)), - ) - .addRecoveryBit(Number(numFrom(signatureBytes.slice(64, 65)))) - .toBytes(), + bytesConcat(raw.slice(64), raw.slice(0, 64)), bytesFrom(messageHashCkbSecp256k1(message)), bytesFrom(publicKey), + { format: "recovered", prehash: false }, ); } diff --git a/packages/core/src/signer/doge/signerDogePrivateKey.ts b/packages/core/src/signer/doge/signerDogePrivateKey.ts index ad5e36d0..b1c2988a 100644 --- a/packages/core/src/signer/doge/signerDogePrivateKey.ts +++ b/packages/core/src/signer/doge/signerDogePrivateKey.ts @@ -1,4 +1,4 @@ -import { secp256k1 } from "@noble/curves/secp256k1"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; import { Bytes, bytesConcat, @@ -87,10 +87,13 @@ export class SignerDogePrivateKey extends SignerDoge { const signature = secp256k1.sign( messageHashDogeEcdsa(challenge), this.privateKey, + { + format: "recovered", + prehash: false, + }, ); - return bytesTo( - bytesConcat([31 + signature.recovery], signature.toCompactRawBytes()), + bytesConcat([31 + Number(signature[0])], signature.slice(1)), "base64", ); } diff --git a/packages/core/src/signer/doge/verify.ts b/packages/core/src/signer/doge/verify.ts index b9d586bf..561be254 100644 --- a/packages/core/src/signer/doge/verify.ts +++ b/packages/core/src/signer/doge/verify.ts @@ -1,4 +1,4 @@ -import { secp256k1 } from "@noble/curves/secp256k1"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; import { Bytes, bytesFrom, BytesLike } from "../../bytes/index.js"; import { hexFrom } from "../../hex/index.js"; import { @@ -38,18 +38,17 @@ export function verifyMessageDogeEcdsa( const challenge = typeof message === "string" ? message : hexFrom(message).slice(2); const signatureBytes = bytesFrom(signature, "base64"); - const recoveryBit = signatureBytes[0]; - const rawSign = signatureBytes.slice(1); - - const sig = secp256k1.Signature.fromCompact( - hexFrom(rawSign).slice(2), - ).addRecoveryBit(recoveryBit - 31); + signatureBytes[0] -= 31; return ( btcPublicKeyFromP2pkhAddress(address) === hexFrom( btcEcdsaPublicKeyHash( - sig.recoverPublicKey(messageHashDogeEcdsa(challenge)).toHex(), + secp256k1.recoverPublicKey( + signatureBytes, + messageHashDogeEcdsa(challenge), + { prehash: false }, + ), ), ) ); diff --git a/packages/core/src/signer/nostr/signerNostrPrivateKey.ts b/packages/core/src/signer/nostr/signerNostrPrivateKey.ts index b299596f..97fe9060 100644 --- a/packages/core/src/signer/nostr/signerNostrPrivateKey.ts +++ b/packages/core/src/signer/nostr/signerNostrPrivateKey.ts @@ -1,7 +1,8 @@ -import { schnorr } from "@noble/curves/secp256k1"; +import { schnorr } from "@noble/curves/secp256k1.js"; import { bech32 } from "bech32"; +import { Bytes, bytesFrom, BytesLike } from "../../bytes/index.js"; import { Client } from "../../client/index.js"; -import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { hexFrom } from "../../hex/index.js"; import { NostrEvent } from "./signerNostr.js"; import { SignerNostrPublicKeyReadonly } from "./signerNostrPublicKeyReadonly.js"; import { nostrEventHash } from "./verify.js"; @@ -11,22 +12,22 @@ import { nostrEventHash } from "./verify.js"; * Support nsec and hex format */ export class SignerNostrPrivateKey extends SignerNostrPublicKeyReadonly { - private readonly privateKey: Hex; + private readonly privateKey: Bytes; - constructor(client: Client, privateKeyLike: HexLike) { + constructor(client: Client, privateKeyLike: BytesLike) { const privateKey = (() => { if ( typeof privateKeyLike === "string" && privateKeyLike.startsWith("nsec") ) { const { words } = bech32.decode(privateKeyLike); - return hexFrom(bech32.fromWords(words)); + return bytesFrom(bech32.fromWords(words)); } - return hexFrom(privateKeyLike); + return bytesFrom(privateKeyLike); })(); - super(client, schnorr.getPublicKey(privateKey.slice(2))); + super(client, schnorr.getPublicKey(privateKey)); this.privateKey = privateKey; } @@ -34,7 +35,7 @@ export class SignerNostrPrivateKey extends SignerNostrPublicKeyReadonly { async signNostrEvent(event: NostrEvent): Promise> { const pubkey = (await this.getNostrPublicKey()).slice(2); const eventHash = nostrEventHash({ ...event, pubkey }); - const signature = schnorr.sign(eventHash, this.privateKey.slice(2)); + const signature = schnorr.sign(eventHash, this.privateKey); return { ...event, diff --git a/packages/core/src/signer/nostr/verify.ts b/packages/core/src/signer/nostr/verify.ts index 879de0a7..da2db9b7 100644 --- a/packages/core/src/signer/nostr/verify.ts +++ b/packages/core/src/signer/nostr/verify.ts @@ -1,4 +1,4 @@ -import { schnorr } from "@noble/curves/secp256k1"; +import { schnorr } from "@noble/curves/secp256k1.js"; import { sha256 } from "@noble/hashes/sha2.js"; import { bech32 } from "bech32"; import { Bytes, BytesLike, bytesFrom } from "../../bytes/index.js"; @@ -65,7 +65,7 @@ export function verifyMessageNostrEvent( const eventHash = nostrEventHash({ ...event, pubkey }); try { - return schnorr.verify(hexFrom(signature).slice(2), eventHash, pubkey); + return schnorr.verify(bytesFrom(signature), eventHash, bytesFrom(pubkey)); } catch (_) { return false; } diff --git a/packages/core/tsdown.config.mts b/packages/core/tsdown.config.mts new file mode 100644 index 00000000..1afef7d2 --- /dev/null +++ b/packages/core/tsdown.config.mts @@ -0,0 +1,38 @@ +import { defineConfig } from "tsdown"; + +const common = { + minify: true, + dts: true, + platform: "neutral" as const, + exports: true, +}; + +const entry = { + index: "src/index.ts", + barrel: "src/barrel.ts", + advanced: "src/advanced.ts", + advancedBarrel: "src/advancedBarrel.ts", +} as const; + +export default defineConfig( + ( + [ + { + entry, + format: "esm", + copy: "./misc/basedirs/dist/*", + }, + { + entry, + noExternal: [ + "@noble/curves/*", + "@noble/hashes/*", + "@noble/ciphers/*", + ] as string[], + format: "cjs", + outDir: "dist.commonjs", + copy: "./misc/basedirs/dist.commonjs/*", + }, + ] as const + ).map((c) => ({ ...c, ...common })), +); diff --git a/packages/examples/package.json b/packages/examples/package.json index bac19e45..36c459a7 100644 --- a/packages/examples/package.json +++ b/packages/examples/package.json @@ -32,8 +32,8 @@ "dependencies": { "@ckb-ccc/ccc": "workspace:*", "@ckb-ccc/playground": "file:src/playground", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0" + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1" }, "packageManager": "pnpm@10.8.1" } diff --git a/packages/examples/src/createDidWithLocalId.ts b/packages/examples/src/createDidWithLocalId.ts index 957e4b08..bd194711 100644 --- a/packages/examples/src/createDidWithLocalId.ts +++ b/packages/examples/src/createDidWithLocalId.ts @@ -1,13 +1,16 @@ import { ccc } from "@ckb-ccc/ccc"; import { render, signer } from "@ckb-ccc/playground"; -import { secp256k1 } from "@noble/curves/secp256k1"; -import { sha256 } from "@noble/hashes/sha2"; +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { sha256 } from "@noble/hashes/sha2.js"; // From https://github.com/bluesky-social/atproto/blob/main/packages/crypto function plcSign(key: ccc.BytesLike, msg: ccc.BytesLike): ccc.Bytes { const msgHash = sha256(ccc.bytesFrom(msg)); - const sig = secp256k1.sign(msgHash, ccc.bytesFrom(key), { lowS: true }); - return sig.toBytes("compact"); + return secp256k1.sign(msgHash, ccc.bytesFrom(key), { + lowS: true, + format: "compact", + prehash: false, + }); } // Construct create did tx diff --git a/packages/playground/next.config.mjs b/packages/playground/next.config.mjs index ea5cd9ba..ded43ebc 100644 --- a/packages/playground/next.config.mjs +++ b/packages/playground/next.config.mjs @@ -6,6 +6,10 @@ const nextConfig = { loaders: ["raw-loader"], as: "*.mjs", }, + "*.d.mts": { + loaders: ["raw-loader"], + as: "*.mjs", + }, }, }, }; diff --git a/packages/playground/package.json b/packages/playground/package.json index 1959e46a..5374dfc5 100644 --- a/packages/playground/package.json +++ b/packages/playground/package.json @@ -16,8 +16,8 @@ "@monaco-editor/react": "^4.7.0", "@nervina-labs/dob-render": "^0.2.5", "@next/third-parties": "^15.5.2", - "@noble/curves": "^1.9.7", - "@noble/hashes": "^1.8.0", + "@noble/curves": "^2.0.1", + "@noble/hashes": "^2.0.1", "@shikijs/monaco": "^3.12.0", "axios": "^1.11.0", "bech32": "^2.0.0", diff --git a/packages/playground/src/app/components/Editor.tsx b/packages/playground/src/app/components/Editor.tsx index 4bd227fb..d17ce939 100644 --- a/packages/playground/src/app/components/Editor.tsx +++ b/packages/playground/src/app/components/Editor.tsx @@ -5,12 +5,14 @@ import { editor } from "monaco-editor"; import { useEffect, useRef, useState } from "react"; import { createHighlighter } from "shiki"; +const COMMON_REGEX = /^\.\/(.*\.d\.ts|.*\.d\.mts|package.json)$/; + const EXTRA_SOURCES = [ { files: require.context( "../../../node_modules/@types/react", true, - /^\.\/(.*\.d\.ts|package.json)$/, + COMMON_REGEX, ), name: "@types/react", }, @@ -18,7 +20,7 @@ const EXTRA_SOURCES = [ files: require.context( "../../../../", true, - /^\.\/[^\/]*\/(dist\.commonjs\/.*\.d\.ts|package.json)$/, + /^\.\/[^\/]*\/(dist\/(.*\.d\.ts|.*\.d\.mts)|package.json)$/, ), name: "@ckb-ccc", }, @@ -26,7 +28,7 @@ const EXTRA_SOURCES = [ files: require.context( "../../../node_modules/@nervina-labs/dob-render", true, - /^\.\/(.*\.d\.ts|package.json)$/, + COMMON_REGEX, ), name: "@nervina-labs/dob-render", }, @@ -34,7 +36,7 @@ const EXTRA_SOURCES = [ files: require.context( "../../../node_modules/@noble/hashes", true, - /^\.\/(.*\.d\.ts|package.json)$/, + COMMON_REGEX, ), name: "@noble/hashes", }, @@ -42,7 +44,7 @@ const EXTRA_SOURCES = [ files: require.context( "../../../node_modules/@noble/curves", true, - /^\.\/(.*\.d\.ts|package.json)$/, + COMMON_REGEX, ), name: "@noble/curves", }, @@ -129,7 +131,7 @@ export function Editor({ ...monaco.languages.typescript.typescriptDefaults.getCompilerOptions(), module: monaco.languages.typescript.ModuleKind.ESNext, // eslint-disable-next-line @typescript-eslint/no-explicit-any - moduleResolution: 99 as any, // NodeNext + moduleResolution: 100 as any, // Bundler noImplicitAny: true, strictNullChecks: true, jsx: monaco.languages.typescript.JsxEmit.ReactJSX, diff --git a/packages/playground/src/app/execute/index.tsx b/packages/playground/src/app/execute/index.tsx index cd081999..c59e6182 100644 --- a/packages/playground/src/app/execute/index.tsx +++ b/packages/playground/src/app/execute/index.tsx @@ -8,8 +8,8 @@ const LIBS_MAP_ = new Map(); const LIBS = await Promise.all( ( [ - ["@noble/curves/secp256k1"], - ["@noble/hashes/sha2"], + ["@noble/curves/secp256k1.js"], + ["@noble/hashes/sha2.js"], ["@ckb-ccc/ccc", "@ckb-ccc/core"], ["@ckb-ccc/ccc/advanced", "@ckb-ccc/core/advanced"], ["@nervina-labs/dob-render"], diff --git a/packages/tests/package.json b/packages/tests/package.json index 3bbd4cf8..93234cb8 100644 --- a/packages/tests/package.json +++ b/packages/tests/package.json @@ -14,6 +14,7 @@ "test": "node ./tests/cjs.test.cjs && tsx ./tests/esm.test.mts" }, "devDependencies": { + "@ckb-ccc/ccc": "workspace:*", "@eslint/js": "^9.34.0", "eslint": "^9.34.0", "eslint-config-prettier": "^10.1.8", @@ -22,8 +23,7 @@ "prettier-plugin-organize-imports": "^4.2.0", "tsx": "^4.20.5", "typescript": "^5.9.2", - "typescript-eslint": "^8.41.0", - "@ckb-ccc/ccc": "workspace:*" + "typescript-eslint": "^8.41.0" }, "packageManager": "pnpm@10.8.1" } diff --git a/packages/tests/tests/esm.test.mts b/packages/tests/tests/esm.test.mts index 51f5b9bf..59a4a6eb 100644 --- a/packages/tests/tests/esm.test.mts +++ b/packages/tests/tests/esm.test.mts @@ -1,11 +1,12 @@ import { ccc } from "@ckb-ccc/ccc"; import assert from "node:assert/strict"; +import { fileURLToPath } from "url"; import path from "path"; assert.ok(ccc, "CCC package should be imported successfully in ESM"); assert.strictEqual( import.meta.resolve("@ckb-ccc/ccc"), - `file://${path.join(import.meta.dirname, "../../ccc/dist/index.js")}`, + `file://${path.join(path.dirname(fileURLToPath(import.meta.url)), "../../ccc/dist/index.js")}`, "CCC package should be imported from dist in ESM", ); console.log("ESM require test passed"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 33d5adee..400ac46a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,14 +234,14 @@ importers: specifier: ^1.1.2 version: 1.1.2(typescript@5.9.2) '@noble/ciphers': - specifier: ^0.5.3 - version: 0.5.3 + specifier: ^2.1.1 + version: 2.1.1 '@noble/curves': - specifier: ^1.9.7 - version: 1.9.7 + specifier: ^2.0.1 + version: 2.0.1 '@noble/hashes': - specifier: ^1.8.0 - version: 1.8.0 + specifier: ^2.0.1 + version: 2.0.1 bech32: specifier: ^2.0.0 version: 2.0.0 @@ -267,9 +267,6 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 - copyfiles: - specifier: ^2.4.1 - version: 2.4.1 eslint: specifier: ^9.34.0 version: 9.34.0(jiti@2.5.1) @@ -285,9 +282,9 @@ importers: prettier-plugin-organize-imports: specifier: ^4.2.0 version: 4.2.0(prettier@3.6.2)(typescript@5.9.2) - rimraf: - specifier: ^6.0.1 - version: 6.0.1 + tsdown: + specifier: 0.19.0-beta.3 + version: 0.19.0-beta.3(synckit@0.11.11)(typescript@5.9.2) typescript: specifier: ^5.9.2 version: 5.9.2 @@ -533,11 +530,11 @@ importers: specifier: file:src/playground version: playground@file:packages/examples/src/playground '@noble/curves': - specifier: ^1.9.7 - version: 1.9.7 + specifier: ^2.0.1 + version: 2.0.1 '@noble/hashes': - specifier: ^1.8.0 - version: 1.8.0 + specifier: ^2.0.1 + version: 2.0.1 devDependencies: '@eslint/js': specifier: ^9.34.0 @@ -881,11 +878,11 @@ importers: specifier: ^15.5.2 version: 15.5.2(next@16.0.10(@babel/core@7.28.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) '@noble/curves': - specifier: ^1.9.7 - version: 1.9.7 + specifier: ^2.0.1 + version: 2.0.1 '@noble/hashes': - specifier: ^1.8.0 - version: 1.8.0 + specifier: ^2.0.1 + version: 2.0.1 '@shikijs/monaco': specifier: ^3.12.0 version: 3.12.0 @@ -3696,17 +3693,21 @@ packages: '@noble/ciphers@0.5.3': resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + '@noble/ciphers@2.1.1': + resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} + engines: {node: '>= 20.19.0'} + '@noble/curves@1.2.0': resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} - '@noble/curves@1.9.7': - resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} - engines: {node: ^14.21.3 || >=16} - '@noble/curves@2.0.0': resolution: {integrity: sha512-RiwZZeJnsTnhT+/gg2KvITJZhK5oagQrpZo+yQyd3mv3D5NAG2qEeEHpw7IkXRlpkoD45wl2o4ydHAvY9wyEfw==} engines: {node: '>= 20.19.0'} + '@noble/curves@2.0.1': + resolution: {integrity: sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@1.3.2': resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} engines: {node: '>= 16'} @@ -3719,6 +3720,10 @@ packages: resolution: {integrity: sha512-h8VUBlE8R42+XIDO229cgisD287im3kdY6nbNZJFjc6ZvKIXPYXe6Vc/t+kyjFdMFyt5JpapzTsEg8n63w5/lw==} engines: {node: '>= 20.19.0'} + '@noble/hashes@2.0.1': + resolution: {integrity: sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==} + engines: {node: '>= 20.19.0'} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -11173,7 +11178,7 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -11219,14 +11224,14 @@ snapshots: '@babel/helper-member-expression-to-functions@7.27.1': dependencies: '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -11241,7 +11246,7 @@ snapshots: '@babel/helper-optimise-call-expression@7.27.1': dependencies: - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 '@babel/helper-plugin-utils@7.27.1': {} @@ -11266,7 +11271,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -11282,18 +11287,18 @@ snapshots: dependencies: '@babel/template': 7.27.2 '@babel/traverse': 7.28.3 - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color '@babel/helpers@7.28.3': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 '@babel/highlight@7.25.9': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 chalk: 2.4.2 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -11736,7 +11741,7 @@ snapshots: '@babel/helper-module-imports': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.3) - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 transitivePeerDependencies: - supports-color @@ -11916,7 +11921,7 @@ snapshots: dependencies: '@babel/core': 7.28.3 '@babel/helper-plugin-utils': 7.27.1 - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 esutils: 2.0.3 '@babel/preset-react@7.27.1(@babel/core@7.28.3)': @@ -11953,8 +11958,8 @@ snapshots: '@babel/template@7.27.2': dependencies: '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@babel/traverse@7.28.3': dependencies: @@ -14374,24 +14379,28 @@ snapshots: '@noble/ciphers@0.5.3': {} + '@noble/ciphers@2.1.1': {} + '@noble/curves@1.2.0': dependencies: '@noble/hashes': 1.3.2 - '@noble/curves@1.9.7': - dependencies: - '@noble/hashes': 1.8.0 - '@noble/curves@2.0.0': dependencies: '@noble/hashes': 2.0.0 + '@noble/curves@2.0.1': + dependencies: + '@noble/hashes': 2.0.1 + '@noble/hashes@1.3.2': {} '@noble/hashes@1.8.0': {} '@noble/hashes@2.0.0': {} + '@noble/hashes@2.0.1': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -14747,7 +14756,7 @@ snapshots: '@svgr/hast-util-to-babel-ast@8.0.0': dependencies: - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 entities: 4.5.0 '@svgr/plugin-jsx@8.1.0(@svgr/core@8.1.0(typescript@5.9.2))': @@ -14907,24 +14916,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.3 - '@babel/types': 7.28.2 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 '@types/blake2b@2.1.3': {} @@ -15954,7 +15963,7 @@ snapshots: babel-plugin-jest-hoist@30.0.1: dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.2 + '@babel/types': 7.28.5 '@types/babel__core': 7.20.5 babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.28.3): @@ -18637,7 +18646,7 @@ snapshots: istanbul-lib-instrument@6.0.3: dependencies: '@babel/core': 7.28.3 - '@babel/parser': 7.28.3 + '@babel/parser': 7.28.5 '@istanbuljs/schema': 0.1.3 istanbul-lib-coverage: 3.2.2 semver: 7.7.3 From 0fe523849fc20c55c7450e49783f716eec9b1df1 Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sat, 10 Jan 2026 01:42:03 +0800 Subject: [PATCH 4/6] feat(core): multisig Signers --- .changeset/little-zebras-pump.md | 6 + .../client/clientPublicMainnet.advanced.ts | 17 + .../client/clientPublicTestnet.advanced.ts | 17 + packages/core/src/client/knownScript.ts | 1 + packages/core/src/hasher/hasherCkb.ts | 4 +- packages/core/src/signer/ckb/index.ts | 4 +- .../src/signer/ckb/secp256k1Signing.test.ts | 85 +++ .../core/src/signer/ckb/secp256k1Signing.ts | 94 +++ .../src/signer/ckb/signerCkbPrivateKey.ts | 17 +- .../core/src/signer/ckb/signerCkbPublicKey.ts | 11 +- .../src/signer/ckb/signerMultisigCkb.test.ts | 78 +++ .../signer/ckb/signerMultisigCkbPrivateKey.ts | 96 +++ .../signer/ckb/signerMultisigCkbReadonly.ts | 576 ++++++++++++++++++ .../src/signer/ckb/verifyCkbSecp256k1.test.ts | 40 -- .../core/src/signer/ckb/verifyCkbSecp256k1.ts | 31 - packages/core/src/signer/signer/index.ts | 56 +- packages/examples/src/transferFromMultisig.ts | 50 ++ .../src/transferFromMultisigAggregateTxs.ts | 44 ++ packages/examples/src/transferToMultisig.ts | 35 ++ 19 files changed, 1174 insertions(+), 88 deletions(-) create mode 100644 .changeset/little-zebras-pump.md create mode 100644 packages/core/src/signer/ckb/secp256k1Signing.test.ts create mode 100644 packages/core/src/signer/ckb/secp256k1Signing.ts create mode 100644 packages/core/src/signer/ckb/signerMultisigCkb.test.ts create mode 100644 packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts create mode 100644 packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts delete mode 100644 packages/core/src/signer/ckb/verifyCkbSecp256k1.test.ts delete mode 100644 packages/core/src/signer/ckb/verifyCkbSecp256k1.ts create mode 100644 packages/examples/src/transferFromMultisig.ts create mode 100644 packages/examples/src/transferFromMultisigAggregateTxs.ts create mode 100644 packages/examples/src/transferToMultisig.ts 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 bdcec4cc..e6b3259c 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/hasher/hasherCkb.ts b/packages/core/src/hasher/hasherCkb.ts index fbcddd7f..efb5ec8e 100644 --- a/packages/core/src/hasher/hasherCkb.ts +++ b/packages/core/src/hasher/hasherCkb.ts @@ -4,6 +4,9 @@ import { Hex, hexFrom } from "../hex/index.js"; import { CKB_BLAKE2B_PERSONAL } from "./advanced.js"; import { Hasher } from "./hasher.js"; +export const HASH_CKB_LENGTH = 32; +export const HASH_CKB_SHORT_LENGTH = 20; + /** * @public */ @@ -73,7 +76,6 @@ export class HasherCkb implements Hasher { * const hash = hashCkb("some data"); // Outputs something like "0x..." * ``` */ - export function hashCkb(...data: BytesLike[]): Hex { const hasher = new HasherCkb(); data.forEach((d) => hasher.update(d)); diff --git a/packages/core/src/signer/ckb/index.ts b/packages/core/src/signer/ckb/index.ts index 61cec4a3..5a9dc928 100644 --- a/packages/core/src/signer/ckb/index.ts +++ b/packages/core/src/signer/ckb/index.ts @@ -1,5 +1,7 @@ +export * from "./secp256k1Signing.js"; export * from "./signerCkbPrivateKey.js"; export * from "./signerCkbPublicKey.js"; export * from "./signerCkbScriptReadonly.js"; -export * from "./verifyCkbSecp256k1.js"; +export * from "./signerMultisigCkbPrivateKey.js"; +export * from "./signerMultisigCkbReadonly.js"; export * from "./verifyJoyId.js"; diff --git a/packages/core/src/signer/ckb/secp256k1Signing.test.ts b/packages/core/src/signer/ckb/secp256k1Signing.test.ts new file mode 100644 index 00000000..87b339d4 --- /dev/null +++ b/packages/core/src/signer/ckb/secp256k1Signing.test.ts @@ -0,0 +1,85 @@ +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { describe, expect, it } from "vitest"; +import { ccc } from "../../index"; +import { + recoverMessageSecp256k1, + signMessageSecp256k1, + verifyMessageSecp256k1, +} from "./secp256k1Signing"; + +const client = new ccc.ClientPublicTestnet(); +const signer = new ccc.SignerCkbPrivateKey( + client, + "0x0123456789012345678901234567890123456789012345678901234567890123", +); + +describe("verifyMessageCkbSecp256k1", () => { + it("should verify a message signed by SignerCkbPrivateKey", async () => { + const message = "Hello CKB!"; + const { signature, identity } = await signer.signMessage(message); + + const isValid = ccc.verifyMessageCkbSecp256k1(message, signature, identity); + expect(isValid).toBe(true); + }); + + it("should fail to verify a message with a wrong signature", async () => { + const message = "Hello CKB!"; + const { identity } = await signer.signMessage(message); + + const signature = + "0x0010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000"; + + const isValid = ccc.verifyMessageCkbSecp256k1(message, signature, identity); + expect(isValid).toBe(false); + }); + + it("should fail to verify a message with a wrong public key", async () => { + const message = "Hello CKB!"; + const { signature } = await signer.signMessage(message); + + const identity = + "0x000000000000000000000000000000000000000000000000000000000000000000"; + + const isValid = ccc.verifyMessageCkbSecp256k1(message, signature, identity); + expect(isValid).toBe(false); + }); +}); + +describe("Secp256k1 Helpers", () => { + const privateKey = + "0x0123456789012345678901234567890123456789012345678901234567890123"; + const publicKey = ccc.hexFrom( + secp256k1.getPublicKey(ccc.bytesFrom(privateKey), true), + ); + const messageHash = + "0x1234567890123456789012345678901234567890123456789012345678901234"; + + it("should verifies a message", () => { + const isValid = verifyMessageSecp256k1( + messageHash, + "0xf71fd3e5b90289fa939bd3f3c0e263e8ea8e37550417344e58c9b1675084be456c506a30789a6ec98919e5458b3898199b560a41d5262cb18db37058cff339a300", + publicKey, + ); + expect(isValid).toBe(true); + }); + + it("should sign and verify a message hash", () => { + const signature = signMessageSecp256k1(messageHash, privateKey); + const isValid = verifyMessageSecp256k1(messageHash, signature, publicKey); + expect(isValid).toBe(true); + }); + + it("should recover the public key from the signature", () => { + const signature = signMessageSecp256k1(messageHash, privateKey); + const recovered = recoverMessageSecp256k1(messageHash, signature); + expect(recovered).toBe(publicKey); + }); + + it("should fail verification with wrong message", () => { + const signature = signMessageSecp256k1(messageHash, privateKey); + const wrongMessage = + "0x0000000000000000000000000000000000000000000000000000000000000000"; + const isValid = verifyMessageSecp256k1(wrongMessage, signature, publicKey); + expect(isValid).toBe(false); + }); +}); diff --git a/packages/core/src/signer/ckb/secp256k1Signing.ts b/packages/core/src/signer/ckb/secp256k1Signing.ts new file mode 100644 index 00000000..827b5e1e --- /dev/null +++ b/packages/core/src/signer/ckb/secp256k1Signing.ts @@ -0,0 +1,94 @@ +import { secp256k1 } from "@noble/curves/secp256k1.js"; +import { bytesConcat, bytesFrom, BytesLike } from "../../bytes/index.js"; +import { hashCkb } from "../../hasher/index.js"; +import { Hex, hexFrom } from "../../hex/index.js"; + +export const SECP256K1_SIGNATURE_LENGTH = 65; + +/** + * Sign a message using Secp256k1. + * + * @param message - The message to sign. + * @param privateKey - The private key. + * @returns The signature. + * @public + */ +export function signMessageSecp256k1( + message: BytesLike, + privateKey: BytesLike, +): Hex { + const signature = secp256k1.sign(bytesFrom(message), bytesFrom(privateKey), { + format: "recovered", + prehash: false, + }); + return hexFrom(bytesConcat(signature.slice(1), signature.slice(0, 1))); +} + +/** + * Verify a message using Secp256k1. + * + * @param message - The message to verify. + * @param signature - The signature. + * @param publicKey - The public key. + * @returns True if the signature is valid, false otherwise. + * @public + */ +export function verifyMessageSecp256k1( + message: BytesLike, + signature: BytesLike, + publicKey: BytesLike, +): boolean { + const signatureBytes = bytesFrom(signature); + return secp256k1.verify( + bytesConcat(signatureBytes.slice(64), signatureBytes.slice(0, 64)), + bytesFrom(message), + bytesFrom(publicKey), + { format: "recovered", prehash: false }, + ); +} + +/** + * Recover the public key from a Secp256k1 signature. + * + * @param message - The message. + * @param signature - The signature. + * @returns The recovered public key. + * @public + */ +export function recoverMessageSecp256k1( + message: BytesLike, + signature: BytesLike, +): Hex { + const signatureBytes = bytesFrom(signature); + return hexFrom( + secp256k1.recoverPublicKey( + bytesConcat(signatureBytes.slice(64), signatureBytes.slice(0, 64)), + bytesFrom(message), + { prehash: false }, + ), + ); +} + +/** + * @public + */ +export function messageHashCkbSecp256k1(message: string | BytesLike): Hex { + const msg = typeof message === "string" ? message : hexFrom(message); + const buffer = bytesFrom(`Nervos Message:${msg}`, "utf8"); + return hashCkb(buffer); +} + +/** + * @public + */ +export function verifyMessageCkbSecp256k1( + message: string | BytesLike, + signature: string, + publicKey: string, +): boolean { + return verifyMessageSecp256k1( + messageHashCkbSecp256k1(message), + signature, + publicKey, + ); +} diff --git a/packages/core/src/signer/ckb/signerCkbPrivateKey.ts b/packages/core/src/signer/ckb/signerCkbPrivateKey.ts index 41a507d3..4f627212 100644 --- a/packages/core/src/signer/ckb/signerCkbPrivateKey.ts +++ b/packages/core/src/signer/ckb/signerCkbPrivateKey.ts @@ -1,10 +1,13 @@ import { secp256k1 } from "@noble/curves/secp256k1.js"; -import { bytesConcat, bytesFrom, BytesLike } from "../../bytes/index.js"; +import { bytesFrom, BytesLike } from "../../bytes/index.js"; import { Transaction, TransactionLike, WitnessArgs } from "../../ckb/index.js"; import { Client } from "../../client/index.js"; import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { + messageHashCkbSecp256k1, + signMessageSecp256k1, +} from "./secp256k1Signing.js"; import { SignerCkbPublicKey } from "./signerCkbPublicKey.js"; -import { messageHashCkbSecp256k1 } from "./verifyCkbSecp256k1.js"; /** * @public @@ -23,15 +26,7 @@ export class SignerCkbPrivateKey extends SignerCkbPublicKey { } async _signMessage(message: HexLike): Promise { - const signature = secp256k1.sign( - bytesFrom(message), - bytesFrom(this.privateKey), - { - format: "recovered", - prehash: false, - }, - ); - return hexFrom(bytesConcat(signature.slice(1), signature.slice(0, 1))); + return signMessageSecp256k1(message, this.privateKey); } async signMessageRaw(message: string | BytesLike): Promise { diff --git a/packages/core/src/signer/ckb/signerCkbPublicKey.ts b/packages/core/src/signer/ckb/signerCkbPublicKey.ts index 20cf8f2c..81855c34 100644 --- a/packages/core/src/signer/ckb/signerCkbPublicKey.ts +++ b/packages/core/src/signer/ckb/signerCkbPublicKey.ts @@ -2,9 +2,10 @@ import { Address } from "../../address/index.js"; import { bytesFrom } from "../../bytes/index.js"; import { Script, Transaction, TransactionLike } from "../../ckb/index.js"; import { CellDepInfo, Client, KnownScript } from "../../client/index.js"; -import { hashCkb } from "../../hasher/index.js"; +import { HASH_CKB_SHORT_LENGTH, hashCkb } from "../../hasher/index.js"; import { Hex, HexLike, hexFrom } from "../../hex/index.js"; import { Signer, SignerSignType, SignerType } from "../signer/index.js"; +import { SECP256K1_SIGNATURE_LENGTH } from "./secp256k1Signing.js"; /** * @public @@ -47,7 +48,7 @@ export class SignerCkbPublicKey extends Signer { return Address.fromKnownScript( this.client, KnownScript.Secp256k1Blake160, - bytesFrom(hashCkb(this.publicKey)).slice(0, 20), + bytesFrom(hashCkb(this.publicKey)).slice(0, HASH_CKB_SHORT_LENGTH), ); } @@ -140,7 +141,11 @@ export class SignerCkbPublicKey extends Signer { await Promise.all( (await this.getRelatedScripts(tx)).map(async ({ script, cellDeps }) => { - await tx.prepareSighashAllWitness(script, 65, this.client); + await tx.prepareSighashAllWitness( + script, + SECP256K1_SIGNATURE_LENGTH, + this.client, + ); await tx.addCellDepInfos(this.client, cellDeps); }), ); 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..e51e24bf --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigCkb.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { ccc } from "../../index.js"; + +const client = new ccc.ClientPublicTestnet(); + +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("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); + }); +}); diff --git a/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts b/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts new file mode 100644 index 00000000..3f532c09 --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts @@ -0,0 +1,96 @@ +import { SinceLike, Transaction, TransactionLike } from "../../ckb/index.js"; +import { Client, KnownScript, ScriptInfoLike } from "../../client/index.js"; +import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { + signMessageSecp256k1, + verifyMessageSecp256k1, +} 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); + + 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) => { + if ( + witness.signatures.some( + (sig) => + sig !== SignerMultisigCkbPrivateKey.EmptySignature && + verifyMessageSecp256k1( + info.message, + sig, + this.signer.publicKey, + ), + ) + ) { + // Has signed + return; + } + + const empty = witness.signatures.findIndex( + (sig) => sig === SignerMultisigCkbPrivateKey.EmptySignature, + ); + if (empty === -1) { + return; + } + + const signature = signMessageSecp256k1(info.message, this.privateKey); + + witness.signatures[empty] = signature; + }, + ); + } + + 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..ee58d336 --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts @@ -0,0 +1,576 @@ +import { Address } from "../../address/index.js"; +import { Bytes, bytesConcat, bytesFrom } 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, hashCkb } 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) => { + 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) => hashCkb(k).slice(0, 42)); + })(); + + return new MultisigCkbWitness( + publicKeyHashes.map(hexFrom), + Number(numFrom(witness.threshold)), + Number(numFrom(witness.mustMatch ?? 0)), + witness.signatures?.map(hexFrom) ?? [], + ); + } + + /** + * Get the script args of the multisig script. + * + * @param since - The since value. + * @returns The script args. + */ + scriptArgs(since?: SinceLike | null): Bytes { + const hash = hashCkb(this.toBytes()).slice(0, 42); + + 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 + ); + } +} + +/** + * 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) => + typeof v === "string" ? client.getKnownScript(v) : ScriptInfo.from(v), + ), + ).then((infos) => + infos.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; + } + + 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(); + + multisigWitness.signatures = multisigWitness.signatures.slice( + 0, + this.multisigInfo.threshold, + ); + multisigWitness.signatures.push( + ...Array.from( + new Array( + this.multisigInfo.threshold - multisigWitness.signatures.length, + ), + () => SignerMultisigCkbReadonly.EmptySignature, + ), + ); + + witnessArgs.lock = MultisigCkbWitness.from( + (await transformer?.(multisigWitness, witnessArgs)) ?? multisigWitness, + ).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 signatures in the transaction. + * + * @param txLike - The transaction. + * @returns The number of signatures. + */ + async getSignaturesCount( + txLike: TransactionLike, + ): Promise { + const tx = Transaction.from(txLike); + let minSignaturesCount = undefined; + + for (const { script } of await this.scriptInfos) { + const index = await tx.findInputIndexByLock(script, this.client); + if (index === undefined) { + continue; + } + + const multisigWitness = this.decodeWitnessArgsAt(tx, index); + + if (!multisigWitness) { + minSignaturesCount = 0; + } else { + minSignaturesCount = Math.min( + minSignaturesCount ?? 256, + multisigWitness.signatures.reduce( + (acc, s) => + acc + (s === SignerMultisigCkbReadonly.EmptySignature ? 0 : 1), + 0, + ), + ); + } + } + + return minSignaturesCount; + } + + /** + * Check if the transaction needs more signatures + * + * @param txLike - The transaction to check. + * @returns A promise that resolves to true if the multisig witness is fulfilled, 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(); + for (const txLike of txs) { + const tx = Transaction.from(txLike); + const multisigWitness = this.decodeWitnessArgsAt(tx, info.position); + + if (!multisigWitness) { + continue; + } + + for (const sig of multisigWitness.signatures) { + try { + signatures.set(recoverMessageSecp256k1(info.message, sig), sig); + } catch (_) { + // Ignore invalid signatures + } + 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/ckb/verifyCkbSecp256k1.test.ts b/packages/core/src/signer/ckb/verifyCkbSecp256k1.test.ts deleted file mode 100644 index 2a2d176d..00000000 --- a/packages/core/src/signer/ckb/verifyCkbSecp256k1.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ccc } from "../../index"; - -const client = new ccc.ClientPublicTestnet(); -const signer = new ccc.SignerCkbPrivateKey( - client, - "0x0123456789012345678901234567890123456789012345678901234567890123", -); - -describe("verifyMessageCkbSecp256k1", () => { - it("should verify a message signed by SignerCkbPrivateKey", async () => { - const message = "Hello CKB!"; - const { signature, identity } = await signer.signMessage(message); - - const isValid = ccc.verifyMessageCkbSecp256k1(message, signature, identity); - expect(isValid).toBe(true); - }); - - it("should fail to verify a message with a wrong signature", async () => { - const message = "Hello CKB!"; - const { identity } = await signer.signMessage(message); - - const signature = - "0x0010000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000"; - - const isValid = ccc.verifyMessageCkbSecp256k1(message, signature, identity); - expect(isValid).toBe(false); - }); - - it("should fail to verify a message with a wrong public key", async () => { - const message = "Hello CKB!"; - const { signature } = await signer.signMessage(message); - - const identity = - "0x000000000000000000000000000000000000000000000000000000000000000000"; - - const isValid = ccc.verifyMessageCkbSecp256k1(message, signature, identity); - expect(isValid).toBe(false); - }); -}); diff --git a/packages/core/src/signer/ckb/verifyCkbSecp256k1.ts b/packages/core/src/signer/ckb/verifyCkbSecp256k1.ts deleted file mode 100644 index 28244e1b..00000000 --- a/packages/core/src/signer/ckb/verifyCkbSecp256k1.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { secp256k1 } from "@noble/curves/secp256k1.js"; -import { bytesConcat, bytesFrom, BytesLike } from "../../bytes/index.js"; -import { hashCkb } from "../../hasher/index.js"; -import { Hex, hexFrom } from "../../hex/index.js"; - -/** - * @public - */ -export function messageHashCkbSecp256k1(message: string | BytesLike): Hex { - const msg = typeof message === "string" ? message : hexFrom(message); - const buffer = bytesFrom(`Nervos Message:${msg}`, "utf8"); - return hashCkb(buffer); -} - -/** - * @public - */ -export function verifyMessageCkbSecp256k1( - message: string | BytesLike, - signature: string, - publicKey: string, -): boolean { - const raw = bytesFrom(signature); - - return secp256k1.verify( - bytesConcat(raw.slice(64), raw.slice(0, 64)), - bytesFrom(messageHashCkbSecp256k1(message)), - bytesFrom(publicKey), - { format: "recovered", prehash: false }, - ); -} diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index a2d0504b..b3b694b1 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -11,7 +11,7 @@ import { import { Hex } from "../../hex/index.js"; import { Num } from "../../num/index.js"; import { verifyMessageBtcEcdsa } from "../btc/verify.js"; -import { verifyMessageCkbSecp256k1 } from "../ckb/verifyCkbSecp256k1.js"; +import { verifyMessageCkbSecp256k1 } from "../ckb/secp256k1Signing.js"; import { verifyMessageJoyId } from "../ckb/verifyJoyId.js"; import { verifyMessageDogeEcdsa } from "../doge/verify.js"; import { verifyMessageEvmPersonal } from "../evm/verify.js"; @@ -491,6 +491,60 @@ 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 number of signatures in the transaction. + * @param _ - The transaction. + * @returns The number of signatures. + */ + abstract getSignaturesCount(_: TransactionLike): Promise; + + /** + * Check if the transaction needs more signatures + * + * @param txLike - The transaction to check. + * @returns A promise that resolves to true if the multisig witness is fulfilled, false otherwise. + */ + abstract needMoreSignatures(_: TransactionLike): Promise; + + /** + * Aggregate transactions. + * @param _ - The transactions to aggregate. + * @returns The aggregated transaction. + */ + abstract aggregateTransactions(_: TransactionLike[]): Promise; + + /** + * Send a transaction. + * @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)) { + throw Error("Not enough signatures"); + } + + return this.client.sendTransaction(signedTx); + } +} + /** * 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..c6b8808a --- /dev/null +++ b/packages/examples/src/transferFromMultisig.ts @@ -0,0 +1,50 @@ +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, + }), +); + +// === Prepare multisig signer === + +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)) { + 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`, + ); + } + } 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..cf67cf94 --- /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, + }), +); + +// === Prepare multisig signer === + +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..a76414df --- /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, +}); + +// === Prepare multisig signer === + +// 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`); From a137473c59d50c7051116660d5dfb05f85a7b11f Mon Sep 17 00:00:00 2001 From: Hanssen0 Date: Sat, 10 Jan 2026 06:00:00 +0800 Subject: [PATCH 5/6] docs(docs): add examples --- packages/docs/docs/code-examples.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/docs/docs/code-examples.md b/packages/docs/docs/code-examples.md index 850efdb5..46d16149 100644 --- a/packages/docs/docs/code-examples.md +++ b/packages/docs/docs/code-examples.md @@ -28,4 +28,11 @@ That's it! The transaction is sent. - [Use all supported wallets in custom UI.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/customUiWithController.ts) - [Sign and verify any message.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/sign.ts) - [Transfer all native CKB token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferAll.ts) -- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts) \ No newline at end of file +- [Transfer UDT token.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferUdt.ts) +- [Create a DID on CKB.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/createDid.ts) +- [Create a DID with Local ID.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/createDidWithLocalId.ts) +- [Destroy a DID on CKB.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/destroyDid.ts) +- [Transfer a DID on CKB.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferDid.ts) +- [Transfer from a multisig address.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferFromMultisig.ts) +- [Transfer from a multisig address with aggregated transactions.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferFromMultisigAggregateTxs.ts) +- [Transfer to a multisig address.](https://live.ckbccc.com/?src=https://raw.githubusercontent.com/ckb-devrel/ccc/refs/heads/master/packages/examples/src/transferToMultisig.ts) \ No newline at end of file From a770d5215d1a159c1eba3315bb9dc7a476f4fd7f Mon Sep 17 00:00:00 2001 From: phroi <90913182+phroi@users.noreply.github.com> Date: Mon, 2 Mar 2026 01:12:31 +0000 Subject: [PATCH 6/6] feat(core): extract SignerMultisigCkbBase for Omnilock multisig Extract shared multisig logic into abstract SignerMultisigCkbBase with encodeWitnessLock/decodeWitnessLock template methods. CKB multisig and Omnilock multisig are thin subclasses differing only in witness encoding. MultisigCkbWitness entity moved to its own module. OmniLockWitnessLock molecule encoding added as standalone utility. --- packages/core/src/signer/ckb/index.ts | 5 + .../core/src/signer/ckb/multisigCkbWitness.ts | 185 ++++++ .../src/signer/ckb/omniLockWitnessLock.ts | 105 ++++ .../src/signer/ckb/signerMultisigCkbBase.ts | 342 +++++++++++ .../signer/ckb/signerMultisigCkbPrivateKey.ts | 6 +- .../signer/ckb/signerMultisigCkbReadonly.ts | 539 +----------------- .../ckb/signerMultisigOmniLockPrivateKey.ts | 98 ++++ .../ckb/signerMultisigOmniLockReadonly.ts | 159 ++++++ 8 files changed, 913 insertions(+), 526 deletions(-) create mode 100644 packages/core/src/signer/ckb/multisigCkbWitness.ts create mode 100644 packages/core/src/signer/ckb/omniLockWitnessLock.ts create mode 100644 packages/core/src/signer/ckb/signerMultisigCkbBase.ts create mode 100644 packages/core/src/signer/ckb/signerMultisigOmniLockPrivateKey.ts create mode 100644 packages/core/src/signer/ckb/signerMultisigOmniLockReadonly.ts diff --git a/packages/core/src/signer/ckb/index.ts b/packages/core/src/signer/ckb/index.ts index 5a9dc928..2cae387d 100644 --- a/packages/core/src/signer/ckb/index.ts +++ b/packages/core/src/signer/ckb/index.ts @@ -1,7 +1,12 @@ +export * from "./multisigCkbWitness.js"; +export * from "./omniLockWitnessLock.js"; export * from "./secp256k1Signing.js"; export * from "./signerCkbPrivateKey.js"; export * from "./signerCkbPublicKey.js"; export * from "./signerCkbScriptReadonly.js"; +export * from "./signerMultisigCkbBase.js"; export * from "./signerMultisigCkbPrivateKey.js"; export * from "./signerMultisigCkbReadonly.js"; +export * from "./signerMultisigOmniLockPrivateKey.js"; +export * from "./signerMultisigOmniLockReadonly.js"; export * from "./verifyJoyId.js"; diff --git a/packages/core/src/signer/ckb/multisigCkbWitness.ts b/packages/core/src/signer/ckb/multisigCkbWitness.ts new file mode 100644 index 00000000..d7f046f9 --- /dev/null +++ b/packages/core/src/signer/ckb/multisigCkbWitness.ts @@ -0,0 +1,185 @@ +import { Bytes, bytesConcat, bytesFrom } from "../../bytes/index.js"; +import { Since, SinceLike } from "../../ckb/index.js"; +import { codec, Entity } from "../../codec/index.js"; +import { HASH_CKB_SHORT_LENGTH, hashCkb } from "../../hasher/index.js"; +import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { numFrom, NumLike, numToBytes } from "../../num/index.js"; +import { 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) => { + 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) => hashCkb(k).slice(0, 42)); + })(); + + return new MultisigCkbWitness( + publicKeyHashes.map(hexFrom), + Number(numFrom(witness.threshold)), + Number(numFrom(witness.mustMatch ?? 0)), + witness.signatures?.map(hexFrom) ?? [], + ); + } + + /** + * Get the script args of the multisig script. + * + * @param since - The since value. + * @returns The script args. + */ + scriptArgs(since?: SinceLike | null): Bytes { + const hash = hashCkb(this.toBytes()).slice(0, 42); + + 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 + ); + } +} diff --git a/packages/core/src/signer/ckb/omniLockWitnessLock.ts b/packages/core/src/signer/ckb/omniLockWitnessLock.ts new file mode 100644 index 00000000..0519b171 --- /dev/null +++ b/packages/core/src/signer/ckb/omniLockWitnessLock.ts @@ -0,0 +1,105 @@ +/** + * OmniLockWitnessLock molecule table encoding/decoding. + * + * table OmniLockWitnessLock { + * signature: BytesOpt, + * omni_identity: IdentityOpt, + * preimage: BytesOpt, + * } + * + * For bridge use (auth modes 0x00-0x06, 0xFC), only the signature field is + * populated. omni_identity and preimage are None (zero-length). + */ + +import { Bytes, bytesConcat, bytesFrom } from "../../bytes/index.js"; +import { Hex, hexFrom } from "../../hex/index.js"; +import { numToBytes } from "../../num/index.js"; + +const NUM_FIELDS = 3; +const HEADER_BYTES = (1 + NUM_FIELDS) * 4; // full_size + 3 offsets = 16 + +/** + * Encode a signature into the OmniLockWitnessLock molecule table format. + * + * The result is suitable for WitnessArgs.lock when the Omnilock cell uses + * a signature-based auth mode (secp256k1, Ethereum, Bitcoin, CKB multisig, + * owner lock, etc.) and does not use the administrator (omni_identity) or + * preimage fields. + * + * @param signature - The raw signature bytes. + * @returns The encoded OmniLockWitnessLock bytes. + * @public + */ +export function encodeOmniLockWitnessLock(signature: Bytes): Bytes { + const fullSize = HEADER_BYTES + 4 + signature.length; + return bytesFrom( + bytesConcat( + numToBytes(fullSize, 4), // full_size + numToBytes(HEADER_BYTES, 4), // offset[0]: signature starts here + numToBytes(fullSize, 4), // offset[1]: omni_identity (absent, at end) + numToBytes(fullSize, 4), // offset[2]: preimage (absent, at end) + numToBytes(signature.length, 4), // Bytes item count + signature, + ), + ); +} + +/** + * Encode a signature into OmniLockWitnessLock and return as Hex. + * + * @param signature - The raw signature bytes. + * @returns The encoded OmniLockWitnessLock hex string. + * @public + */ +export function encodeOmniLockWitnessLockToHex(signature: Bytes): Hex { + return hexFrom(encodeOmniLockWitnessLock(signature)); +} + +/** + * Decode the signature from an OmniLockWitnessLock molecule table. + * + * @param data - The full OmniLockWitnessLock bytes. + * @returns The decoded signature, or undefined if the signature field is absent. + * @public + */ +export function decodeOmniLockWitnessLock(data: Bytes): Bytes | undefined { + if (data.length < HEADER_BYTES) { + throw new Error("OmniLockWitnessLock: data too short for header"); + } + + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const fullSize = view.getUint32(0, true); + if (fullSize !== data.length) { + throw new Error("OmniLockWitnessLock: full_size mismatch"); + } + + const signatureOffset = view.getUint32(4, true); + const omniIdentityOffset = view.getUint32(8, true); + + // signature field is present if offset[0] < offset[1] + if (signatureOffset >= omniIdentityOffset) { + return undefined; + } + + const sigBytesCount = view.getUint32(signatureOffset, true); + const sigStart = signatureOffset + 4; + if (sigStart + sigBytesCount > data.length) { + throw new Error("OmniLockWitnessLock: signature exceeds data bounds"); + } + + return data.slice(sigStart, sigStart + sigBytesCount); +} + +/** + * Compute the WitnessArgs.lock byte length for an OmniLockWitnessLock + * containing a signature of the given length. + * + * Used to prepare the witness placeholder before computing sighash_all. + * + * @param signatureLength - The raw signature byte length. + * @returns The total OmniLockWitnessLock byte length. + * @public + */ +export function omniLockWitnessLockLength(signatureLength: number): number { + return HEADER_BYTES + 4 + signatureLength; +} diff --git a/packages/core/src/signer/ckb/signerMultisigCkbBase.ts b/packages/core/src/signer/ckb/signerMultisigCkbBase.ts new file mode 100644 index 00000000..5fd39301 --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigCkbBase.ts @@ -0,0 +1,342 @@ +import { Address } from "../../address/index.js"; +import { + Script, + ScriptLike, + Transaction, + TransactionLike, + WitnessArgs, + WitnessArgsLike, +} from "../../ckb/index.js"; +import { CellDepInfo, CellDepInfoLike, Client } from "../../client/index.js"; +import { Hex, hexFrom } from "../../hex/index.js"; +import { reduceAsync } from "../../utils/index.js"; +import { SignerMultisig, SignerSignType, SignerType } from "../signer/index.js"; +import { + MultisigCkbWitness, + MultisigCkbWitnessLike, +} from "./multisigCkbWitness.js"; +import { + recoverMessageSecp256k1, + SECP256K1_SIGNATURE_LENGTH, +} from "./secp256k1Signing.js"; + +/** + * Abstract base class for CKB-family multisig signers. + * + * Provides shared logic for witness preparation, signature counting, + * aggregation, etc. Subclasses implement two template methods that + * control witness encoding (plain multisig bytes vs. OmniLock envelope). + * + * @public + */ +export abstract class SignerMultisigCkbBase 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 scriptInfos: Promise< + { + script: Script; + cellDeps: CellDepInfo[]; + }[] + >; + + /** + * @param client - The client instance. + * @param multisigInfo - The resolved multisig witness info. + * @param scriptInfos - Promise resolving to the script(s) this signer manages. + */ + constructor( + client: Client, + multisigInfo: MultisigCkbWitness, + scriptInfos: Promise<{ script: Script; cellDeps: CellDepInfo[] }[]>, + ) { + super(client); + this.multisigInfo = multisigInfo; + this.scriptInfos = scriptInfos; + } + + /** + * Encode a MultisigCkbWitness into the bytes stored in WitnessArgs.lock. + * CKB multisig: raw multisig hex. OmniLock: wrapped in OmniLockWitnessLock. + */ + protected abstract encodeWitnessLock(witness: MultisigCkbWitness): Hex; + + /** + * Decode WitnessArgs.lock bytes back into a MultisigCkbWitness. + * Returns undefined if the data does not match this signer's format. + */ + protected abstract decodeWitnessLock( + lock: Hex, + ): MultisigCkbWitness | undefined; + + async getMemberCount(): Promise { + return this.multisigInfo.publicKeyHashes.length; + } + + async getMemberThreshold(): Promise { + return this.multisigInfo.threshold; + } + + 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. + */ + decodeWitnessArgsAt( + txLike: TransactionLike, + index: number, + ): MultisigCkbWitness | undefined { + const tx = Transaction.from(txLike); + return this.decodeWitnessArgs(tx.getWitnessArgsAt(index)); + } + + /** + * Decode the witness args. + */ + decodeWitnessArgs( + witnessLike?: WitnessArgsLike | null, + ): MultisigCkbWitness | undefined { + if (!witnessLike) { + return; + } + const witness = WitnessArgs.from(witnessLike); + + if (witness.lock == null) { + return; + } + + try { + const decoded = this.decodeWitnessLock(witness.lock); + if (decoded && decoded.eqInfo(this.multisigInfo)) { + return decoded; + } + } catch (_) { + // Returns undefined for invalid data + } + } + + /** + * Prepare the witness args at a specific index. + */ + 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(); + + multisigWitness.signatures = multisigWitness.signatures.slice( + 0, + this.multisigInfo.threshold, + ); + multisigWitness.signatures.push( + ...Array.from( + new Array( + this.multisigInfo.threshold - multisigWitness.signatures.length, + ), + () => SignerMultisigCkbBase.EmptySignature, + ), + ); + + witnessArgs.lock = this.encodeWitnessLock( + MultisigCkbWitness.from( + (await transformer?.(multisigWitness, witnessArgs)) ?? multisigWitness, + ), + ); + tx.setWitnessArgsAt(index, witnessArgs); + + return tx; + } + + /** + * Prepare multisig witness for a single script variant. + */ + async prepareTransactionOneScript( + txLike: TransactionLike, + script: ScriptLike, + cellDeps: CellDepInfoLike[], + ): Promise { + 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. + */ + 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 signatures in the transaction. + */ + async getSignaturesCount( + txLike: TransactionLike, + ): Promise { + const tx = Transaction.from(txLike); + let minSignaturesCount = undefined; + + for (const { script } of await this.scriptInfos) { + const index = await tx.findInputIndexByLock(script, this.client); + if (index === undefined) { + continue; + } + + const multisigWitness = this.decodeWitnessArgsAt(tx, index); + + if (!multisigWitness) { + minSignaturesCount = 0; + } else { + minSignaturesCount = Math.min( + minSignaturesCount ?? 256, + multisigWitness.signatures.reduce( + (acc, s) => + acc + (s === SignerMultisigCkbBase.EmptySignature ? 0 : 1), + 0, + ), + ); + } + } + + return minSignaturesCount; + } + + /** + * Check if the transaction needs more signatures. + */ + 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. + */ + 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 = this.encodeWitnessLock( + MultisigCkbWitness.from({ + ...this.multisigInfo, + signatures: Array.from( + new Array(this.multisigInfo.threshold), + () => SignerMultisigCkbBase.EmptySignature, + ), + }), + ); + + const clonedTx = tx.clone(); + clonedTx.setWitnessArgsAt(position, witness); + // === Replace the witness with a dummy one === + + return clonedTx.getSignHashInfo(script, this.client); + } + + /** + * Aggregate transactions. + */ + 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(); + for (const txLike of txs) { + const tx = Transaction.from(txLike); + const multisigWitness = this.decodeWitnessArgsAt(tx, info.position); + + if (!multisigWitness) { + continue; + } + + for (const sig of multisigWitness.signatures) { + try { + signatures.set(recoverMessageSecp256k1(info.message, sig), sig); + } catch (_) { + // Ignore invalid signatures + } + 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/ckb/signerMultisigCkbPrivateKey.ts b/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts index 3f532c09..fa81966a 100644 --- a/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts +++ b/packages/core/src/signer/ckb/signerMultisigCkbPrivateKey.ts @@ -1,15 +1,13 @@ import { SinceLike, Transaction, TransactionLike } from "../../ckb/index.js"; import { Client, KnownScript, ScriptInfoLike } from "../../client/index.js"; import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { MultisigCkbWitnessLike } from "./multisigCkbWitness.js"; import { signMessageSecp256k1, verifyMessageSecp256k1, } from "./secp256k1Signing.js"; import { SignerCkbPrivateKey } from "./signerCkbPrivateKey.js"; -import { - MultisigCkbWitnessLike, - SignerMultisigCkbReadonly, -} from "./signerMultisigCkbReadonly.js"; +import { SignerMultisigCkbReadonly } from "./signerMultisigCkbReadonly.js"; /** * A class extending Signer that provides access to a CKB multisig script and supports signing operations. diff --git a/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts b/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts index ee58d336..d2862f3a 100644 --- a/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts +++ b/packages/core/src/signer/ckb/signerMultisigCkbReadonly.ts @@ -1,237 +1,25 @@ -import { Address } from "../../address/index.js"; -import { Bytes, bytesConcat, bytesFrom } from "../../bytes/index.js"; +import { Script, Since, SinceLike } from "../../ckb/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, hashCkb } 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 { Hex } from "../../hex/index.js"; +import { apply } from "../../utils/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) => { - 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< + MultisigCkbWitness, 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) => hashCkb(k).slice(0, 42)); - })(); - - return new MultisigCkbWitness( - publicKeyHashes.map(hexFrom), - Number(numFrom(witness.threshold)), - Number(numFrom(witness.mustMatch ?? 0)), - witness.signatures?.map(hexFrom) ?? [], - ); - } - - /** - * Get the script args of the multisig script. - * - * @param since - The since value. - * @returns The script args. - */ - scriptArgs(since?: SinceLike | null): Bytes { - const hash = hashCkb(this.toBytes()).slice(0, 42); - - 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 - ); - } -} +} from "./multisigCkbWitness.js"; +import { SignerMultisigCkbBase } from "./signerMultisigCkbBase.js"; /** * 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; - +export class SignerMultisigCkbReadonly extends SignerMultisigCkbBase { public readonly since?: Since; - public readonly scriptInfos: Promise< - { - script: Script; - cellDeps: CellDepInfo[]; - }[] - >; /** * Creates an instance of SignerMultisigCkbReadonly. @@ -248,13 +36,11 @@ export class SignerMultisigCkbReadonly extends SignerMultisig { scriptInfos?: (KnownScript | ScriptInfoLike)[] | null; } | null, ) { - super(client); - - this.multisigInfo = MultisigCkbWitness.from(multisigInfoLike); - this.since = apply(Since.from, options?.since); + const multisigInfo = MultisigCkbWitness.from(multisigInfoLike); + const since = apply(Since.from, options?.since); - const args = this.multisigInfo.scriptArgs(this.since); - this.scriptInfos = Promise.all( + const args = multisigInfo.scriptArgs(since); + const scriptInfos = Promise.all( ( options?.scriptInfos ?? [ KnownScript.Secp256k1MultisigV2, @@ -270,307 +56,16 @@ export class SignerMultisigCkbReadonly extends SignerMultisig { 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; + super(client, multisigInfo, scriptInfos); + this.since = since; } - async connect(): Promise {} - - async isConnected(): Promise { - return true; + protected encodeWitnessLock(witness: MultisigCkbWitness): Hex { + return witness.toHex(); } - 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(); - - multisigWitness.signatures = multisigWitness.signatures.slice( - 0, - this.multisigInfo.threshold, - ); - multisigWitness.signatures.push( - ...Array.from( - new Array( - this.multisigInfo.threshold - multisigWitness.signatures.length, - ), - () => SignerMultisigCkbReadonly.EmptySignature, - ), - ); - - witnessArgs.lock = MultisigCkbWitness.from( - (await transformer?.(multisigWitness, witnessArgs)) ?? multisigWitness, - ).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 signatures in the transaction. - * - * @param txLike - The transaction. - * @returns The number of signatures. - */ - async getSignaturesCount( - txLike: TransactionLike, - ): Promise { - const tx = Transaction.from(txLike); - let minSignaturesCount = undefined; - - for (const { script } of await this.scriptInfos) { - const index = await tx.findInputIndexByLock(script, this.client); - if (index === undefined) { - continue; - } - - const multisigWitness = this.decodeWitnessArgsAt(tx, index); - - if (!multisigWitness) { - minSignaturesCount = 0; - } else { - minSignaturesCount = Math.min( - minSignaturesCount ?? 256, - multisigWitness.signatures.reduce( - (acc, s) => - acc + (s === SignerMultisigCkbReadonly.EmptySignature ? 0 : 1), - 0, - ), - ); - } - } - - return minSignaturesCount; - } - - /** - * Check if the transaction needs more signatures - * - * @param txLike - The transaction to check. - * @returns A promise that resolves to true if the multisig witness is fulfilled, 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(); - for (const txLike of txs) { - const tx = Transaction.from(txLike); - const multisigWitness = this.decodeWitnessArgsAt(tx, info.position); - - if (!multisigWitness) { - continue; - } - - for (const sig of multisigWitness.signatures) { - try { - signatures.set(recoverMessageSecp256k1(info.message, sig), sig); - } catch (_) { - // Ignore invalid signatures - } - 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; + protected decodeWitnessLock(lock: Hex): MultisigCkbWitness | undefined { + return MultisigCkbWitness.decode(lock); } } diff --git a/packages/core/src/signer/ckb/signerMultisigOmniLockPrivateKey.ts b/packages/core/src/signer/ckb/signerMultisigOmniLockPrivateKey.ts new file mode 100644 index 00000000..ab623e2d --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigOmniLockPrivateKey.ts @@ -0,0 +1,98 @@ +import { Transaction, TransactionLike } from "../../ckb/index.js"; +import { Client } from "../../client/index.js"; +import { Hex, hexFrom, HexLike } from "../../hex/index.js"; +import { MultisigCkbWitnessLike } from "./multisigCkbWitness.js"; +import { + signMessageSecp256k1, + verifyMessageSecp256k1, +} from "./secp256k1Signing.js"; +import { SignerCkbPrivateKey } from "./signerCkbPrivateKey.js"; +import { + OmniLockMultisigOptions, + SignerMultisigOmniLockReadonly, +} from "./signerMultisigOmniLockReadonly.js"; + +/** + * A signing-capable signer for Omnilock cells using CKB multisig auth (0x06). + * + * Extends SignerMultisigOmniLockReadonly with the ability to sign transactions + * using a single private key. For M-of-N multisig, each guard creates an + * instance with their own private key. Partial signatures are aggregated via + * `aggregateTransactions()`. + * + * @public + */ +export class SignerMultisigOmniLockPrivateKey extends SignerMultisigOmniLockReadonly { + private readonly privateKey: Hex; + private readonly signer: SignerCkbPrivateKey; + + /** + * Creates an instance of SignerMultisigOmniLockPrivateKey. + * + * @param client - The client instance. + * @param privateKey - The secp256k1 private key for this guard. + * @param multisigInfoLike - The multisig configuration (all public keys, threshold, mustMatch). + * @param options - Omnilock-specific options (flags, ACP minimums, script override). + */ + constructor( + client: Client, + privateKey: HexLike, + multisigInfoLike: MultisigCkbWitnessLike, + options?: OmniLockMultisigOptions | null, + ) { + super(client, multisigInfoLike, options); + + this.privateKey = hexFrom(privateKey); + this.signer = new SignerCkbPrivateKey(client, this.privateKey); + } + + /** + * Sign a transaction with this guard's private key. + * + * Finds the first empty signature slot in the multisig witness and fills it. + * If this key has already signed, the transaction is returned unchanged. + */ + async signOnlyTransaction(txLike: TransactionLike): Promise { + let tx = Transaction.from(txLike); + + for (const { script } of await this.scriptInfos) { + const info = await this.getSignInfo(tx, script); + if (!info) { + continue; + } + + tx = await this.prepareWitnessArgsAt( + tx, + info.position, + async (witness) => { + if ( + witness.signatures.some( + (sig) => + sig !== SignerMultisigOmniLockPrivateKey.EmptySignature && + verifyMessageSecp256k1( + info.message, + sig, + this.signer.publicKey, + ), + ) + ) { + // Already signed by this key + return; + } + + const empty = witness.signatures.findIndex( + (sig) => sig === SignerMultisigOmniLockPrivateKey.EmptySignature, + ); + if (empty === -1) { + return; + } + + const signature = signMessageSecp256k1(info.message, this.privateKey); + witness.signatures[empty] = signature; + }, + ); + } + + return tx; + } +} diff --git a/packages/core/src/signer/ckb/signerMultisigOmniLockReadonly.ts b/packages/core/src/signer/ckb/signerMultisigOmniLockReadonly.ts new file mode 100644 index 00000000..3a77e8bf --- /dev/null +++ b/packages/core/src/signer/ckb/signerMultisigOmniLockReadonly.ts @@ -0,0 +1,159 @@ +import { bytesConcat, bytesFrom } from "../../bytes/index.js"; +import { Script } from "../../ckb/index.js"; +import { + CellDepInfo, + Client, + KnownScript, + ScriptInfo, + ScriptInfoLike, +} from "../../client/index.js"; +import { Hex, hexFrom } from "../../hex/index.js"; +import { numFrom, NumLike } from "../../num/index.js"; +import { + MultisigCkbWitness, + MultisigCkbWitnessLike, +} from "./multisigCkbWitness.js"; +import { + decodeOmniLockWitnessLock, + encodeOmniLockWitnessLockToHex, +} from "./omniLockWitnessLock.js"; +import { SignerMultisigCkbBase } from "./signerMultisigCkbBase.js"; + +/** + * Auth flag byte for CKB multisig inside Omnilock args (IdentityCkbMultisig). + */ +const AUTH_FLAG_CKB_MULTISIG = 0x06; + +/** + * Omnilock flags bitmask for Anyone-Can-Pay mode. + */ +const ACP_MASK = 0x02; + +export interface OmniLockMultisigOptions { + /** + * Omnilock flags byte. Set bit 1 (0x02) to enable ACP mode. + * Default: 0x00 (no modes, pure multisig custody). + */ + omniLockFlags?: NumLike | null; + + /** + * ACP minimum CKB exponent (10^n shannons). Only used when ACP is enabled. + * Default: 0 (minimum 1 shannon). + */ + acpMinCkb?: NumLike | null; + + /** + * ACP minimum UDT exponent (10^n base units). Only used when ACP is enabled. + * Default: 0 (minimum 1 base unit). + */ + acpMinUdt?: NumLike | null; + + /** + * Override the Omnilock script info. By default, resolved from + * KnownScript.OmniLock via the client. + */ + scriptInfo?: ScriptInfoLike | null; +} + +/** + * Build the Omnilock script args. + * + * Layout: <0x06> <20B blake160(multisig_script)> <1B omnilock_flags> + * [<2B ckb_min|udt_min> if ACP] + * + * Must be static (called before super()). + */ +function buildOmniLockArgs( + multisigInfo: MultisigCkbWitness, + omniLockFlags: number, + acpMinCkb: number, + acpMinUdt: number, +): Hex { + const multisigBlake160 = multisigInfo.scriptArgs(); + const parts = [ + bytesFrom([AUTH_FLAG_CKB_MULTISIG]), + multisigBlake160, + bytesFrom([omniLockFlags]), + ]; + if (omniLockFlags & ACP_MASK) { + parts.push(bytesFrom([acpMinCkb, acpMinUdt])); + } + return hexFrom(bytesConcat(...parts)); +} + +/** + * A read-only signer for Omnilock cells using CKB multisig auth (flag 0x06). + * + * Omnilock with auth flag 0x06 uses the same M-of-N secp256k1 multisig + * verification as the standalone multisig system script. The multisig witness + * bytes (S|R|M|N|pubkey_hashes|signatures) are placed inside the + * OmniLockWitnessLock.signature field rather than directly in WitnessArgs.lock. + * + * When ACP mode is enabled (omniLockFlags & 0x02), the cell can also be + * unlocked without a signature (ACP deposit path), following RFC 0026 rules. + * + * @public + */ +export class SignerMultisigOmniLockReadonly extends SignerMultisigCkbBase { + public readonly omniLockFlags: number; + public readonly acpMinCkb: number; + public readonly acpMinUdt: number; + + /** + * Creates an instance of SignerMultisigOmniLockReadonly. + * + * @param client - The client instance. + * @param multisigInfoLike - The multisig information (public keys, threshold, mustMatch). + * @param options - Omnilock-specific options (flags, ACP minimums, script override). + */ + constructor( + client: Client, + multisigInfoLike: MultisigCkbWitnessLike, + options?: OmniLockMultisigOptions | null, + ) { + const multisigInfo = MultisigCkbWitness.from(multisigInfoLike); + const omniLockFlags = Number(numFrom(options?.omniLockFlags ?? 0)); + const acpMinCkb = + omniLockFlags & ACP_MASK ? Number(numFrom(options?.acpMinCkb ?? 0)) : 0; + const acpMinUdt = + omniLockFlags & ACP_MASK ? Number(numFrom(options?.acpMinUdt ?? 0)) : 0; + + const args = buildOmniLockArgs( + multisigInfo, + omniLockFlags, + acpMinCkb, + acpMinUdt, + ); + const scriptInfos = (async (): Promise< + { script: Script; cellDeps: CellDepInfo[] }[] + > => { + const info = options?.scriptInfo + ? ScriptInfo.from(options.scriptInfo) + : await client.getKnownScript(KnownScript.OmniLock); + return [ + { + script: Script.from({ ...info, args }), + cellDeps: info.cellDeps, + }, + ]; + })(); + + super(client, multisigInfo, scriptInfos); + this.omniLockFlags = omniLockFlags; + this.acpMinCkb = acpMinCkb; + this.acpMinUdt = acpMinUdt; + } + + protected encodeWitnessLock(witness: MultisigCkbWitness): Hex { + return encodeOmniLockWitnessLockToHex(bytesFrom(witness.toHex())); + } + + protected decodeWitnessLock(lock: Hex): MultisigCkbWitness | undefined { + const lockBytes = bytesFrom(lock); + const signature = decodeOmniLockWitnessLock(lockBytes); + if (!signature) { + return undefined; + } + return MultisigCkbWitness.decode(signature); + } +}