ccc icon indicating copy to clipboard operation
ccc copied to clipboard

feat: Introduce `FeePayer` concept for flexible transaction fee handling

Open Hanssen0 opened this issue 5 months ago • 6 comments

Currently in ccc, the fee provider for a transaction is tightly coupled with the Signer. The Signer is not only responsible for signing the transaction but also implicitly acts as the fee provider, responsible for completing the transaction's inputs to cover the fee.

While this design works for many common scenarios, it lacks flexibility. We want to support more complex transaction scenarios, for instance, where the fee is not paid by the primary Signer's CKB, but by the extra CKB Capacity from a cell that contains an asset (like a Spore or mNFT).

The current architecture makes it difficult to implement such features because the fee-handling logic is encapsulated within the Transaction class and is strongly tied to the Signer's implementation, making it hard to extend.

Describe the solution you'd like

We propose introducing a new abstraction layer: FeePayer.

FeePayer is a concept dedicated to providing fees for a transaction and completing it (by populating inputs/outputs). Its core responsibility is to provide the necessary Capacity to pay the fee and to implement the corresponding transaction completion logic.

The specific changes are proposed as follows:

  1. Define the FeePayer Interface A new FeePayer interface will be created. It will define the methods required to complete a transaction, establishing a standard for fee payment logic. The existing Signer interface will be updated to implement this new interface.

  2. Refactor the Transaction Class for Compatibility The methods in the Transaction class responsible for fee calculation and completion (e.g., complete) will be updated. The parameter that currently accepts a Signer will be changed to accept a FeePayer.

    This change is fully backward-compatible and will not break existing code. Since the Signer interface will implement the new FeePayer interface, any existing code that passes a Signer to these methods will continue to work seamlessly. This polymorphic approach avoids the need for new parameters or conditional logic, resulting in a cleaner API.

  3. Signer Provides the Default Behavior By implementing the FeePayer interface, the Signer will continue to provide the default fee payment behavior. Its implementation will be consistent with the current logic, using the CKB from the Signer's own address to pay the fee.

  4. Support for Custom FeePayers With this new interface, developers can easily implement diverse fee payment strategies. For example, a SporeFeePayer could be created. Its logic would be to find a specific Spore asset in the transaction and use the surplus Capacity from that Spore's cell to cover the transaction fee.

Affected Codebase

  • packages/core/src/ckb/transaction.ts: Will need to be refactored so that its complete method accepts a FeePayer.
  • packages/core/src/signer/index.ts: The Signer interface will be updated to implement FeePayer.
  • All call sites for transaction.complete() will remain compatible due to polymorphism.

Conclusion

Introducing the FeePayer abstraction will greatly enhance the flexibility and extensibility of ccc, allowing us to support a broader and more innovative range of application scenarios. At the same time, through careful backward-compatible design, it ensures the stability of the existing API. This is a significant architectural improvement that will contribute to the long-term growth of the ccc ecosystem.

Hanssen0 avatar Aug 21 '25 04:08 Hanssen0

Related to #241

Hanssen0 avatar Aug 21 '25 04:08 Hanssen0

There is some convergence of ideas with this proposal:

https://talk.nervos.org/t/udt-payment-solutions/8956

phroi avatar Aug 28 '25 08:08 phroi

Let me also cross post this here from the aforementioned proposal comment:

An instant service idea for iCKB

I'll try to see if I can create some instant conversion service directly integrated with the iCKB bot, issue is usually partial matching tho :thinking:

Interestingly enough, iCKB Limit Order can be Dual Ratio Limit Orders, so basically they can Provide Liquidity to whoever matches them in both directions, similar to AMM, just the price is fixed.

If the bot keeps its funds in these Dual Ratio LO (as I initially foreseen). With high enough fees, price drifting over time between iCKB and CKB should not create issues.

We can achieve exactly the instant conversion service for iCKB:

  1. User needs some small liquidity (either iCKB to CKB or CKB to iCKB )
  2. Bot(s) already has on-chain funds wrapped in Dual Ratio LO
  3. User signs his transaction converting some liquidity using bot LO

Downsides to what's currently offered in iCKB:

  • Fees will be much higher to offset iCKB/CKB price drift over time, if bot has no activity for a period of time.
  • Only funds actually in the Dual Ratio LO are available (1M iCKB will not be instant).
  • LO state contention may require user to resign transaction (currently unlikely tho)
  • It needs some integration in CCC, similarly to the service presented in this proposal.

Love & Peace, Phroi

phroi avatar Sep 15 '25 09:09 phroi

