Fee system
In order to be accepted by the Namada ledger, transactions must pay fees. Transaction fees serve two purposes: first, the efficient allocation of block space and gas (which are scarce resources) given permissionless transaction submission and varying demand, and second, incentive-compatibility to encourage block producers to add transactions to the blocks that they create and publish.
Namada transaction fees can be paid in any fungible token that is a member of a whitelist controlled by Namada governance. Governance also sets minimum fee rates (which can be periodically updated so that they are usually sufficient) that transactions must pay in order to be accepted. However, transactions can always pay more to encourage the proposer to prioritize them. When using the shielded pool, transactions can also unshield tokens in order to pay the required fees.
The token whitelist consists of a list of pairs, where is a token identifier and is the minimum (base) price per unit gas that must be paid by a transaction paying fees using that asset. This whitelist can be updated with a standard governance proposal. All fees collected are paid directly to the block proposer (incentive-compatible, so that side payments are no more profitable).
Fee payment
The WrapperTx
struct holds all the data necessary for the payment of fees in
the form of the types Fee
, GasLimit
, and the PublicKey
used to derive the
address of the fee payer, which coincides with the signer of the wrapper
transaction itself.
Since fees have a purpose in allocating scarce block resources (space and gas
limit) they have to be paid upfront, as soon as the transaction is deemed valid
and accepted into a block (refer to
replay protection specs for more details
on transactions' validity). Moreover, for the same reasons, the fee payer will
pay for the entire GasLimit
allocated and not the actual gas consumed for the
transaction: this will incentivize fee payers to stick to a reasonable gas limit
for their transactions, allowing for the inclusion of more transactions into a
block.
Fees are not distributed among the validators who actively participate in the block validation process. This is because a tx submitter could be side-paying the block proposer for tx inclusion, which would prevent the correct distribution of fees among validators. The fair distribution of fees is enforced by the stake-proportional block proposer rotation policy of CometBFT.
By requesting an upfront payment, fees also serve as prevention against denial-of-service (DoS) attacks since the signer needs to pay for all the submitted transactions. More specifically, to serve as a DoS and spam prevention mechanism, the fee system needs to enforce:
- Successful payment at block inclusion time (implying the ability to check the good outcome at block creation time)
- Minimal payment overhead in terms of computation/memory requirements (otherwise fee payment itself could be exploited as a DoS vector)
The protocol executes the fee payment part of a transaction before any of the inner transactions that compose the batch. By doing this, we make sure to prevent inner transactions from draining the addresses of the funds needed to pay fees. The proposers will be able to check in advance that fee payers have enough unshielded funds and, if this is not the case, exclude the transaction from the block and leave it in the mempool for future inclusion. This behavior ultimately leads to more resource-optimized blocks.
As a drawback, this behavior could cause some inner txs to fail because funds have been moved to the block proposer as a fee payment for another transaction included earlier in the same block. This is somehow undesirable since inner transactions' execution should have priority over the wrapper. There are two ways to overcome this issue:
- Users are responsible for correctly timing/funding their transactions with the help of the wallet
- Namada forces in protocol that a block should execute transactions right after the fee payment operation
If we follow the second option the block proposers will no more be able to optimize the block (this would require running the inner transactions to calculate the possible new balance) and, inevitably, some wrapper transactions for which fees cannot be paid will end up in the block. These will be deemed invalid during validation so that the corresponding inner transaction will not be executed, preserving the correctness of the state machine, but it represents a slight underoptimization of the block and a potential vector for DoS attacks since the invalid wrapper has allocated space and gas in the block without being charged due to the lack of funds. Because of this, we stick to the first option.
Fees are collected via protocol for WrapperTx
s that have been processed with
success; this is to prevent a malicious block proposer from including
transactions that are known in advance to be invalid just to collect more fees.
Note that in this case we imply the correctness of the transaction's Header
, i.e. we make sure that this is correct with respect to the constraints that we impose on it, and we don't validate anything about the actual inner transactions that could end up failing.
Since a signer might submit more than one transaction per block, the
process_proposal
function needs to cache the updated balances to
correctly manage fees.
For every transaction in the block, if enough funds are available, these are deducted from the storage balances of the fee payers and directed to the balance of the block proposer. If instead, the balance is not enough to cover fees, then the entire proposed block is considered invalid and rejected, and a new CometBFT round is initiated.
From the consensus block proposer's address (included in the CometBFT request), it is possible to derive the relative Namada address for the payment.
The Fee
field of WrapperTx
is defined as follows:
pub struct Fee {
/// Amount of fees paid per gas unit.
pub amount_per_gas_unit: DenominatedAmount,
/// Address of the fee token.
pub token: Address,
}
The signer of the wrapper transaction defines the token in which fees must be
paid among those available in the token whitelist. At the same time, he also
sets the amount which must meet the minimum price per gas unit for that token,
(also defined in the whitelist). The difference between the minimum
and the actual value set by the submitter represents the incentive for the block
proposer to prefer the inclusion of this transaction over other ones. The block proposer can check the validity of these two parameters while
constructing the block. These validity checks are also replicated in process_proposal
and mempool_check
.
Since the whitelist can be changed via governance, transactions could fail these
checks in the block where the whitelist change happens. For mempool_check
, the
checks could reject transactions that may become valid in the future or
vice-versa: since we can assume a slow rate of change for these parameters and
mempool and block space optimizations are a priority, it is up to the clients to
track any changes in these parameters and act accordingly.
MASP fee payment
To provide improved data protection, Namada allows to unshield some funds on the go to cover the cost of the fee. This also addresses a possible locked-out problem in which a user doesn't have enough funds to pay fees (preventing any sort of operation on the chain).
When the transparent fee payment performed directly from the implicit address of the signer fails, the protocol tries to execute the first transaction of the batch: if this is a valid MASP transaction, it then reattempts to perform the same exact transparent fee payment operation. If it is successful, the transaction is accepted, otherwise it gets rejected (possibly the entire block is rejected if we are validating a new block). So, essentially, MASP fee payment involves allowing the transaction to unshield some tokens to the balance of the fee payer to cover the missing amount.
In case of an atomic batch the masp fee payment transaction will be committed even if the batch eventually fails. That is, all the state changes coming from the other inner transactions of the batch will be dropped (as per the default behavior of an atomic batch), but an exception will be made for the fee paying inner transaction that will instead be committed to storage to guarantee that fees are paid.
Since this operation comes before the actual payment, it can be exploited as a DoS vector. To prevent this, the protocol enforces a maximum gas limit that can be used for this operation. When the time comes, the protocol picks the gas limit for this operation as the minimum between the gas available to the transaction and the protocol parameter; if the transaction runs out of gas, it gets discarded. If instead it gets applied correctly, the gas meter of the transaction accounts for the gas used and the execution proceeds.
The spending key(s) associated with this operation could be relative to any address as long as the signature of the transfer itself is valid.
Governance proposals
Governance proposals may carry some WASM code to be executed in case the proposal passed. This code is embedded into a new transaction crafted directly by the validators at block processing time and is not inserted into the block itself. These transactions are exempt from fees and don't charge gas.
Protocol transactions
Protocol transactions can only be correctly crafted by validators and serve a role in allowing the chain to function properly. Thus, they are not subject to fees and do not charge gas.
Gas accounting
Gas must take into account the two scarce resources of a block: gas and space.
Regarding the space limit, Namada charges a fixed amount of gas per byte for every transaction.
The cost of a WASM tx/vp is given by the run time cost of it.
In addition to these, each inner transaction spends gas for loading the WASM module from storage, compilation costs (of both the tx and the associated, non-native, VPs) which are charged even if the compiled transactions was already available in cache, the calls to the exposed host functions and the sections of native vps that are more computationally complex.
To summarize, the gas for a given transaction can be computed as:
The runtime gas meter instruments WASM modules before their execution to charge gas based on the actual opcodes that get executed. The actual code that ends up running though, is an optimized version of the one declared in the WASM module. This leads to an excessive gas being charged for code that runs in the WASM context. The protocol compensates this by adjusting the non-WASM gas costs accordingly by a predefined factor.
Gas accounting is about preventing a transaction from exceeding two gas limits:
- Its own
GasLimit
(declared in the transaction's header) - The gas limit of the entire block
Tx GasLimit
The protocol injects a gas counter in each transaction and VP to be executed, which allows monitoring of the exact amount of gas utilized.
As soon as the gas limit defined in the transaction's header is exceeded, the transaction is immediately terminated and all the modifications applied to the WAL get discarded.
Block GasLimit
This constraint is given by the following two:
- The compliance of each inner transaction with the tx gas limit explained in the previous section
- The compliance of the cumulative transactions'
GasLimit
s with the maximum gas allowed for a block
CometBFT provides a BlockSize.MaxGas
parameter, and applies some optional
validation in mempool if this parameter is initialized. It doesn't instead
perform any check in consensus, leaving this task to the application itself (see
CometBFT app spec (opens in a new tab),
CometBFT spec (opens in a new tab)).
Therefore, instead of using the CometBFT provided param (and its mempool
validation), Namada introduces a MaxBlockGas
protocol parameter. This limit is
checked during mempool and block validation in process_proposal
: if the block
exceeds the maximum amount of gas allowed, the validators will reject it.
Checks
This section summarizes the checks performed in protocol.
Method | Checks | If check fails |
---|---|---|
CheckTx and ProcessProposal |
| Reject the block |
ProcessProposal |
| Reject the block |
FinalizeBlock |
| Reject the transaction and discard its modification to the state |