diff --git a/.changeset/cold-years-dream.md b/.changeset/cold-years-dream.md new file mode 100644 index 000000000..cc32f9ab3 --- /dev/null +++ b/.changeset/cold-years-dream.md @@ -0,0 +1,5 @@ +--- +"@ckb-lumos/common-scripts": minor +--- + +feat: support registered lock in dao operations diff --git a/packages/common-scripts/src/dao.ts b/packages/common-scripts/src/dao.ts index 3c9d55494..4ea2cb7dc 100644 --- a/packages/common-scripts/src/dao.ts +++ b/packages/common-scripts/src/dao.ts @@ -1,8 +1,11 @@ +/* eslint-disable import/no-named-as-default-member */ + import { parseAddress, TransactionSkeletonType, Options, generateAddress, + encodeToAddress, minimalCellCapacityCompatible, } from "@ckb-lumos/helpers"; import { @@ -22,7 +25,7 @@ import { getConfig, Config } from "@ckb-lumos/config-manager"; const { parseSince } = sinceUtils; import secp256k1Blake160 from "./secp256k1_blake160"; import secp256k1Blake160Multisig from "./secp256k1_blake160_multisig"; -import { FromInfo, parseFromInfo } from "./from_info"; +import { FromInfo, isMultisigFromInfo, parseFromInfo } from "./from_info"; import { addCellDep, isSecp256k1Blake160Script, @@ -31,6 +34,7 @@ import { } from "./helper"; import { BI, BIish } from "@ckb-lumos/bi"; import { RPC } from "@ckb-lumos/rpc"; +import common from "./common"; const DEPOSIT_DAO_DATA: HexString = "0x0000000000000000"; const DAO_LOCK_PERIOD_EPOCHS_COMPATIBLE = BI.from(180); @@ -98,12 +102,20 @@ export async function* listDaoCells( } } +interface DepositOptions { + config?: Config; + /** + * enable using non-system script in inputs + */ + enableNonSystemScript?: boolean; +} + // TODO: reject multisig with non absolute-epoch-number locktime lock /** * deposit a cell to DAO * * @param txSkeleton - * @param fromInfo + * @param fromInfo only system script is enabled by default, to enable non-system script as inputs, please set enableNonSystemScript to true in options * @param toAddress deposit cell lock address * @param amount capacity in shannon * @param options @@ -113,7 +125,7 @@ export async function deposit( fromInfo: FromInfo, toAddress: Address, amount: BIish, - { config = undefined }: Options = {} + { config = undefined, enableNonSystemScript = false }: DepositOptions = {} ): Promise { config = config || getConfig(); const DAO_SCRIPT = config.SCRIPTS.DAO; @@ -179,16 +191,36 @@ export async function deposit( fromInfo, { config } ); + } else if (enableNonSystemScript) { + txSkeleton = await common.injectCapacity( + txSkeleton, + [fromInfo], + amount, + encodeToAddress(parseFromInfo(fromInfo).fromScript, { config }), + undefined, + { config } + ); } } else if (fromInfo) { - txSkeleton = await secp256k1Blake160Multisig.injectCapacity( - txSkeleton, - outputIndex, - fromInfo, - { - config, - } - ); + if (isMultisigFromInfo(fromInfo)) { + txSkeleton = await secp256k1Blake160Multisig.injectCapacity( + txSkeleton, + outputIndex, + fromInfo, + { + config, + } + ); + } else if (enableNonSystemScript) { + txSkeleton = await common.injectCapacity( + txSkeleton, + [fromInfo], + amount, + encodeToAddress(parseFromInfo(fromInfo).fromScript, { config }), + undefined, + { config } + ); + } } return txSkeleton; @@ -217,6 +249,14 @@ function _checkFromInfoSince(fromInfo: FromInfo, config: Config): void { } } +export interface WithdrawOptions { + config?: Config; + /** + * enable using non-system script in inputs + */ + enableNonSystemScript?: boolean; +} + /** * withdraw an deposited DAO cell * @@ -229,7 +269,7 @@ async function withdraw( txSkeleton: TransactionSkeletonType, fromInput: Cell, fromInfo?: FromInfo, - { config = undefined }: Options = {} + { config = undefined, enableNonSystemScript = false }: WithdrawOptions = {} ): Promise { config = config || getConfig(); _checkDaoScript(config); @@ -268,9 +308,7 @@ async function withdraw( txSkeleton, fromInput, undefined, - { - config, - } + { config } ); } else if (isSecp256k1Blake160MultisigScript(fromLockScript, config)) { txSkeleton = await secp256k1Blake160Multisig.setupInputCell( @@ -279,6 +317,13 @@ async function withdraw( fromInfo || generateAddress(fromLockScript, { config }), { config } ); + } else if (enableNonSystemScript) { + txSkeleton = await common.setupInputCell( + txSkeleton, + fromInput, + fromInfo || encodeToAddress(fromLockScript, { config }), + { config } + ); } const targetOutputIndex: number = txSkeleton.get("outputs").size - 1; diff --git a/packages/common-scripts/src/from_info.ts b/packages/common-scripts/src/from_info.ts index d8cac1fc0..412473d09 100644 --- a/packages/common-scripts/src/from_info.ts +++ b/packages/common-scripts/src/from_info.ts @@ -29,6 +29,18 @@ export interface MultisigScript { since?: PackedSince; } +export function isMultisigFromInfo(obj: unknown): obj is MultisigScript { + if (!obj || typeof obj !== "object") return false; + + const maybeMultisig = obj as Partial; + + return ( + typeof maybeMultisig?.R === "number" && + typeof maybeMultisig?.M === "number" && + Array.isArray(maybeMultisig?.publicKeyHashes) + ); +} + export interface ACP { address: Address; destroyable?: boolean; // default to false diff --git a/packages/common-scripts/tests/dao-with-custom-lock.test.ts b/packages/common-scripts/tests/dao-with-custom-lock.test.ts new file mode 100644 index 000000000..99ddc9cce --- /dev/null +++ b/packages/common-scripts/tests/dao-with-custom-lock.test.ts @@ -0,0 +1,142 @@ +import test, { afterEach, beforeEach } from "ava"; +import { registerCustomLockScriptInfos } from "../src/common"; +import { TestCellCollector } from "./helper"; +import { + encodeToAddress, + TransactionSkeleton, + TransactionSkeletonType, +} from "@ckb-lumos/helpers"; +import { Cell, Script } from "@ckb-lumos/base"; +import { BI, parseUnit } from "@ckb-lumos/bi"; +import { CellProvider } from "./cell_provider"; +import { dao } from "../src"; +import { Config, predefined } from "@ckb-lumos/config-manager"; +import { hexify } from "@ckb-lumos/codec/lib/bytes"; +import { Uint64 } from "@ckb-lumos/codec/lib/number"; +import { randomBytes } from "node:crypto"; + +const { LINA } = predefined; + +const nonSystemLockCodeHash = "0x" + "aa".repeat(32); + +beforeEach(() => { + registerCustomLockScriptInfos([ + { + codeHash: nonSystemLockCodeHash, + hashType: "type", + lockScriptInfo: { + CellCollector: TestCellCollector, + async setupInputCell( + txSkeleton: TransactionSkeletonType, + inputCell: Cell + ): Promise { + txSkeleton = txSkeleton.update("inputs", (inputs) => + inputs.push(inputCell) + ); + + txSkeleton = txSkeleton.update("outputs", (outputs) => + outputs.push(inputCell) + ); + + return txSkeleton; + }, + prepareSigningEntries(txSkeleton) { + return txSkeleton; + }, + }, + }, + ]); +}); + +// reset custom lock script infos +afterEach(() => { + registerCustomLockScriptInfos([]); +}); + +test("deposit from the non-system script", async (t) => { + const fromScript: Script = { + codeHash: nonSystemLockCodeHash, + hashType: "type", + args: "0x", + }; + + const toScript: Script = { + codeHash: "0x" + "bb".repeat(32), + hashType: "type", + args: "0x", + }; + const nonSystemLockCell = { + cellOutput: { + capacity: parseUnit("5000000", "ckb").toHexString(), + lock: fromScript, + }, + data: "0x", + }; + let txSkeleton = TransactionSkeleton({ + cellProvider: new CellProvider([nonSystemLockCell]), + }); + + txSkeleton = await dao.deposit( + txSkeleton, + encodeToAddress(fromScript), + encodeToAddress(toScript), + parseUnit("10000", "ckb"), + { enableNonSystemScript: true } + ); + + t.deepEqual(txSkeleton.get("inputs").get(0), nonSystemLockCell); + t.is(txSkeleton.get("outputs").size, 2); + t.deepEqual(txSkeleton.get("outputs").get(0)?.cellOutput, { + capacity: parseUnit("10000", "ckb").toHexString(), + lock: toScript, + type: generateDaoTypeScript(LINA), + }); + t.deepEqual(txSkeleton.get("outputs").get(1)?.cellOutput, { + capacity: BI.from(nonSystemLockCell.cellOutput.capacity) + .sub(parseUnit("10000", "ckb")) + .toHexString(), + lock: fromScript, + type: undefined, + }); +}); + +test("withdraw with registered lock script", async (t) => { + const fromScript: Script = { + codeHash: nonSystemLockCodeHash, + hashType: "type", + args: "0x", + }; + + const nonSystemLockCell: Cell = { + cellOutput: { + capacity: parseUnit("5000000", "ckb").toHexString(), + lock: fromScript, + type: generateDaoTypeScript(LINA), + }, + data: hexify(Uint64.pack(0)), + blockHash: hexify(randomBytes(32)), + blockNumber: "0x123456", + outPoint: { txHash: hexify(randomBytes(32)), index: "0x0" }, + }; + let txSkeleton = TransactionSkeleton({ + cellProvider: new CellProvider([nonSystemLockCell]), + }); + + txSkeleton = await dao.withdraw(txSkeleton, nonSystemLockCell, undefined, { + enableNonSystemScript: true, + }); + + t.deepEqual(txSkeleton.inputs.get(-1), nonSystemLockCell); + t.deepEqual(txSkeleton.outputs.get(-1), { + ...nonSystemLockCell, + data: hexify(Uint64.pack(0x123456)), + }); +}); + +const generateDaoTypeScript = (config: Config): Script => { + return { + codeHash: config.SCRIPTS.DAO!.CODE_HASH, + hashType: config.SCRIPTS.DAO!.HASH_TYPE, + args: "0x", + }; +}; diff --git a/packages/common-scripts/tests/helper.ts b/packages/common-scripts/tests/helper.ts index 322be9b98..ff2fcec5a 100644 --- a/packages/common-scripts/tests/helper.ts +++ b/packages/common-scripts/tests/helper.ts @@ -1,9 +1,20 @@ import { TransactionSkeletonType, TransactionSkeleton, + Options, } from "@ckb-lumos/helpers"; -import { Cell, CellDep, blockchain } from "@ckb-lumos/base"; +import { + Cell, + CellDep, + blockchain, + Script, + CellProvider, + QueryOptions, + CellCollector as BaseCellCollectorType, +} from "@ckb-lumos/base"; import { bytes } from "@ckb-lumos/codec"; +import { CellCollectorType, FromInfo, parseFromInfo } from "../src"; +import { Config, getConfig } from "@ckb-lumos/config-manager"; export interface txObject { inputs: Cell[]; @@ -41,3 +52,42 @@ export function txSkeletonFromJson( return skeleton; } + +export class TestCellCollector implements CellCollectorType { + readonly fromScript: Script; + private readonly config: Config; + private cellCollector: BaseCellCollectorType; + + constructor( + fromInfo: FromInfo, + cellProvider: CellProvider, + { + config = undefined, + queryOptions = {}, + }: Options & { + queryOptions?: QueryOptions; + } = {} + ) { + if (!cellProvider) { + throw new Error(`Cell provider is missing!`); + } + config = config || getConfig(); + this.fromScript = parseFromInfo(fromInfo, { config }).fromScript; + + this.config = config; + + queryOptions = { + ...queryOptions, + lock: this.fromScript, + type: queryOptions.type || "empty", + }; + + this.cellCollector = cellProvider.collector(queryOptions); + } + + async *collect(): AsyncGenerator { + for await (const inputCell of this.cellCollector.collect()) { + yield inputCell; + } + } +}