From 239835ade499639b14e3cd435f74821a9b8a4e78 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 30 Oct 2025 14:38:10 +0800 Subject: [PATCH 01/11] feat: fee payer initialization --- .../core/src/signer/feePayer/defaultFee.ts | 232 ++++++++++++++++++ packages/core/src/signer/feePayer/index.ts | 28 +++ 2 files changed, 260 insertions(+) create mode 100644 packages/core/src/signer/feePayer/defaultFee.ts create mode 100644 packages/core/src/signer/feePayer/index.ts diff --git a/packages/core/src/signer/feePayer/defaultFee.ts b/packages/core/src/signer/feePayer/defaultFee.ts new file mode 100644 index 00000000..7aeb46f0 --- /dev/null +++ b/packages/core/src/signer/feePayer/defaultFee.ts @@ -0,0 +1,232 @@ +import { Address } from "../../address/index.js"; +import { Cell, Transaction } from "../../ckb/transaction.js"; +import { ErrorTransactionInsufficientCapacity } from "../../ckb/transactionErrors.js"; +import { Client } from "../../client/client.js"; +import { ClientCollectableSearchKeyFilterLike } from "../../client/clientTypes.advanced.js"; +import { Zero } from "../../fixedPoint/index.js"; +import { Num, numFrom, NumLike } from "../../num/index.js"; +import { FeePayer } from "./index.js"; + +export class DefaultFeePayer implements FeePayer { + constructor(private addresses: Address[]) {} + + private feeRate?: NumLike; + private filter?: ClientCollectableSearchKeyFilterLike; + private options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + shouldAddInputs?: boolean; + }; + private changeFn?: ( + tx: Transaction, + capacity: Num, + ) => Promise | NumLike; + + async completeTxFee(tx: Transaction, client: Client): Promise { + return tx; + } + + async completeFee( + tx: Transaction, + client: Client, + ): Promise<{ + tx: Transaction; + result: [number, boolean]; + }> { + const feeRate = + this.feeRate ?? + (await client.getFeeRate(this.options?.feeRateBlockRange, this.options)); + + // Complete all inputs extra infos for cache + await tx.getInputsCapacity(client); + + let leastFee = Zero; + let leastExtraCapacity = Zero; + let collected = 0; + + // === + // Usually, for the worst situation, three iterations are needed + // 1. First attempt to complete the transaction. + // 2. Not enough capacity for the change cell. + // 3. Fee increased by the change cell. + // === + while (true) { + collected += await (async () => { + if (!(this.options?.shouldAddInputs ?? true)) { + return 0; + } + + try { + return await this.completeInputsByCapacity( + tx, + client, + leastFee + leastExtraCapacity, + ); + } catch (err) { + if ( + err instanceof ErrorTransactionInsufficientCapacity && + leastExtraCapacity !== Zero + ) { + throw new ErrorTransactionInsufficientCapacity(err.amount, { + isForChange: true, + }); + } + + throw err; + } + })(); + + const fee = await this.getFee(from.client); + if (fee < leastFee + leastExtraCapacity) { + // Not enough capacity are collected, it should only happens when shouldAddInputs is false + throw new ErrorTransactionInsufficientCapacity( + leastFee + leastExtraCapacity - fee, + { isForChange: leastExtraCapacity !== Zero }, + ); + } + + await from.prepareTransaction(this); + if (leastFee === Zero) { + // The initial fee is calculated based on prepared transaction + // This should only happens during the first iteration + leastFee = this.estimateFee(feeRate); + } + // The extra capacity paid the fee without a change + // leastExtraCapacity should be 0 here, otherwise we should failed in the previous check + // So this only happens in the first iteration + if (fee === leastFee) { + return [collected, false]; + } + + // Invoke the change function on a transaction multiple times may cause problems, so we clone it + const tx = tx.clone(); + const needed = numFrom(await Promise.resolve(this.changeFn?.(tx, fee - leastFee))); + if (needed > Zero) { + // No enough extra capacity to create new cells for change, collect inputs again + leastExtraCapacity = needed; + continue; + } + + if ((await tx.getFee(from.client)) !== leastFee) { + throw new Error( + "The change function doesn't use all available capacity", + ); + } + + // New change cells created, update the fee + await from.prepareTransaction(tx); + const changedFee = tx.estimateFee(feeRate); + if (leastFee > changedFee) { + throw new Error("The change function removed existed transaction data"); + } + // The fee has been paid + if (leastFee === changedFee) { + this.copy(tx); + return [collected, true]; + } + + // The fee after changing is more than the original fee + leastFee = changedFee; + } + } + + async completeInputsByCapacity( + tx: Transaction, + client: Client, + capacityTweak?: NumLike, + ): Promise { + const expectedCapacity = + tx.getOutputsCapacity() + numFrom(capacityTweak ?? 0); + const inputsCapacity = await tx.getInputsCapacity(client); + if (inputsCapacity >= expectedCapacity) { + return 0; + } + + const { addedCount, accumulated } = await this.completeInputs( + tx, + client, + this.filter ?? { + scriptLenRange: [0, 1], + outputDataLenRange: [0, 1], + }, + (acc, { cellOutput: { capacity } }) => { + const sum = acc + capacity; + return sum >= expectedCapacity ? undefined : sum; + }, + inputsCapacity, + ); + + if (accumulated === undefined) { + return addedCount; + } + + throw new ErrorTransactionInsufficientCapacity( + expectedCapacity - accumulated, + ); + } + + async completeInputs( + tx: Transaction, + client: Client, + filter: ClientCollectableSearchKeyFilterLike, + accumulator: ( + acc: T, + v: Cell, + i: number, + array: Cell[], + ) => Promise | T | undefined, + init: T, + ): Promise<{ + tx: Transaction; + addedCount: number; + accumulated?: T; + }> { + const collectedCells = []; + + let acc: T = init; + let fulfilled = false; + for (const address of this.addresses) { + for await (const cell of client.findCells({ + script: address.script, + scriptType: "lock", + filter, + scriptSearchMode: "exact", + withData: true, + })) { + if ( + tx.inputs.some(({ previousOutput }) => + previousOutput.eq(cell.outPoint), + ) + ) { + continue; + } + const i = collectedCells.push(cell); + const next = await Promise.resolve( + accumulator(acc, cell, i - 1, collectedCells), + ); + if (next === undefined) { + fulfilled = true; + break; + } + acc = next; + } + if (fulfilled) { + break; + } + } + + collectedCells.forEach((cell) => tx.addInput(cell)); + if (fulfilled) { + return { + tx, + addedCount: collectedCells.length, + }; + } + + return { + tx, + addedCount: collectedCells.length, + accumulated: acc, + }; + } +} diff --git a/packages/core/src/signer/feePayer/index.ts b/packages/core/src/signer/feePayer/index.ts new file mode 100644 index 00000000..6a173060 --- /dev/null +++ b/packages/core/src/signer/feePayer/index.ts @@ -0,0 +1,28 @@ +import { Transaction } from "../../ckb/index.js"; +import { Client } from "../../client/client.js"; + +export abstract class FeePayer { + constructor() {} + + abstract completeTxFee(tx: Transaction, client: Client): Promise; +} + +export class FeePayerManager { + constructor(private payers: FeePayer[]) {} + + push(...payers: FeePayer[]): FeePayerManager { + this.payers.push(...payers); + return this; + } + + pop(): FeePayer | undefined { + return this.payers.pop(); + } + + async completeTxFee(tx: Transaction, client: Client): Promise { + for (const payer of this.payers) { + tx = await payer.completeTxFee(tx, client); + } + return tx; + } +} From 7656820de06f24c9baf14a42e37cc23373e7c951 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Mon, 3 Nov 2025 19:14:42 +0800 Subject: [PATCH 02/11] feat: create a template for fee payer layer --- packages/core/src/ckb/transaction.ts | 193 +++--------------- .../core/src/signer/feePayer/defaultFee.ts | 115 ++++++++--- packages/core/src/signer/feePayer/index.ts | 7 +- packages/core/src/signer/signer/index.ts | 27 +-- 4 files changed, 131 insertions(+), 211 deletions(-) diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 9a5920bd..530a428e 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -23,10 +23,7 @@ import type { Signer } from "../signer/index.js"; import { apply, reduceAsync } from "../utils/index.js"; import { Script, ScriptLike, ScriptOpt } from "./script.js"; import { DEP_TYPE_TO_NUM, NUM_TO_DEP_TYPE } from "./transaction.advanced.js"; -import { - ErrorTransactionInsufficientCapacity, - ErrorTransactionInsufficientCoin, -} from "./transactionErrors.js"; +import { ErrorTransactionInsufficientCoin } from "./transactionErrors.js"; import type { LumosTransactionSkeletonType } from "./transactionLumos.js"; export const DepTypeCodec: mol.Codec = mol.Codec.from({ @@ -1954,40 +1951,19 @@ export class Transaction extends mol.Entity.Base< addedCount: number; accumulated?: T; }> { - const collectedCells = []; - - let acc: T = init; - let fulfilled = false; - for await (const cell of from.findCells(filter, true)) { - if ( - this.inputs.some(({ previousOutput }) => - previousOutput.eq(cell.outPoint), - ) - ) { - continue; - } - const i = collectedCells.push(cell); - const next = await Promise.resolve( - accumulator(acc, cell, i - 1, collectedCells), - ); - if (next === undefined) { - fulfilled = true; - break; - } - acc = next; - } - - collectedCells.forEach((cell) => this.addInput(cell)); - if (fulfilled) { - return { - addedCount: collectedCells.length, - }; - } - - return { - addedCount: collectedCells.length, - accumulated: acc, - }; + from.setAddresses(await from.getAddressObjs()); + from.setOptionalProperties({ + filter, + }); + const { addedCount, accumulated } = await from.completeInputs( + this, + from.client, + filter, + accumulator, + init, + ); + from.setOptionalProperties({}); + return { addedCount, accumulated }; } async completeInputsByCapacity( @@ -1995,33 +1971,17 @@ export class Transaction extends mol.Entity.Base< capacityTweak?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, ): Promise { - const expectedCapacity = - this.getOutputsCapacity() + numFrom(capacityTweak ?? 0); - const inputsCapacity = await this.getInputsCapacity(from.client); - if (inputsCapacity >= expectedCapacity) { - return 0; - } - - const { addedCount, accumulated } = await this.completeInputs( - from, - filter ?? { - scriptLenRange: [0, 1], - outputDataLenRange: [0, 1], - }, - (acc, { cellOutput: { capacity } }) => { - const sum = acc + capacity; - return sum >= expectedCapacity ? undefined : sum; - }, - inputsCapacity, - ); - - if (accumulated === undefined) { - return addedCount; - } - - throw new ErrorTransactionInsufficientCapacity( - expectedCapacity - accumulated, + from.setAddresses(await from.getAddressObjs()); + from.setOptionalProperties({ + filter, + }); + const addedCount = await from.completeInputsByCapacity( + this, + from.client, + capacityTweak, ); + from.setOptionalProperties({}); + return addedCount; } async completeInputsAll( @@ -2208,101 +2168,16 @@ export class Transaction extends mol.Entity.Base< shouldAddInputs?: boolean; }, ): Promise<[number, boolean]> { - const feeRate = - expectedFeeRate ?? - (await from.client.getFeeRate(options?.feeRateBlockRange, options)); - - // Complete all inputs extra infos for cache - await this.getInputsCapacity(from.client); - - let leastFee = Zero; - let leastExtraCapacity = Zero; - let collected = 0; - - // === - // Usually, for the worst situation, three iterations are needed - // 1. First attempt to complete the transaction. - // 2. Not enough capacity for the change cell. - // 3. Fee increased by the change cell. - // === - while (true) { - collected += await (async () => { - if (!(options?.shouldAddInputs ?? true)) { - return 0; - } - - try { - return await this.completeInputsByCapacity( - from, - leastFee + leastExtraCapacity, - filter, - ); - } catch (err) { - if ( - err instanceof ErrorTransactionInsufficientCapacity && - leastExtraCapacity !== Zero - ) { - throw new ErrorTransactionInsufficientCapacity(err.amount, { - isForChange: true, - }); - } - - throw err; - } - })(); - - const fee = await this.getFee(from.client); - if (fee < leastFee + leastExtraCapacity) { - // Not enough capacity are collected, it should only happens when shouldAddInputs is false - throw new ErrorTransactionInsufficientCapacity( - leastFee + leastExtraCapacity - fee, - { isForChange: leastExtraCapacity !== Zero }, - ); - } - - await from.prepareTransaction(this); - if (leastFee === Zero) { - // The initial fee is calculated based on prepared transaction - // This should only happens during the first iteration - leastFee = this.estimateFee(feeRate); - } - // The extra capacity paid the fee without a change - // leastExtraCapacity should be 0 here, otherwise we should failed in the previous check - // So this only happens in the first iteration - if (fee === leastFee) { - return [collected, false]; - } - - // Invoke the change function on a transaction multiple times may cause problems, so we clone it - const tx = this.clone(); - const needed = numFrom(await Promise.resolve(change(tx, fee - leastFee))); - if (needed > Zero) { - // No enough extra capacity to create new cells for change, collect inputs again - leastExtraCapacity = needed; - continue; - } - - if ((await tx.getFee(from.client)) !== leastFee) { - throw new Error( - "The change function doesn't use all available capacity", - ); - } - - // New change cells created, update the fee - await from.prepareTransaction(tx); - const changedFee = tx.estimateFee(feeRate); - if (leastFee > changedFee) { - throw new Error("The change function removed existed transaction data"); - } - // The fee has been paid - if (leastFee === changedFee) { - this.copy(tx); - return [collected, true]; - } - - // The fee after changing is more than the original fee - leastFee = changedFee; - } + from.setAddresses(await from.getAddressObjs()); + from.setOptionalProperties({ + changeFn: change, + feeRate: expectedFeeRate, + filter, + options, + }); + const result = await from.completeFee(this, from.client); + from.setOptionalProperties({}); + return result; } /** diff --git a/packages/core/src/signer/feePayer/defaultFee.ts b/packages/core/src/signer/feePayer/defaultFee.ts index 7aeb46f0..a71083cf 100644 --- a/packages/core/src/signer/feePayer/defaultFee.ts +++ b/packages/core/src/signer/feePayer/defaultFee.ts @@ -1,15 +1,40 @@ -import { Address } from "../../address/index.js"; -import { Cell, Transaction } from "../../ckb/transaction.js"; +import { Address, AddressLike } from "../../address/index.js"; +import { Script } from "../../ckb/script.js"; +import { + Cell, + CellOutput, + Transaction, + TransactionLike, +} from "../../ckb/transaction.js"; import { ErrorTransactionInsufficientCapacity } from "../../ckb/transactionErrors.js"; import { Client } from "../../client/client.js"; import { ClientCollectableSearchKeyFilterLike } from "../../client/clientTypes.advanced.js"; -import { Zero } from "../../fixedPoint/index.js"; +import { fixedPointFrom, Zero } from "../../fixedPoint/index.js"; import { Num, numFrom, NumLike } from "../../num/index.js"; import { FeePayer } from "./index.js"; +function defaultChangeFn( + tx: Transaction, + changeScript: Script, + capacity: Num, +): NumLike { + const changeCell = CellOutput.from({ capacity: 0, lock: changeScript }); + const occupiedCapacity = fixedPointFrom(changeCell.occupiedSize); + if (capacity < occupiedCapacity) { + return occupiedCapacity; + } + changeCell.capacity = capacity; + tx.addOutput(changeCell); + return 0; +} + export class DefaultFeePayer implements FeePayer { - constructor(private addresses: Address[]) {} + private addresses: Address[] = []; + private changeFn?: ( + tx: Transaction, + capacity: Num, + ) => Promise | NumLike; private feeRate?: NumLike; private filter?: ClientCollectableSearchKeyFilterLike; private options?: { @@ -17,22 +42,38 @@ export class DefaultFeePayer implements FeePayer { maxFeeRate?: NumLike; shouldAddInputs?: boolean; }; - private changeFn?: ( - tx: Transaction, - capacity: Num, - ) => Promise | NumLike; - async completeTxFee(tx: Transaction, client: Client): Promise { - return tx; + async completeTxFee(tx: Transaction, client: Client): Promise { + await this.completeFee(tx, client); + } + + setAddresses(addresses: AddressLike[]): void { + this.addresses = addresses.map((address) => Address.from(address)); + if (this.addresses.length === 0) { + throw new Error("Addresses cannot be empty"); + } + } + + setOptionalProperties(props: { + changeFn?: (tx: Transaction, capacity: Num) => Promise | NumLike; + feeRate?: NumLike; + filter?: ClientCollectableSearchKeyFilterLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + shouldAddInputs?: boolean; + }; + }): void { + this.changeFn = props.changeFn; + this.feeRate = props.feeRate; + this.filter = props.filter; + this.options = props.options; } async completeFee( tx: Transaction, client: Client, - ): Promise<{ - tx: Transaction; - result: [number, boolean]; - }> { + ): Promise<[number, boolean]> { const feeRate = this.feeRate ?? (await client.getFeeRate(this.options?.feeRateBlockRange, this.options)); @@ -76,7 +117,7 @@ export class DefaultFeePayer implements FeePayer { } })(); - const fee = await this.getFee(from.client); + const fee = await tx.getFee(client); if (fee < leastFee + leastExtraCapacity) { // Not enough capacity are collected, it should only happens when shouldAddInputs is false throw new ErrorTransactionInsufficientCapacity( @@ -85,11 +126,11 @@ export class DefaultFeePayer implements FeePayer { ); } - await from.prepareTransaction(this); + await this.prepareTransaction(tx); if (leastFee === Zero) { // The initial fee is calculated based on prepared transaction // This should only happens during the first iteration - leastFee = this.estimateFee(feeRate); + leastFee = tx.estimateFee(feeRate); } // The extra capacity paid the fee without a change // leastExtraCapacity should be 0 here, otherwise we should failed in the previous check @@ -99,29 +140,34 @@ export class DefaultFeePayer implements FeePayer { } // Invoke the change function on a transaction multiple times may cause problems, so we clone it - const tx = tx.clone(); - const needed = numFrom(await Promise.resolve(this.changeFn?.(tx, fee - leastFee))); + const txCopy = tx.clone(); + const needed = numFrom( + await Promise.resolve( + this.changeFn?.(txCopy, fee - leastFee) ?? + defaultChangeFn(txCopy, this.addresses[0].script, fee - leastFee), + ), + ); if (needed > Zero) { // No enough extra capacity to create new cells for change, collect inputs again leastExtraCapacity = needed; continue; } - if ((await tx.getFee(from.client)) !== leastFee) { + if ((await txCopy.getFee(client)) !== leastFee) { throw new Error( "The change function doesn't use all available capacity", ); } // New change cells created, update the fee - await from.prepareTransaction(tx); - const changedFee = tx.estimateFee(feeRate); + await this.prepareTransaction(txCopy); + const changedFee = txCopy.estimateFee(feeRate); if (leastFee > changedFee) { throw new Error("The change function removed existed transaction data"); } // The fee has been paid if (leastFee === changedFee) { - this.copy(tx); + tx.copy(txCopy); return [collected, true]; } @@ -130,6 +176,26 @@ export class DefaultFeePayer implements FeePayer { } } + /** + * Prepares a transaction before signing. + * This method can be overridden by subclasses to perform any necessary steps, + * such as adding cell dependencies or witnesses, before the transaction is signed. + * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object + * without modification. + * + * @remarks + * Note that this default implementation does not add any cell dependencies or dummy witnesses. + * This may lead to an underestimation of transaction size and fees if used with methods + * like `Transaction.completeFee`. Subclasses for signers that are intended to sign + * transactions should override this method to perform necessary preparations. + * + * @param tx - The transaction to prepare. + * @returns A promise that resolves to the prepared {@link Transaction} object. + */ + async prepareTransaction(tx: TransactionLike): Promise { + return Transaction.from(tx); + } + async completeInputsByCapacity( tx: Transaction, client: Client, @@ -177,7 +243,6 @@ export class DefaultFeePayer implements FeePayer { ) => Promise | T | undefined, init: T, ): Promise<{ - tx: Transaction; addedCount: number; accumulated?: T; }> { @@ -218,13 +283,11 @@ export class DefaultFeePayer implements FeePayer { collectedCells.forEach((cell) => tx.addInput(cell)); if (fulfilled) { return { - tx, addedCount: collectedCells.length, }; } return { - tx, addedCount: collectedCells.length, accumulated: acc, }; diff --git a/packages/core/src/signer/feePayer/index.ts b/packages/core/src/signer/feePayer/index.ts index 6a173060..d895eccd 100644 --- a/packages/core/src/signer/feePayer/index.ts +++ b/packages/core/src/signer/feePayer/index.ts @@ -4,7 +4,7 @@ import { Client } from "../../client/client.js"; export abstract class FeePayer { constructor() {} - abstract completeTxFee(tx: Transaction, client: Client): Promise; + abstract completeTxFee(tx: Transaction, client: Client): Promise; } export class FeePayerManager { @@ -19,10 +19,9 @@ export class FeePayerManager { return this.payers.pop(); } - async completeTxFee(tx: Transaction, client: Client): Promise { + async completeTxFee(tx: Transaction, client: Client): Promise { for (const payer of this.payers) { - tx = await payer.completeTxFee(tx, client); + await payer.completeTxFee(tx, client); } - return tx; } } diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index 1522b335..b68ee5bc 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -15,6 +15,7 @@ import { verifyMessageCkbSecp256k1 } from "../ckb/verifyCkbSecp256k1.js"; import { verifyMessageJoyId } from "../ckb/verifyJoyId.js"; import { verifyMessageDogeEcdsa } from "../doge/verify.js"; import { verifyMessageEvmPersonal } from "../evm/verify.js"; +import { DefaultFeePayer } from "../feePayer/defaultFee.js"; import { verifyMessageNostrEvent } from "../nostr/verify.js"; /** @@ -78,8 +79,10 @@ export class Signature { * This class provides methods to connect, get addresses, and sign transactions. * @public */ -export abstract class Signer { - constructor(protected client_: Client) {} +export abstract class Signer extends DefaultFeePayer { + constructor(protected client_: Client) { + super(); + } abstract get type(): SignerType; abstract get signType(): SignerSignType; @@ -459,26 +462,6 @@ export abstract class Signer { return this.signOnlyTransaction(preparedTx); } - /** - * Prepares a transaction before signing. - * This method can be overridden by subclasses to perform any necessary steps, - * such as adding cell dependencies or witnesses, before the transaction is signed. - * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object - * without modification. - * - * @remarks - * Note that this default implementation does not add any cell dependencies or dummy witnesses. - * This may lead to an underestimation of transaction size and fees if used with methods - * like `Transaction.completeFee`. Subclasses for signers that are intended to sign - * transactions should override this method to perform necessary preparations. - * - * @param tx - The transaction to prepare. - * @returns A promise that resolves to the prepared {@link Transaction} object. - */ - async prepareTransaction(tx: TransactionLike): Promise { - return Transaction.from(tx); - } - /** * Signs a transaction without preparing information for it. This method is not implemented and should be overridden by subclasses. * From 69b5e50270a89d3b541870b7b8d352fc74611e19 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Tue, 4 Nov 2025 21:48:06 +0800 Subject: [PATCH 03/11] chore: apply fee payer manager on transaction --- packages/core/src/ckb/transaction.ts | 13 +++++++++++++ .../__examples__/createSporeWithoutCluster.test.ts | 3 ++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 530a428e..11f7742e 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -19,6 +19,7 @@ import { numToBytes, numToHex, } from "../num/index.js"; +import { FeePayer, FeePayerManager } from "../signer/feePayer/index.js"; import type { Signer } from "../signer/index.js"; import { apply, reduceAsync } from "../utils/index.js"; import { Script, ScriptLike, ScriptOpt } from "./script.js"; @@ -2285,6 +2286,18 @@ export class Transaction extends mol.Entity.Base< return this.completeFeeChangeToLock(from, script, feeRate, filter, options); } + async completeByFeePayers( + client: Client, + feePayers: Array | FeePayerManager, + ): Promise { + if (feePayers instanceof FeePayerManager) { + await feePayers.completeTxFee(this, client); + } else { + const manager = new FeePayerManager(feePayers); + await manager.completeTxFee(this, client); + } + } + /** * Completes the transaction fee by adding excess capacity to an existing output. * Instead of creating a new change output, this method adds any excess capacity diff --git a/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts b/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts index 74b587b1..a6e29712 100644 --- a/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts +++ b/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts @@ -25,7 +25,8 @@ describe("createSpore [testnet]", () => { console.log("sporeId:", id); // Complete transaction - await tx.completeFeeBy(signer); + // await tx.completeFeeBy(signer); + await tx.completeByFeePayers(client, [signer]); tx = await signer.signTransaction(tx); console.log(JSON.stringify(JsonRpcTransformers.transactionFrom(tx))); From 5d1b34475a0dc742020134a6e37a3ce7bb51ed48 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Wed, 5 Nov 2025 13:31:27 +0800 Subject: [PATCH 04/11] chore: downgrade address related methods from Signer to SignerFeePayer --- packages/core/src/ckb/transaction.ts | 3 - .../{defaultFee.ts => defaultPayer.ts} | 99 ++++++++++++------- packages/core/src/signer/signer/index.ts | 43 +------- 3 files changed, 68 insertions(+), 77 deletions(-) rename packages/core/src/signer/feePayer/{defaultFee.ts => defaultPayer.ts} (84%) diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 11f7742e..18771020 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -1952,7 +1952,6 @@ export class Transaction extends mol.Entity.Base< addedCount: number; accumulated?: T; }> { - from.setAddresses(await from.getAddressObjs()); from.setOptionalProperties({ filter, }); @@ -1972,7 +1971,6 @@ export class Transaction extends mol.Entity.Base< capacityTweak?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, ): Promise { - from.setAddresses(await from.getAddressObjs()); from.setOptionalProperties({ filter, }); @@ -2169,7 +2167,6 @@ export class Transaction extends mol.Entity.Base< shouldAddInputs?: boolean; }, ): Promise<[number, boolean]> { - from.setAddresses(await from.getAddressObjs()); from.setOptionalProperties({ changeFn: change, feeRate: expectedFeeRate, diff --git a/packages/core/src/signer/feePayer/defaultFee.ts b/packages/core/src/signer/feePayer/defaultPayer.ts similarity index 84% rename from packages/core/src/signer/feePayer/defaultFee.ts rename to packages/core/src/signer/feePayer/defaultPayer.ts index a71083cf..b9993ee3 100644 --- a/packages/core/src/signer/feePayer/defaultFee.ts +++ b/packages/core/src/signer/feePayer/defaultPayer.ts @@ -1,4 +1,4 @@ -import { Address, AddressLike } from "../../address/index.js"; +import { Address } from "../../address/index.js"; import { Script } from "../../ckb/script.js"; import { Cell, @@ -28,9 +28,7 @@ function defaultChangeFn( return 0; } -export class DefaultFeePayer implements FeePayer { - private addresses: Address[] = []; - +export abstract class SignerFeePayer implements FeePayer { private changeFn?: ( tx: Transaction, capacity: Num, @@ -43,15 +41,66 @@ export class DefaultFeePayer implements FeePayer { shouldAddInputs?: boolean; }; - async completeTxFee(tx: Transaction, client: Client): Promise { - await this.completeFee(tx, client); + /** + * Gets an array of Address objects associated with the signer. + * + * @returns A promise that resolves to an array of Address objects. + */ + abstract getAddressObjs(): Promise; + + /** + * Gets the recommended Address object for the signer. + * + * @param _preference - Optional preference parameter. + * @returns A promise that resolves to the recommended Address object. + */ + async getRecommendedAddressObj(_preference?: unknown): Promise
{ + return (await this.getAddressObjs())[0]; } - setAddresses(addresses: AddressLike[]): void { - this.addresses = addresses.map((address) => Address.from(address)); - if (this.addresses.length === 0) { - throw new Error("Addresses cannot be empty"); - } + /** + * Gets the recommended address for the signer as a string. + * + * @param preference - Optional preference parameter. + * @returns A promise that resolves to the recommended address as a string. + */ + async getRecommendedAddress(preference?: unknown): Promise { + return (await this.getRecommendedAddressObj(preference)).toString(); + } + + /** + * Gets an array of addresses associated with the signer as strings. + * + * @returns A promise that resolves to an array of addresses as strings. + */ + async getAddresses(): Promise { + return this.getAddressObjs().then((addresses) => + addresses.map((address) => address.toString()), + ); + } + + /** + * Prepares a transaction before signing. + * This method can be overridden by subclasses to perform any necessary steps, + * such as adding cell dependencies or witnesses, before the transaction is signed. + * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object + * without modification. + * + * @remarks + * Note that this default implementation does not add any cell dependencies or dummy witnesses. + * This may lead to an underestimation of transaction size and fees if used with methods + * like `Transaction.completeFee`. Subclasses for signers that are intended to sign + * transactions should override this method to perform necessary preparations. + * + * @param tx - The transaction to prepare. + * @returns A promise that resolves to the prepared {@link Transaction} object. + */ + async prepareTransaction(tx: TransactionLike): Promise { + return Transaction.from(tx); + } + + async completeTxFee(tx: Transaction, client: Client): Promise { + await this.completeFee(tx, client); } setOptionalProperties(props: { @@ -144,7 +193,11 @@ export class DefaultFeePayer implements FeePayer { const needed = numFrom( await Promise.resolve( this.changeFn?.(txCopy, fee - leastFee) ?? - defaultChangeFn(txCopy, this.addresses[0].script, fee - leastFee), + defaultChangeFn( + txCopy, + (await this.getRecommendedAddressObj()).script, + fee - leastFee, + ), ), ); if (needed > Zero) { @@ -176,26 +229,6 @@ export class DefaultFeePayer implements FeePayer { } } - /** - * Prepares a transaction before signing. - * This method can be overridden by subclasses to perform any necessary steps, - * such as adding cell dependencies or witnesses, before the transaction is signed. - * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object - * without modification. - * - * @remarks - * Note that this default implementation does not add any cell dependencies or dummy witnesses. - * This may lead to an underestimation of transaction size and fees if used with methods - * like `Transaction.completeFee`. Subclasses for signers that are intended to sign - * transactions should override this method to perform necessary preparations. - * - * @param tx - The transaction to prepare. - * @returns A promise that resolves to the prepared {@link Transaction} object. - */ - async prepareTransaction(tx: TransactionLike): Promise { - return Transaction.from(tx); - } - async completeInputsByCapacity( tx: Transaction, client: Client, @@ -250,7 +283,7 @@ export class DefaultFeePayer implements FeePayer { let acc: T = init; let fulfilled = false; - for (const address of this.addresses) { + for (const address of await this.getAddressObjs()) { for await (const cell of client.findCells({ script: address.script, scriptType: "lock", diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index b68ee5bc..62973121 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -1,4 +1,3 @@ -import { Address } from "../../address/index.js"; import { ClientCollectableSearchKeyFilterLike } from "../../advancedBarrel.js"; import { BytesLike } from "../../bytes/index.js"; import { Cell, Transaction, TransactionLike } from "../../ckb/index.js"; @@ -15,7 +14,7 @@ import { verifyMessageCkbSecp256k1 } from "../ckb/verifyCkbSecp256k1.js"; import { verifyMessageJoyId } from "../ckb/verifyJoyId.js"; import { verifyMessageDogeEcdsa } from "../doge/verify.js"; import { verifyMessageEvmPersonal } from "../evm/verify.js"; -import { DefaultFeePayer } from "../feePayer/defaultFee.js"; +import { SignerFeePayer } from "../feePayer/defaultPayer.js"; import { verifyMessageNostrEvent } from "../nostr/verify.js"; /** @@ -79,7 +78,7 @@ export class Signature { * This class provides methods to connect, get addresses, and sign transactions. * @public */ -export abstract class Signer extends DefaultFeePayer { +export abstract class Signer extends SignerFeePayer { constructor(protected client_: Client) { super(); } @@ -206,44 +205,6 @@ export abstract class Signer extends DefaultFeePayer { return this.getInternalAddress(); } - /** - * Gets an array of Address objects associated with the signer. - * - * @returns A promise that resolves to an array of Address objects. - */ - abstract getAddressObjs(): Promise; - - /** - * Gets the recommended Address object for the signer. - * - * @param _preference - Optional preference parameter. - * @returns A promise that resolves to the recommended Address object. - */ - async getRecommendedAddressObj(_preference?: unknown): Promise
{ - return (await this.getAddressObjs())[0]; - } - - /** - * Gets the recommended address for the signer as a string. - * - * @param preference - Optional preference parameter. - * @returns A promise that resolves to the recommended address as a string. - */ - async getRecommendedAddress(preference?: unknown): Promise { - return (await this.getRecommendedAddressObj(preference)).toString(); - } - - /** - * Gets an array of addresses associated with the signer as strings. - * - * @returns A promise that resolves to an array of addresses as strings. - */ - async getAddresses(): Promise { - return this.getAddressObjs().then((addresses) => - addresses.map((address) => address.toString()), - ); - } - /** * Find cells of this signer * From ed5bfe8b9a5c5f28c38d30a09fc270800f86b022 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Wed, 12 Nov 2025 21:57:22 +0800 Subject: [PATCH 05/11] feat: solve cocurrence issue and doing rename --- packages/core/src/ckb/transaction.ts | 43 +++++----- ...defaultPayer.ts => feePayerFromAddress.ts} | 85 ++++++++++--------- .../core/src/signer/feePayer/feePayerGroup | 32 +++++++ packages/core/src/signer/feePayer/index.ts | 60 +++++++++---- packages/core/src/signer/index.ts | 1 + packages/core/src/signer/signer/index.ts | 4 +- 6 files changed, 146 insertions(+), 79 deletions(-) rename packages/core/src/signer/feePayer/{defaultPayer.ts => feePayerFromAddress.ts} (82%) create mode 100644 packages/core/src/signer/feePayer/feePayerGroup diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 18771020..a71f9f21 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -19,7 +19,7 @@ import { numToBytes, numToHex, } from "../num/index.js"; -import { FeePayer, FeePayerManager } from "../signer/feePayer/index.js"; +import { FeePayer } from "../signer/feePayer/index.js"; import type { Signer } from "../signer/index.js"; import { apply, reduceAsync } from "../utils/index.js"; import { Script, ScriptLike, ScriptOpt } from "./script.js"; @@ -292,6 +292,10 @@ export class CellOutput extends mol.Entity.Base() { clone(): CellOutput { return new CellOutput(this.capacity, this.lock.clone(), this.type?.clone()); } + + margin(dataLen: NumLike = 0): Num { + return this.capacity - fixedPointFrom(this.occupiedSize) - numFrom(dataLen); + } } export const CellOutputVec = mol.vector(CellOutput); @@ -1938,6 +1942,14 @@ export class Transaction extends mol.Entity.Base< }, numFrom(0)); } + getOutputCapacityMargin(index: number): Num { + const output = this.outputs[index]; + if (output === undefined) { + return Zero; + } + return output.margin(bytesFrom(this.outputsData[index] ?? "0x").length); + } + async completeInputs( from: Signer, filter: ClientCollectableSearchKeyFilterLike, @@ -1952,9 +1964,6 @@ export class Transaction extends mol.Entity.Base< addedCount: number; accumulated?: T; }> { - from.setOptionalProperties({ - filter, - }); const { addedCount, accumulated } = await from.completeInputs( this, from.client, @@ -1962,7 +1971,6 @@ export class Transaction extends mol.Entity.Base< accumulator, init, ); - from.setOptionalProperties({}); return { addedCount, accumulated }; } @@ -1971,15 +1979,14 @@ export class Transaction extends mol.Entity.Base< capacityTweak?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, ): Promise { - from.setOptionalProperties({ - filter, - }); const addedCount = await from.completeInputsByCapacity( this, from.client, capacityTweak, + { + filter, + }, ); - from.setOptionalProperties({}); return addedCount; } @@ -2167,14 +2174,12 @@ export class Transaction extends mol.Entity.Base< shouldAddInputs?: boolean; }, ): Promise<[number, boolean]> { - from.setOptionalProperties({ + const result = await from.completeFee(this, from.client, { changeFn: change, feeRate: expectedFeeRate, filter, options, }); - const result = await from.completeFee(this, from.client); - from.setOptionalProperties({}); return result; } @@ -2283,15 +2288,15 @@ export class Transaction extends mol.Entity.Base< return this.completeFeeChangeToLock(from, script, feeRate, filter, options); } - async completeByFeePayers( + async completeByFeePayer( client: Client, - feePayers: Array | FeePayerManager, + ...feePayers: FeePayer[] ): Promise { - if (feePayers instanceof FeePayerManager) { - await feePayers.completeTxFee(this, client); - } else { - const manager = new FeePayerManager(feePayers); - await manager.completeTxFee(this, client); + for (const feePayer of feePayers) { + await feePayer.prepareTransaction(this); + } + for (const feePayer of feePayers) { + await feePayer.completeTxFee(this, client); } } diff --git a/packages/core/src/signer/feePayer/defaultPayer.ts b/packages/core/src/signer/feePayer/feePayerFromAddress.ts similarity index 82% rename from packages/core/src/signer/feePayer/defaultPayer.ts rename to packages/core/src/signer/feePayer/feePayerFromAddress.ts index b9993ee3..3503f72f 100644 --- a/packages/core/src/signer/feePayer/defaultPayer.ts +++ b/packages/core/src/signer/feePayer/feePayerFromAddress.ts @@ -1,17 +1,12 @@ import { Address } from "../../address/index.js"; import { Script } from "../../ckb/script.js"; -import { - Cell, - CellOutput, - Transaction, - TransactionLike, -} from "../../ckb/transaction.js"; +import { Cell, CellOutput, Transaction } from "../../ckb/transaction.js"; import { ErrorTransactionInsufficientCapacity } from "../../ckb/transactionErrors.js"; import { Client } from "../../client/client.js"; import { ClientCollectableSearchKeyFilterLike } from "../../client/clientTypes.advanced.js"; import { fixedPointFrom, Zero } from "../../fixedPoint/index.js"; import { Num, numFrom, NumLike } from "../../num/index.js"; -import { FeePayer } from "./index.js"; +import { FeePayer, FeeRateOptions } from "./index.js"; function defaultChangeFn( tx: Transaction, @@ -28,14 +23,22 @@ function defaultChangeFn( return 0; } -export abstract class SignerFeePayer implements FeePayer { - private changeFn?: ( - tx: Transaction, - capacity: Num, - ) => Promise | NumLike; - private feeRate?: NumLike; - private filter?: ClientCollectableSearchKeyFilterLike; - private options?: { +export interface FeePayerFromAddressOptions { + changeFn?: (tx: Transaction, capacity: Num) => Promise | NumLike; + feeRate?: NumLike; + filter?: ClientCollectableSearchKeyFilterLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + shouldAddInputs?: boolean; + }; +} + +export abstract class FeePayerFromAddress extends FeePayer { + changeFn?: (tx: Transaction, capacity: Num) => Promise | NumLike; + feeRate?: NumLike; + filter?: ClientCollectableSearchKeyFilterLike; + options?: { feeRateBlockRange?: NumLike; maxFeeRate?: NumLike; shouldAddInputs?: boolean; @@ -79,28 +82,23 @@ export abstract class SignerFeePayer implements FeePayer { ); } - /** - * Prepares a transaction before signing. - * This method can be overridden by subclasses to perform any necessary steps, - * such as adding cell dependencies or witnesses, before the transaction is signed. - * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object - * without modification. - * - * @remarks - * Note that this default implementation does not add any cell dependencies or dummy witnesses. - * This may lead to an underestimation of transaction size and fees if used with methods - * like `Transaction.completeFee`. Subclasses for signers that are intended to sign - * transactions should override this method to perform necessary preparations. - * - * @param tx - The transaction to prepare. - * @returns A promise that resolves to the prepared {@link Transaction} object. - */ - async prepareTransaction(tx: TransactionLike): Promise { - return Transaction.from(tx); + async completeTxFee( + tx: Transaction, + client: Client, + options?: FeeRateOptions, + ): Promise { + await this.completeFee(tx, client, options); } - async completeTxFee(tx: Transaction, client: Client): Promise { - await this.completeFee(tx, client); + mergeOptions( + manualOptions?: FeePayerFromAddressOptions, + ): FeePayerFromAddressOptions { + return { + changeFn: manualOptions?.changeFn ?? this.changeFn, + feeRate: manualOptions?.feeRate ?? this.feeRate, + filter: manualOptions?.filter ?? this.filter, + options: manualOptions?.options ?? this.options, + }; } setOptionalProperties(props: { @@ -122,10 +120,12 @@ export abstract class SignerFeePayer implements FeePayer { async completeFee( tx: Transaction, client: Client, + options?: FeePayerFromAddressOptions, ): Promise<[number, boolean]> { - const feeRate = - this.feeRate ?? - (await client.getFeeRate(this.options?.feeRateBlockRange, this.options)); + const mergedOptions = this.mergeOptions(options); + + // Get fee rate at first + const feeRate = await FeePayer.getFeeRate(client, mergedOptions); // Complete all inputs extra infos for cache await tx.getInputsCapacity(client); @@ -142,7 +142,7 @@ export abstract class SignerFeePayer implements FeePayer { // === while (true) { collected += await (async () => { - if (!(this.options?.shouldAddInputs ?? true)) { + if (!(mergedOptions.options?.shouldAddInputs ?? true)) { return 0; } @@ -151,6 +151,7 @@ export abstract class SignerFeePayer implements FeePayer { tx, client, leastFee + leastExtraCapacity, + options, ); } catch (err) { if ( @@ -192,7 +193,7 @@ export abstract class SignerFeePayer implements FeePayer { const txCopy = tx.clone(); const needed = numFrom( await Promise.resolve( - this.changeFn?.(txCopy, fee - leastFee) ?? + mergedOptions.changeFn?.(txCopy, fee - leastFee) ?? defaultChangeFn( txCopy, (await this.getRecommendedAddressObj()).script, @@ -233,6 +234,7 @@ export abstract class SignerFeePayer implements FeePayer { tx: Transaction, client: Client, capacityTweak?: NumLike, + options?: FeePayerFromAddressOptions, ): Promise { const expectedCapacity = tx.getOutputsCapacity() + numFrom(capacityTweak ?? 0); @@ -241,10 +243,11 @@ export abstract class SignerFeePayer implements FeePayer { return 0; } + const mergedOptions = this.mergeOptions(options); const { addedCount, accumulated } = await this.completeInputs( tx, client, - this.filter ?? { + mergedOptions.filter ?? { scriptLenRange: [0, 1], outputDataLenRange: [0, 1], }, diff --git a/packages/core/src/signer/feePayer/feePayerGroup b/packages/core/src/signer/feePayer/feePayerGroup new file mode 100644 index 00000000..2d71084b --- /dev/null +++ b/packages/core/src/signer/feePayer/feePayerGroup @@ -0,0 +1,32 @@ +import { FeePayer, FeeRateOptions } from "./index.js"; +import { Transaction, TransactionLike } from "../../ckb/transaction.js"; +import { Client } from "../../client/client.js"; + +export class FeePayerGroup extends FeePayer { + constructor(private feePayers: FeePayer[]) { + super(); + } + + push(...payers: FeePayer[]): FeePayerGroup { + this.feePayers.push(...payers); + return this; + } + + pop(): FeePayer | undefined { + return this.feePayers.pop(); + } + + async prepareTransaction(txLike: TransactionLike): Promise { + let tx = Transaction.from(txLike); + for (const payer of this.feePayers) { + tx = await payer.prepareTransaction(tx); + } + return tx; + } + + async completeTxFee(tx: Transaction, client: Client, options?: FeeRateOptions): Promise { + for (const payer of this.feePayers) { + await payer.completeTxFee(tx, client, options); + } + } +} diff --git a/packages/core/src/signer/feePayer/index.ts b/packages/core/src/signer/feePayer/index.ts index d895eccd..aa51ac62 100644 --- a/packages/core/src/signer/feePayer/index.ts +++ b/packages/core/src/signer/feePayer/index.ts @@ -1,27 +1,53 @@ -import { Transaction } from "../../ckb/index.js"; +import { Transaction, TransactionLike } from "../../ckb/index.js"; import { Client } from "../../client/client.js"; +import { Num, NumLike, numFrom } from "../../num/index.js"; -export abstract class FeePayer { - constructor() {} - - abstract completeTxFee(tx: Transaction, client: Client): Promise; +export interface FeeRateOptions { + feeRate?: NumLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + }; } -export class FeePayerManager { - constructor(private payers: FeePayer[]) {} +export abstract class FeePayer { + constructor() {} - push(...payers: FeePayer[]): FeePayerManager { - this.payers.push(...payers); - return this; - } + abstract completeTxFee( + tx: Transaction, + client: Client, + options?: FeeRateOptions, + ): Promise; - pop(): FeePayer | undefined { - return this.payers.pop(); + /** + * Prepares a transaction before signing. + * This method can be overridden by subclasses to perform any necessary steps, + * such as adding cell dependencies or witnesses, before the transaction is signed. + * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object + * without modification. + * + * @remarks + * Note that this default implementation does not add any cell dependencies or dummy witnesses. + * This may lead to an underestimation of transaction size and fees if used with methods + * like `Transaction.completeFee`. Subclasses for signers that are intended to sign + * transactions should override this method to perform necessary preparations. + * + * @param tx - The transaction to prepare. + * @returns A promise that resolves to the prepared {@link Transaction} object. + */ + async prepareTransaction(tx: TransactionLike): Promise { + return Transaction.from(tx); } - async completeTxFee(tx: Transaction, client: Client): Promise { - for (const payer of this.payers) { - await payer.completeTxFee(tx, client); - } + static async getFeeRate( + client: Client, + options?: FeeRateOptions, + ): Promise { + return options?.feeRate + ? numFrom(options.feeRate) + : await client.getFeeRate( + options?.options?.feeRateBlockRange, + options?.options, + ); } } diff --git a/packages/core/src/signer/index.ts b/packages/core/src/signer/index.ts index 350b487e..9657b263 100644 --- a/packages/core/src/signer/index.ts +++ b/packages/core/src/signer/index.ts @@ -3,5 +3,6 @@ export * from "./ckb/index.js"; export * from "./doge/index.js"; export * from "./dummy/index.js"; export * from "./evm/index.js"; +export * from "./feePayer/index.js"; export * from "./nostr/index.js"; export * from "./signer/index.js"; diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index 62973121..8869fc7c 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -14,7 +14,7 @@ import { verifyMessageCkbSecp256k1 } from "../ckb/verifyCkbSecp256k1.js"; import { verifyMessageJoyId } from "../ckb/verifyJoyId.js"; import { verifyMessageDogeEcdsa } from "../doge/verify.js"; import { verifyMessageEvmPersonal } from "../evm/verify.js"; -import { SignerFeePayer } from "../feePayer/defaultPayer.js"; +import { FeePayerFromAddress } from "../feePayer/feePayerFromAddress.js"; import { verifyMessageNostrEvent } from "../nostr/verify.js"; /** @@ -78,7 +78,7 @@ export class Signature { * This class provides methods to connect, get addresses, and sign transactions. * @public */ -export abstract class Signer extends SignerFeePayer { +export abstract class Signer extends FeePayerFromAddress { constructor(protected client_: Client) { super(); } From e796fc1f7640c73208318eec6d58dad102e325e7 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 13 Nov 2025 12:11:28 +0800 Subject: [PATCH 06/11] chore: rename group file --- .changeset/tender-nails-stick.md | 6 ++ packages/core/src/signer/feePayer/feePayer.ts | 53 ++++++++++++++++++ .../signer/feePayer/feePayerFromAddress.ts | 2 +- .../{feePayerGroup => feePayerGroup.ts} | 8 ++- packages/core/src/signer/feePayer/index.ts | 56 +------------------ 5 files changed, 69 insertions(+), 56 deletions(-) create mode 100644 .changeset/tender-nails-stick.md create mode 100644 packages/core/src/signer/feePayer/feePayer.ts rename packages/core/src/signer/feePayer/{feePayerGroup => feePayerGroup.ts} (81%) diff --git a/.changeset/tender-nails-stick.md b/.changeset/tender-nails-stick.md new file mode 100644 index 00000000..91b6438a --- /dev/null +++ b/.changeset/tender-nails-stick.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +Abstract transaction completion step by a new layer named FeePayer + \ No newline at end of file diff --git a/packages/core/src/signer/feePayer/feePayer.ts b/packages/core/src/signer/feePayer/feePayer.ts new file mode 100644 index 00000000..aa51ac62 --- /dev/null +++ b/packages/core/src/signer/feePayer/feePayer.ts @@ -0,0 +1,53 @@ +import { Transaction, TransactionLike } from "../../ckb/index.js"; +import { Client } from "../../client/client.js"; +import { Num, NumLike, numFrom } from "../../num/index.js"; + +export interface FeeRateOptions { + feeRate?: NumLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + }; +} + +export abstract class FeePayer { + constructor() {} + + abstract completeTxFee( + tx: Transaction, + client: Client, + options?: FeeRateOptions, + ): Promise; + + /** + * Prepares a transaction before signing. + * This method can be overridden by subclasses to perform any necessary steps, + * such as adding cell dependencies or witnesses, before the transaction is signed. + * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object + * without modification. + * + * @remarks + * Note that this default implementation does not add any cell dependencies or dummy witnesses. + * This may lead to an underestimation of transaction size and fees if used with methods + * like `Transaction.completeFee`. Subclasses for signers that are intended to sign + * transactions should override this method to perform necessary preparations. + * + * @param tx - The transaction to prepare. + * @returns A promise that resolves to the prepared {@link Transaction} object. + */ + async prepareTransaction(tx: TransactionLike): Promise { + return Transaction.from(tx); + } + + static async getFeeRate( + client: Client, + options?: FeeRateOptions, + ): Promise { + return options?.feeRate + ? numFrom(options.feeRate) + : await client.getFeeRate( + options?.options?.feeRateBlockRange, + options?.options, + ); + } +} diff --git a/packages/core/src/signer/feePayer/feePayerFromAddress.ts b/packages/core/src/signer/feePayer/feePayerFromAddress.ts index 3503f72f..60c9a3d6 100644 --- a/packages/core/src/signer/feePayer/feePayerFromAddress.ts +++ b/packages/core/src/signer/feePayer/feePayerFromAddress.ts @@ -6,7 +6,7 @@ import { Client } from "../../client/client.js"; import { ClientCollectableSearchKeyFilterLike } from "../../client/clientTypes.advanced.js"; import { fixedPointFrom, Zero } from "../../fixedPoint/index.js"; import { Num, numFrom, NumLike } from "../../num/index.js"; -import { FeePayer, FeeRateOptions } from "./index.js"; +import { FeePayer, FeeRateOptions } from "./feePayer.js"; function defaultChangeFn( tx: Transaction, diff --git a/packages/core/src/signer/feePayer/feePayerGroup b/packages/core/src/signer/feePayer/feePayerGroup.ts similarity index 81% rename from packages/core/src/signer/feePayer/feePayerGroup rename to packages/core/src/signer/feePayer/feePayerGroup.ts index 2d71084b..0c19b408 100644 --- a/packages/core/src/signer/feePayer/feePayerGroup +++ b/packages/core/src/signer/feePayer/feePayerGroup.ts @@ -1,6 +1,6 @@ -import { FeePayer, FeeRateOptions } from "./index.js"; import { Transaction, TransactionLike } from "../../ckb/transaction.js"; import { Client } from "../../client/client.js"; +import { FeePayer, FeeRateOptions } from "./feePayer.js"; export class FeePayerGroup extends FeePayer { constructor(private feePayers: FeePayer[]) { @@ -24,7 +24,11 @@ export class FeePayerGroup extends FeePayer { return tx; } - async completeTxFee(tx: Transaction, client: Client, options?: FeeRateOptions): Promise { + async completeTxFee( + tx: Transaction, + client: Client, + options?: FeeRateOptions, + ): Promise { for (const payer of this.feePayers) { await payer.completeTxFee(tx, client, options); } diff --git a/packages/core/src/signer/feePayer/index.ts b/packages/core/src/signer/feePayer/index.ts index aa51ac62..0afca899 100644 --- a/packages/core/src/signer/feePayer/index.ts +++ b/packages/core/src/signer/feePayer/index.ts @@ -1,53 +1,3 @@ -import { Transaction, TransactionLike } from "../../ckb/index.js"; -import { Client } from "../../client/client.js"; -import { Num, NumLike, numFrom } from "../../num/index.js"; - -export interface FeeRateOptions { - feeRate?: NumLike; - options?: { - feeRateBlockRange?: NumLike; - maxFeeRate?: NumLike; - }; -} - -export abstract class FeePayer { - constructor() {} - - abstract completeTxFee( - tx: Transaction, - client: Client, - options?: FeeRateOptions, - ): Promise; - - /** - * Prepares a transaction before signing. - * This method can be overridden by subclasses to perform any necessary steps, - * such as adding cell dependencies or witnesses, before the transaction is signed. - * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object - * without modification. - * - * @remarks - * Note that this default implementation does not add any cell dependencies or dummy witnesses. - * This may lead to an underestimation of transaction size and fees if used with methods - * like `Transaction.completeFee`. Subclasses for signers that are intended to sign - * transactions should override this method to perform necessary preparations. - * - * @param tx - The transaction to prepare. - * @returns A promise that resolves to the prepared {@link Transaction} object. - */ - async prepareTransaction(tx: TransactionLike): Promise { - return Transaction.from(tx); - } - - static async getFeeRate( - client: Client, - options?: FeeRateOptions, - ): Promise { - return options?.feeRate - ? numFrom(options.feeRate) - : await client.getFeeRate( - options?.options?.feeRateBlockRange, - options?.options, - ); - } -} +export * from "./feePayer.js"; +export * from "./feePayerFromAddress.js"; +export * from "./feePayerGroup.js"; From efa1e9e7e9119122285594fa2ec430147b6c4871 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 13 Nov 2025 12:14:22 +0800 Subject: [PATCH 07/11] chore: rewise spore test --- .../spore/src/__examples__/createSporeWithoutCluster.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts b/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts index a6e29712..74b587b1 100644 --- a/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts +++ b/packages/spore/src/__examples__/createSporeWithoutCluster.test.ts @@ -25,8 +25,7 @@ describe("createSpore [testnet]", () => { console.log("sporeId:", id); // Complete transaction - // await tx.completeFeeBy(signer); - await tx.completeByFeePayers(client, [signer]); + await tx.completeFeeBy(signer); tx = await signer.signTransaction(tx); console.log(JSON.stringify(JsonRpcTransformers.transactionFrom(tx))); From e36515a382abef310cff67cc24cecb367b8ed51c Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 13 Nov 2025 19:51:53 +0800 Subject: [PATCH 08/11] chore: adapt transaction test cases for new implementation --- packages/core/src/ckb/transaction.test.ts | 45 +++++++++++++++++++++-- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/packages/core/src/ckb/transaction.test.ts b/packages/core/src/ckb/transaction.test.ts index 6271943a..630ee92d 100644 --- a/packages/core/src/ckb/transaction.test.ts +++ b/packages/core/src/ckb/transaction.test.ts @@ -58,6 +58,20 @@ describe("Transaction", () => { }, ); + // Mock the findCells method to return our mock UDT cells + vi.spyOn(client, "findCells").mockImplementation( + async function* (searchKey) { + if ( + searchKey.filter?.script && + ccc.Script.from(searchKey.filter.script).eq(type) + ) { + for (const cell of mockUdtCells) { + yield cell; + } + } + }, + ); + // Mock client.getCell to return the cell data for inputs vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { const cell = mockUdtCells.find((c) => c.outPoint.eq(outPoint)); @@ -263,9 +277,12 @@ describe("Transaction", () => { it("should use only one cell when user has only one cell available", async () => { // Mock signer to return only one cell - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { + vi.spyOn(client, "findCells").mockImplementation( + async function* (searchKey) { + if ( + searchKey.filter?.script && + ccc.Script.from(searchKey.filter.script).eq(type) + ) { yield mockUdtCells[0]; // Only yield the first cell } }, @@ -328,6 +345,18 @@ describe("Transaction", () => { }, ); + // Mock the findCells method to return capacity cells + vi.spyOn(client, "findCells").mockImplementation( + async function* (searchKey) { + // Return capacity cells for general queries + if (!searchKey.filter?.script || searchKey.filter?.scriptLenRange) { + for (const cell of mockCapacityCells) { + yield cell; + } + } + }, + ); + // Mock client.getCell to return the cell data for inputs vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { const cell = mockCapacityCells.find((c) => c.outPoint.eq(outPoint)); @@ -551,7 +580,15 @@ describe("Transaction", () => { ); // Verify that findCells was called with the custom filter - expect(signer.findCells).toHaveBeenCalledWith(customFilter, true); + for (const address of await signer.getAddressObjs()) { + expect(client.findCells).toHaveBeenCalledWith({ + script: address.script, + scriptType: "lock", + filter: customFilter, + scriptSearchMode: "exact", + withData: true, + }); + } }); it("should throw error when change function doesn't use all capacity", async () => { From a524235a6234ea975fa4c6ffe873ac7cf8cb247c Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Thu, 20 Nov 2025 20:22:17 +0800 Subject: [PATCH 09/11] chore: fix data length fixed point issue and prepare dedicated test cases --- packages/core/src/ckb/transaction.test.ts | 437 ++++++++++++++++++++++ packages/core/src/ckb/transaction.ts | 9 +- 2 files changed, 445 insertions(+), 1 deletion(-) diff --git a/packages/core/src/ckb/transaction.test.ts b/packages/core/src/ckb/transaction.test.ts index 630ee92d..ee952b85 100644 --- a/packages/core/src/ckb/transaction.test.ts +++ b/packages/core/src/ckb/transaction.test.ts @@ -1165,4 +1165,441 @@ describe("Transaction", () => { }); }); }); + + describe("Margin Concept", () => { + describe("CellOutput.margin", () => { + it("should calculate margin correctly with no data", () => { + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock, + }); + + const margin = cellOutput.margin(0); + const expectedMargin = + ccc.fixedPointFrom(1000) - + ccc.fixedPointFrom(cellOutput.occupiedSize); + expect(margin).toBe(expectedMargin); + }); + + it("should calculate margin correctly with data length", () => { + const dataLen = 100; + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock, + }); + + const margin = cellOutput.margin(dataLen); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should calculate margin with type script", () => { + const dataLen = 50; + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(2000), + lock, + type, + }); + + const margin = cellOutput.margin(dataLen); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should return zero margin when capacity equals occupied size plus data", () => { + const dataLen = 10; + const cellOutput = ccc.CellOutput.from( + { + capacity: 0, + lock, + }, + "0x" + "00".repeat(dataLen), + ); + + // Capacity is auto-calculated as occupiedSize + dataLen, so margin should be 0 + const margin = cellOutput.margin(dataLen); + // The margin should be approximately 0, but due to fixed-point precision, + // we check it's very close to zero (within rounding error) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should return zero margin when capacity is insufficient", () => { + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(50), + lock, + }); + + const margin = cellOutput.margin(100); // Data length exceeds available capacity + expect(margin).toBe(ccc.Zero); + }); + + it("should handle large data length", () => { + const dataLen = 10000; + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(20000), + lock, + }); + + const margin = cellOutput.margin(dataLen); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + }); + + describe("Transaction.getOutputCapacityMargin", () => { + it("should get margin for existing output", () => { + const outputData = "0x12345678"; // 4 bytes + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + ], + outputsData: [outputData], + }); + + const margin = tx.getOutputCapacityMargin(0); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const dataLen = ccc.bytesFrom(outputData).length; + const expectedMargin = + tx.outputs[0].capacity - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should return zero for non-existent output", () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + const margin = tx.getOutputCapacityMargin(0); + expect(margin).toBe(ccc.Zero); + }); + + it("should get margin for output with type script", () => { + const outputData = "0xabcd"; // 2 bytes + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(2000), + lock, + type, + }, + ], + outputsData: [outputData], + }); + + const margin = tx.getOutputCapacityMargin(0); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const dataLen = ccc.bytesFrom(outputData).length; + const expectedMargin = + tx.outputs[0].capacity - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should handle empty output data", () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + ], + outputsData: ["0x"], + }); + + const margin = tx.getOutputCapacityMargin(0); + const expectedMargin = + ccc.fixedPointFrom(1000) - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize); + expect(margin).toBe(expectedMargin); + }); + + it("should handle missing output data", () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + ], + }); + + const margin = tx.getOutputCapacityMargin(0); + const expectedMargin = + ccc.fixedPointFrom(1000) - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize); + expect(margin).toBe(expectedMargin); + }); + + it("should get margin for multiple outputs", () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + { + capacity: ccc.fixedPointFrom(2000), + lock, + type, + }, + ], + outputsData: ["0x12", "0x3456"], + }); + + const margin0 = tx.getOutputCapacityMargin(0); + const margin1 = tx.getOutputCapacityMargin(1); + + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const dataLen0 = ccc.bytesFrom(tx.outputsData[0]).length; + const dataLen1 = ccc.bytesFrom(tx.outputsData[1]).length; + expect(margin0).toBe( + tx.outputs[0].capacity - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen0)), + ); + expect(margin1).toBe( + tx.outputs[1].capacity - + ccc.fixedPointFrom(tx.outputs[1].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen1)), + ); + }); + }); + }); + + describe("Fee Payer Layer", () => { + let mockFeePayer1: ccc.FeePayer; + let mockFeePayer2: ccc.FeePayer; + + beforeEach(() => { + // Create mock fee payers + mockFeePayer1 = { + prepareTransaction: vi.fn().mockResolvedValue(undefined), + completeTxFee: vi.fn().mockResolvedValue(undefined), + } as unknown as ccc.FeePayer; + + mockFeePayer2 = { + prepareTransaction: vi.fn().mockResolvedValue(undefined), + completeTxFee: vi.fn().mockResolvedValue(undefined), + } as unknown as ccc.FeePayer; + }); + + it("should call prepareTransaction on all fee payers", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + await tx.completeByFeePayer(client, mockFeePayer1, mockFeePayer2); + + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledWith(tx); + expect(mockFeePayer2.prepareTransaction).toHaveBeenCalledWith(tx); + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledTimes(1); + expect(mockFeePayer2.prepareTransaction).toHaveBeenCalledTimes(1); + }); + + it("should call completeTxFee on all fee payers after prepareTransaction", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + await tx.completeByFeePayer(client, mockFeePayer1, mockFeePayer2); + + // Verify both methods were called + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledWith(tx); + expect(mockFeePayer2.prepareTransaction).toHaveBeenCalledWith(tx); + expect(mockFeePayer1.completeTxFee).toHaveBeenCalledWith(tx, client); + expect(mockFeePayer2.completeTxFee).toHaveBeenCalledWith(tx, client); + + // Verify prepareTransaction was called before completeTxFee + // by checking the order of calls + const prepare1Order = ( + mockFeePayer1.prepareTransaction as ReturnType + ).mock.invocationCallOrder[0]; + const complete1Order = ( + mockFeePayer1.completeTxFee as ReturnType + ).mock.invocationCallOrder[0]; + expect(prepare1Order).toBeLessThan(complete1Order); + }); + + it("should handle single fee payer", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + await tx.completeByFeePayer(client, mockFeePayer1); + + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledTimes(1); + expect(mockFeePayer1.completeTxFee).toHaveBeenCalledTimes(1); + expect(mockFeePayer2.prepareTransaction).not.toHaveBeenCalled(); + expect(mockFeePayer2.completeTxFee).not.toHaveBeenCalled(); + }); + + it("should handle empty fee payer list", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + // Should not throw with empty fee payer list + await expect(tx.completeByFeePayer(client)).resolves.not.toThrow(); + }); + + it("should handle multiple fee payers in sequence", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const callOrder: string[] = []; + ( + mockFeePayer1.prepareTransaction as ReturnType + ).mockImplementation(async () => { + callOrder.push("prepare1"); + return undefined; + }); + ( + mockFeePayer2.prepareTransaction as ReturnType + ).mockImplementation(async () => { + callOrder.push("prepare2"); + return undefined; + }); + ( + mockFeePayer1.completeTxFee as ReturnType + ).mockImplementation(async () => { + callOrder.push("complete1"); + }); + ( + mockFeePayer2.completeTxFee as ReturnType + ).mockImplementation(async () => { + callOrder.push("complete2"); + }); + + await tx.completeByFeePayer(client, mockFeePayer1, mockFeePayer2); + + // Verify order: all prepareTransaction calls first, then all completeTxFee calls + expect(callOrder).toEqual([ + "prepare1", + "prepare2", + "complete1", + "complete2", + ]); + }); + + it("should propagate errors from prepareTransaction", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const error = new Error("Prepare transaction failed"); + ( + mockFeePayer1.prepareTransaction as ReturnType + ).mockRejectedValue(error); + + await expect( + tx.completeByFeePayer(client, mockFeePayer1), + ).rejects.toThrow("Prepare transaction failed"); + }); + + it("should propagate errors from completeTxFee", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const error = new Error("Complete fee failed"); + ( + mockFeePayer1.completeTxFee as ReturnType + ).mockRejectedValue(error); + + await expect( + tx.completeByFeePayer(client, mockFeePayer1), + ).rejects.toThrow("Complete fee failed"); + }); + + it("should handle fee payer that modifies transaction in prepareTransaction", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const modifiedTx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + { + capacity: ccc.fixedPointFrom(50), + lock, + }, + ], + }); + + ( + mockFeePayer1.prepareTransaction as ReturnType + ).mockResolvedValue(modifiedTx); + + await tx.completeByFeePayer(client, mockFeePayer1); + + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledWith(tx); + expect(mockFeePayer1.completeTxFee).toHaveBeenCalledWith(tx, client); + }); + }); }); diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index a71f9f21..3c7307aa 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -294,7 +294,14 @@ export class CellOutput extends mol.Entity.Base() { } margin(dataLen: NumLike = 0): Num { - return this.capacity - fixedPointFrom(this.occupiedSize) - numFrom(dataLen); + let margin = + this.capacity - + fixedPointFrom(this.occupiedSize) - + fixedPointFrom(numFrom(dataLen)); + if (margin < Zero) { + margin = Zero; + } + return margin; } } export const CellOutputVec = mol.vector(CellOutput); From 4c7b4cdd252a68a9e8e4c33f6636c93333a74e69 Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Wed, 3 Dec 2025 13:03:34 +0800 Subject: [PATCH 10/11] chore: make use of the result of prepareTransaction --- packages/core/src/ckb/transaction.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 3c7307aa..f9fbc681 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -2299,12 +2299,16 @@ export class Transaction extends mol.Entity.Base< client: Client, ...feePayers: FeePayer[] ): Promise { + let tx = this.clone(); for (const feePayer of feePayers) { - await feePayer.prepareTransaction(this); + tx = await feePayer.prepareTransaction(tx); } + for (const feePayer of feePayers) { - await feePayer.completeTxFee(this, client); + await feePayer.completeTxFee(tx, client); } + + this.copy(tx); } /** From cca810774e7d9f5872bc2d99b81ab06a87bf44bc Mon Sep 17 00:00:00 2001 From: ashuralyk Date: Wed, 3 Dec 2025 19:26:19 +0800 Subject: [PATCH 11/11] chore: return Transaction for mock prepareTransaction method --- packages/core/src/ckb/transaction.test.ts | 54 ++++++++++++++++++----- 1 file changed, 42 insertions(+), 12 deletions(-) diff --git a/packages/core/src/ckb/transaction.test.ts b/packages/core/src/ckb/transaction.test.ts index ee952b85..c10a279e 100644 --- a/packages/core/src/ckb/transaction.test.ts +++ b/packages/core/src/ckb/transaction.test.ts @@ -1395,12 +1395,20 @@ describe("Transaction", () => { beforeEach(() => { // Create mock fee payers mockFeePayer1 = { - prepareTransaction: vi.fn().mockResolvedValue(undefined), + prepareTransaction: vi + .fn() + .mockImplementation(async (tx: ccc.TransactionLike) => + ccc.Transaction.from(tx), + ), completeTxFee: vi.fn().mockResolvedValue(undefined), } as unknown as ccc.FeePayer; mockFeePayer2 = { - prepareTransaction: vi.fn().mockResolvedValue(undefined), + prepareTransaction: vi + .fn() + .mockImplementation(async (tx: ccc.TransactionLike) => + ccc.Transaction.from(tx), + ), completeTxFee: vi.fn().mockResolvedValue(undefined), } as unknown as ccc.FeePayer; }); @@ -1436,10 +1444,22 @@ describe("Transaction", () => { await tx.completeByFeePayer(client, mockFeePayer1, mockFeePayer2); // Verify both methods were called - expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledWith(tx); - expect(mockFeePayer2.prepareTransaction).toHaveBeenCalledWith(tx); - expect(mockFeePayer1.completeTxFee).toHaveBeenCalledWith(tx, client); - expect(mockFeePayer2.completeTxFee).toHaveBeenCalledWith(tx, client); + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalled(); + expect(mockFeePayer2.prepareTransaction).toHaveBeenCalled(); + expect(mockFeePayer1.completeTxFee).toHaveBeenCalled(); + expect(mockFeePayer2.completeTxFee).toHaveBeenCalled(); + + // Verify completeTxFee was called with a Transaction and client + const completeTxFee1Call = ( + mockFeePayer1.completeTxFee as ReturnType + ).mock.calls[0]; + const completeTxFee2Call = ( + mockFeePayer2.completeTxFee as ReturnType + ).mock.calls[0]; + expect(completeTxFee1Call[0]).toBeInstanceOf(ccc.Transaction); + expect(completeTxFee1Call[1]).toBe(client); + expect(completeTxFee2Call[0]).toBeInstanceOf(ccc.Transaction); + expect(completeTxFee2Call[1]).toBe(client); // Verify prepareTransaction was called before completeTxFee // by checking the order of calls @@ -1497,15 +1517,15 @@ describe("Transaction", () => { const callOrder: string[] = []; ( mockFeePayer1.prepareTransaction as ReturnType - ).mockImplementation(async () => { + ).mockImplementation(async (tx: ccc.TransactionLike) => { callOrder.push("prepare1"); - return undefined; + return ccc.Transaction.from(tx); }); ( mockFeePayer2.prepareTransaction as ReturnType - ).mockImplementation(async () => { + ).mockImplementation(async (tx: ccc.TransactionLike) => { callOrder.push("prepare2"); - return undefined; + return ccc.Transaction.from(tx); }); ( mockFeePayer1.completeTxFee as ReturnType @@ -1598,8 +1618,18 @@ describe("Transaction", () => { await tx.completeByFeePayer(client, mockFeePayer1); - expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledWith(tx); - expect(mockFeePayer1.completeTxFee).toHaveBeenCalledWith(tx, client); + // prepareTransaction is called with a clone of the original transaction + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalled(); + const prepareCallArg = ( + mockFeePayer1.prepareTransaction as ReturnType + ).mock.calls[0][0] as ccc.Transaction; + expect(prepareCallArg).toBeInstanceOf(ccc.Transaction); + expect(prepareCallArg.outputs.length).toBe(1); + // completeTxFee should be called with the modified transaction returned by prepareTransaction + expect(mockFeePayer1.completeTxFee).toHaveBeenCalledWith( + modifiedTx, + client, + ); }); }); });