LIP: 0048
Title: Introduce Fee module
Author: Maxime Gagnebin <maxime.gagnebin@lightcurve.io>
Mitsuaki Uchimoto <mitsuaki.uchimoto@lightcurve.io>
Discussions-To: https://research.lisk.com/t/introduce-fee-module/318
Status: Active
Type: Standards Track
Created: 2021-08-09
Updated: 2024-01-04
Requires: 0051
The Fee module is responsible for handling the fee of transactions. It allows chains to choose the token used to pay the fee and to define a minimum fee for transactions to be valid.
This LIP is licensed under the Creative Commons Zero 1.0 Universal.
This LIP defines the fee system in a modular way, as currently used in the Lisk ecosystem. The fee handling is implemented in a separate module to allow sidechains to freely update or replace the fee handling module, possibly to implement a more complex fee structure, without needing to modify or update the Token module.
Blockchain transaction fees are instrumental for various reasons, including:
- Paying for state growth, node computation, and blockspace utilization.
- Preventing spamming a blockchain with unlimited zero-cost transactions.
- Incentivizing validators who operate the network and maintain its security, by providing them with a part of collected fees.
Consequently, each transaction in the Lisk ecosystem has a transaction fee associated with it, as defined by the fee
property of the transaction schema in LIP 0068. Each chain can configure the token used to pay fees, as defined by TOKEN_ID_FEE
. On the Lisk mainchain, the token used for transaction fees is the LSK token.
The fee system proposed here automates the calculation of blockspace fees using a minimum fee approach: The transaction fee should be greater than or equal to a threshold that depends on the size of the transaction. Fees associated to other factors, such as state growth or computational effort, are determined by the corresponding parts of the protocol, e.g., fee for registering a validator in the PoS module. The fee module manages these additional fees through the payFee
method.
As introduced in LIP 0013, all transactions must have a transaction fee that is at least equal to a minimum fee. This fee corresponds to the cost of using blockspace and is therefore dependent on the size of the transaction, with the proportionality constant of MIN_FEE_PER_BYTE
:
trsSize = length(encodeTransaction(trs))
minFee = MIN_FEE_PER_BYTE * trsSize
The minimum fee is always subtracted from the part of the fee that goes to the block generator, as we explain below. This way, block generators do not have the option to include zero-cost transactions in the blocks they generate, unless minimum fee is set to zero.
Note that chains can freely configure MIN_FEE_PER_BYTE
, which can even be set to zero. The MIN_FEE_PER_BYTE
value cannot be changed, unless a hard fork occurs. For this reason, we allow sidechains to define a period of MAX_BLOCK_HEIGHT_ZERO_FEE_PER_BYTE
blocks after the genesis block, for which the minimum fee does not apply.
This could, for example, be beneficial for sidechains that want to use LSK as the fee token. Initially, there is no LSK on the sidechain, so the sidechain cannot process transactions unless MIN_FEE_PER_BYTE
is set to zero, which is generally not desirable since it essentially disables the feature of minimum fee for the chain. By granting an initial exemption period of MAX_BLOCK_HEIGHT_ZERO_FEE_PER_BYTE
blocks, users have a window of time to transfer LSK tokens to the sidechain, after which the initially set value of MIN_FEE_PER_BYTE
would start being applied.
In principle, any transaction that results in a state increase or significant computational effort could require a fee. Since the current implementation does not provide an automatic way to charge fees in these cases, we introduce the payFee
method.
This method can be invoked by other modules to charge an additional fee when a state increase is triggered during the execution of a transaction, e.g., when a new validator is registered in the PoS module, a new sidechain is registered, or a user account is initialized in the Token module:
Fee.payFee(USER_ACCOUNT_INITIALIZATION_FEE)
In addition, developers may decide to call the payFee
method for transactions that require extensive computation, e.g., when verifying an inclusion proof, or if any other resource is extensively used during transaction execution.
We refer to the sum of the minimum fee and the additional fees processed by the payFee
method as the "consumed" part of the fee. The remainder of the fee, called the "available" part of the fee, is transferred to the validator as a reward. In this way, validators are assumed to prioritize transactions with a higher available fee in case of network congestion. After the transaction is executed, consumed fees are either burned or transferred to a dedicated pool address.
The option to transfer the consumed fees to a dedicated pool address ADDRESS_FEE_POOL
could be beneficial for sidechains, as fees collected this way can be used as an incentive for validators and other protocol participants for various purposes: Funding a community pool for on-chain governance spending, sharing funds proportionally among validators per round, incentivizing ecosystem developers, supporting chain maintenance and improvement, rewarding users' staking and voting activities, etc.
In the Lisk mainchain, the consumed fees are always burned. This reduces the total supply, creating a deflationary pressure that works against inflation.
Cross-chain messages (CCMs) facilitate interoperability by enabling cross-chain transfer of data. Each CCM contains a message fee, intended to cover the cost of transaction processing on the receiving chain (see LIP 0049).
Since managing message fees requires checking and updating the escrow balances for the message fee token, this task is assigned to the Token module whose payMessageFee
method charges message fees in the sending chain. In the receiving chain, the Token module assigns the message fee to the relayer account and updates escrows if necessary, and then the Fee module handles the message fee processing in a similar way as it handles transaction fees.
For protocol reasons (see, e.g., bounce
and terminateChain
methods in LIP 0045), the Interoperability module allows the message fee to be zero. Consequently, the choice of setting a minimum message fee is delegated to each module that provides its own cross-chain commands. Nevertheless, there is a minimum fee in case the user wants the failed CCM to be returned (see bounce
method in LIP 0045). Thus, even though this fee is not mandatory, it is advisable to treat it as a suggested minimum fee. Note also that the CCM sender should include any additional fees necessary for the CCM execution, e.g., to pay for the user account initialization on the receiving chain.
Processing of message fees is similar to that of transaction fees. The only essential difference is that the consumed part of the message fee contains only the so-called additional fees, as there is no mandatory minimum fee. The consumed part is handled in the same way as for transactions, i.e., it is either burned or transferred to a fee pool, depending on the chain configuration.
The available part of the message fee is left to the relayer to cover the cost of CCU execution (recall that a CCU is a regular transaction, therefore the relayer has to pay transaction fees for posting the CCU in the receiving chain), as well as to incentivize its participation in CCM relaying.
Finally, note that, in contrast to including transactions in a block, a relayer cannot prioritize CCMs with higher fees as the interoperability protocol requires them to be sent in order. This implies that users have no reason to pay higher message fees than are required for successful CCM execution. As a consequence, users understand that CCMs will be delivered as soon as a relayer decides to post the CCU in the target chain, eliminating any concerns about potential delays due to priority-related reasons.
We define the following constants:
Name | Type | Value | Description |
---|---|---|---|
General Constants | |||
MODULE_NAME_FEE |
string | "fee" | Module name of the Fee module. |
EVENT_NAME_GENERATOR_FEE_PROCESSED |
string | "generatorFeeProcessed" | Event name of the GeneratorFeeProcessed event. |
EVENT_NAME_RELAYER_FEE_PROCESSED |
string | "relayerFeeProcessed" | Event name of the RelayerFeeProcessed event. |
EVENT_NAME_INSUFFICIENT_FEE |
string | "insufficientFee" | Event name of the InsufficientFee event. |
LENGTH_ADDRESS |
uint32 | 20 | The length of an address in bytes. |
LENGTH_TOKEN_ID |
uint32 | 8 | The length of a token ID in bytes. |
Configurable Constants | Mainchain Value | ||
MIN_FEE_PER_BYTE |
uint64 | 1000 | Minimum amount of fee per byte required for transaction validity. |
MAX_BLOCK_HEIGHT_ZERO_FEE_PER_BYTE |
uint32 | 0 | Block height ending the MIN_FEE_PER_BYTE = 0 initial period. |
TOKEN_ID_FEE |
bytes | OWN_CHAIN_ID[0:1] + '00000000000000' |
Token ID of the token used to pay the transaction fees. |
ADDRESS_FEE_POOL |
bytes | None | Address of the fee pool. |
We use the definition of the following types:
Name | Type | Validation | Description |
---|---|---|---|
TokenID |
bytes | Must be of length LENGTH_TOKEN_ID . |
Used for token identifiers. |
Furthermore, for the rest of this LIP we indicate with ctx
the execution context which is passed as extra input to each method call.
Calling a function fct
from another module (named module
) is represented by module.fct(required inputs)
.
The Fee module does not store information in the state.
The Fee module does not contain any commands.
This event has name name = EVENT_NAME_GENERATOR_FEE_PROCESSED
. The event is emitted when a transaction fee is assigned to the generator. Event's data includes the amount of burnt fee tokens and the amount of fee tokens paid to the block generator.
senderAddress
: the address of the account paying the fee.generatorAddress
: the address of the generator of the block receiving the fee.
generatorFeeProcessedEventDataSchema = {
"type": "object",
"required": ["senderAddress", "generatorAddress", "requiredAmount", "generatorAmount"],
"properties": {
"senderAddress": {
"dataType": "bytes",
"length": LENGTH_ADDRESS,
"fieldNumber": 1
},
"generatorAddress": {
"dataType": "bytes",
"length": LENGTH_ADDRESS,
"fieldNumber": 2
},
"requiredAmount": {
"dataType": "uint64",
"fieldNumber": 3
},
"generatorAmount": {
"dataType": "uint64",
"fieldNumber": 4
}
}
}
This event has name name = EVENT_NAME_RELAYER_FEE_PROCESSED
. The event is emitted when a message fee is assigned to the CCU relayer. Event's data includes the amount of burnt fee tokens and the amount of fee tokens paid to the relayer.
relayerAddress
: the address of the relayer.
relayerFeeProcessedEventDataSchema = {
"type": "object",
"required": ["ccmID", "relayerAddress", "requiredAmount", "relayerAmount"],
"properties": {
"ccmID": {
"dataType": "bytes",
"length": LENGTH_HASH,
"fieldNumber": 1
},
"relayerAddress": {
"dataType": "bytes",
"length": LENGTH_ADDRESS,
"fieldNumber": 2
},
"requiredAmount": {
"dataType": "uint64",
"fieldNumber": 3
},
"relayerAmount": {
"dataType": "uint64",
"fieldNumber": 4
}
}
}
This event has name name = EVENT_NAME_INSUFFICIENT_FEE
. The event is emitted when there is not enough transaction or cross-chain message fee left.
This event has no extra topics (only the default transaction or cross-chain message ID).
insufficientFeeDataSchema = {
"type": "object",
"required": [],
"properties": {}
}
def payFee(amount: uint64) -> None:
# ctx.ccmProcessing is set by the Interoperability module during the CCM processing.
# In particular, before the CCM is processed, ctx.ccmProcessing is set to True.
# After the CCM is processed, ctx.ccmProcessing is set to False.
if ctx.ccmProcessing:
ctx.availableCCMFee -= amount
if ctx.availableCCMFee < 0:
ctx.availableCCMFee = 0
emitPersistentEvent(
module = MODULE_NAME_FEE,
name = EVENT_NAME_INSUFFICIENT_FEE,
data = {},
topics = []
)
raise Exception("Cross-chain message ran out of fee.")
else:
ctx.availableTransactionFee -= amount
if ctx.availableTransactionFee < 0:
ctx.availableTransactionFee = 0
emitPersistentEvent(
module = MODULE_NAME_FEE,
name = EVENT_NAME_INSUFFICIENT_FEE,
data = {},
topics = []
)
raise Exception("Transaction ran out of fee.")
def getFeeTokenID() -> TokenID:
return TOKEN_ID_FEE
This step is run for every transaction before it is submitted to the chain. See LIP 0055 for details of block processing.
def verify(trs: Transaction) -> None:
b = block including trs
h = b.header.height
if h < MAX_BLOCK_HEIGHT_ZERO_FEE_PER_BYTE:
# Set minFee = 0 for the first MAX_BLOCK_HEIGHT_ZERO_FEE_PER_BYTE blocks.
minFee = 0
else:
trsSize = length(encodeTransaction(trs))
minFee = MIN_FEE_PER_BYTE * trsSize
if trs.fee < minFee:
raise Exception("Insufficient transaction fee")
senderAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
if not Token.userSubstoreExists(senderAddress, TOKEN_ID_FEE):
raise Exception("Account not initialized")
if trs.fee > Token.getAvailableBalance(senderAddress, TOKEN_ID_FEE):
raise Exception("Insufficient balance")
def beforeCommandExecute(trs: Transaction) -> None:
senderAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
Token.lock(senderAddress, MODULE_NAME_FEE, TOKEN_ID_FEE, trs.fee)
minFee = MIN_FEE_PER_BYTE * len(encodeTransaction(trs))
ctx.availableTransactionFee = trs.fee - minFee
def afterCommandExecute(trs: Transaction) -> None:
let b be the block including trs
generatorAddress = b.header.generatorAddress
senderAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
# The fee paid to the generator is the difference between the fee paid by the sender and the paid fees.
Token.unlock(senderAddress, MODULE_NAME_FEE, TOKEN_ID_FEE, trs.fee)
if Token.userSubstoreExists(generatorAddress, TOKEN_ID_FEE):
Token.transfer(senderAddress, generatorAddress, TOKEN_ID_FEE, ctx.availableTransactionFee)
else:
ctx.availableTransactionFee = 0
burnConsumedFee = False if (ADDRESS_FEE_POOL is not None and Token.userSubstoreExists(ADDRESS_FEE_POOL, TOKEN_ID_FEE)) else True
if burnConsumedFee:
Token.burn(senderAddress, TOKEN_ID_FEE, trs.fee - ctx.availableTransactionFee)
else:
Token.transfer(senderAddress, ADDRESS_FEE_POOL, TOKEN_ID_FEE, trs.fee - ctx.availableTransactionFee)
emitEvent(
module = MODULE_NAME_FEE,
name = EVENT_NAME_GENERATOR_FEE_PROCESSED,
data = {
"senderAddress": senderAddress,
"generatorAddress": generatorAddress,
"requiredAmount": trs.fee - ctx.availableTransactionFee,
"generatorAmount": ctx.availableTransactionFee
},
topics = [senderAddress, generatorAddress]
)
ctx.availableTransactionFee = 0
def beforeCrossChainCommandExecution(trs: Transaction, ccm: CCM) -> None:
# The Token module handles checks on the ccm.fee validity
# with respect to the escrow account of the ccm sending chain.
messageFeeTokenID = Interoperability.getMessageFeeTokenIDFromCCM(ccm)
relayerAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
# The Token module beforeCrossChainCommandExecution needs to be called first
# to ensure that the relayer has enough funds.
Token.lock(relayerAddress, MODULE_NAME_FEE, messageFeeTokenID, ccm.fee)
ctx.availableCCMFee = ccm.fee
def afterCrossChainCommandExecution(trs: Transaction, ccm: CCM) -> None:
# The fee paid to the relayer is the difference between the message fee and the paid fees.
relayerAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
messageFeeTokenID = Interoperability.getMessageFeeTokenIDFromCCM(ccm)
Token.unlock(relayerAddress, MODULE_NAME_FEE, messageFeeTokenID, ccm.fee)
burnConsumedFee = False if (ADDRESS_FEE_POOL is not None and Token.userSubstoreExists(ADDRESS_FEE_POOL, messageFeeTokenID)) else True
if burnConsumedFee:
Token.burn(relayerAddress, messageFeeTokenID, ccm.fee - ctx.availableCCMFee)
else:
# Transfer the cross-chain message fees to the pool account address, if it exists and it is initialized for the cross-chain message fee token.
Token.transfer(relayerAddress, ADDRESS_FEE_POOL, messageFeeTokenID, ccm.fee - ctx.availableCCMFee)
# No need to transfer as the remaining fee is already in the relayer account.
ccmID = sha256(encodeCCM(ccm))
emitEvent(
module = MODULE_NAME_FEE,
name = EVENT_NAME_RELAYER_FEE_PROCESSED,
data = {
"ccmID": ccmID,
"relayerAddress": relayerAddress,
"requiredAmount": ccm.fee - ctx.availableCCMFee,
"relayerAmount": ctx.availableCCMFee
},
topics = [relayerAddress]
)
ctx.availableCCMFee = 0
This module does not have any non-trivial or recommended endpoints for off-chain services.
This LIP defines a new Fee module, which follows the same protocol as currently implemented. Changing the implementation to include the Fee module will be backwards compatible.