diff --git a/.changeset/tender-nails-stick.md b/.changeset/tender-nails-stick.md new file mode 100644 index 000000000..17e2c9476 --- /dev/null +++ b/.changeset/tender-nails-stick.md @@ -0,0 +1,5 @@ +--- +"@ckb-ccc/core": minor +--- + +Abstract transaction completion step by a new layer named FeePayer diff --git a/packages/core/src/ckb/transaction.test.ts b/packages/core/src/ckb/transaction.test.ts index c6a3a9d46..0afbbb2f2 100644 --- a/packages/core/src/ckb/transaction.test.ts +++ b/packages/core/src/ckb/transaction.test.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/unbound-method */ import { beforeEach, describe, expect, it, vi } from "vitest"; import { ccc } from "../index.js"; @@ -58,6 +57,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 +276,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 } }, @@ -292,362 +308,6 @@ describe("Transaction", () => { }); }); - describe("completeFee", () => { - // Mock cells for capacity completion (100 CKB each) - let mockCapacityCells: ccc.Cell[]; - const cellCapacity = ccc.fixedPointFrom(100); // 100 CKB per cell - const minChangeCapacity = ccc.fixedPointFrom(61); // Minimum capacity for a change cell - - beforeEach(async () => { - // Create mock cells for capacity completion - mockCapacityCells = Array.from({ length: 10 }, (_, i) => - ccc.Cell.from({ - outPoint: { - txHash: `0x${"1".repeat(63)}${i.toString(16)}`, - index: 0, - }, - cellOutput: { - capacity: cellCapacity, - lock, - }, - outputData: "0x", - }), - ); - }); - - beforeEach(() => { - // Mock the findCells method to return capacity cells - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - // Return capacity cells for general queries - if (!filter.script || 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)); - return cell; - }); - - // Mock client.getFeeRate to return a predictable fee rate - vi.spyOn(client, "getFeeRate").mockResolvedValue(ccc.numFrom(1000)); // 1000 shannons per 1000 bytes - - // Mock signer.prepareTransaction to return the transaction as-is - vi.spyOn(signer, "prepareTransaction").mockImplementation(async (tx) => - ccc.Transaction.from(tx), - ); - - // Mock signer.getRecommendedAddressObj - vi.spyOn(signer, "getRecommendedAddressObj").mockResolvedValue({ - script: lock, - prefix: "ckt", - }); - }); - - it("should complete fee without change when exact fee is available", async () => { - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockCapacityCells[0].outPoint, - }, - ], - outputs: [ - { - capacity: ccc.fixedPointFrom(99.9), // Leave small amount for fee - lock, - }, - ], - }); - - const [addedInputs, hasChange] = await tx.completeFee( - signer, - (tx, capacity) => { - // Always use all available capacity by adding to first output - tx.outputs[0].capacity += capacity; - return 0; - }, - 1000n, // 1000 shannons per 1000 bytes - ); - - expect(addedInputs).toBe(0); // No additional inputs needed - expect(hasChange).toBe(true); // Change was applied (capacity added to existing output) - expect(tx.outputs.length).toBe(1); // Original output only (no new outputs) - }); - - it("should complete fee with change when excess capacity is available", async () => { - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockCapacityCells[0].outPoint, - }, - { - previousOutput: mockCapacityCells[1].outPoint, - }, - ], - outputs: [ - { - capacity: ccc.fixedPointFrom(30), // Leave 70 CKB excess - lock, - }, - ], - }); - - const [addedInputs, hasChange] = await tx.completeFee( - signer, - (tx, capacity) => { - // Create change if capacity is sufficient - if (capacity >= minChangeCapacity) { - tx.addOutput({ capacity, lock }); - return 0; - } - return minChangeCapacity; - }, - 1000n, - ); - - expect(addedInputs).toBe(0); // No additional inputs needed - expect(hasChange).toBe(true); // Change created - expect(tx.outputs.length).toBe(2); // Original output + change - expect(tx.outputs[1].capacity).toBeGreaterThan(minChangeCapacity); - }); - - it("should add inputs when insufficient capacity for fee", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - capacity: ccc.fixedPointFrom(50), // Need inputs to cover this - lock, - }, - ], - }); - - const [addedInputs, _hasChange] = await tx.completeFee( - signer, - (tx, capacity) => { - if (capacity >= minChangeCapacity) { - tx.addOutput({ capacity, lock }); - return 0; - } - return minChangeCapacity; - }, - 1000n, - ); - - expect(addedInputs).toBeGreaterThan(0); // Inputs were added - expect(tx.inputs.length).toBe(addedInputs); - expect(tx.outputs.length).toBe(2); // Original output + change - }); - - it("should handle change function requesting more capacity", async () => { - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockCapacityCells[0].outPoint, - }, - ], - outputs: [ - { - capacity: ccc.fixedPointFrom(30), // Leave 70 CKB excess - lock, - }, - ], - }); - - let callCount = 0; - const [addedInputs, hasChange] = await tx.completeFee( - signer, - (tx, capacity) => { - callCount++; - // First call: request more capacity than available (but reasonable) - if (callCount === 1) { - return ccc.fixedPointFrom(80); // Request 80 CKB but only ~70 available - } - // Second call: after more inputs added, use all available capacity - tx.outputs[0].capacity += capacity; - return 0; - }, - 1000n, - ); - - expect(addedInputs).toBeGreaterThan(0); // Additional inputs added - expect(hasChange).toBe(true); // Change eventually created - }); - - it("should use provided fee rate instead of fetching from client", async () => { - const customFeeRate = 2000n; // 2000 shannons per 1000 bytes - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockCapacityCells[0].outPoint, - }, - ], - outputs: [ - { - capacity: ccc.fixedPointFrom(99), - lock, - }, - ], - }); - - await tx.completeFee( - signer, - (tx, capacity) => { - // Use all available capacity - tx.outputs[0].capacity += capacity; - return 0; - }, - customFeeRate, - ); - - // Verify that client.getFeeRate was not called since we provided the rate - expect(client.getFeeRate).not.toHaveBeenCalled(); - }); - - it("should respect shouldAddInputs option when set to false", async () => { - const tx = ccc.Transaction.from({ - outputs: [ - { - capacity: ccc.fixedPointFrom(50), // Would normally need inputs - lock, - }, - ], - }); - - await expect( - tx.completeFee(signer, (_tx, _capacity) => 0, 1000n, undefined, { - shouldAddInputs: false, - }), - ).rejects.toThrow("Insufficient CKB"); - }); - - it("should handle filter parameter for input selection", async () => { - const customFilter = { - scriptLenRange: [0, 1] as [number, number], - outputDataLenRange: [0, 10] as [number, number], - }; - - const tx = ccc.Transaction.from({ - outputs: [ - { - capacity: ccc.fixedPointFrom(50), - lock, - }, - ], - }); - - await tx.completeFee( - signer, - (tx, capacity) => { - if (capacity >= minChangeCapacity) { - tx.addOutput({ capacity, lock }); - return 0; - } - return minChangeCapacity; - }, - 1000n, - customFilter, - ); - - // Verify that findCells was called with the custom filter - expect(signer.findCells).toHaveBeenCalledWith(customFilter, true); - }); - - it("should throw error when change function doesn't use all capacity", async () => { - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockCapacityCells[0].outPoint, - }, - ], - outputs: [ - { - capacity: ccc.fixedPointFrom(30), - lock, - }, - ], - }); - - await expect( - tx.completeFee( - signer, - (_tx, _capacity) => { - // Don't use the capacity but return 0 (claiming it's handled) - return 0; - }, - 1000n, - ), - ).rejects.toThrow("doesn't use all available capacity"); - }); - - it("should handle fee rate from client when not provided", async () => { - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockCapacityCells[0].outPoint, - }, - ], - outputs: [ - { - capacity: ccc.fixedPointFrom(99), - lock, - }, - ], - }); - - await tx.completeFee(signer, (tx, capacity) => { - // Use all available capacity - tx.outputs[0].capacity += capacity; - return 0; - }); - - // Verify that client.getFeeRate was called - expect(client.getFeeRate).toHaveBeenCalledWith(undefined, undefined); - }); - - it("should pass feeRateBlockRange option to client.getFeeRate", async () => { - const tx = ccc.Transaction.from({ - inputs: [ - { - previousOutput: mockCapacityCells[0].outPoint, - }, - ], - outputs: [ - { - capacity: ccc.fixedPointFrom(99), - lock, - }, - ], - }); - - const options = { - feeRateBlockRange: 10n, - maxFeeRate: 5000n, - }; - - await tx.completeFee( - signer, - (tx, capacity) => { - // Use all available capacity - tx.outputs[0].capacity += capacity; - return 0; - }, - undefined, - undefined, - options, - ); - - expect(client.getFeeRate).toHaveBeenCalledWith( - options.feeRateBlockRange, - options, - ); - }); - }); - describe("Automatic Capacity Completion", () => { describe("CellOutput.from", () => { it("should not modify capacity when data is not provided", () => { diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 3d3bc325c..c5aa72e70 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -1,13 +1,20 @@ import { Bytes, BytesLike, bytesFrom } from "../bytes/index.js"; -import type { ClientCollectableSearchKeyFilterLike } from "../client/clientTypes.advanced.js"; import { ClientBlockHeader, + ClientCollectableSearchKeyFilterLike, type CellDepInfoLike, type Client, type ClientBlockHeaderLike, } from "../client/index.js"; import { KnownScript } from "../client/knownScript.js"; import { Codec, Entity, codec } from "../codec/index.js"; +import { + FeePayer, + FeePayerCompleteFeeChangeFn, + FeePayerCompleteInputsContext, + FeePayerCompleteInputsOptionsLike, + FeePayerFromAddress, +} from "../feePayer/index.js"; import { Zero, fixedPointFrom } from "../fixedPoint/index.js"; import { Hasher, HasherCkb, hashCkb } from "../hasher/index.js"; import { Hex, HexLike, hexFrom } from "../hex/index.js"; @@ -21,15 +28,11 @@ import { numToBytes, numToHex, } from "../num/index.js"; -import type { Signer } from "../signer/index.js"; import { apply, reduceAsync } from "../utils/index.js"; import { Epoch } from "./epoch.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: Codec = Codec.from({ @@ -1899,8 +1902,18 @@ export class Transaction extends Entity.Base() { }, Zero); } + /** + * Completes transaction inputs using an accumulator function. + * + * @param from - The fee payer to collect cells from. + * @param filter - The filter for selecting cells. + * @param accumulator - A function that accumulates cells until a condition is met. + * @param init - The initial value for the accumulator. + * @returns A promise that resolves to the number of inputs added and the final accumulated value. + * @deprecated Use `FeePayer.completeInputs` instead. + */ async completeInputs( - from: Signer, + from: FeePayerFromAddress, filter: ClientCollectableSearchKeyFilterLike, accumulator: ( acc: T, @@ -1913,106 +1926,63 @@ export class Transaction extends 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, - }; + const res = await from.completeInputs(this, accumulator, init, { filter }); + this.copy(res.tx); + return res; } + /** + * Completes transaction inputs to satisfy a required capacity. + * + * @param from - The fee payer to collect cells from. + * @param capacityTweak - Optional additional capacity needed. + * @param filter - Optional filter for selecting cells. + * @returns A promise that resolves to the number of inputs added. + * @deprecated Use `FeePayer.completeInputsByCapacity` instead. + */ async completeInputsByCapacity( - from: Signer, + from: FeePayerFromAddress, 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, - ); + const res = await from.completeInputsByCapacity(this, capacityTweak, { + filter, + }); + this.copy(res.tx); + return res.addedCount; } + /** + * Completes transaction inputs by adding all available cells from the fee payer. + * + * @param from - The fee payer to collect cells from. + * @param filter - Optional filter for selecting cells. + * @returns A promise that resolves to the number of inputs added. + * @deprecated Use `FeePayer.completeInputsAll` instead. + */ async completeInputsAll( - from: Signer, + from: FeePayerFromAddress, filter?: ClientCollectableSearchKeyFilterLike, ): Promise { - const { addedCount } = await this.completeInputs( - from, - filter ?? { - scriptLenRange: [0, 1], - outputDataLenRange: [0, 1], - }, - (acc, { cellOutput: { capacity } }) => acc + capacity, - Zero, - ); - - return addedCount; + const res = await from.completeInputsAll(this, { filter }); + this.copy(res.tx); + return res.addedCount; } /** - * Complete inputs by UDT balance + * Completes transaction inputs to satisfy a required UDT balance. * - * This method succeeds only if enough balance is collected. + * It will try to collect at least two inputs, even when the first input already contains enough balance, + * to avoid extra occupation fees introduced by the change cell. An edge case: + * If the first cell has the same amount as the output, a new cell is not needed. * - * It will try to collect at least two inputs, even when the first input already contains enough balance, to avoid extra occupation fees introduced by the change cell. An edge case: If the first cell has the same amount as the output, a new cell is not needed. - * @param from - The signer to complete the inputs. - * @param type - The type script of the UDT. - * @param balanceTweak - The tweak of the balance. + * @param from - The fee payer to collect cells from. + * @param type - The UDT type script. + * @param balanceTweak - Optional additional balance needed. * @returns A promise that resolves to the number of inputs added. */ async completeInputsByUdt( - from: Signer, + from: FeePayerFromAddress, type: ScriptLike, balanceTweak?: NumLike, ): Promise { @@ -2069,36 +2039,38 @@ export class Transaction extends Entity.Base() { ); } + /** + * Completes transaction inputs by adding exactly one more cell. + * + * @param from - The fee payer to collect cells from. + * @param filter - Optional filter for selecting cells. + * @returns A promise that resolves to the number of inputs added. + * @deprecated Use `FeePayer.completeInputsAddOne` instead. + */ async completeInputsAddOne( - from: Signer, + from: FeePayerFromAddress, filter?: ClientCollectableSearchKeyFilterLike, ): Promise { - const { addedCount, accumulated } = await this.completeInputs( - from, - filter ?? { - scriptLenRange: [0, 1], - outputDataLenRange: [0, 1], - }, - () => undefined, - true, - ); - - if (accumulated === undefined) { - return addedCount; - } - - throw new Error(`Insufficient CKB, need at least one new cell`); + const res = await from.completeInputsAddOne(this, { filter }); + this.copy(res.tx); + return res.addedCount; } + /** + * Completes transaction inputs by adding at least one cell if no inputs exist. + * + * @param from - The fee payer to collect cells from. + * @param filter - Optional filter for selecting cells. + * @returns A promise that resolves to the number of inputs added. + * @deprecated Use `FeePayer.completeInputsAtLeastOne` instead. + */ async completeInputsAtLeastOne( - from: Signer, + from: FeePayerFromAddress, filter?: ClientCollectableSearchKeyFilterLike, ): Promise { - if (this.inputs.length > 0) { - return 0; - } - - return this.completeInputsAddOne(from, filter); + const res = await from.completeInputsAtLeastOne(this, { filter }); + this.copy(res.tx); + return res.addedCount; } async getFee(client: Client): Promise { @@ -2123,10 +2095,9 @@ export class Transaction extends Entity.Base() { * This method automatically calculates the required fee based on the transaction size and fee rate, * adds necessary inputs to cover the fee, and handles change outputs through the provided change function. * - * @param from - The signer to complete inputs from and prepare the transaction. + * @param from - The fee payer to complete inputs from and prepare the transaction. * @param change - A function that handles change capacity. It receives the transaction and excess capacity, - * and should return the additional capacity needed (0 if change is handled successfully, - * positive number if more capacity is needed for change cell creation). + * and should return the additional capacity needed (0 if change is handled successfully). * @param expectedFeeRate - The expected fee rate in shannons per 1000 bytes. If not provided, * it will be fetched from the client. * @param filter - Optional filter for selecting cells when adding inputs. @@ -2136,15 +2107,14 @@ export class Transaction extends Entity.Base() { * @param options.shouldAddInputs - Whether to add inputs automatically. Defaults to true. * @returns A promise that resolves to a tuple containing: * - The number of inputs added during the process - * - A boolean indicating whether change outputs were created (true) or fee was paid without change (false) + * - A boolean indicating whether the transaction was modified to handle the change. * - * @throws {ErrorTransactionInsufficientCapacity} When there's not enough capacity to cover the fee. - * @throws {Error} When the change function doesn't properly handle the available capacity. + * @deprecated Use `FeePayer.completeFeeChangeTo` instead. * * @example * ```typescript * const [addedInputs, hasChange] = await tx.completeFee( - * signer, + * feePayer, * (tx, capacity) => { * if (capacity >= 61_00000000n) { // Minimum for a change cell * tx.addOutput({ capacity, lock: changeScript }); @@ -2157,8 +2127,11 @@ export class Transaction extends Entity.Base() { * ``` */ async completeFee( - from: Signer, - change: (tx: Transaction, capacity: Num) => Promise | NumLike, + from: FeePayer< + FeePayerCompleteInputsOptionsLike, + FeePayerCompleteInputsContext + >, + change: FeePayerCompleteFeeChangeFn, expectedFeeRate?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, options?: { @@ -2167,101 +2140,17 @@ export class Transaction extends 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; - } + const { + tx, + hasChanged, + context: { addedCount }, + } = await from.completeFeeChangeTo(this, change, { + feeRate: expectedFeeRate, + filter, + ...options, + }); + this.copy(tx); + return [addedCount, hasChanged]; } /** @@ -2269,7 +2158,7 @@ export class Transaction extends Entity.Base() { * This is a convenience method that automatically creates a change cell with the provided lock script * when there's excess capacity after paying the transaction fee. * - * @param from - The signer to complete inputs from and prepare the transaction. + * @param from - The fee payer to complete inputs from and prepare the transaction. * @param change - The lock script for the change output cell. * @param feeRate - Optional fee rate in shannons per 1000 bytes. If not provided, it will be fetched from the client. * @param filter - Optional filter for selecting cells when adding inputs. @@ -2281,6 +2170,8 @@ export class Transaction extends Entity.Base() { * - The number of inputs added during the process * - A boolean indicating whether change outputs were created (true) or fee was paid without change (false) * + * @deprecated Use `FeePayer.completeFeeChangeToLock` instead. + * * @example * ```typescript * const changeScript = Script.from({ @@ -2290,14 +2181,17 @@ export class Transaction extends Entity.Base() { * }); * * const [addedInputs, hasChange] = await tx.completeFeeChangeToLock( - * signer, + * feePayer, * changeScript, * 1000n // 1000 shannons per 1000 bytes * ); * ``` */ - completeFeeChangeToLock( - from: Signer, + async completeFeeChangeToLock( + from: FeePayer< + FeePayerCompleteInputsOptionsLike, + FeePayerCompleteInputsContext + >, change: ScriptLike, feeRate?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, @@ -2307,33 +2201,26 @@ export class Transaction extends Entity.Base() { shouldAddInputs?: boolean; }, ): Promise<[number, boolean]> { - const script = Script.from(change); - - return this.completeFee( - from, - (tx, capacity) => { - const changeCell = CellOutput.from({ capacity: 0, lock: script }); - const occupiedCapacity = fixedPointFrom(changeCell.occupiedSize); - if (capacity < occupiedCapacity) { - return occupiedCapacity; - } - changeCell.capacity = capacity; - tx.addOutput(changeCell); - return 0; - }, + const { + tx, + hasChanged, + context: { addedCount }, + } = await from.completeFeeChangeToLock(this, change, { feeRate, filter, - options, - ); + ...options, + }); + this.copy(tx); + return [addedCount, hasChanged]; } /** - * Completes the transaction fee using the signer's recommended address for change. - * This is a convenience method that automatically uses the signer's recommended + * Completes the transaction fee using the fee payer's recommended address for change. + * This is a convenience method that automatically uses the fee payer's recommended * address as the change destination, making it easier to complete transactions * without manually specifying a change address. * - * @param from - The signer to complete inputs from and prepare the transaction. + * @param from - The fee payer to complete inputs from and prepare the transaction. * @param feeRate - Optional fee rate in shannons per 1000 bytes. If not provided, it will be fetched from the client. * @param filter - Optional filter for selecting cells when adding inputs. * @param options - Optional configuration object. @@ -2344,18 +2231,23 @@ export class Transaction extends Entity.Base() { * - The number of inputs added during the process * - A boolean indicating whether change outputs were created (true) or fee was paid without change (false) * + * @deprecated Use `FeePayer.completeFee` instead. + * * @example * ```typescript * const [addedInputs, hasChange] = await tx.completeFeeBy( - * signer, + * feePayer, * 1000n // 1000 shannons per 1000 bytes * ); * - * // Change will automatically go to signer's recommended address + * // Change will automatically go to fee payer's recommended address * ``` */ async completeFeeBy( - from: Signer, + from: FeePayer< + FeePayerCompleteInputsOptionsLike, + FeePayerCompleteInputsContext + >, feeRate?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, options?: { @@ -2364,9 +2256,13 @@ export class Transaction extends Entity.Base() { shouldAddInputs?: boolean; }, ): Promise<[number, boolean]> { - const { script } = await from.getRecommendedAddressObj(); - - return this.completeFeeChangeToLock(from, script, feeRate, filter, options); + const res = await from.completeFee(this, { + feeRate, + filter, + ...options, + }); + this.copy(res.tx); + return [res.context.addedCount, res.hasChanged]; } /** @@ -2374,7 +2270,7 @@ export class Transaction extends Entity.Base() { * Instead of creating a new change output, this method adds any excess capacity * to the specified existing output in the transaction. * - * @param from - The signer to complete inputs from and prepare the transaction. + * @param from - The fee payer to complete inputs from and prepare the transaction. * @param index - The index of the existing output to add excess capacity to. * @param feeRate - Optional fee rate in shannons per 1000 bytes. If not provided, it will be fetched from the client. * @param filter - Optional filter for selecting cells when adding inputs. @@ -2388,18 +2284,23 @@ export class Transaction extends Entity.Base() { * * @throws {Error} When the specified output index doesn't exist. * + * @deprecated Use `FeePayer.completeFeeChangeToOutput` instead. + * * @example * ```typescript * // Add excess capacity to the first output (index 0) * const [addedInputs, hasChange] = await tx.completeFeeChangeToOutput( - * signer, + * feePayer, * 0, // Output index * 1000n // 1000 shannons per 1000 bytes * ); * ``` */ - completeFeeChangeToOutput( - from: Signer, + async completeFeeChangeToOutput( + from: FeePayer< + FeePayerCompleteInputsOptionsLike, + FeePayerCompleteInputsContext + >, index: NumLike, feeRate?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, @@ -2409,20 +2310,17 @@ export class Transaction extends Entity.Base() { shouldAddInputs?: boolean; }, ): Promise<[number, boolean]> { - const change = Number(numFrom(index)); - if (!this.outputs[change]) { - throw new Error("Non-existed output to change"); - } - return this.completeFee( - from, - (tx, capacity) => { - tx.outputs[change].capacity += capacity; - return 0; - }, + const { + tx, + hasChanged, + context: { addedCount }, + } = await from.completeFeeChangeToOutput(this, index, { feeRate, filter, - options, - ); + ...options, + }); + this.copy(tx); + return [addedCount, hasChanged]; } } diff --git a/packages/core/src/ckb/transactionErrors.ts b/packages/core/src/ckb/transactionErrors.ts index 09c1cb745..80ae2ffbb 100644 --- a/packages/core/src/ckb/transactionErrors.ts +++ b/packages/core/src/ckb/transactionErrors.ts @@ -2,6 +2,9 @@ import { fixedPointToString } from "../fixedPoint/index.js"; import { Num, numFrom, NumLike } from "../num/index.js"; import { Script, ScriptLike } from "./script.js"; +/** + * @deprecated Use `ErrorFeePayerInsufficientCapacity` in `feePayer` instead. + */ export class ErrorTransactionInsufficientCapacity extends Error { public readonly amount: Num; public readonly isForChange: boolean; diff --git a/packages/core/src/client/cache/cache.ts b/packages/core/src/client/cache/cache.ts index e3b9b77e8..b561b4d97 100644 --- a/packages/core/src/client/cache/cache.ts +++ b/packages/core/src/client/cache/cache.ts @@ -7,12 +7,12 @@ import { } from "../../ckb/index.js"; import { HexLike } from "../../hex/index.js"; import { numFrom, NumLike } from "../../num/index.js"; -import { ClientCollectableSearchKeyLike } from "../clientTypes.advanced.js"; import { ClientBlock, ClientBlockHeader, ClientBlockHeaderLike, ClientBlockLike, + ClientCollectableSearchKeyLike, ClientTransactionResponse, ClientTransactionResponseLike, } from "../clientTypes.js"; diff --git a/packages/core/src/client/cache/memory.advanced.ts b/packages/core/src/client/cache/memory.advanced.ts index f1940b000..47ad574fa 100644 --- a/packages/core/src/client/cache/memory.advanced.ts +++ b/packages/core/src/client/cache/memory.advanced.ts @@ -2,11 +2,11 @@ import { bytesFrom } from "../../bytes/index.js"; import { Cell, CellLike, Script, ScriptLike } from "../../ckb/index.js"; import { HexLike, hexFrom } from "../../hex/index.js"; import { NumLike, numFrom } from "../../num/index.js"; +import { clientSearchKeyRangeFrom } from "../clientTypes.advanced.js"; import { ClientCollectableSearchKeyLike, - clientSearchKeyRangeFrom, -} from "../clientTypes.advanced.js"; -import { ClientIndexerSearchKey } from "../clientTypes.js"; + ClientIndexerSearchKey, +} from "../clientTypes.js"; export const DEFAULT_CONFIRMED_BLOCK_TIME = numFrom(1000 * 10 * 50); // 50 blocks * 10s diff --git a/packages/core/src/client/cache/memory.ts b/packages/core/src/client/cache/memory.ts index 74e058f38..a0ff47670 100644 --- a/packages/core/src/client/cache/memory.ts +++ b/packages/core/src/client/cache/memory.ts @@ -1,12 +1,12 @@ import { Cell, CellLike, OutPoint, OutPointLike } from "../../ckb/index.js"; import { hexFrom, HexLike } from "../../hex/index.js"; import { Num, numFrom, NumLike } from "../../num/index.js"; -import { ClientCollectableSearchKeyLike } from "../clientTypes.advanced.js"; import { ClientBlock, ClientBlockHeader, ClientBlockHeaderLike, ClientBlockLike, + ClientCollectableSearchKeyLike, ClientTransactionResponse, ClientTransactionResponseLike, } from "../clientTypes.js"; diff --git a/packages/core/src/client/client.ts b/packages/core/src/client/client.ts index f8d8d5e2a..73b5a4355 100644 --- a/packages/core/src/client/client.ts +++ b/packages/core/src/client/client.ts @@ -14,7 +14,6 @@ import { reduceAsync, sleep } from "../utils/index.js"; import { ClientCache } from "./cache/index.js"; import { ClientCacheMemory } from "./cache/memory.js"; import { - ClientCollectableSearchKeyLike, DEFAULT_MAX_FEE_RATE, DEFAULT_MIN_FEE_RATE, } from "./clientTypes.advanced.js"; @@ -23,6 +22,7 @@ import { CellDepInfoLike, ClientBlock, ClientBlockHeader, + ClientCollectableSearchKeyLike, ClientFindCellsResponse, ClientFindTransactionsGroupedResponse, ClientFindTransactionsResponse, @@ -55,9 +55,16 @@ export abstract class Client { abstract getFeeRateStatistics( blockRange?: NumLike, ): Promise<{ mean: Num; median: Num }>; + + /** + * Gets the current fee rate. + * @param blockRange - Optional block range for fee rate calculation. + * @param options - Optional configuration for getting the fee rate. + * @returns A promise that resolves to the fee rate. + */ async getFeeRate( blockRange?: NumLike, - options?: { maxFeeRate?: NumLike }, + options?: { maxFeeRate?: NumLike } | null, ): Promise { const feeRate = numMax( (await this.getFeeRateStatistics(blockRange)).median, diff --git a/packages/core/src/client/clientTypes.advanced.ts b/packages/core/src/client/clientTypes.advanced.ts index e82f63d30..eaea719af 100644 --- a/packages/core/src/client/clientTypes.advanced.ts +++ b/packages/core/src/client/clientTypes.advanced.ts @@ -1,5 +1,3 @@ -import { ScriptLike } from "../ckb/index.js"; -import { HexLike } from "../hex/index.js"; import { Num, numFrom, NumLike } from "../num/index.js"; export const DEFAULT_MAX_FEE_RATE = numFrom(10000000); @@ -11,19 +9,3 @@ export function clientSearchKeyRangeFrom([a, b]: [NumLike, NumLike]): [ ] { return [numFrom(a), numFrom(b)]; } - -export type ClientCollectableSearchKeyFilterLike = { - script?: ScriptLike | null; - scriptLenRange?: [NumLike, NumLike] | null; - outputData?: HexLike | null; - outputDataSearchMode?: "prefix" | "exact" | "partial" | null; - outputDataLenRange?: [NumLike, NumLike] | null; - outputCapacityRange?: [NumLike, NumLike] | null; -}; -export type ClientCollectableSearchKeyLike = { - script: ScriptLike; - scriptType: "lock" | "type"; - scriptSearchMode: "prefix" | "exact" | "partial"; - filter?: ClientCollectableSearchKeyFilterLike | null; - withData?: boolean | null; -}; diff --git a/packages/core/src/client/clientTypes.ts b/packages/core/src/client/clientTypes.ts index ec57a1a12..9c6354976 100644 --- a/packages/core/src/client/clientTypes.ts +++ b/packages/core/src/client/clientTypes.ts @@ -17,11 +17,7 @@ import { import { Hex, HexLike, hexFrom } from "../hex/index.js"; import { Num, NumLike, numFrom } from "../num/index.js"; import { apply } from "../utils/index.js"; -import { - ClientCollectableSearchKeyFilterLike, - ClientCollectableSearchKeyLike, - clientSearchKeyRangeFrom, -} from "./clientTypes.advanced.js"; +import { clientSearchKeyRangeFrom } from "./clientTypes.advanced.js"; /** * @public @@ -157,16 +153,49 @@ export class ClientTransactionResponse { } /** + * Options for filtering cells when searching for cells. * @public */ -export type ClientIndexerSearchKeyFilterLike = - ClientCollectableSearchKeyFilterLike & { - blockRange?: [NumLike, NumLike] | null; - }; +export type ClientCollectableSearchKeyFilterLike = { + /** + * The type script of the cell. + */ + script?: ScriptLike | null; + /** + * The length range of the script. + */ + scriptLenRange?: [NumLike, NumLike] | null; + /** + * The data of the cell. + */ + outputData?: HexLike | null; + /** + * The search mode for the cell data. + */ + outputDataSearchMode?: "prefix" | "exact" | "partial" | null; + /** + * The length range of the cell data. + */ + outputDataLenRange?: [NumLike, NumLike] | null; + /** + * The capacity range of the cell. + */ + outputCapacityRange?: [NumLike, NumLike] | null; +}; /** + * Options for filtering cells when searching for cells. * @public */ -export class ClientIndexerSearchKeyFilter { +export class ClientCollectableSearchKeyFilter { + /** + * Creates an instance of ClientCollectableSearchKeyFilter. + * @param script - The type script of the cell. + * @param scriptLenRange - The length range of the script. + * @param outputData - The data of the cell. + * @param outputDataSearchMode - The search mode for the cell data. + * @param outputDataLenRange - The length range of the cell data. + * @param outputCapacityRange - The capacity range of the cell. + */ constructor( public script: Script | undefined, public scriptLenRange: [Num, Num] | undefined, @@ -174,9 +203,150 @@ export class ClientIndexerSearchKeyFilter { public outputDataSearchMode: "prefix" | "exact" | "partial" | undefined, public outputDataLenRange: [Num, Num] | undefined, public outputCapacityRange: [Num, Num] | undefined, - public blockRange: [Num, Num] | undefined, ) {} + /** + * Creates an instance of ClientCollectableSearchKeyFilter from a partial filter object. + * @param filterLike - The partial filter object. + * @returns An instance of ClientCollectableSearchKeyFilter. + */ + static from( + filterLike: ClientCollectableSearchKeyFilterLike, + ): ClientCollectableSearchKeyFilter { + if (filterLike instanceof ClientCollectableSearchKeyFilter) { + return filterLike; + } + + return new ClientCollectableSearchKeyFilter( + apply(Script.from, filterLike.script), + apply(clientSearchKeyRangeFrom, filterLike.scriptLenRange), + apply(hexFrom, filterLike.outputData), + filterLike.outputDataSearchMode ?? undefined, + apply(clientSearchKeyRangeFrom, filterLike.outputDataLenRange), + apply(clientSearchKeyRangeFrom, filterLike.outputCapacityRange), + ); + } +} + +/** + * Options for searching for cells. + * @public + */ +export type ClientCollectableSearchKeyLike = { + /** + * The script to search for. + */ + script: ScriptLike; + /** + * The type of the script. + */ + scriptType: "lock" | "type"; + /** + * The search mode for the script. + */ + scriptSearchMode: "prefix" | "exact" | "partial"; + /** + * The filter to use when searching for cells. + */ + filter?: ClientCollectableSearchKeyFilterLike | null; + /** + * Whether to include cell data in the response. + */ + withData?: boolean | null; +}; +/** + * Options for searching for cells. + * @public + */ +export class ClientCollectableSearchKey { + /** + * Creates an instance of ClientCollectableSearchKey. + * @param script - The script to search for. + * @param scriptType - The type of the script. + * @param scriptSearchMode - The search mode for the script. + * @param filter - The filter to use when searching for cells. + * @param withData - Whether to include cell data in the response. + */ + constructor( + public script: Script, + public scriptType: "lock" | "type", + public scriptSearchMode: "prefix" | "exact" | "partial", + public filter: ClientCollectableSearchKeyFilter | undefined, + public withData: boolean | undefined, + ) {} + + /** + * Creates an instance of ClientCollectableSearchKey from a partial search key object. + * @param keyLike - The partial search key object. + * @returns An instance of ClientCollectableSearchKey. + */ + static from( + keyLike: ClientCollectableSearchKeyLike, + ): ClientCollectableSearchKey { + if (keyLike instanceof ClientCollectableSearchKey) { + return keyLike; + } + + return new ClientCollectableSearchKey( + Script.from(keyLike.script), + keyLike.scriptType, + keyLike.scriptSearchMode, + apply(ClientCollectableSearchKeyFilter.from, keyLike.filter), + keyLike.withData ?? undefined, + ); + } +} + +/** + * Options for filtering cells when searching for cells on chain. + * @public + */ +export type ClientIndexerSearchKeyFilterLike = + ClientCollectableSearchKeyFilterLike & { + /** + * The block range to search for cells. + */ + blockRange?: [NumLike, NumLike] | null; + }; +/** + * Options for filtering cells when searching for cells on chain. + * @public + */ +export class ClientIndexerSearchKeyFilter extends ClientCollectableSearchKeyFilter { + /** + * Creates an instance of ClientIndexerSearchKeyFilter. + * @param script - The type script of the cell. + * @param scriptLenRange - The length range of the script. + * @param outputData - The data of the cell. + * @param outputDataSearchMode - The search mode for the cell data. + * @param outputDataLenRange - The length range of the cell data. + * @param outputCapacityRange - The capacity range of the cell. + * @param blockRange - The block range to search for cells. + */ + constructor( + script: Script | undefined, + scriptLenRange: [Num, Num] | undefined, + outputData: Hex | undefined, + outputDataSearchMode: "prefix" | "exact" | "partial" | undefined, + outputDataLenRange: [Num, Num] | undefined, + outputCapacityRange: [Num, Num] | undefined, + public blockRange: [Num, Num] | undefined, + ) { + super( + script, + scriptLenRange, + outputData, + outputDataSearchMode, + outputDataLenRange, + outputCapacityRange, + ); + } + + /** + * Creates an instance of ClientIndexerSearchKeyFilter from a partial filter object. + * @param filterLike - The partial filter object. + * @returns An instance of ClientIndexerSearchKeyFilter. + */ static from( filterLike: ClientIndexerSearchKeyFilterLike, ): ClientIndexerSearchKeyFilter { @@ -197,16 +367,29 @@ export class ClientIndexerSearchKeyFilter { } /** + * Options for searching for cells on chain. * @public */ export type ClientIndexerSearchKeyLike = ClientCollectableSearchKeyLike & { + /** + * The filter to use when searching for cells. + */ filter?: ClientIndexerSearchKeyFilterLike | null; }; /** + * Options for searching for cells on chain. * @public */ export class ClientIndexerSearchKey { + /** + * Creates an instance of ClientIndexerSearchKey. + * @param script - The script to search for. + * @param scriptType - The type of the script. + * @param scriptSearchMode - The search mode for the script. + * @param filter - The filter to use when searching for cells. + * @param withData - Whether to include cell data in the response. + */ constructor( public script: Script, public scriptType: "lock" | "type", @@ -215,6 +398,11 @@ export class ClientIndexerSearchKey { public withData: boolean | undefined, ) {} + /** + * Creates an instance of ClientIndexerSearchKey from a partial search key object. + * @param keyLike - The partial search key object. + * @returns An instance of ClientIndexerSearchKey. + */ static from(keyLike: ClientIndexerSearchKeyLike): ClientIndexerSearchKey { if (keyLike instanceof ClientIndexerSearchKey) { return keyLike; diff --git a/packages/core/src/feePayer/errors.ts b/packages/core/src/feePayer/errors.ts new file mode 100644 index 000000000..b1eefa150 --- /dev/null +++ b/packages/core/src/feePayer/errors.ts @@ -0,0 +1,38 @@ +import { fixedPointToString } from "../fixedPoint/index.js"; +import { Num, numFrom, NumLike } from "../num/index.js"; + +/** + * Error thrown when a fee payer has insufficient capacity to complete a transaction. + * @public + */ +export class ErrorFeePayerInsufficientCapacity extends Error { + /** + * The amount of extra capacity needed. + */ + public readonly amount: Num; + /** + * Whether the extra capacity is needed for a change cell. + */ + public readonly isForChange: boolean; + + /** + * Creates an instance of ErrorFeePayerInsufficientCapacity. + * @param amountLike - The amount of extra capacity needed. + * @param reason - Optional reason object. + * @param reason.isForChange - Whether the extra capacity is needed for a change cell. + */ + constructor( + amountLike: NumLike, + reason?: { + isForChange?: boolean; + }, + ) { + const amount = numFrom(amountLike); + const isForChange = reason?.isForChange ?? false; + super( + `Insufficient CKB, need ${fixedPointToString(amount)} extra CKB${isForChange ? " for the change cell" : ""}`, + ); + this.amount = amount; + this.isForChange = isForChange; + } +} diff --git a/packages/core/src/feePayer/feePayer.test.ts b/packages/core/src/feePayer/feePayer.test.ts new file mode 100644 index 000000000..f39e4c0b2 --- /dev/null +++ b/packages/core/src/feePayer/feePayer.test.ts @@ -0,0 +1,586 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ccc } from "../index.js"; +import { FeePayerFromAddress } from "./feePayerFromAddress.js"; + +class MockFeePayer extends FeePayerFromAddress { + constructor( + client: ccc.Client, + private readonly lock: ccc.Script, + ) { + super(client); + } + + async getAddressObjs(): Promise { + return [ + ccc.Address.from({ + prefix: this.client.addressPrefix, + script: this.lock, + }), + ]; + } +} + +describe("FeePayer", () => { + let client: ccc.Client; + let feePayer: MockFeePayer; + let lock: ccc.Script; + + // Mock cells for capacity completion (200 CKB each) + let mockCapacityCells: ccc.Cell[]; + const cellCapacity = ccc.fixedPointFrom(200); // 200 CKB per cell + const minChangeCapacity = ccc.fixedPointFrom(61); // Minimum capacity for a change cell + + beforeEach(async () => { + client = new ccc.ClientPublicTestnet(); + lock = ccc.Script.from({ + codeHash: `0x${"0".repeat(64)}`, + hashType: "type", + args: "0x", + }); + feePayer = new MockFeePayer(client, lock); + + // Create mock cells for capacity completion + mockCapacityCells = Array.from({ length: 10 }, (_, i) => + ccc.Cell.from({ + outPoint: { + txHash: `0x${"1".repeat(63)}${i.toString(16)}`, + index: 0, + }, + cellOutput: { + capacity: cellCapacity, + lock, + }, + outputData: "0x", + }), + ); + + // Mock the findCells method to return capacity cells only for the main lock + vi.spyOn(client, "findCells").mockImplementation( + async function* (searchKey) { + if (ccc.Script.from(searchKey.script).eq(lock)) { + 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)); + return cell; + }); + + // Mock client.getFeeRate to return a predictable fee rate + vi.spyOn(client, "getFeeRate").mockResolvedValue(ccc.numFrom(1000)); // 1000 shannons per 1000 bytes + }); + + describe("completeInputs", () => { + it("should collect cells using the accumulator", async () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + // Collect 3 cells + const { addedCount, accumulated } = await feePayer.completeInputs( + tx, + (acc, _cell, _i, collected) => { + return collected.length >= 3 ? undefined : acc + 1; + }, + 0, + ); + + expect(addedCount).toBe(3); + expect(tx.inputs.length).toBe(3); + expect(accumulated).toBeUndefined(); + }); + + it("should skip cells already in inputs", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + }); + + const { addedCount } = await feePayer.completeInputs( + tx, + (_acc, cell, _i, _collected) => { + // Should not see mockCapacityCells[0] here + expect(cell.outPoint.eq(mockCapacityCells[0].outPoint)).toBe(false); + return undefined; // Stop after one + }, + 0, + ); + + expect(addedCount).toBe(1); + expect(tx.inputs.length).toBe(2); + expect( + tx.inputs[1].previousOutput.eq(mockCapacityCells[1].outPoint), + ).toBe(true); + }); + + describe("completeInputsByCapacity", () => { + it("should collect enough capacity", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(250), + lock, + }, + ], + }); + + const { addedCount } = await feePayer.completeInputsByCapacity(tx); + + // Each cell has 200 CKB, so need 2 cells for 250 CKB + expect(addedCount).toBe(2); + expect(tx.inputs.length).toBe(2); + }); + + it("should return 0 if already have enough capacity", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + { + previousOutput: mockCapacityCells[1].outPoint, + }, + { + previousOutput: mockCapacityCells[2].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(250), + lock, + }, + ], + }); + + const { addedCount } = await feePayer.completeInputsByCapacity(tx); + + expect(addedCount).toBe(0); + expect(tx.inputs.length).toBe(3); + }); + + it("should handle capacityTweak", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(150), + lock, + }, + ], + }); + + // Need 150 + 200 = 350 CKB -> 2 cells + const { addedCount } = await feePayer.completeInputsByCapacity( + tx, + ccc.fixedPointFrom(100), + ); + + expect(addedCount).toBe(2); + }); + + it("should throw error if insufficient capacity", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(3000), // More than 10 * 200 available + lock, + }, + ], + }); + + await expect(feePayer.completeInputsByCapacity(tx)).rejects.toThrow( + "Insufficient CKB", + ); + }); + }); + + describe("completeInputsAll", () => { + it("should collect all cells", async () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + const { addedCount } = await feePayer.completeInputsAll(tx); + + expect(addedCount).toBe(mockCapacityCells.length); + expect(tx.inputs.length).toBe(mockCapacityCells.length); + }); + }); + + describe("completeInputsAddOne", () => { + it("should add exactly one cell", async () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + const { addedCount } = await feePayer.completeInputsAddOne(tx); + + expect(addedCount).toBe(1); + expect(tx.inputs.length).toBe(1); + }); + + it("should throw if no cells available", async () => { + // Mock no cells available + vi.spyOn(client, "findCells").mockImplementation(async function* () {}); + + const tx = ccc.Transaction.from({ + outputs: [], + }); + + await expect(feePayer.completeInputsAddOne(tx)).rejects.toThrow( + "at least one new cell", + ); + }); + }); + + describe("completeInputsAtLeastOne", () => { + it("should add one cell if empty", async () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + const { addedCount } = await feePayer.completeInputsAtLeastOne(tx); + + expect(addedCount).toBe(1); + expect(tx.inputs.length).toBe(1); + }); + + it("should add nothing if not empty", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + }); + + const { addedCount } = await feePayer.completeInputsAtLeastOne(tx); + + expect(addedCount).toBe(0); + expect(tx.inputs.length).toBe(1); + }); + }); + }); + + describe("completeFee", () => { + it("should complete fee without change when exact fee is available", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(99.9), // Leave small amount for fee + lock, + }, + ], + }); + + const { + tx: resTx, + hasChanged, + context: { addedCount }, + } = await feePayer.completeFeeChangeTo( + tx, + (tx, capacity) => { + // Always use all available capacity by adding to first output + tx.outputs[0].capacity += capacity; + return 0; + }, + { feeRate: 1000n }, + ); + + expect(addedCount).toBe(0); // No additional inputs needed + expect(hasChanged).toBe(true); // Change was applied (capacity added to existing output) + expect(resTx.outputs.length).toBe(1); // Original output only (no new outputs) + }); + + it("should complete fee with change when excess capacity is available", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(30), // Leave 70 CKB excess + lock, + }, + ], + }); + + const { + tx: resTx, + hasChanged, + context: { addedCount }, + } = await feePayer.completeFeeChangeTo( + tx, + (tx, capacity) => { + // Create change if capacity is sufficient + if (capacity >= minChangeCapacity) { + tx.addOutput({ capacity, lock }); + return 0; + } + return minChangeCapacity; + }, + { feeRate: 1000n }, + ); + + expect(addedCount).toBe(0); // No additional inputs needed + expect(hasChanged).toBe(true); // Change created + expect(resTx.outputs.length).toBe(2); // Original output + change + expect(resTx.outputs[1].capacity).toBeGreaterThan(minChangeCapacity); + }); + + it("should add inputs when insufficient capacity for fee", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(50), // Need inputs to cover this + lock, + }, + ], + }); + + const { + tx: resTx, + context: { addedCount }, + } = await feePayer.completeFeeChangeTo( + tx, + (tx, capacity) => { + if (capacity >= minChangeCapacity) { + tx.addOutput({ capacity, lock }); + return 0; + } + return minChangeCapacity; + }, + { feeRate: 1000n }, + ); + + expect(addedCount).toBeGreaterThan(0); // Inputs were added + expect(resTx.inputs.length).toBe(addedCount); + expect(resTx.outputs.length).toBe(2); // Original output + change + }); + + it("should handle change function requesting more capacity", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(130), // Leave 70 CKB excess + lock, + }, + ], + }); + + let callCount = 0; + const { + hasChanged, + context: { addedCount }, + } = await feePayer.completeFeeChangeTo( + tx, + (tx, capacity) => { + callCount++; + // First call: request more capacity than available (but reasonable) + if (callCount === 1) { + return ccc.fixedPointFrom(80); // Request 80 CKB but only ~70 available + } + // Second call: after more inputs added, use all available capacity + tx.outputs[0].capacity += capacity; + return 0; + }, + { feeRate: 1000n }, + ); + + expect(addedCount).toBeGreaterThan(0); // Additional inputs added + expect(hasChanged).toBe(true); // Change eventually created + }); + + it("should use provided fee rate instead of fetching from client", async () => { + const customFeeRate = 2000n; // 2000 shannons per 1000 bytes + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(99), + lock, + }, + ], + }); + + await feePayer.completeFeeChangeTo( + tx, + (tx, capacity) => { + // Use all available capacity + tx.outputs[0].capacity += capacity; + return 0; + }, + { feeRate: customFeeRate }, + ); + + // Verify that client.getFeeRate was not called since we provided the rate + expect(client.getFeeRate).not.toHaveBeenCalled(); + }); + + it("should respect shouldAddInputs option when set to false", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(50), // Would normally need inputs + lock, + }, + ], + }); + + await expect( + feePayer.completeFeeChangeTo(tx, (_tx, _capacity) => 0, { + feeRate: 1000n, + shouldAddInputs: false, + }), + ).rejects.toThrow("Insufficient CKB"); + }); + + it("should handle filter parameter for input selection", async () => { + const customFilter = { + scriptLenRange: [0n, 1n] as [ccc.NumLike, ccc.NumLike], + outputDataLenRange: [0n, 10n] as [ccc.NumLike, ccc.NumLike], + }; + + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(50), + lock, + }, + ], + }); + + await feePayer.completeFeeChangeTo( + tx, + (tx, capacity) => { + if (capacity >= minChangeCapacity) { + tx.addOutput({ capacity, lock }); + return 0; + } + return minChangeCapacity; + }, + { + feeRate: 1000n, + filter: customFilter, + }, + ); + + // Verify that findCells was called with the custom filter + expect(client.findCells).toHaveBeenCalledWith( + expect.objectContaining({ + script: lock, + scriptType: "lock", + filter: expect.objectContaining({ + scriptLenRange: customFilter.scriptLenRange, + outputDataLenRange: customFilter.outputDataLenRange, + }) as ccc.ClientCollectableSearchKeyFilterLike, + scriptSearchMode: "exact", + withData: true, + }), + ); + }); + + it("should throw error when change function doesn't use all capacity", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(30), + lock, + }, + ], + }); + + await expect( + feePayer.completeFeeChangeTo( + tx, + (_tx, _capacity) => { + // Don't use the capacity but return 0 (claiming it's handled) + return 0; + }, + { feeRate: 1000n }, + ), + ).rejects.toThrow("doesn't use all available capacity"); + }); + + it("should handle fee rate from client when not provided", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(99), + lock, + }, + ], + }); + + await feePayer.completeFee(tx); + + // Verify that client.getFeeRate was called + expect(client.getFeeRate).toHaveBeenCalledOnce(); + }); + + it("should pass feeRateBlockRange option to client.getFeeRate", async () => { + const tx = ccc.Transaction.from({ + inputs: [ + { + previousOutput: mockCapacityCells[0].outPoint, + }, + ], + outputs: [ + { + capacity: ccc.fixedPointFrom(99), + lock, + }, + ], + }); + + const options = { + feeRateBlockRange: 10n, + maxFeeRate: 5000n, + }; + + await feePayer.completeFee(tx, options); + + expect(client.getFeeRate).toHaveBeenCalledWith( + options.feeRateBlockRange, + expect.objectContaining({ + feeRateBlockRange: options.feeRateBlockRange, + maxFeeRate: options.maxFeeRate, + }), + ); + }); + }); +}); diff --git a/packages/core/src/feePayer/feePayer.ts b/packages/core/src/feePayer/feePayer.ts new file mode 100644 index 000000000..801158a24 --- /dev/null +++ b/packages/core/src/feePayer/feePayer.ts @@ -0,0 +1,322 @@ +import { + CellOutput, + Script, + ScriptLike, + Transaction, + TransactionLike, +} from "../ckb/index.js"; +import { Client } from "../client/index.js"; +import { fixedPointFrom, Zero } from "../fixedPoint/index.js"; +import { Num, numFrom, NumLike } from "../num/index.js"; +import { ErrorFeePayerInsufficientCapacity } from "./errors.js"; +import { + FeePayerCollectCapacityResult, + FeePayerCompleteFeeChangeFn, + FeePayerCompleteFeeResult, + FeePayerGetFeeRateOptionsLike, +} from "./types.js"; + +/** + * An abstract class representing a fee payer. + * This class provides methods to complete transaction inputs and fees. + * + * @typeParam CollectCapacityOptions - The options for collecting capacity. + * @typeParam CollectCapacityContext - The context for collecting capacity. + * @typeParam CompleteFeeOptions - The options for completing the fee. + * @public + */ +export abstract class FeePayer< + CollectCapacityOptions = undefined, + CollectCapacityContext = undefined, + CompleteFeeOptions extends FeePayerGetFeeRateOptionsLike & + CollectCapacityOptions = FeePayerGetFeeRateOptionsLike & + CollectCapacityOptions, +> { + /** + * Creates an instance of FeePayer. + * @param client_ - The client used to interact with the CKB network. + */ + constructor(protected client_: Client) {} + + /** + * Gets the client associated with this fee payer. + */ + get client(): Client { + return this.client_; + } + + /** + * Collects capacity for the transaction. + * + * @param txLike - The transaction to collect capacity for. + * @param capacityTweak - Optional additional capacity needed. + * @param options - Optional configuration for collecting capacity. + * @param context - Optional context for collecting capacity. + * @returns A promise that resolves to the result of collecting capacity, including the total capacity collected from added inputs. + * + * @remarks + * This method should attempt to collect sufficient capacity to meet the requirement (outputs capacity plus `capacityTweak`). + * If the requirement cannot be fully met, it should still add as many inputs as possible to minimize the deficit and MUST NOT throw an error. + * This behavior enables multiple fee payers to collaborate in providing the necessary capacity. + */ + abstract collectCapacity( + txLike: TransactionLike, + capacityTweak?: NumLike, + options?: CollectCapacityOptions, + context?: CollectCapacityContext, + ): Promise>; + + /** + * Completes the transaction fee by adding a change output to a recommended address. + * + * @param txLike - The transaction to complete the fee for. + * @param options - Optional configuration for completing the fee. + * @returns A promise that resolves to the transaction with the fee paid, whether it was modified, and the operation context. + */ + abstract completeFee( + txLike: TransactionLike, + options?: CompleteFeeOptions, + ): 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); + } + + /** + * Gets the fee rate for the transaction. + * + * @param options - Optional configuration for getting the fee rate. + * @returns A promise that resolves to the fee rate. + */ + async getFeeRate(options?: FeePayerGetFeeRateOptionsLike): Promise { + return options?.feeRate + ? numFrom(options.feeRate) + : await this.client_.getFeeRate(options?.feeRateBlockRange, options); + } + + /** + * Completes the transaction fee by applying a custom change function. + * + * @param txLike - The transaction to complete the fee for. + * @param change - A function that modifies the transaction to handle the change. + * @param optionsLike - Optional configuration for completing the fee. + * @returns A promise that resolves to the transaction with the fee paid, whether it was modified, and the operation context. + */ + async completeFeeChangeTo( + txLike: TransactionLike, + change: FeePayerCompleteFeeChangeFn, + options?: CompleteFeeOptions, + ): Promise> { + let tx = Transaction.from(txLike); + + // Get fee rate at first + const feeRate = await this.getFeeRate(options); + + // Complete all inputs extra infos for cache + await tx.getInputsCapacity(this.client); + + let leastFee = Zero; + let leastExtraCapacity = Zero; + let collectCapacityContext = undefined as CollectCapacityContext; // It's fine because it's assigned below + + // === + // 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) { + try { + const res = await this.collectCapacity( + tx, + leastFee + leastExtraCapacity, + options, + collectCapacityContext, + ); + tx = res.tx; + collectCapacityContext = res.context; // Now collectCapacityContext is assigned + } catch (err) { + if ( + err instanceof ErrorFeePayerInsufficientCapacity && + leastExtraCapacity !== Zero + ) { + throw new ErrorFeePayerInsufficientCapacity(err.amount, { + isForChange: true, + }); + } + + throw err; + } + + const fee = await tx.getFee(this.client); + if (fee < leastFee + leastExtraCapacity) { + // Not enough capacity are collected previously + throw new ErrorFeePayerInsufficientCapacity( + leastFee + leastExtraCapacity - fee, + { isForChange: leastExtraCapacity !== Zero }, + ); + } + + 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 = 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 + // So this only happens in the first iteration + if (fee === leastFee) { + return { + tx, + hasChanged: false, + context: collectCapacityContext, + }; + } + + // Invoke the change function on a transaction multiple times may cause problems, so we clone it + const txCopy = tx.clone(); + const needed = numFrom( + await Promise.resolve(change(txCopy, fee - leastFee)), + ); + if (needed > Zero) { + // No enough extra capacity to create new cells for change, collect inputs again + leastExtraCapacity = needed; + continue; + } + + if ((await txCopy.getFee(this.client)) !== leastFee) { + throw new Error( + "The change function doesn't use all available capacity", + ); + } + + // New change cells created, update the fee + 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) { + return { + tx: txCopy, + hasChanged: true, + context: collectCapacityContext, + }; + } + + // The fee after changing is more than the original fee + leastFee = changedFee; + } + } + + /** + * Completes the transaction fee by adding inputs and creating a change output with the specified lock script. + * This is a convenience method that automatically creates a change cell with the provided lock script + * when there's excess capacity after paying the transaction fee. + * + * @param txLike - The transaction to complete the fee for. + * @param change - The lock script for the change output cell. + * @param options - Optional configuration for completing the fee. + * @returns A promise that resolves to the transaction with the fee paid, whether it was modified, and the operation context. + * + * @example + * ```typescript + * const changeScript = Script.from({ + * codeHash: "0x...", + * hashType: "type", + * args: "0x..." + * }); + * + * const { hasChanged } = await feePayer.completeFeeChangeToLock( + * tx, + * changeScript, + * ); + * ``` + */ + completeFeeChangeToLock( + txLike: TransactionLike, + change: ScriptLike, + options?: CompleteFeeOptions, + ): Promise> { + const script = Script.from(change); + const tx = Transaction.from(txLike); + + return this.completeFeeChangeTo( + tx, + (tx, capacity) => { + const changeCell = CellOutput.from({ + capacity: 0, // For auto conducting capacity occupation + lock: script, + }); + const occupiedCapacity = fixedPointFrom(changeCell.occupiedSize); + if (capacity < occupiedCapacity) { + return occupiedCapacity; + } + changeCell.capacity = capacity; + tx.addOutput(changeCell); + return 0; + }, + options, + ); + } + + /** + * 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 + * to the specified existing output in the transaction. + * + * @param txLike - The transaction to complete the fee for. + * @param index - The index of the existing output to add excess capacity to. + * @param options - Optional configuration for completing the fee. + * @returns A promise that resolves to the transaction with the fee paid, whether it was modified, and the operation context. + * + * @throws {Error} When the specified output index doesn't exist. + * + * @example + * ```typescript + * // Add excess capacity to the first output (index 0) + * const { hasChanged } = await feePayer.completeFeeChangeToOutput( + * tx, + * 0, // Output index + * ); + * ``` + */ + completeFeeChangeToOutput( + txLike: TransactionLike, + index: NumLike, + options?: CompleteFeeOptions, + ): Promise> { + const tx = Transaction.from(txLike); + const change = Number(numFrom(index)); + if (!tx.outputs[change]) { + throw new Error("Non-existed output to change"); + } + + return this.completeFeeChangeTo( + tx, + (tx, capacity) => { + tx.outputs[change].capacity += capacity; + return 0; + }, + options, + ); + } +} diff --git a/packages/core/src/feePayer/feePayerFromAddress.ts b/packages/core/src/feePayer/feePayerFromAddress.ts new file mode 100644 index 000000000..7d74ab75a --- /dev/null +++ b/packages/core/src/feePayer/feePayerFromAddress.ts @@ -0,0 +1,335 @@ +import { Address } from "../address/index.js"; +import { Cell, Transaction, TransactionLike } from "../ckb/transaction.js"; +import { Zero } from "../fixedPoint/index.js"; +import { Num, numFrom, NumLike } from "../num/index.js"; +import { ErrorFeePayerInsufficientCapacity } from "./errors.js"; +import { FeePayer } from "./feePayer.js"; +import { + FeePayerCollectCapacityResult, + FeePayerCompleteFeeResult, + FeePayerCompleteInputsContext, + FeePayerCompleteInputsOptions, + FeePayerCompleteInputsOptionsLike, + FeePayerCompleteInputsResult, + FeePayerGetFeeRateOptionsLike, +} from "./types.js"; + +/** + * A fee payer that uses addresses to find cells and complete transactions. + * @public + */ +export abstract class FeePayerFromAddress extends FeePayer< + FeePayerCompleteInputsOptionsLike, + FeePayerCompleteInputsContext +> { + /** + * 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 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()), + ); + } + + /** + * 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(); + } + + /** + * Completes transaction inputs by searching for cells associated with the fee payer's addresses. + * + * @param txLike - The transaction to complete inputs for. + * @param accumulator - A function that accumulates cells until a condition is met. + * @param init - The initial value for the accumulator. + * @param options - Optional configuration for completing inputs. + * @returns A promise that resolves to the result of completing inputs. + */ + async completeInputs( + txLike: TransactionLike, + accumulator: ( + acc: T, + v: Cell, + i: number, + array: Cell[], + ) => Promise | T | undefined, + init: T, + options?: FeePayerCompleteInputsOptionsLike, + ): Promise { + const tx = Transaction.from(txLike); + const collectedCells = []; + + let acc: T = init; + let fulfilled = false; + + if (!FeePayerCompleteInputsOptions.from(options).shouldAddInputs) { + return { + tx, + addedCount: 0, + accumulated: acc, + collectedCapacity: Zero, + }; + } + + for (const address of await this.getAddressObjs()) { + for await (const cell of this.client.findCells({ + script: address.script, + scriptType: "lock", + filter: options?.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)); + const collectedCapacity = collectedCells.reduce( + (acc, { cellOutput: { capacity } }) => acc + capacity, + Zero, + ); + if (fulfilled) { + return { + tx, + addedCount: collectedCells.length, + collectedCapacity, + }; + } + + return { + tx, + addedCount: collectedCells.length, + collectedCapacity, + accumulated: acc, + }; + } + + /** + * Completes transaction inputs to satisfy a required capacity. + * + * @param txLike - The transaction to complete inputs for. + * @param capacityTweak - Optional additional capacity needed. + * @param options - Optional configuration for completing inputs. + * @returns A promise that resolves to the result of completing inputs. + */ + async completeInputsByCapacity( + txLike: TransactionLike, + capacityTweak?: NumLike, + options?: FeePayerCompleteInputsOptionsLike, + ): Promise { + const { + tx, + context: { addedCount, collectedCapacity }, + expectedCapacity, + accumulated, + } = await this.collectCapacity(txLike, capacityTweak, options); + + if (accumulated !== undefined) { + throw new ErrorFeePayerInsufficientCapacity( + expectedCapacity - accumulated, + ); + } + + return { + tx, + addedCount, + collectedCapacity, + }; + } + + /** + * Completes transaction inputs by adding all available cells from the fee payer. + * + * @param txLike - The transaction to complete inputs for. + * @param options - Optional configuration for completing inputs. + * @returns A promise that resolves to the result of completing inputs. + */ + async completeInputsAll( + txLike: TransactionLike, + options?: FeePayerCompleteInputsOptionsLike, + ): Promise { + return this.completeInputs( + txLike, + (acc, { cellOutput: { capacity } }) => acc + capacity, + Zero, + options, + ); + } + + /** + * Completes transaction inputs by adding exactly one more cell. + * + * @param txLike - The transaction to complete inputs for. + * @param options - Optional configuration for completing inputs. + * @returns A promise that resolves to the result of completing inputs. + */ + async completeInputsAddOne( + txLike: TransactionLike, + options?: FeePayerCompleteInputsOptionsLike, + ): Promise { + const res = await this.completeInputs( + txLike, + () => undefined, + true, + options, + ); + + if (res.accumulated === undefined) { + return res; + } + + throw new Error(`Insufficient CKB, need at least one new cell`); + } + + /** + * Completes transaction inputs by adding at least one cell if no inputs exist. + * + * @param txLike - The transaction to complete inputs for. + * @param options - Optional configuration for completing inputs. + * @returns A promise that resolves to the result of completing inputs. + */ + async completeInputsAtLeastOne( + txLike: TransactionLike, + options?: FeePayerCompleteInputsOptionsLike, + ): Promise { + const tx = Transaction.from(txLike); + if (tx.inputs.length > 0) { + return { + tx, + addedCount: 0, + collectedCapacity: Zero, + }; + } + + return this.completeInputsAddOne(tx, options); + } + + /** + * Collects capacity for the transaction from the fee payer's addresses. + * + * @param txLike - The transaction to collect capacity for. + * @param capacityTweak - Optional additional capacity needed. + * @param options - Optional configuration for completing inputs. + * @param contextLike - Optional context for completing inputs. + * @returns A promise that resolves to the result of collecting capacity, including the total capacity collected from added inputs. + * + * @remarks + * This method attempts to collect sufficient capacity to meet the requirement (outputs capacity plus `capacityTweak`). + * If the requirement cannot be fully met, it adds as many inputs as possible to minimize the deficit and does NOT throw an error. + * This behavior enables multiple fee payers to collaborate in providing the necessary capacity. + */ + async collectCapacity( + txLike: TransactionLike, + capacityTweak?: NumLike, + options?: FeePayerCompleteInputsOptionsLike, + contextLike?: FeePayerCompleteInputsContext, + ): Promise< + FeePayerCollectCapacityResult & { + expectedCapacity: Num; + accumulated?: Num; + } + > { + const tx = Transaction.from(txLike); + const context = { + addedCount: contextLike?.addedCount ?? 0, + collectedCapacity: contextLike?.collectedCapacity ?? Zero, + }; + + const expectedCapacity = + tx.getOutputsCapacity() + numFrom(capacityTweak ?? 0); + const inputsCapacity = await tx.getInputsCapacity(this.client); + if (inputsCapacity >= expectedCapacity) { + return { + tx, + context, + expectedCapacity, + collectedCapacity: Zero, + }; + } + + const { + addedCount, + accumulated, + collectedCapacity, + tx: resTx, + } = await this.completeInputs( + tx, + (acc, { cellOutput: { capacity } }) => { + const sum = acc + capacity; + return sum >= expectedCapacity ? undefined : sum; + }, + inputsCapacity, + options, + ); + context.addedCount += addedCount; + context.collectedCapacity += collectedCapacity; + + return { + tx: resTx, + context, + expectedCapacity, + collectedCapacity, + accumulated, + }; + } + + /** + * Completes the transaction fee by adding a change output to a recommended address. + * + * @param txLike - The transaction to complete the fee for. + * @param options - Optional configuration for completing the fee. + * @returns A promise that resolves to the transaction with the fee paid, whether it was modified, and the operation context. + */ + async completeFee( + txLike: TransactionLike, + options?: FeePayerGetFeeRateOptionsLike & FeePayerCompleteInputsOptionsLike, + ): Promise> { + return this.completeFeeChangeToLock( + txLike, + (await this.getRecommendedAddressObj()).script, + options, + ); + } +} diff --git a/packages/core/src/feePayer/index.ts b/packages/core/src/feePayer/index.ts new file mode 100644 index 000000000..b15a2a2b2 --- /dev/null +++ b/packages/core/src/feePayer/index.ts @@ -0,0 +1,4 @@ +export * from "./errors.js"; +export * from "./feePayer.js"; +export * from "./feePayerFromAddress.js"; +export * from "./types.js"; diff --git a/packages/core/src/feePayer/types.ts b/packages/core/src/feePayer/types.ts new file mode 100644 index 000000000..ce366411d --- /dev/null +++ b/packages/core/src/feePayer/types.ts @@ -0,0 +1,172 @@ +import { Transaction } from "../ckb/index.js"; +import { + ClientCollectableSearchKeyFilter, + ClientCollectableSearchKeyFilterLike, +} from "../client/index.js"; +import { Num, NumLike } from "../num/index.js"; + +/** + * A filter that matches cells with no data and no type script. + * @public + */ +export const EMPTY_CELL_FILTER = ClientCollectableSearchKeyFilter.from({ + scriptLenRange: [0, 1], + outputDataLenRange: [0, 1], +}); + +/** + * The result of collecting capacity from a fee payer. + * @public + */ +export type FeePayerCollectCapacityResult = { + /** + * The transaction that completed. + */ + tx: Transaction; + + /** + * The total capacity collected from the fee payer's cells. + */ + collectedCapacity: Num; + + /** + * The context of the operation. + */ + context: Context; +}; + +/** + * Options for getting the fee rate from a fee payer. + * @public + */ +export type FeePayerGetFeeRateOptionsLike = + | { + /** + * The fee rate to use. If provided, the fee payer will not fetch the fee rate from the client. + */ + feeRate?: NumLike; + /** + * The block range to use for calculating the fee rate. + */ + feeRateBlockRange?: NumLike; + /** + * The maximum allowed fee rate. + */ + maxFeeRate?: NumLike; + } + | undefined + | null; + +/** + * A function that modifies a transaction to handle the change after paying the fee. + * @param tx - The transaction to modify. + * @param capacity - The excess capacity available for the change. + * @returns The additional capacity needed if the excess capacity is insufficient, or 0 if the fee is paid. + * @public + */ +export type FeePayerCompleteFeeChangeFn = ( + tx: Transaction, + capacity: Num, +) => Promise | NumLike; + +/** + * The result of completing the transaction fee. + * @public + */ +export type FeePayerCompleteFeeResult = { + /** + * The transaction that completed. + */ + tx: Transaction; + /** + * Whether the transaction was modified to handle the change. + */ + hasChanged: boolean; + + /** + * The context of the operation. + */ + context: Context; +}; + +/** + * Options for completing transaction inputs. + * @public + */ +export type FeePayerCompleteInputsOptionsLike = + | { + /** + * The filter to use when searching for cells. + */ + filter?: ClientCollectableSearchKeyFilterLike; + + /** + * Whether to add inputs automatically to cover the fee. Defaults to true. + */ + shouldAddInputs?: boolean; + } + | undefined + | null; + +/** + * Options for completing transaction inputs. + * @public + */ +export class FeePayerCompleteInputsOptions { + /** + * Creates an instance of FeePayerCompleteInputsOptions. + * @param filter - The filter to use when searching for cells. + * @param shouldAddInputs - Whether to add inputs automatically to cover the fee. + */ + constructor( + public filter: ClientCollectableSearchKeyFilter, + public shouldAddInputs: boolean, + ) {} + + /** + * Creates an instance of FeePayerCompleteInputsOptions from a partial options object. + * @param options - The partial options object. + * @returns An instance of FeePayerCompleteInputsOptions. + */ + static from( + options?: FeePayerCompleteInputsOptionsLike, + ): FeePayerCompleteInputsOptions { + if (options instanceof FeePayerCompleteInputsOptions) { + return options; + } + + return new FeePayerCompleteInputsOptions( + ClientCollectableSearchKeyFilter.from( + options?.filter ?? EMPTY_CELL_FILTER, + ), + options?.shouldAddInputs ?? true, + ); + } +} + +/** + * The context of completing transaction inputs. + * @public + */ +export type FeePayerCompleteInputsContext = { + /** + * The number of inputs added. + */ + addedCount: number; + + /** + * The total capacity collected from the fee payer's cells. + */ + collectedCapacity: Num; +}; + +/** + * The result of completing transaction inputs. + * @public + */ +export type FeePayerCompleteInputsResult = { + /** + * The transaction with added inputs. + */ + tx: Transaction; +} & FeePayerCompleteInputsContext; diff --git a/packages/core/src/signer/index.ts b/packages/core/src/signer/index.ts index 350b487e8..0cb068e49 100644 --- a/packages/core/src/signer/index.ts +++ b/packages/core/src/signer/index.ts @@ -1,3 +1,4 @@ +export * from "../feePayer/index.js"; export * from "./btc/index.js"; export * from "./ckb/index.js"; export * from "./doge/index.js"; diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index a2d0504b7..1fa930a00 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -1,13 +1,13 @@ -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"; import { Client, + ClientCollectableSearchKeyFilterLike, ClientFindTransactionsGroupedResponse, ClientFindTransactionsResponse, ClientIndexerSearchKeyFilterLike, } from "../../client/index.js"; +import { FeePayerFromAddress } from "../../feePayer/feePayerFromAddress.js"; import { Hex } from "../../hex/index.js"; import { Num } from "../../num/index.js"; import { verifyMessageBtcEcdsa } from "../btc/verify.js"; @@ -78,16 +78,14 @@ 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 FeePayerFromAddress { + constructor(protected client_: Client) { + super(client_); + } abstract get type(): SignerType; abstract get signType(): SignerSignType; - get client(): Client { - return this.client_; - } - // Returns the preference if we need to switch network // undefined otherwise matchNetworkPreference( @@ -203,44 +201,6 @@ export abstract class Signer { 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 * @@ -459,26 +419,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. * diff --git a/packages/type-id/src/advancedBarrel.test.ts b/packages/type-id/src/advancedBarrel.test.ts index d7fad2376..301302e04 100644 --- a/packages/type-id/src/advancedBarrel.test.ts +++ b/packages/type-id/src/advancedBarrel.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/unbound-method */ import { ccc } from "@ckb-ccc/core"; import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; import { buildTypeIdOperations } from "./advancedBarrel.js"; @@ -21,7 +22,11 @@ describe("type-id", () => { depType: "code" as const, }; + const mockTypeId = ccc.hexFrom("1".repeat(64)); + beforeEach(() => { + vi.spyOn(ccc, "hashTypeId").mockReturnValue(mockTypeId); + client = { getKnownScript: vi.fn(), getCellDeps: vi.fn(), @@ -31,7 +36,17 @@ describe("type-id", () => { signer = { client, getRecommendedAddressObj: vi.fn(), - findCells: vi.fn(), + completeInputsAtLeastOne: vi + .fn() + .mockImplementation(async (tx: ccc.Transaction) => { + tx.addInput({ + previousOutput: { + txHash: "2".repeat(64), + index: 0, + }, + }); + return { tx, addedCount: 1 }; + }), } as unknown as ccc.Signer; (client.getKnownScript as Mock).mockResolvedValue({ @@ -68,22 +83,6 @@ describe("type-id", () => { codec: customCodec, }); - const inputCell = ccc.Cell.from({ - outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, - cellOutput: { - capacity: ccc.fixedPointFrom(1000), - lock: ccc.Script.from({ - codeHash: "0x" + "0".repeat(64), - hashType: "type", - args: "0x", - }), - }, - outputData: "0x", - }); - (signer.findCells as Mock).mockImplementation(async function* () { - yield inputCell; - }); - const { tx } = await create({ signer, data: 123456, @@ -104,22 +103,6 @@ describe("type-id", () => { calculateTypeId, }); - const inputCell = ccc.Cell.from({ - outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, - cellOutput: { - capacity: ccc.fixedPointFrom(1000), - lock: ccc.Script.from({ - codeHash: "0x" + "0".repeat(64), - hashType: "type", - args: "0x", - }), - }, - outputData: "0x", - }); - (signer.findCells as Mock).mockImplementation(async function* () { - yield inputCell; - }); - const { id, tx } = await create({ signer, data: "0x", @@ -150,22 +133,6 @@ describe("type-id", () => { addCellDeps, }); - const inputCell = ccc.Cell.from({ - outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, - cellOutput: { - capacity: ccc.fixedPointFrom(1000), - lock: ccc.Script.from({ - codeHash: "0x" + "0".repeat(64), - hashType: "type", - args: "0x", - }), - }, - outputData: "0x", - }); - (signer.findCells as Mock).mockImplementation(async function* () { - yield inputCell; - }); - const { tx } = await create({ signer, data: "0x", @@ -186,41 +153,19 @@ describe("type-id", () => { describe("create", () => { it("should create a transaction with correct type id", async () => { - const inputCell = ccc.Cell.from({ - outPoint: { - txHash: "0x" + "2".repeat(64), - index: 0, - }, - cellOutput: { - capacity: ccc.fixedPointFrom(1000), - lock: ccc.Script.from({ - codeHash: "0x" + "0".repeat(64), - hashType: "type", - args: "0x", - }), - }, - outputData: "0x", - }); - - (signer.findCells as Mock).mockImplementation(async function* () { - yield inputCell; - }); - const data = "0x1234"; const { tx, id, index } = await create({ signer, data, }); - expect(tx.inputs.length).toBe(1); - expect(tx.inputs[0].previousOutput).toEqual(inputCell.outPoint); + expect(signer.completeInputsAtLeastOne).toHaveBeenCalledWith(tx); + expect(ccc.hashTypeId).toHaveBeenCalled(); + expect(id).toBe(mockTypeId); expect(tx.outputs.length).toBe(1); expect(index).toBe(0); - const expectedId = ccc.hashTypeId(tx.inputs[0], 0); - expect(id).toBe(expectedId); - const output = tx.outputs[0]; expect(output.type).toBeDefined(); expect(output.type?.codeHash).toBe(typeIdScript.codeHash); @@ -239,22 +184,6 @@ describe("type-id", () => { headerDeps: ["0x" + "e".repeat(64)], }); - const inputCell = ccc.Cell.from({ - outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, - cellOutput: { - capacity: ccc.fixedPointFrom(1000), - lock: ccc.Script.from({ - codeHash: "0x" + "0".repeat(64), - hashType: "type", - args: "0x", - }), - }, - outputData: "0x", - }); - (signer.findCells as Mock).mockImplementation(async function* () { - yield inputCell; - }); - const { tx } = await create({ signer, data: "0x", @@ -272,22 +201,6 @@ describe("type-id", () => { args: "0xffee", }); - const inputCell = ccc.Cell.from({ - outPoint: { txHash: "0x" + "2".repeat(64), index: 0 }, - cellOutput: { - capacity: ccc.fixedPointFrom(1000), - lock: ccc.Script.from({ - codeHash: "0x" + "0".repeat(64), - hashType: "type", - args: "0x", - }), - }, - outputData: "0x", - }); - (signer.findCells as Mock).mockImplementation(async function* () { - yield inputCell; - }); - const { tx } = await create({ signer, data: "0x", diff --git a/packages/type-id/src/advancedBarrel.ts b/packages/type-id/src/advancedBarrel.ts index 29a171d4c..4e438704b 100644 --- a/packages/type-id/src/advancedBarrel.ts +++ b/packages/type-id/src/advancedBarrel.ts @@ -93,7 +93,7 @@ export function buildTypeIdOperations< const { signer, receiver, data, tx: txLike } = props; const tx = ccc.Transaction.from(txLike ?? {}); - await tx.completeInputsAtLeastOne(signer); + await signer.completeInputsAtLeastOne(tx); const id = await calculateTypeId(signer.client, tx); const scriptInfo = await getScriptInfo(signer.client);