As far as I investigate in CCC's transaction.ts' implementation, its completeFeemethod is like a typical dedicated one to balance an incomplete transaction, which is the core duty taken by theFeePayer`.

To the suggestion proposed from the udt-payment-solution, it introduced a financial balancing strategy that enlightens us there's an innovative way to complete a transaction only for UDT assets. From my perspective, this creative way can be seen as a specific derivative implementation under the principle of FeePayer layer.

So with all of above considerations, I'll treat the role of balancing a transaction, the balancer so called as FeePayer, is an outsourcer of transaction.ts's balancing responsibility, to be a metaphor.

As a result, the FeePayer acts like a set of balancing strategies running after the initialization of a new transaction, waiting for the final COMPLETE step. Back to the side of code, we can design a new parallel layer for FeePayer class, after the basic steps of transaction creation, we instance a set of fee payers to take advantage of them to balance this transaction, taking both instances of Transaction and Signer as import parameter, to replace the previous final step of calling completeFeeBy method.

If follow above implementation of FeePayer, it should be done by below steps:

  1. In the core module of CCC, we write a fallback FeePayer, which is just calling completeFeeBy method as the final balancer for every incomplete transaction.
  2. In spore module of CCC, we write a dedicated FeePayer to detect margin CKBytes in Spore cells and then use them as the fee providers.
  3. Also in the core module, we write a dedicated FeePayer as well like we did in Spore, it follows the financial balancing strategy I mentioned above to balance an UDT assets transaction
  4. At last, there also should be a container or manager of FeePayer, it accepts multiple fee payers to balance the transaction in order, calling our fallback one if transaction is still incomplete after all of payers.

Example code:

// current signer
const signer = new SignerCkbPrivateKey();

// spore creation transaction waiting for balancing
let tx = await spore.createSpore(signer, ...);

// instance the manager of fee payers
const marginBalancer = new spore.balancer.payFeeByMargin(); // can accept more parameters for functional tweaks
const feePayer = Balancer.manager([marginBalancer]);

// complete transaction
// note: if the provided transaction didn't contain spore cell with enough margin CKBytes, fallback FeePayer would be on call
tx = await feePayer.completeFee(tx, signer);

// sign
await signer.signTransaction(tx);

Advantages:

  1. No more changes on current implementation of Transaction and Signer.
  2. Fully compatible with older versions of CCC.
  3. Good extensibility and programmability for outside FeePayer

ashuralyk avatar Oct 22 '25 13:10 ashuralyk

To come up with a proper name, rather than FeePayer, which highly focused on Fee concept, I made a deeper consideration on this, and here is my recommendations:

Candidate 1: TxFinalizer

Considering we call it to finally complete a transaction to be ready to sign to, this call would be always happened at final step of transaction assembly, and its manipulating object is fundamentally on transaction itself, so I throw out a name TxFinalizer for easy comprehension for front-end users.

Candidate 2: SealHelper

If we see "Completion on a transaction" at final step as a metaphor of Seal, it's considerable to name it with SealHelper, which stands for the meaning about "A collection of helpers to help transaction seal".

Candidate 3: CellPayer

Cell is a platform that can hold CKBytes to be the fee for a transaction, and also it can implicate more objects not only on a single concept of fee, so cell is more general than fee in concept aspect.

Candidate 4: MarginPayer

Fee is like a margin on a transaction, but the latter would be more professional in sound, no matter what scenarios to provide fee for a transaction, they all act like making margin on it.

At last, we have more Suffix to use, for example, Supplier, Completer, Manipulator, Coordinator, Orchestrater, Transformer, Operator, Handler, etc. However, these are a bit complicated.

please review @Hanssen0

ashuralyk avatar Oct 24 '25 12:10 ashuralyk

To update with latest findings about FeePayer refactor discussion, we have made consensus on priorities between Signer and FeePayer, which is the former should be inherited from the latter.

After the analysis of current code base, I designed a refactor plan, which is described below:

  1. Create a basic FeePayer abstract class for every specific payers
  2. Create a default successor, named DefaultFeePayer, to accept all of complete* related methods from transaction.ts
  3. Make abstract class Signer as an implementer of DefaultFeePayer to automatically apply FeePayer identity to all of current specific signers, like btc, ckb, and so on.
  4. Replace implementations of above complete* related functions with making use of DefaultFeePayer, so that demand of back-compatibility is accomplished.
  5. Other specific payers, like MarginPayer from Spore and UdtPayer from UDT are also created by inheriting from basic FeePayer abstract class.
  6. Use a payer manager to be a collector of all payers that provides an unified entry to complete the transaction, like providing method, named CompleteTxFee, in which will call every single payers to complete transaction in order.

Following above steps, I thought a low-level FeePayer concept can be fully implemented in CCC. However, in step 4, I meet an obstacle wards me off, which is if we insist seeing FeePayer as in low-level layer, it cannot invoke methods from Signer, because the Signer is its successor, but when migrating complete* methods of transaction.ts, one of the methods will call method from Signer, here is the code, it's prepareTransaction.

So I feel like when completing a transaction as we did now in current code structure, methods from Signer are deeply engaged in, but while refactoring with introducing a new low-level layer of FeePayer, those methods are limited to use, it would be a paradox to us.

please analyse and leave your perspectives, calling @Hanssen0

ashuralyk avatar Oct 28 '25 15:10 ashuralyk