cip | title | author | discussions-to | status | type | category | created | license |
---|---|---|---|---|---|---|---|---|
66 |
New Transaction Type: Celo Denominated Fees |
Karl Bartel (@karlb) |
Draft |
Standards Track |
Ring 0 |
2024-03-05 |
Apache 2.0 |
Create a new EIP-2718 Transaction Type that will act as an EIP-1559 transaction but includes the feeCurrency
field for paying in a different currency. Contrary to previous fee currency tx types, the fee amounts are denominated in the native Celo token and not in the fee currency. This makes the tx type more similar to standard EIP-1559 txs and thereby increases compatibility with unmodified Ethereum tooling.
This CIP proposes to create a new EIP-2718 Transaction Type 0x7a
.
This new transaction is encoded as follows: 0x7a || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, feeCurrency, maxFeeInFeeCurrency, signatureYParity, signatureR, signatureS])
.
Compared to EIP-1559, the fields feeCurrency
and maxFeeInFeeCurrency
are added.
Compared to CIP-64, the field maxFeeInFeeCurrency
is added, and the unit for the fields maxPriorityFeePerGas
, maxFeePerGas
is changed from the fee currency to the native CELO token.
While CIP-66 txs will work like CIP-64 txs for end users in most cases, they provide multiple benefits.
Specifying the fees in CELO provides the same fee price accuracy for all fee currencies, even those with a low number of decimals. Some of the most popular ERC-20 tokens on Ethereum have only 6 decimals. That is enough to specify the total costs for a tx, but not sufficient to accurately specify the cost per unit of gas. E.g., for USDC, even the smallest possible maxFeePerGas
of 0.00001 USDC without a tip would make a native transfer cost 21000 * 0.00001 = 0.21 USDC.
The fields maxPriorityFeePerGas
, maxFeePerGas
and effectiveGasPrice
are specified in the native CELO token, just like it is done for EIP-1559 txs. In many cases, this allows unmodified Ethereum tooling to work on these txs without knowing about CIP-66 and still getting the correct result. In contracts, CIP-64 txs would be misinterpreted by the exchange rate between CELO and the fee currency.
Creating CIP-66 txs still requires explicit tooling support, though.
Data analysis queries, indexers and other tools often ask questions like "How much fees have been paid by account X", "What was the average tip?" or "What was the average cost of a tx?". When only looking at EIP-1559 (and to a certain degree legacy Ethereum-compatible txs) and CIP-66 txs, these questions can be answered without making any distinction between the different tx types, since they share the same fields with the same meaning. Although other tx types are in use now, this will likely change and make this a clear advantage in the long run.
Keeping the tx field meaning consistent with EIP-1559 also allows us to reduce the necessary changes in celo-blockchain compared to geth. This is most relevant in two places:
- The tx pool, where txs are sorted, replaced or discarded based on their fee values.
baseFee
handling andeffectiveGasPrice
calculation (see example diff)
As of FORK_BLOCK_NUMBER
, a new EIP-2718 transaction is introduced with TransactionType
122
.
TransactionType
122
⇒0x7a
. See EIP-2718||
is the byte/byte-array concatenation operator//
is integer division (discarding the remainder)feeCurrency
is the address of the whitelisted currency to be used to pay for gas. In contrast to older tx types, this field can not be omitted to mean "native CELO token".- The exchange rate from CELO to
feeCurrency
is specified as a rational number consisting of twouint256
:rateNum
/rateDen
feeInFeeCurrency
is the total amount of fees paid by the user denominated infeeCurrency
, sofeeInFeeCurrency = gasUsed * (baseFeePerGas + tipPerGas) * rateNum // rateDen
.debitInFeeCurrency
is the amount of tokens that would be spent if the wholegasLimit
is used up, thusdebitInFeeCurrency = maxFeePerGas * gasLimit * rateNum // rateDen
.maxFeeInFeeCurrency
is chosen by the tx sender to limit the exchange rate risk. Txs must only be included ifmaxFeeInFeeCurrency >= debitInFeeCurrency
.
The EIP-2718 TransactionPayload
for this transaction is 0x7a || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, feeCurrency, maxFeeInFeeCurrency, signatureYParity, signatureR, signatureS])
.
The signatureYParity, signatureR, signatureS
elements of this transaction represent a secp256k1 signature over keccak256(0x7a || rlp([chainId, nonce, maxPriorityFeePerGas, maxFeePerGas, gasLimit, to, value, data, accessList, feeCurrency, maxFeeInFeeCurrency]))
.
The EIP-2718 ReceiptPayload
for this transaction is rlp([status, cumulativeGasUsed, logsBloom, logs, feeInFeeCurrency])
.
The new feeInFeeCurrency
receipt field is also included in RPC responses for CIP-66 txs.
In the tx pool, CIP-66 txs are rejected if the CELO/feeCurrency exchange rate exceeds the limit implied by the maxFeeInFeeCurrency
. The exchange rate is acceptable if maxFeeInFeeCurrency >= debitInFeeCurrency
. The maxFeeInFeeCurrency
field is only used for this purpose, not for any other calculations.
As for all tx types, the tx pool also ensures that the sender can be debited successfully. For CIP-66 txs, this means ensuring that debitGasFees(debitInFeeCurrency)
can be successfully executed.
During the state transition, a CIP-66 tx is processed in the usual steps of debit gas fees, main tx execution and credit gas fees. While the main tx execution goes unchanged, the debiting and crediting are adapted to the CIP-66 fee requirements as shown in the following pseudocode:
# CIP-66 fee debit
debitInFeeCurrency = maxFeePerGas * gasLimit * rateNum // rateDen
assert maxFeeInFeeCurrency >= debitInFeeCurrency # ensured by tx pool check
feeCurrency.debitGasFees(debitInFeeCurrency)
# Normal tx handling, all values in native CELO token
gasUsed = evm.Call(sender, to, data, gasLimit, value)
tipPerGas = min(maxFeePerGas - baseFeePerGas, maxPriorityFeePerGas)
effectiveGasPrice = baseFeePerGas + tipPerGas
# CIP-66 specific fee credit
baseFeeInFeeCurrency = baseFeePerGas * gasUsed * rateNum // rateDen
tipInFeeCurrency = tipPerGas * gasUsed * rateNum // rateDen
feeInFeeCurrency = baseFeeInFeeCurrency + tipInFeeCurrency
refundInFeeCurrency = debitInFeeCurrency - feeInFeeCurrency
assert refundInFeeCurrency >= 0 # because `debitInFeeCurrency >= feeInFeeCurrency`, see "Invariants"
feeCurrency.creditGasFees(refundInFeeCurrency, tipInFeeCurrency, baseFeeInFeeCurrency)
maxFeeInFeeCurrency >= debitInFeeCurrency
is explicitly checked and failing transactions are discarded.
debitInFeeCurrency >= feeInFeeCurrency
is true because gasLimit >= gasUsed
, maxFeePerGas >= baseFeePerGas + tipPerGas
and doing the integer division twice instead of once can only lower the resulting fees. Here's the full proof:
debitInFeeCurrency = maxFeePerGas * gasLimit * rateNum // rateDen
⇒ >= (baseFeePerGas + tipPerGas) * gasLimit * rateNum // rateDen
⇒ >= (baseFeePerGas + tipPerGas) * gasUsed * rateNum // rateDen
⇒ >= (baseFeePerGas * gasUsed * rateNum // rateDen) + (tipPerGas * gasUsed * rateNum // rateDen)
⇔ >= baseFeeInFeeCurrency + tipInFeeCurrency
⇔ >= feeInFeeCurrency
The fields maxFeeInFeeCurrency
and feeInFeeCurrency
are meant to convey information about conversion rates (allowed conversion rates for the former and used rate for the latter). Actually encoding this information as conversion rate fields would not be straightforward, as only integer numbers are supported natively by the EVM. There are many ways to encode such information, but none is as accurate and easy to use as plain integer values for the resulting totals.
It must be obvious exactly how much has been paid in fees for a tx and since the fees are paid in feeCurrency
, the total fees for a tx must also be available denominated in that fee currency. Without modifying the receipt, the feeInFeeCurrency
could be derived from the tx, the base fee and the conversion rate at the beginning of the block.
But the conversion rate is stored in the block state, which gets pruned from non-archive nodes after a while. This would mean that feeInFeeCurrency
is returned via JSON RPC after the tx was executed, but then "disappears" from the JSON receipt after a while. This is both surprising (receipt responses should not change over time) and problematic, since it makes the exact fee amount unavailable. To avoid this, we chose to include the feeInFeeCurrency
in the RLP-encoded receipt.
While this introduces a difference to EIP-1559, the downsides are limited because
- Most tools access the receipts via JSON RPC, where the additional field won't do any harm.
- The field is hidden inside the EIP-2718 compliant receipt payload.
Considering that JSON RPC responses contain an effectiveGasPrice
field, it would be natural to add an effectiveGasPriceInFeeCurrency
field instead of the suggested feeInFeeCurrency
field. This does not work due to the limited accuracy of defining prices per gas in fee currency. For tokens with a low number of decimals, this could not accurately represent all relevant prices, thereby either limiting the allowed prices or misrepresenting the actually paid price.
This is analogous to the feeInFeeCurrency
decision in the previous section. Since txs already contain a maxFeePerGas
field, it would be nice to limit the exchange rate risk by specifying a maxFeePerGasInFeeCurrency
field. But per-gas values denominated in the fee currency won't be precise enough for fee currencies with a low number of decimals. Therefore, we use a maxFeeInFeeCurrency
field.
Previous fee currency tx types allowed omitting the feeCurrency
field, causing the fees to be paid in CELO like for an EIP-1559 tx. Disallowing this has the following advantages:
- Txs which don't need to specify a feeCurrency will be sent as EIP-1559 tx, staying closer to Ethereum
- Simpler code for both celo-blockchain and all kinds of tooling, since less cases have to be handled
The maxFeeInFeeCurrency
value is only intended to limit the exchange rate risk and should not have any other effects. If the tx sender decides to fully accept all exchange rate risk and sends maxFeeInFeeCurrency = maxInt
(which is strongly discouraged!) the tx should still be able to succeed. We already know that debitInFeeCurrency = maxFeePerGas * gasLimit * rateNum // rateDen
will be sufficient to pay for the tx, so debiting that amount is enough and there is no reason to increase the debit to maxFeeInFeeCurrency
.
As with every new transaction type, this needs to be added as part of a fork, and as it is a new transaction type, it shouldn't change any actual behaviour.
TODO
The exchange rate might change from the moment the user signed a transaction to the moment the transaction is included in the block. This means that the user might end up paying a different fee than what they were expecting (in terms of feeCurrency
, but not in terms of CELO). The risk is limited by the maxFeeInFeeCurrency
value, so the trade-off between exchange rate risk and chances of tx rejection is fully under the user's control.
With CIP-64, the same risk already exists, it is just less clearly visible. The fee currency's baseFeePerGas
is calculated for each currency by converting the CELO baseFeePerGas
to the respective currency based on the Oracle. The oracle result can change just before it is used to calculate the base fee. In this case, the risk is not limited by maxFeeInFeeCurrency
, but instead by maxFeePerGas
which can be exceeded if the oracle price changed strongly. So this CIP does not create a new risk, it just makes the already existing exchange rate risk more explicit.
This work is licensed under the Apache License, Version 2.0.