diff --git a/packages/common-scripts/examples/custom_lock_script_info/custom_lock_script_info.ts b/packages/common-scripts/examples/custom_lock_script_info/custom_lock_script_info.ts new file mode 100644 index 000000000..8e1e955ab --- /dev/null +++ b/packages/common-scripts/examples/custom_lock_script_info/custom_lock_script_info.ts @@ -0,0 +1,281 @@ +import { + Cell, + CellCollector, + CellProvider, + QueryOptions, + blockchain, +} from "@ckb-lumos/base"; +import * as apiTypes from "@ckb-lumos/base/lib/api"; +import { BI } from "@ckb-lumos/bi"; +import * as codec from "@ckb-lumos/codec"; +import { getConfig } from "@ckb-lumos/config-manager"; +import { Options, TransactionSkeletonType } from "@ckb-lumos/helpers"; +import { List, Map } from "immutable"; +import * as commons from "../../src"; +import { addCellDep } from "../../src/helper"; + +// Compare two objects by convert them to molecule buffer to ignore the representation only +// differences. +function isSameScript(a: apiTypes.Script, b: apiTypes.Script) { + return ( + codec.bytes.equal( + blockchain.Script.pack(a), + blockchain.Script.pack(b), + ) + ); +} + +const MAX_U64_PLUS_ONE = BI.from(1).shl(64); +function packInt64LE(number: BI) { + return codec.number.Uint64LE.pack( + number.isNegative() ? MAX_U64_PLUS_ONE.add(number) : number + ); +} + +// This example demonstrates how to use a custom script [CapacityDiff](https://github.com/doitian/ckb-sdk-examples-capacity-diff). +// +// CapacityDiff verifies the witness matches the capacity difference. +// +// - The script loads the witness for the first input in the script group using the WitnessArgs layout. +// - The total input capacity is the sum of all the input cells in the script group. +// - The total output capacity is the sum of all the output cells having the same lock script as the script group. +// - The capacity difference is a 64-bit signed integer which equals to total output capacity minus total input capacity. +// - The witness is encoded using two's complement and little endian. +const capacityDiffLockInfo: commons.LockScriptInfo = { + codeHash: "0x", + hashType: "type", + lockScriptInfo: { + CellCollector: class { + cellCollector: CellCollector; + fromScript = { + codeHash: "0x", + hashType: "type" as apiTypes.HashType, + args: "0x", + }; + + constructor( + fromInfo: commons.FromInfo, + cellProvider: CellProvider, + { config, queryOptions }: Options & { queryOptions?: QueryOptions } + ) { + if (!cellProvider) { + throw new Error(`Cell provider is missing!`); + } + config ??= getConfig(); + const script = commons.parseFromInfo(fromInfo, { config }).fromScript; + + // Please note that the cell collector is called for each specific fromInfo. + // Be cautious not to include input cells for accounts that are not locked by this script. + const template = config.SCRIPTS.CAPACITY_DIFF!; + if ( + script.codeHash !== template.CODE_HASH || + script.hashType !== template.HASH_TYPE + ) { + return; + } + + // Now we can apply the queryOptions to search the live cells. + queryOptions ??= {}; + queryOptions = { + ...queryOptions, + lock: script, + type: queryOptions.type ?? "empty", + }; + + this.cellCollector = cellProvider.collector(queryOptions); + } + + async *collect() { + if (this.cellCollector) { + for await (const inputCell of this.cellCollector.collect()) { + yield inputCell; + } + } + } + }, + + // What to do when a inputCell has been found by the cell provider. + // - Add input and output cell + // - Add cell deps. + // - Fill witness to make fee calculation correct. + setupInputCell: async ( + txSkeleton: TransactionSkeletonType, + inputCell: Cell, + _fromInfo: commons.FromInfo, + { config, since, defaultWitness } = {} + ) => { + // use default config when config is not provided + config ??= getConfig(); + const fromScript = inputCell.cellOutput.lock; + const txMutable = txSkeleton.asMutable(); + + //=========================== + // I. Common Skeletons + // + // There are many steps that setupInputCell must perform carefully, otherwise the whole transaction builder will fail. + //=========================== + // 1.Add inputCell to txSkeleton + txMutable.update("inputs", (inputs: List) => + inputs.push(inputCell) + ); + + // 2. Add output. The function `lumos.commons.common.transfer` will scan outputs for available balance for each account. + const outputCell = { + cellOutput: { + ...inputCell.cellOutput, + }, + data: inputCell.data, + }; + txMutable.update("outputs", (outputs: List) => + outputs.push(outputCell) + ); + + // 3. Set Since + if (since) { + txMutable.setIn( + ["inputSinces", txMutable.get("inputs").size - 1], + since + ); + } + + // 4. Insert a witness to ensure they are aligned to the location of the corresponding input cells. + txMutable.update("witnesses", (witnesses: List) => + witnesses.push(defaultWitness ?? "0x") + ); + //=> Common Skeletons End Here + + //=========================== + // II. CellDeps + //=========================== + // Assume that script onchain infos are stored as CAPACITY_DIFF + const template = config.SCRIPTS.CAPACITY_DIFF; + if (!template) { + throw new Error( + "Provided config does not have CAPACITY_DIFF script setup!" + ); + } + const scriptOutPoint = { + txHash: template.TX_HASH, + index: template.INDEX, + }; + // The helper method addCellDep avoids adding duplicated cell deps. + addCellDep(txMutable, { + outPoint: scriptOutPoint, + depType: template.DEP_TYPE, + }); + + //=========================== + // II. Witness Placeholder + //=========================== + // Fill witness. These code are copied from + // https://github.com/ckb-js/lumos/blob/1cb43fe72dc95c4b3283acccb5120b7bcaeb9346/packages/common-scripts/src/secp256k1_blake160.ts#L90 + // + // It takes a lot of code to set the witness for the first input cell in + // the script group to 8 bytes of zeros. + const firstIndex = txMutable + .get("inputs") + .findIndex((input: Cell) => + isSameScript(input.cellOutput.lock, fromScript) + ); + + if (firstIndex !== -1) { + // Ensure witnesses are aligned to inputs + const toFillWitnessesCount = + firstIndex + 1 - txMutable.get("witnesses").size; + if (toFillWitnessesCount > 0) { + txMutable.update("witnesses", (witnesses: List) => + witnesses.concat(Array(toFillWitnessesCount).fill("0x")) + ); + } + txMutable.updateIn(["witnesses", firstIndex], (witness: any) => { + const witnessArgs = { + ...(witness === "0x" + ? {} + : blockchain.WitnessArgs.unpack(codec.bytes.bytify(witness))), + lock: "0x0000000000000000", + }; + return codec.bytes.hexify(blockchain.WitnessArgs.pack(witnessArgs)); + }); + } + + return txMutable.asImmutable(); + }, + + // Create entries in txSkeleton.signingEntries + prepareSigningEntries: ( + txSkeleton: TransactionSkeletonType, + { config } + ) => { + // use default config when config is not provided + config ??= getConfig(); + const template = config.SCRIPTS.CAPACITY_DIFF; + if (!template) { + throw new Error( + `Provided config does not have CAPACITY_DIFF script setup!` + ); + } + + const balances = Map< + string, + { index: number; capacity: BI } + >().asMutable(); + // Group inputs by args and tally the total capacity as negative values. + txSkeleton.get("inputs").forEach((input: Cell, index: number) => { + const { + capacity, + lock: { codeHash, hashType, args }, + } = input.cellOutput; + if ( + template.CODE_HASH === codeHash && + template.HASH_TYPE === hashType + ) { + if (balances.has(args)) { + balances.updateIn([args, "capacity"], (total: any) => + total.sub(capacity) + ); + } else { + balances.set(args, { index, capacity: BI.from(0).sub(capacity) }); + } + } + }); + // Add capacity of output cells to the tally. + txSkeleton.get("outputs").forEach((output: Cell) => { + const { + capacity, + lock: { codeHash, hashType, args }, + } = output.cellOutput; + if ( + template.CODE_HASH === codeHash && + template.HASH_TYPE === hashType && + balances.has(args) + ) { + balances.updateIn([args, "capacity"], (total: any) => + total.add(capacity) + ); + } + }); + // Create signing entries. Indeed, for this simple script, we could set + // the witness directly. However, for serious lock script, it often + // requires sining by the private + // key. + return txSkeleton.update( + "signingEntries", + (entries: List<{ index: number; type: string; message: string }>) => + entries.concat( + balances + .asImmutable() + .valueSeq() + .map(({ index, capacity }: any) => ({ + index, + // This is the only supported type, which indicate the signature + // follows the WitnewsArgs layout. + type: "witness_args_lock", + message: codec.bytes.hexify(packInt64LE(capacity)), + })) + ) + ); + }, + }, +}; + +commons.common.registerCustomLockScriptInfos([capacityDiffLockInfo]); diff --git a/packages/common-scripts/src/common.ts b/packages/common-scripts/src/common.ts index 352f80d3d..3ec93aff9 100644 --- a/packages/common-scripts/src/common.ts +++ b/packages/common-scripts/src/common.ts @@ -31,22 +31,61 @@ const { ScriptValue } = values; import { Set } from "immutable"; import { isAcpScript } from "./helper"; import { BI, BIish } from "@ckb-lumos/bi"; -import { CellCollectorConstructor } from "./type"; +import { CellCollectorConstructor, CellCollectorType } from "./type"; import omnilock from "./omnilock"; function defaultLogger(level: string, message: string) { console.log(`[${level}] ${message}`); } + +export type { CellCollectorConstructor, CellCollectorType }; + /** - * CellCollector should be a class which implement CellCollectorInterface. - * If you want to work well with `transfer`, `injectCapacity`, `payFee`, `payFeeByFeeRate`, - * please add the `output` at the end of `txSkeleton.get("outputs")` + * LockScriptInfo describes how to integrate a lock script in transaction building. + * + * Custom lock scripts must register their LockScriptInfo before using + * `transfer`, `injectCapacity`, `payFee`, `payFeeByFeeRate` via + * `registerCustomLockScriptInfos`. + * + * See an example in + * [custom_lock_script_info.ts](https://github.com/ckb-js/lumos/blob/develop/packages/common-scripts/examples/custom_lock_script_info/custom_lock_script_info.ts). */ export interface LockScriptInfo { codeHash: Hash; hashType: HashType; + /** + * @interface + */ lockScriptInfo: { + /** + * Collects input cell candidates for the lock script. + * + * It's a constructor that initializes objects implementing function + * `collect()` to provide input cells. Attention that transaction builders + * will not match `fromInfo` and lock script. It's the responsibility of + * `CellCollector` to filter based on `fromInfo`. For example, when + * `fromInfo` does not match, the function `collect()` should not return + * any cell. + */ CellCollector: CellCollectorConstructor; + + /** + * Called when a candidate input cell is found. + * + * What this function should do: + * + * 1. Frist double-check the cell and decide whether continue the following steps or skip. + * 2. Add the cell as an input in the `txSkeleton` and an output cell with + * the same fields since functions like `transfer`, `injectCapacity`, + * `payFee`, and `payFeeByFeeRate` collects account balance in outputs. + * 3. Add `cellDeps` + * 4. Prefill witnesses to ensure the transaction size will not increase after signing. + * + * @param txSkeleton transaction skeleton built so far + * @param inputCell the new input cell candidate + * @param fromInfo which account the inputCell belongs to + * @return the updated transaction skeleton + */ setupInputCell( txSkeleton: TransactionSkeletonType, inputCell: Cell, @@ -57,10 +96,16 @@ export interface LockScriptInfo { since?: PackedSince; } ): Promise; + + /** + * Scans the transaction and add signing entries into `txSkeleton.signingEnties`. + * @return the updated txSkeleton + */ prepareSigningEntries( txSkeleton: TransactionSkeletonType, options: Options ): TransactionSkeletonType; + setupOutputCell?: ( txSkeleton: TransactionSkeletonType, outputCell: Cell, @@ -96,6 +141,7 @@ function getLockScriptInfos(): LockScriptInfosType { return lockScriptInfos; } +/** Registers LockScriptInfo for custom scripts. */ export function registerCustomLockScriptInfos(infos: LockScriptInfo[]): void { lockScriptInfos._customInfos = infos; } diff --git a/packages/common-scripts/src/index.ts b/packages/common-scripts/src/index.ts index 24e27ee7f..cae0e05f6 100644 --- a/packages/common-scripts/src/index.ts +++ b/packages/common-scripts/src/index.ts @@ -3,7 +3,11 @@ import secp256k1Blake160Multisig from "./secp256k1_blake160_multisig"; import { MultisigScript, FromInfo, parseFromInfo } from "./from_info"; import dao from "./dao"; import locktimePool, { LocktimeCell } from "./locktime_pool"; -import common, { LockScriptInfo } from "./common"; +import common, { + LockScriptInfo, + CellCollectorConstructor, + CellCollectorType, +} from "./common"; import sudt from "./sudt"; import anyoneCanPay from "./anyone_can_pay"; import { createP2PKHMessageGroup } from "./p2pkh"; @@ -34,4 +38,11 @@ export default { anyoneCanPay, }; -export type { LocktimeCell, MultisigScript, FromInfo, LockScriptInfo }; +export type { + LocktimeCell, + MultisigScript, + FromInfo, + LockScriptInfo, + CellCollectorConstructor, + CellCollectorType, +};