From df47fb7e61e4d5ed242133cee3431fdc789a7768 Mon Sep 17 00:00:00 2001 From: ian Date: Fri, 29 Dec 2023 16:48:58 +0800 Subject: [PATCH] cobuild: handle fee payment and set change output I call payFee before filling the witness in the previous version. The fee estimation is smaller because the tx size is smaller than the final one after adding all the witnesses. I set feeRate to 3000 to work around this. In this commit, I refactor code to make it possible to create a dummy tx for fee estimation. It's guaranteed that the dummy tx is not less than the final tx. --- src/actions/claim.js | 11 ++- src/actions/deposit.js | 11 ++- src/actions/transfer.js | 16 +++- src/actions/withdraw.js | 11 ++- src/app/accounts/[address]/sign-form.js | 12 +-- src/lib/cobuild/fee-manager.js | 67 +++++++++++++ src/lib/cobuild/general-lock-actions.js | 18 ++++ src/lib/cobuild/lock-actions.js | 7 +- src/lib/cobuild/script-group.js | 8 ++ .../create-building-packet-from-skeleton.js | 13 ++- .../lumos-adapter/create-lumos-ckb-builder.js | 41 ++++---- .../init-lumos-common-scripts.js | 1 + .../pay-fee-with-building-packet.js | 94 +++++++++++++++++++ 13 files changed, 264 insertions(+), 46 deletions(-) create mode 100644 src/lib/cobuild/fee-manager.js create mode 100644 src/lib/cobuild/script-group.js create mode 100644 src/lib/lumos-adapter/pay-fee-with-building-packet.js diff --git a/src/actions/claim.js b/src/actions/claim.js index e576e7a..d4900b8 100644 --- a/src/actions/claim.js +++ b/src/actions/claim.js @@ -2,12 +2,21 @@ import { claimDao } from "@/lib/cobuild/publishers"; import { useConfig } from "@/lib/config"; +import { prepareLockActions } from "@/lib/cobuild/lock-actions"; +import { payFee } from "@/lib/cobuild/fee-manager"; export default async function withdraw(from, cell, config) { config = config ?? useConfig(); try { - const buildingPacket = await claimDao(config)({ from, cell }); + let buildingPacket = await claimDao(config)({ from, cell }); + buildingPacket = await payFee( + buildingPacket, + [{ address: from, feeRate: 1200 }], + config, + ); + buildingPacket = prepareLockActions(buildingPacket, config.ckbChainConfig); + return { buildingPacket, }; diff --git a/src/actions/deposit.js b/src/actions/deposit.js index 46904ef..53e77d2 100644 --- a/src/actions/deposit.js +++ b/src/actions/deposit.js @@ -3,6 +3,8 @@ import { parseUnit } from "@ckb-lumos/bi"; import { depositDao } from "@/lib/cobuild/publishers"; import { useConfig } from "@/lib/config"; +import { prepareLockActions } from "@/lib/cobuild/lock-actions"; +import { payFee } from "@/lib/cobuild/fee-manager"; export default async function deposit(_prevState, formData, config) { config = config ?? useConfig(); @@ -11,7 +13,14 @@ export default async function deposit(_prevState, formData, config) { const amount = parseUnit(formData.get("amount"), "ckb"); try { - const buildingPacket = await depositDao(config)({ from, amount }); + let buildingPacket = await depositDao(config)({ from, amount }); + buildingPacket = await payFee( + buildingPacket, + [{ address: from, feeRate: 1200 }], + config, + ); + buildingPacket = prepareLockActions(buildingPacket, config.ckbChainConfig); + return { buildingPacket, }; diff --git a/src/actions/transfer.js b/src/actions/transfer.js index 8b5f087..c7c27a1 100644 --- a/src/actions/transfer.js +++ b/src/actions/transfer.js @@ -2,6 +2,8 @@ import { parseUnit } from "@ckb-lumos/bi"; import { transferCkb } from "@/lib/cobuild/publishers"; +import { prepareLockActions } from "@/lib/cobuild/lock-actions"; +import { payFee } from "@/lib/cobuild/fee-manager"; import { useConfig } from "@/lib/config"; export default async function transfer(_prevState, formData, config) { @@ -12,11 +14,23 @@ export default async function transfer(_prevState, formData, config) { const amount = parseUnit(formData.get("amount"), "ckb"); try { - const buildingPacket = await transferCkb(config)({ from, to, amount }); + let buildingPacket = await transferCkb(config)({ + from, + to, + amount, + }); + buildingPacket = await payFee( + buildingPacket, + [{ address: from, feeRate: 1200 }], + config, + ); + buildingPacket = prepareLockActions(buildingPacket, config.ckbChainConfig); + return { buildingPacket, }; } catch (err) { + console.error(err.stack); return { error: err.toString(), }; diff --git a/src/actions/withdraw.js b/src/actions/withdraw.js index 8a26735..af0c898 100644 --- a/src/actions/withdraw.js +++ b/src/actions/withdraw.js @@ -2,12 +2,21 @@ import { withdrawDao } from "@/lib/cobuild/publishers"; import { useConfig } from "@/lib/config"; +import { prepareLockActions } from "@/lib/cobuild/lock-actions"; +import { payFee } from "@/lib/cobuild/fee-manager"; export default async function withdraw(from, cell, config) { config = config ?? useConfig(); try { - const buildingPacket = await withdrawDao(config)({ from, cell }); + let buildingPacket = await withdrawDao(config)({ from, cell }); + buildingPacket = await payFee( + buildingPacket, + [{ address: from, feeRate: 1200 }], + config, + ); + buildingPacket = prepareLockActions(buildingPacket, config.ckbChainConfig); + return { buildingPacket, }; diff --git a/src/app/accounts/[address]/sign-form.js b/src/app/accounts/[address]/sign-form.js index 1a2e535..81872a1 100644 --- a/src/app/accounts/[address]/sign-form.js +++ b/src/app/accounts/[address]/sign-form.js @@ -8,7 +8,6 @@ import * as joyid from "@/lib/wallet/joyid"; import SubmitButton from "@/components/submit-button"; import BuildingPacketReview from "@/lib/cobuild/react/building-packet-review"; import { - prepareLockActions, findLockActionByLockScript, finalizeWitnesses, } from "@/lib/cobuild/lock-actions"; @@ -25,15 +24,8 @@ export default function SignForm({ onCancel, }) { const [error, setError] = useState(); - const preparedBuildingPacket = prepareLockActions( - buildingPacket, - ckbChainConfig, - ); const lockScript = addressToScript(address, { config: ckbChainConfig }); - const lockAction = findLockActionByLockScript( - preparedBuildingPacket, - lockScript, - ); + const lockAction = findLockActionByLockScript(buildingPacket, lockScript); const lockActionData = GeneralLockAction.unpack(lockAction.data); const sign = async () => { try { @@ -64,7 +56,7 @@ export default function SignForm({ diff --git a/src/lib/cobuild/fee-manager.js b/src/lib/cobuild/fee-manager.js new file mode 100644 index 0000000..55db0b8 --- /dev/null +++ b/src/lib/cobuild/fee-manager.js @@ -0,0 +1,67 @@ +import { bytes } from "@ckb-lumos/codec"; + +import payFeeWithBuildingPacket from "../lumos-adapter/pay-fee-with-building-packet"; +import * as generalLockActions from "./general-lock-actions"; +import { finalizeWitnesses } from "./lock-actions"; +import { groupByLock } from "./script-group"; + +// feePayments: [{address, fee?, feeRate?}] +export async function payFee(buildingPacket, feePayments, config) { + const groups = groupByLock(buildingPacket.value.resolvedInputs.outputs); + + // Rember fields that should be restored + const witnesses = buildingPacket.value.payload.witnesses; + + const buildingPacketWillPayFee = finalizeWitnesses( + Object.entries(groups).reduce( + (acc, [scriptHash, inputs]) => + storeWitnessForFeeEstimation( + acc, + scriptHash, + inputs.map((e) => e[0]), + config.ckbChainConfig, + ), + buildingPacket, + ), + ); + + const buildingPacketHavePaidFee = await payFeeWithBuildingPacket( + buildingPacketWillPayFee, + feePayments, + config, + ); + + return { + type: buildingPacketHavePaidFee.type, + value: { + ...buildingPacketHavePaidFee.value, + payload: { + ...buildingPacketHavePaidFee.value.payload, + witnesses, + }, + }, + }; +} + +function storeWitnessForFeeEstimation( + buildingPacket, + scriptHash, + inputIndices, + ckbChainConfig, +) { + const script = + buildingPacket.value.resolvedInputs.outputs[inputIndices[0]].lock; + if (script.codeHash === ckbChainConfig.SCRIPTS.JOYID_COBUILD_POC.CODE_HASH) { + return generalLockActions.storeWitnessForFeeEstimation( + buildingPacket, + scriptHash, + inputIndices, + // Variable length, but 500 is usually enough. + () => bytes.hexify(new Uint8Array(500)), + ); + } + + throw new Error( + `NotSupportedLock: codeHash=${script.codeHash} hashType=${script.hashType}`, + ); +} diff --git a/src/lib/cobuild/general-lock-actions.js b/src/lib/cobuild/general-lock-actions.js index 20ef4a8..4d96e02 100644 --- a/src/lib/cobuild/general-lock-actions.js +++ b/src/lib/cobuild/general-lock-actions.js @@ -48,6 +48,24 @@ export function prepareLockAction( }; } +export function storeWitnessForFeeEstimation( + buildingPacket, + scriptHash, + inputIndices, + createSealPlaceHolder, +) { + buildingPacket = prepareLockAction( + buildingPacket, + scriptHash, + inputIndices, + createSealPlaceHolder, + ); + const lockAction = buildingPacket.value.lockActions.find( + (action) => action.scriptHash === scriptHash, + ); + return applyLockAction(buildingPacket, lockAction, createSealPlaceHolder()); +} + export function applyLockAction(buildingPacket, lockAction, seal) { const witnesses = [...buildingPacket.value.payload.witnesses]; const { witnessStore } = GeneralLockAction.unpack(lockAction.data); diff --git a/src/lib/cobuild/lock-actions.js b/src/lib/cobuild/lock-actions.js index 75673e8..1b1f231 100644 --- a/src/lib/cobuild/lock-actions.js +++ b/src/lib/cobuild/lock-actions.js @@ -2,6 +2,7 @@ import { utils as lumosBaseUtils } from "@ckb-lumos/base"; import * as generalLockActions from "./general-lock-actions"; import { parseWitnessType } from "./types"; +import { groupByLock } from "./script-group"; const { computeScriptHash } = lumosBaseUtils; @@ -66,12 +67,6 @@ export function findLockActionByLockScript(buildingPacket, lockScript) { ); } -function groupByLock(cellOutputs) { - return Object.groupBy(cellOutputs.entries(), ([_i, v]) => - computeScriptHash(v.lock), - ); -} - function dispatchLockActions( buildingPacket, scriptHash, diff --git a/src/lib/cobuild/script-group.js b/src/lib/cobuild/script-group.js new file mode 100644 index 0000000..825b022 --- /dev/null +++ b/src/lib/cobuild/script-group.js @@ -0,0 +1,8 @@ +import { utils as lumosBaseUtils } from "@ckb-lumos/base"; +const { computeScriptHash } = lumosBaseUtils; + +export function groupByLock(cellOutputs) { + return Object.groupBy(cellOutputs.entries(), ([_i, v]) => + computeScriptHash(v.lock), + ); +} diff --git a/src/lib/lumos-adapter/create-building-packet-from-skeleton.js b/src/lib/lumos-adapter/create-building-packet-from-skeleton.js index 0985910..4687c6c 100644 --- a/src/lib/lumos-adapter/create-building-packet-from-skeleton.js +++ b/src/lib/lumos-adapter/create-building-packet-from-skeleton.js @@ -1,9 +1,12 @@ import { createTransactionFromSkeleton } from "@ckb-lumos/helpers"; -export default function createBuildingPacketFromSkeleton(txSkeleton) { +export default function createBuildingPacketFromSkeleton( + txSkeleton, + changeOutput, +) { const resolvedInputs = { outputs: txSkeleton.inputs.map((cell) => cell.cellOutput).toJSON(), - outputData: txSkeleton.inputs.map((cell) => cell.data).toJSON(), + outputsData: txSkeleton.inputs.map((cell) => cell.data).toJSON(), }; const payload = createTransactionFromSkeleton(txSkeleton); return { @@ -12,11 +15,11 @@ export default function createBuildingPacketFromSkeleton(txSkeleton) { message: { actions: [], }, - payload, - resolvedInputs, - changeOutput: null, scriptInfos: [], lockActions: [], + payload, + resolvedInputs, + changeOutput, }, }; } diff --git a/src/lib/lumos-adapter/create-lumos-ckb-builder.js b/src/lib/lumos-adapter/create-lumos-ckb-builder.js index ee70604..5432aa6 100644 --- a/src/lib/lumos-adapter/create-lumos-ckb-builder.js +++ b/src/lib/lumos-adapter/create-lumos-ckb-builder.js @@ -10,19 +10,6 @@ import { packDaoWitnessArgs, } from "../dao"; -// **Attention:** There's no witnesses set yet, so I set fee rate to 3000 to hope that the final tx fee rate will be larger than 1000. -async function payFee(txSkeleton, from, ckbChainConfig) { - return await commonScripts.payFeeByFeeRate( - txSkeleton, - [from], - 3000, - undefined, - { - config: ckbChainConfig, - }, - ); -} - function buildCellDep(scriptInfo) { return { outPoint: { @@ -33,6 +20,11 @@ function buildCellDep(scriptInfo) { }; } +function useLastOutputAsChangeOutput(txSkeleton) { + const size = txSkeleton.get("outputs").size; + return size > 1 ? size - 1 : null; +} + export default function createLumosCkbBuilder({ ckbRpcUrl, ckbChainConfig }) { initLumosCommonScripts(ckbChainConfig); const rpc = new RPC(ckbRpcUrl); @@ -56,8 +48,11 @@ export default function createLumosCkbBuilder({ ckbRpcUrl, ckbChainConfig }) { }, ); - txSkeleton = await payFee(txSkeleton, from, ckbChainConfig); - return createBuildingPacketFromSkeleton(txSkeleton); + // lumos always add the target output first + return createBuildingPacketFromSkeleton( + txSkeleton, + useLastOutputAsChangeOutput(txSkeleton), + ); }, depositDao: async function ({ from, amount }) { @@ -78,8 +73,10 @@ export default function createLumosCkbBuilder({ ckbRpcUrl, ckbChainConfig }) { { config: ckbChainConfig }, ); - txSkeleton = await payFee(txSkeleton, from, ckbChainConfig); - return createBuildingPacketFromSkeleton(txSkeleton); + return createBuildingPacketFromSkeleton( + txSkeleton, + useLastOutputAsChangeOutput(txSkeleton), + ); }, withdrawDao: async function ({ from, cell }) { @@ -96,8 +93,10 @@ export default function createLumosCkbBuilder({ ckbRpcUrl, ckbChainConfig }) { config: ckbChainConfig, }); - txSkeleton = await payFee(txSkeleton, from, ckbChainConfig); - return createBuildingPacketFromSkeleton(txSkeleton); + return createBuildingPacketFromSkeleton( + txSkeleton, + useLastOutputAsChangeOutput(txSkeleton), + ); }, claimDao: async function ({ from, cell }) { @@ -157,8 +156,8 @@ export default function createLumosCkbBuilder({ ckbRpcUrl, ckbChainConfig }) { let txSkeleton = txSkeletonMutable.asImmutable(); - txSkeleton = await payFee(txSkeleton, from, ckbChainConfig); - return createBuildingPacketFromSkeleton(txSkeleton); + // Allow pay fee from the unlocked cell directly. + return createBuildingPacketFromSkeleton(txSkeleton, 0); }, }; } diff --git a/src/lib/lumos-adapter/init-lumos-common-scripts.js b/src/lib/lumos-adapter/init-lumos-common-scripts.js index 9575f84..6adf462 100644 --- a/src/lib/lumos-adapter/init-lumos-common-scripts.js +++ b/src/lib/lumos-adapter/init-lumos-common-scripts.js @@ -77,6 +77,7 @@ export function buildLockInfo(ckbChainConfig, scriptInfo) { //=========================== // 1.Add inputCell to txSkeleton txMutable.update("inputs", (inputs) => inputs.push(inputCell)); + txMutable.update("witnesses", (witnesses) => witnesses.push("0x")); // 2. Add output. The function `lumos.commons.common.transfer` will scan outputs for available balance for each account. const outputCell = { diff --git a/src/lib/lumos-adapter/pay-fee-with-building-packet.js b/src/lib/lumos-adapter/pay-fee-with-building-packet.js new file mode 100644 index 0000000..2363170 --- /dev/null +++ b/src/lib/lumos-adapter/pay-fee-with-building-packet.js @@ -0,0 +1,94 @@ +import { Indexer } from "@ckb-lumos/ckb-indexer"; +import { createTransactionSkeleton } from "@ckb-lumos/helpers"; +import { common as commonScripts } from "@ckb-lumos/common-scripts"; + +import createBuildingPacketFromSkeleton from "./create-building-packet-from-skeleton"; + +function outPointEqual(a, b) { + return a.txHash === b.txHash && a.index === b.index; +} + +// feePayments: [{address, fee?, feeRate?}] +export default async function payFeeWithBuildingPacket( + buildingPacket, + feePayments, + { ckbRpcUrl, ckbChainConfig }, +) { + let txSkeleton = await createTransactionSkeleton( + buildingPacket.value.payload, + createCellFetcherFromBuildingPacket(buildingPacket), + ); + // lock outputs except the change output + const rememberOutputsSize = buildingPacket.value.payload.outputs.length; + const lockedOutputs = Array.from(Array(rememberOutputsSize).keys()) + .filter((i) => i !== buildingPacket.value.changeOutput) + .map((index) => ({ field: "outputs", index })); + txSkeleton = txSkeleton.update("fixedEntries", (fixedEntries) => + fixedEntries.push(...lockedOutputs), + ); + + txSkeleton = txSkeleton.set("cellProvider", new Indexer(ckbRpcUrl)); + for (const { address, fee, feeRate } of feePayments) { + if (fee !== null && fee !== undefined) { + txSkeleton = await commonScripts.payFee( + txSkeleton, + [address], + fee, + undefined, + { + config: ckbChainConfig, + }, + ); + } + if (feeRate !== null && feeRate !== undefined) { + txSkeleton = await commonScripts.payFeeByFeeRate( + txSkeleton, + [address], + feeRate, + undefined, + { config: ckbChainConfig }, + ); + } + } + + const newOutputsSize = txSkeleton.get("outputs").size; + const changeOutput = + buildingPacket.value.changeOutput ?? + (newOutputsSize > rememberOutputsSize ? newOutputsSize - 1 : null); + + const newBuildingPacket = createBuildingPacketFromSkeleton( + txSkeleton, + changeOutput, + ); + return { + type: buildingPacket.type, + value: { + ...buildingPacket.value, + payload: newBuildingPacket.value.payload, + resolvedInputs: newBuildingPacket.value.resolvedInputs, + }, + }; +} + +export function createCellFetcherFromBuildingPacket(buildingPacket) { + const resolvedInputs = buildingPacket.value.resolvedInputs; + const tx = buildingPacket.value.payload; + + const fetcher = async (outPoint) => { + const index = tx.inputs.findIndex((input) => { + return outPointEqual(input.previousOutput, outPoint); + }); + if (index === -1) { + throw new Error( + `Cannot find outponit ${outPoint.txHash} ${outPoint.index}`, + ); + } + return { + outPoint, + cellOutput: resolvedInputs.outputs[index], + data: resolvedInputs.outputsData[index], + }; + }; + + return fetcher; +}