diff --git a/.changeset/tender-nails-stick.md b/.changeset/tender-nails-stick.md new file mode 100644 index 00000000..91b6438a --- /dev/null +++ b/.changeset/tender-nails-stick.md @@ -0,0 +1,6 @@ +--- +"@ckb-ccc/core": patch +--- + +Abstract transaction completion step by a new layer named FeePayer + \ No newline at end of file diff --git a/packages/core/src/ckb/transaction.test.ts b/packages/core/src/ckb/transaction.test.ts index 6271943a..c10a279e 100644 --- a/packages/core/src/ckb/transaction.test.ts +++ b/packages/core/src/ckb/transaction.test.ts @@ -58,6 +58,20 @@ describe("Transaction", () => { }, ); + // Mock the findCells method to return our mock UDT cells + vi.spyOn(client, "findCells").mockImplementation( + async function* (searchKey) { + if ( + searchKey.filter?.script && + ccc.Script.from(searchKey.filter.script).eq(type) + ) { + for (const cell of mockUdtCells) { + yield cell; + } + } + }, + ); + // Mock client.getCell to return the cell data for inputs vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { const cell = mockUdtCells.find((c) => c.outPoint.eq(outPoint)); @@ -263,9 +277,12 @@ describe("Transaction", () => { it("should use only one cell when user has only one cell available", async () => { // Mock signer to return only one cell - vi.spyOn(signer, "findCells").mockImplementation( - async function* (filter) { - if (filter.script && ccc.Script.from(filter.script).eq(type)) { + vi.spyOn(client, "findCells").mockImplementation( + async function* (searchKey) { + if ( + searchKey.filter?.script && + ccc.Script.from(searchKey.filter.script).eq(type) + ) { yield mockUdtCells[0]; // Only yield the first cell } }, @@ -328,6 +345,18 @@ describe("Transaction", () => { }, ); + // Mock the findCells method to return capacity cells + vi.spyOn(client, "findCells").mockImplementation( + async function* (searchKey) { + // Return capacity cells for general queries + if (!searchKey.filter?.script || searchKey.filter?.scriptLenRange) { + for (const cell of mockCapacityCells) { + yield cell; + } + } + }, + ); + // Mock client.getCell to return the cell data for inputs vi.spyOn(client, "getCell").mockImplementation(async (outPoint) => { const cell = mockCapacityCells.find((c) => c.outPoint.eq(outPoint)); @@ -551,7 +580,15 @@ describe("Transaction", () => { ); // Verify that findCells was called with the custom filter - expect(signer.findCells).toHaveBeenCalledWith(customFilter, true); + for (const address of await signer.getAddressObjs()) { + expect(client.findCells).toHaveBeenCalledWith({ + script: address.script, + scriptType: "lock", + filter: customFilter, + scriptSearchMode: "exact", + withData: true, + }); + } }); it("should throw error when change function doesn't use all capacity", async () => { @@ -1128,4 +1165,471 @@ describe("Transaction", () => { }); }); }); + + describe("Margin Concept", () => { + describe("CellOutput.margin", () => { + it("should calculate margin correctly with no data", () => { + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock, + }); + + const margin = cellOutput.margin(0); + const expectedMargin = + ccc.fixedPointFrom(1000) - + ccc.fixedPointFrom(cellOutput.occupiedSize); + expect(margin).toBe(expectedMargin); + }); + + it("should calculate margin correctly with data length", () => { + const dataLen = 100; + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(1000), + lock, + }); + + const margin = cellOutput.margin(dataLen); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should calculate margin with type script", () => { + const dataLen = 50; + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(2000), + lock, + type, + }); + + const margin = cellOutput.margin(dataLen); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should return zero margin when capacity equals occupied size plus data", () => { + const dataLen = 10; + const cellOutput = ccc.CellOutput.from( + { + capacity: 0, + lock, + }, + "0x" + "00".repeat(dataLen), + ); + + // Capacity is auto-calculated as occupiedSize + dataLen, so margin should be 0 + const margin = cellOutput.margin(dataLen); + // The margin should be approximately 0, but due to fixed-point precision, + // we check it's very close to zero (within rounding error) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should return zero margin when capacity is insufficient", () => { + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(50), + lock, + }); + + const margin = cellOutput.margin(100); // Data length exceeds available capacity + expect(margin).toBe(ccc.Zero); + }); + + it("should handle large data length", () => { + const dataLen = 10000; + const cellOutput = ccc.CellOutput.from({ + capacity: ccc.fixedPointFrom(20000), + lock, + }); + + const margin = cellOutput.margin(dataLen); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const expectedMargin = + cellOutput.capacity - + ccc.fixedPointFrom(cellOutput.occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + }); + + describe("Transaction.getOutputCapacityMargin", () => { + it("should get margin for existing output", () => { + const outputData = "0x12345678"; // 4 bytes + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + ], + outputsData: [outputData], + }); + + const margin = tx.getOutputCapacityMargin(0); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const dataLen = ccc.bytesFrom(outputData).length; + const expectedMargin = + tx.outputs[0].capacity - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should return zero for non-existent output", () => { + const tx = ccc.Transaction.from({ + outputs: [], + }); + + const margin = tx.getOutputCapacityMargin(0); + expect(margin).toBe(ccc.Zero); + }); + + it("should get margin for output with type script", () => { + const outputData = "0xabcd"; // 2 bytes + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(2000), + lock, + type, + }, + ], + outputsData: [outputData], + }); + + const margin = tx.getOutputCapacityMargin(0); + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const dataLen = ccc.bytesFrom(outputData).length; + const expectedMargin = + tx.outputs[0].capacity - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen)); + expect(margin).toBe(expectedMargin); + }); + + it("should handle empty output data", () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + ], + outputsData: ["0x"], + }); + + const margin = tx.getOutputCapacityMargin(0); + const expectedMargin = + ccc.fixedPointFrom(1000) - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize); + expect(margin).toBe(expectedMargin); + }); + + it("should handle missing output data", () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + ], + }); + + const margin = tx.getOutputCapacityMargin(0); + const expectedMargin = + ccc.fixedPointFrom(1000) - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize); + expect(margin).toBe(expectedMargin); + }); + + it("should get margin for multiple outputs", () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(1000), + lock, + }, + { + capacity: ccc.fixedPointFrom(2000), + lock, + type, + }, + ], + outputsData: ["0x12", "0x3456"], + }); + + const margin0 = tx.getOutputCapacityMargin(0); + const margin1 = tx.getOutputCapacityMargin(1); + + // Margin = capacity - occupiedSize - dataLen (all in fixed-point) + const dataLen0 = ccc.bytesFrom(tx.outputsData[0]).length; + const dataLen1 = ccc.bytesFrom(tx.outputsData[1]).length; + expect(margin0).toBe( + tx.outputs[0].capacity - + ccc.fixedPointFrom(tx.outputs[0].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen0)), + ); + expect(margin1).toBe( + tx.outputs[1].capacity - + ccc.fixedPointFrom(tx.outputs[1].occupiedSize) - + ccc.fixedPointFrom(ccc.numFrom(dataLen1)), + ); + }); + }); + }); + + describe("Fee Payer Layer", () => { + let mockFeePayer1: ccc.FeePayer; + let mockFeePayer2: ccc.FeePayer; + + beforeEach(() => { + // Create mock fee payers + mockFeePayer1 = { + prepareTransaction: vi + .fn() + .mockImplementation(async (tx: ccc.TransactionLike) => + ccc.Transaction.from(tx), + ), + completeTxFee: vi.fn().mockResolvedValue(undefined), + } as unknown as ccc.FeePayer; + + mockFeePayer2 = { + prepareTransaction: vi + .fn() + .mockImplementation(async (tx: ccc.TransactionLike) => + ccc.Transaction.from(tx), + ), + completeTxFee: vi.fn().mockResolvedValue(undefined), + } as unknown as ccc.FeePayer; + }); + + it("should call prepareTransaction on all fee payers", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + await tx.completeByFeePayer(client, mockFeePayer1, mockFeePayer2); + + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledWith(tx); + expect(mockFeePayer2.prepareTransaction).toHaveBeenCalledWith(tx); + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledTimes(1); + expect(mockFeePayer2.prepareTransaction).toHaveBeenCalledTimes(1); + }); + + it("should call completeTxFee on all fee payers after prepareTransaction", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + await tx.completeByFeePayer(client, mockFeePayer1, mockFeePayer2); + + // Verify both methods were called + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalled(); + expect(mockFeePayer2.prepareTransaction).toHaveBeenCalled(); + expect(mockFeePayer1.completeTxFee).toHaveBeenCalled(); + expect(mockFeePayer2.completeTxFee).toHaveBeenCalled(); + + // Verify completeTxFee was called with a Transaction and client + const completeTxFee1Call = ( + mockFeePayer1.completeTxFee as ReturnType + ).mock.calls[0]; + const completeTxFee2Call = ( + mockFeePayer2.completeTxFee as ReturnType + ).mock.calls[0]; + expect(completeTxFee1Call[0]).toBeInstanceOf(ccc.Transaction); + expect(completeTxFee1Call[1]).toBe(client); + expect(completeTxFee2Call[0]).toBeInstanceOf(ccc.Transaction); + expect(completeTxFee2Call[1]).toBe(client); + + // Verify prepareTransaction was called before completeTxFee + // by checking the order of calls + const prepare1Order = ( + mockFeePayer1.prepareTransaction as ReturnType + ).mock.invocationCallOrder[0]; + const complete1Order = ( + mockFeePayer1.completeTxFee as ReturnType + ).mock.invocationCallOrder[0]; + expect(prepare1Order).toBeLessThan(complete1Order); + }); + + it("should handle single fee payer", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + await tx.completeByFeePayer(client, mockFeePayer1); + + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalledTimes(1); + expect(mockFeePayer1.completeTxFee).toHaveBeenCalledTimes(1); + expect(mockFeePayer2.prepareTransaction).not.toHaveBeenCalled(); + expect(mockFeePayer2.completeTxFee).not.toHaveBeenCalled(); + }); + + it("should handle empty fee payer list", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + // Should not throw with empty fee payer list + await expect(tx.completeByFeePayer(client)).resolves.not.toThrow(); + }); + + it("should handle multiple fee payers in sequence", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const callOrder: string[] = []; + ( + mockFeePayer1.prepareTransaction as ReturnType + ).mockImplementation(async (tx: ccc.TransactionLike) => { + callOrder.push("prepare1"); + return ccc.Transaction.from(tx); + }); + ( + mockFeePayer2.prepareTransaction as ReturnType + ).mockImplementation(async (tx: ccc.TransactionLike) => { + callOrder.push("prepare2"); + return ccc.Transaction.from(tx); + }); + ( + mockFeePayer1.completeTxFee as ReturnType + ).mockImplementation(async () => { + callOrder.push("complete1"); + }); + ( + mockFeePayer2.completeTxFee as ReturnType + ).mockImplementation(async () => { + callOrder.push("complete2"); + }); + + await tx.completeByFeePayer(client, mockFeePayer1, mockFeePayer2); + + // Verify order: all prepareTransaction calls first, then all completeTxFee calls + expect(callOrder).toEqual([ + "prepare1", + "prepare2", + "complete1", + "complete2", + ]); + }); + + it("should propagate errors from prepareTransaction", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const error = new Error("Prepare transaction failed"); + ( + mockFeePayer1.prepareTransaction as ReturnType + ).mockRejectedValue(error); + + await expect( + tx.completeByFeePayer(client, mockFeePayer1), + ).rejects.toThrow("Prepare transaction failed"); + }); + + it("should propagate errors from completeTxFee", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const error = new Error("Complete fee failed"); + ( + mockFeePayer1.completeTxFee as ReturnType + ).mockRejectedValue(error); + + await expect( + tx.completeByFeePayer(client, mockFeePayer1), + ).rejects.toThrow("Complete fee failed"); + }); + + it("should handle fee payer that modifies transaction in prepareTransaction", async () => { + const tx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + ], + }); + + const modifiedTx = ccc.Transaction.from({ + outputs: [ + { + capacity: ccc.fixedPointFrom(100), + lock, + }, + { + capacity: ccc.fixedPointFrom(50), + lock, + }, + ], + }); + + ( + mockFeePayer1.prepareTransaction as ReturnType + ).mockResolvedValue(modifiedTx); + + await tx.completeByFeePayer(client, mockFeePayer1); + + // prepareTransaction is called with a clone of the original transaction + expect(mockFeePayer1.prepareTransaction).toHaveBeenCalled(); + const prepareCallArg = ( + mockFeePayer1.prepareTransaction as ReturnType + ).mock.calls[0][0] as ccc.Transaction; + expect(prepareCallArg).toBeInstanceOf(ccc.Transaction); + expect(prepareCallArg.outputs.length).toBe(1); + // completeTxFee should be called with the modified transaction returned by prepareTransaction + expect(mockFeePayer1.completeTxFee).toHaveBeenCalledWith( + modifiedTx, + client, + ); + }); + }); }); diff --git a/packages/core/src/ckb/transaction.ts b/packages/core/src/ckb/transaction.ts index 9a5920bd..f9fbc681 100644 --- a/packages/core/src/ckb/transaction.ts +++ b/packages/core/src/ckb/transaction.ts @@ -19,14 +19,12 @@ import { numToBytes, numToHex, } from "../num/index.js"; +import { FeePayer } from "../signer/feePayer/index.js"; import type { Signer } from "../signer/index.js"; import { apply, reduceAsync } from "../utils/index.js"; import { Script, ScriptLike, ScriptOpt } from "./script.js"; import { DEP_TYPE_TO_NUM, NUM_TO_DEP_TYPE } from "./transaction.advanced.js"; -import { - ErrorTransactionInsufficientCapacity, - ErrorTransactionInsufficientCoin, -} from "./transactionErrors.js"; +import { ErrorTransactionInsufficientCoin } from "./transactionErrors.js"; import type { LumosTransactionSkeletonType } from "./transactionLumos.js"; export const DepTypeCodec: mol.Codec = mol.Codec.from({ @@ -294,6 +292,17 @@ export class CellOutput extends mol.Entity.Base() { clone(): CellOutput { return new CellOutput(this.capacity, this.lock.clone(), this.type?.clone()); } + + margin(dataLen: NumLike = 0): Num { + let margin = + this.capacity - + fixedPointFrom(this.occupiedSize) - + fixedPointFrom(numFrom(dataLen)); + if (margin < Zero) { + margin = Zero; + } + return margin; + } } export const CellOutputVec = mol.vector(CellOutput); @@ -1940,6 +1949,14 @@ export class Transaction extends mol.Entity.Base< }, numFrom(0)); } + getOutputCapacityMargin(index: number): Num { + const output = this.outputs[index]; + if (output === undefined) { + return Zero; + } + return output.margin(bytesFrom(this.outputsData[index] ?? "0x").length); + } + async completeInputs( from: Signer, filter: ClientCollectableSearchKeyFilterLike, @@ -1954,40 +1971,14 @@ export class Transaction extends mol.Entity.Base< addedCount: number; accumulated?: T; }> { - const collectedCells = []; - - let acc: T = init; - let fulfilled = false; - for await (const cell of from.findCells(filter, true)) { - if ( - this.inputs.some(({ previousOutput }) => - previousOutput.eq(cell.outPoint), - ) - ) { - continue; - } - const i = collectedCells.push(cell); - const next = await Promise.resolve( - accumulator(acc, cell, i - 1, collectedCells), - ); - if (next === undefined) { - fulfilled = true; - break; - } - acc = next; - } - - collectedCells.forEach((cell) => this.addInput(cell)); - if (fulfilled) { - return { - addedCount: collectedCells.length, - }; - } - - return { - addedCount: collectedCells.length, - accumulated: acc, - }; + const { addedCount, accumulated } = await from.completeInputs( + this, + from.client, + filter, + accumulator, + init, + ); + return { addedCount, accumulated }; } async completeInputsByCapacity( @@ -1995,33 +1986,15 @@ export class Transaction extends mol.Entity.Base< capacityTweak?: NumLike, filter?: ClientCollectableSearchKeyFilterLike, ): Promise { - const expectedCapacity = - this.getOutputsCapacity() + numFrom(capacityTweak ?? 0); - const inputsCapacity = await this.getInputsCapacity(from.client); - if (inputsCapacity >= expectedCapacity) { - return 0; - } - - const { addedCount, accumulated } = await this.completeInputs( - from, - filter ?? { - scriptLenRange: [0, 1], - outputDataLenRange: [0, 1], - }, - (acc, { cellOutput: { capacity } }) => { - const sum = acc + capacity; - return sum >= expectedCapacity ? undefined : sum; + const addedCount = await from.completeInputsByCapacity( + this, + from.client, + capacityTweak, + { + filter, }, - inputsCapacity, - ); - - if (accumulated === undefined) { - return addedCount; - } - - throw new ErrorTransactionInsufficientCapacity( - expectedCapacity - accumulated, ); + return addedCount; } async completeInputsAll( @@ -2208,101 +2181,13 @@ export class Transaction extends mol.Entity.Base< shouldAddInputs?: boolean; }, ): Promise<[number, boolean]> { - const feeRate = - expectedFeeRate ?? - (await from.client.getFeeRate(options?.feeRateBlockRange, options)); - - // Complete all inputs extra infos for cache - await this.getInputsCapacity(from.client); - - let leastFee = Zero; - let leastExtraCapacity = Zero; - let collected = 0; - - // === - // Usually, for the worst situation, three iterations are needed - // 1. First attempt to complete the transaction. - // 2. Not enough capacity for the change cell. - // 3. Fee increased by the change cell. - // === - while (true) { - collected += await (async () => { - if (!(options?.shouldAddInputs ?? true)) { - return 0; - } - - try { - return await this.completeInputsByCapacity( - from, - leastFee + leastExtraCapacity, - filter, - ); - } catch (err) { - if ( - err instanceof ErrorTransactionInsufficientCapacity && - leastExtraCapacity !== Zero - ) { - throw new ErrorTransactionInsufficientCapacity(err.amount, { - isForChange: true, - }); - } - - throw err; - } - })(); - - const fee = await this.getFee(from.client); - if (fee < leastFee + leastExtraCapacity) { - // Not enough capacity are collected, it should only happens when shouldAddInputs is false - throw new ErrorTransactionInsufficientCapacity( - leastFee + leastExtraCapacity - fee, - { isForChange: leastExtraCapacity !== Zero }, - ); - } - - await from.prepareTransaction(this); - if (leastFee === Zero) { - // The initial fee is calculated based on prepared transaction - // This should only happens during the first iteration - leastFee = this.estimateFee(feeRate); - } - // The extra capacity paid the fee without a change - // leastExtraCapacity should be 0 here, otherwise we should failed in the previous check - // So this only happens in the first iteration - if (fee === leastFee) { - return [collected, false]; - } - - // Invoke the change function on a transaction multiple times may cause problems, so we clone it - const tx = this.clone(); - const needed = numFrom(await Promise.resolve(change(tx, fee - leastFee))); - if (needed > Zero) { - // No enough extra capacity to create new cells for change, collect inputs again - leastExtraCapacity = needed; - continue; - } - - if ((await tx.getFee(from.client)) !== leastFee) { - throw new Error( - "The change function doesn't use all available capacity", - ); - } - - // New change cells created, update the fee - await from.prepareTransaction(tx); - const changedFee = tx.estimateFee(feeRate); - if (leastFee > changedFee) { - throw new Error("The change function removed existed transaction data"); - } - // The fee has been paid - if (leastFee === changedFee) { - this.copy(tx); - return [collected, true]; - } - - // The fee after changing is more than the original fee - leastFee = changedFee; - } + const result = await from.completeFee(this, from.client, { + changeFn: change, + feeRate: expectedFeeRate, + filter, + options, + }); + return result; } /** @@ -2410,6 +2295,22 @@ export class Transaction extends mol.Entity.Base< return this.completeFeeChangeToLock(from, script, feeRate, filter, options); } + async completeByFeePayer( + client: Client, + ...feePayers: FeePayer[] + ): Promise { + let tx = this.clone(); + for (const feePayer of feePayers) { + tx = await feePayer.prepareTransaction(tx); + } + + for (const feePayer of feePayers) { + await feePayer.completeTxFee(tx, client); + } + + this.copy(tx); + } + /** * Completes the transaction fee by adding excess capacity to an existing output. * Instead of creating a new change output, this method adds any excess capacity diff --git a/packages/core/src/signer/feePayer/feePayer.ts b/packages/core/src/signer/feePayer/feePayer.ts new file mode 100644 index 00000000..aa51ac62 --- /dev/null +++ b/packages/core/src/signer/feePayer/feePayer.ts @@ -0,0 +1,53 @@ +import { Transaction, TransactionLike } from "../../ckb/index.js"; +import { Client } from "../../client/client.js"; +import { Num, NumLike, numFrom } from "../../num/index.js"; + +export interface FeeRateOptions { + feeRate?: NumLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + }; +} + +export abstract class FeePayer { + constructor() {} + + abstract completeTxFee( + tx: Transaction, + client: Client, + options?: FeeRateOptions, + ): Promise; + + /** + * Prepares a transaction before signing. + * This method can be overridden by subclasses to perform any necessary steps, + * such as adding cell dependencies or witnesses, before the transaction is signed. + * The default implementation converts the {@link TransactionLike} object to a {@link Transaction} object + * without modification. + * + * @remarks + * Note that this default implementation does not add any cell dependencies or dummy witnesses. + * This may lead to an underestimation of transaction size and fees if used with methods + * like `Transaction.completeFee`. Subclasses for signers that are intended to sign + * transactions should override this method to perform necessary preparations. + * + * @param tx - The transaction to prepare. + * @returns A promise that resolves to the prepared {@link Transaction} object. + */ + async prepareTransaction(tx: TransactionLike): Promise { + return Transaction.from(tx); + } + + static async getFeeRate( + client: Client, + options?: FeeRateOptions, + ): Promise { + return options?.feeRate + ? numFrom(options.feeRate) + : await client.getFeeRate( + options?.options?.feeRateBlockRange, + options?.options, + ); + } +} diff --git a/packages/core/src/signer/feePayer/feePayerFromAddress.ts b/packages/core/src/signer/feePayer/feePayerFromAddress.ts new file mode 100644 index 00000000..60c9a3d6 --- /dev/null +++ b/packages/core/src/signer/feePayer/feePayerFromAddress.ts @@ -0,0 +1,331 @@ +import { Address } from "../../address/index.js"; +import { Script } from "../../ckb/script.js"; +import { Cell, CellOutput, Transaction } from "../../ckb/transaction.js"; +import { ErrorTransactionInsufficientCapacity } from "../../ckb/transactionErrors.js"; +import { Client } from "../../client/client.js"; +import { ClientCollectableSearchKeyFilterLike } from "../../client/clientTypes.advanced.js"; +import { fixedPointFrom, Zero } from "../../fixedPoint/index.js"; +import { Num, numFrom, NumLike } from "../../num/index.js"; +import { FeePayer, FeeRateOptions } from "./feePayer.js"; + +function defaultChangeFn( + tx: Transaction, + changeScript: Script, + capacity: Num, +): NumLike { + const changeCell = CellOutput.from({ capacity: 0, lock: changeScript }); + const occupiedCapacity = fixedPointFrom(changeCell.occupiedSize); + if (capacity < occupiedCapacity) { + return occupiedCapacity; + } + changeCell.capacity = capacity; + tx.addOutput(changeCell); + return 0; +} + +export interface FeePayerFromAddressOptions { + changeFn?: (tx: Transaction, capacity: Num) => Promise | NumLike; + feeRate?: NumLike; + filter?: ClientCollectableSearchKeyFilterLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + shouldAddInputs?: boolean; + }; +} + +export abstract class FeePayerFromAddress extends FeePayer { + changeFn?: (tx: Transaction, capacity: Num) => Promise | NumLike; + feeRate?: NumLike; + filter?: ClientCollectableSearchKeyFilterLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + shouldAddInputs?: boolean; + }; + + /** + * 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()), + ); + } + + async completeTxFee( + tx: Transaction, + client: Client, + options?: FeeRateOptions, + ): Promise { + await this.completeFee(tx, client, options); + } + + mergeOptions( + manualOptions?: FeePayerFromAddressOptions, + ): FeePayerFromAddressOptions { + return { + changeFn: manualOptions?.changeFn ?? this.changeFn, + feeRate: manualOptions?.feeRate ?? this.feeRate, + filter: manualOptions?.filter ?? this.filter, + options: manualOptions?.options ?? this.options, + }; + } + + setOptionalProperties(props: { + changeFn?: (tx: Transaction, capacity: Num) => Promise | NumLike; + feeRate?: NumLike; + filter?: ClientCollectableSearchKeyFilterLike; + options?: { + feeRateBlockRange?: NumLike; + maxFeeRate?: NumLike; + shouldAddInputs?: boolean; + }; + }): void { + this.changeFn = props.changeFn; + this.feeRate = props.feeRate; + this.filter = props.filter; + this.options = props.options; + } + + async completeFee( + tx: Transaction, + client: Client, + options?: FeePayerFromAddressOptions, + ): Promise<[number, boolean]> { + const mergedOptions = this.mergeOptions(options); + + // Get fee rate at first + const feeRate = await FeePayer.getFeeRate(client, mergedOptions); + + // Complete all inputs extra infos for cache + await tx.getInputsCapacity(client); + + 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 (!(mergedOptions.options?.shouldAddInputs ?? true)) { + return 0; + } + + try { + return await this.completeInputsByCapacity( + tx, + client, + leastFee + leastExtraCapacity, + options, + ); + } catch (err) { + if ( + err instanceof ErrorTransactionInsufficientCapacity && + leastExtraCapacity !== Zero + ) { + throw new ErrorTransactionInsufficientCapacity(err.amount, { + isForChange: true, + }); + } + + throw err; + } + })(); + + const fee = await tx.getFee(client); + if (fee < leastFee + leastExtraCapacity) { + // Not enough capacity are collected, it should only happens when shouldAddInputs is false + throw new ErrorTransactionInsufficientCapacity( + 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 [collected, false]; + } + + // 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( + mergedOptions.changeFn?.(txCopy, fee - leastFee) ?? + defaultChangeFn( + txCopy, + (await this.getRecommendedAddressObj()).script, + 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(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) { + tx.copy(txCopy); + return [collected, true]; + } + + // The fee after changing is more than the original fee + leastFee = changedFee; + } + } + + async completeInputsByCapacity( + tx: Transaction, + client: Client, + capacityTweak?: NumLike, + options?: FeePayerFromAddressOptions, + ): Promise { + const expectedCapacity = + tx.getOutputsCapacity() + numFrom(capacityTweak ?? 0); + const inputsCapacity = await tx.getInputsCapacity(client); + if (inputsCapacity >= expectedCapacity) { + return 0; + } + + const mergedOptions = this.mergeOptions(options); + const { addedCount, accumulated } = await this.completeInputs( + tx, + client, + mergedOptions.filter ?? { + scriptLenRange: [0, 1], + outputDataLenRange: [0, 1], + }, + (acc, { cellOutput: { capacity } }) => { + const sum = acc + capacity; + return sum >= expectedCapacity ? undefined : sum; + }, + inputsCapacity, + ); + + if (accumulated === undefined) { + return addedCount; + } + + throw new ErrorTransactionInsufficientCapacity( + expectedCapacity - accumulated, + ); + } + + async completeInputs( + tx: Transaction, + client: Client, + filter: ClientCollectableSearchKeyFilterLike, + accumulator: ( + acc: T, + v: Cell, + i: number, + array: Cell[], + ) => Promise | T | undefined, + init: T, + ): Promise<{ + addedCount: number; + accumulated?: T; + }> { + const collectedCells = []; + + let acc: T = init; + let fulfilled = false; + for (const address of await this.getAddressObjs()) { + for await (const cell of client.findCells({ + script: address.script, + scriptType: "lock", + filter, + scriptSearchMode: "exact", + withData: true, + })) { + if ( + tx.inputs.some(({ previousOutput }) => + previousOutput.eq(cell.outPoint), + ) + ) { + continue; + } + const i = collectedCells.push(cell); + const next = await Promise.resolve( + accumulator(acc, cell, i - 1, collectedCells), + ); + if (next === undefined) { + fulfilled = true; + break; + } + acc = next; + } + if (fulfilled) { + break; + } + } + + collectedCells.forEach((cell) => tx.addInput(cell)); + if (fulfilled) { + return { + addedCount: collectedCells.length, + }; + } + + return { + addedCount: collectedCells.length, + accumulated: acc, + }; + } +} diff --git a/packages/core/src/signer/feePayer/feePayerGroup.ts b/packages/core/src/signer/feePayer/feePayerGroup.ts new file mode 100644 index 00000000..0c19b408 --- /dev/null +++ b/packages/core/src/signer/feePayer/feePayerGroup.ts @@ -0,0 +1,36 @@ +import { Transaction, TransactionLike } from "../../ckb/transaction.js"; +import { Client } from "../../client/client.js"; +import { FeePayer, FeeRateOptions } from "./feePayer.js"; + +export class FeePayerGroup extends FeePayer { + constructor(private feePayers: FeePayer[]) { + super(); + } + + push(...payers: FeePayer[]): FeePayerGroup { + this.feePayers.push(...payers); + return this; + } + + pop(): FeePayer | undefined { + return this.feePayers.pop(); + } + + async prepareTransaction(txLike: TransactionLike): Promise { + let tx = Transaction.from(txLike); + for (const payer of this.feePayers) { + tx = await payer.prepareTransaction(tx); + } + return tx; + } + + async completeTxFee( + tx: Transaction, + client: Client, + options?: FeeRateOptions, + ): Promise { + for (const payer of this.feePayers) { + await payer.completeTxFee(tx, client, options); + } + } +} diff --git a/packages/core/src/signer/feePayer/index.ts b/packages/core/src/signer/feePayer/index.ts new file mode 100644 index 00000000..0afca899 --- /dev/null +++ b/packages/core/src/signer/feePayer/index.ts @@ -0,0 +1,3 @@ +export * from "./feePayer.js"; +export * from "./feePayerFromAddress.js"; +export * from "./feePayerGroup.js"; diff --git a/packages/core/src/signer/index.ts b/packages/core/src/signer/index.ts index 350b487e..9657b263 100644 --- a/packages/core/src/signer/index.ts +++ b/packages/core/src/signer/index.ts @@ -3,5 +3,6 @@ export * from "./ckb/index.js"; export * from "./doge/index.js"; export * from "./dummy/index.js"; export * from "./evm/index.js"; +export * from "./feePayer/index.js"; export * from "./nostr/index.js"; export * from "./signer/index.js"; diff --git a/packages/core/src/signer/signer/index.ts b/packages/core/src/signer/signer/index.ts index 1522b335..8869fc7c 100644 --- a/packages/core/src/signer/signer/index.ts +++ b/packages/core/src/signer/signer/index.ts @@ -1,4 +1,3 @@ -import { Address } from "../../address/index.js"; import { ClientCollectableSearchKeyFilterLike } from "../../advancedBarrel.js"; import { BytesLike } from "../../bytes/index.js"; import { Cell, Transaction, TransactionLike } from "../../ckb/index.js"; @@ -15,6 +14,7 @@ import { verifyMessageCkbSecp256k1 } from "../ckb/verifyCkbSecp256k1.js"; import { verifyMessageJoyId } from "../ckb/verifyJoyId.js"; import { verifyMessageDogeEcdsa } from "../doge/verify.js"; import { verifyMessageEvmPersonal } from "../evm/verify.js"; +import { FeePayerFromAddress } from "../feePayer/feePayerFromAddress.js"; import { verifyMessageNostrEvent } from "../nostr/verify.js"; /** @@ -78,8 +78,10 @@ export class Signature { * This class provides methods to connect, get addresses, and sign transactions. * @public */ -export abstract class Signer { - constructor(protected client_: Client) {} +export abstract class Signer extends FeePayerFromAddress { + constructor(protected client_: Client) { + super(); + } abstract get type(): SignerType; abstract get signType(): SignerSignType; @@ -203,44 +205,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 +423,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. *