feat: Introduce `FeePayer` concept for flexible transaction fee handling
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:
-
Define the
FeePayerInterface A newFeePayerinterface will be created. It will define the methods required to complete a transaction, establishing a standard for fee payment logic. The existingSignerinterface will be updated to implement this new interface. -
Refactor the
TransactionClass for Compatibility The methods in theTransactionclass responsible for fee calculation and completion (e.g.,complete) will be updated. The parameter that currently accepts aSignerwill be changed to accept aFeePayer.This change is fully backward-compatible and will not break existing code. Since the
Signerinterface will implement the newFeePayerinterface, any existing code that passes aSignerto these methods will continue to work seamlessly. This polymorphic approach avoids the need for new parameters or conditional logic, resulting in a cleaner API. -
SignerProvides the Default Behavior By implementing theFeePayerinterface, theSignerwill continue to provide the default fee payment behavior. Its implementation will be consistent with the current logic, using the CKB from theSigner's own address to pay the fee. -
Support for Custom
FeePayers With this new interface, developers can easily implement diverse fee payment strategies. For example, aSporeFeePayercould be created. Its logic would be to find a specific Spore asset in the transaction and use the surplusCapacityfrom that Spore's cell to cover the transaction fee.
Affected Codebase
-
packages/core/src/ckb/transaction.ts: Will need to be refactored so that itscompletemethod accepts aFeePayer. -
packages/core/src/signer/index.ts: TheSignerinterface will be updated to implementFeePayer. - 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.
Related to #241
There is some convergence of ideas with this proposal:
https://talk.nervos.org/t/udt-payment-solutions/8956
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:
- User needs some small liquidity (either iCKB to CKB or CKB to iCKB )
- Bot(s) already has on-chain funds wrapped in Dual Ratio LO
- 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
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:
- In the core module of CCC, we write a fallback FeePayer, which is just calling
completeFeeBymethod as the final balancer for every incomplete transaction. - 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.
- 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
- 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:
- No more changes on current implementation of Transaction and Signer.
- Fully compatible with older versions of CCC.
- Good extensibility and programmability for outside FeePayer
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
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:
- Create a basic
FeePayerabstract class for every specific payers - Create a default successor, named
DefaultFeePayer, to accept all ofcomplete*related methods from transaction.ts - Make abstract class
Signeras an implementer ofDefaultFeePayerto automatically applyFeePayeridentity to all of current specific signers, like btc, ckb, and so on. - Replace implementations of above
complete*related functions with making use ofDefaultFeePayer, so that demand of back-compatibility is accomplished. - Other specific payers, like
MarginPayerfrom Spore andUdtPayerfrom UDT are also created by inheriting from basicFeePayerabstract class. - 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