Calculating Transaction Fees on Moonbeam¶
Introduction¶
Similar to the Ethereum and Substrate APIs for sending transfers on Moonbeam, the Substrate and EVM layers on Moonbeam also have distinct transaction fee models that developers should be aware of when they need to calculate and keep track of transaction fees for their transactions.
For starters, Ethereum transactions consume gas units based on their computational complexity and data storage requirements. On the other hand, Substrate transactions use the concept of "weight" to determine fees. In this guide, you'll learn how to calculate the transaction fees for both Substrate and Ethereum transactions. In terms of Ethereum transactions, you'll also learn about the key differences between how transaction fees are calculated on Moonbeam and Ethereum.
Key Differences with Ethereum¶
There are some key differences between the transaction fee model on Moonbeam and the one on Ethereum that developers should be mindful of when developing on Moonbeam:
-
The dynamic fee mechanism resembles that of EIP-1559 but the implementation is different
-
The amount of gas used in Moonbeam's transaction fee model is mapped from the transaction's Substrate extrinsic
refTime
component of the transaction weight via a fixed factor of25000
andproofSize
component of the transaction weight via a fixed factor of16
. The transaction weight vector is then multiplied with the unit gas price to calculate the transaction fee. This fee model means it can potentially be significantly cheaper to send transactions such as basic balance transfers via the Ethereum API than the Substrate API. -
The EVM is designed to solely have capacity for gas and Moonbeam requires additional metrics outside of gas. In particular, Moonbeam needs the ability to record proof size, which is the amount of storage required on Moonbeam for a relay chain validator to verify a state transition. When the capacity limit for proof size has been reached for the current block, which is 25% of the block limit, an "Out of Gas" error will be thrown. This can happen even if there is remaining legacy gas in the gasometer. This additional metric also impacts refunds. Refunds are based on the more consumed resource after the execution. In other words, if more proof size has been consumed proportionally than legacy gas, the refund will be calculated using proof size
-
Moonbeam has implemented a new mechanism defined in MBIP-5 that limits block storage and increases gas usage for transactions that result in an increase in storage
Overview of MBIP-5¶
MBIP-5 introduced changes to Moonbeam's fee mechanism that account for storage growth on the network, which deviates from the way Ethereum handles fees. By raising the gas needed to execute transactions that increase chain state and by establishing a block storage limit, it controls storage growth.
This impacts contract deployments that add to the chain state, transactions that create new storage entries, and precompiled contract calls that result in the creation of new accounts.
The block storage limit prevents transactions in a single block from collectively increasing the storage state by more than the limit. The limit for each network is as follows:
80KB
160KB
160KB
To determine the amount of gas for storage in bytes, there is a ratio that is defined as:
Ratio = Block Gas Limit / (Block Storage Limit * 1024 Bytes)
The block gas limit for each network is as follows:
60,000,000
60,000,000
60,000,000
Knowing the block gas and storage limits, the ratio of gas to storage is computed as follows:
Ratio = 60000000 / (80 * 1024)
Ratio = 366
Ratio = 60000000 / (160 * 1024)
Ratio = 366
Ratio = 60000000 / (160 * 1024)
Ratio = 366
Then, you can take the storage growth in bytes for a given transaction and multiply it by the gas-to-storage growth ratio to determine how many units of gas to add to the transaction. For example, if you execute a transaction that increases the storage by 500 bytes, the following calculation is used to determine the units of gas to add:
Additional Gas = 500 * 366
Additional Gas = 183000
Additional Gas = 500 * 366
Additional Gas = 183000
Additional Gas = 500 * 366
Additional Gas = 183000
To see how this MBIP differentiates Moonbeam from Ethereum firsthand, you can estimate the gas for two different contract interactions on both networks: one that modifies an item in the chain state and one that doesn't. For example, you can use a greeting contract that allows you to store a name and then use the name to say "Hello".
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract SayHello {
mapping(address => string) public addressToName;
constructor(string memory _name) {
addressToName[msg.sender] = _name;
}
// Store a name associated to the address of the sender
function setName(string memory _name) public {
addressToName[msg.sender] = _name;
}
// Use the name in storage associated to the sender
function sayHello() external view returns (string memory) {
return string(abi.encodePacked("Hello ", addressToName[msg.sender]));
}
}
You can deploy this contract on both Moonriver and Ethereum, or on Moonbeam's TestNet, Moonbase Alpha, and Ethereum's TestNet, Sepolia. The above contract has already been deployed to Moonbase Alpha and Sepolia. You can feel free to access these contracts at the following addresses:
0xDFF8E772A9B212dc4FbA19fa650B440C5c7fd7fd
0x8D0C059d191011E90b963156569A8299d7fE777d
Next, you can use the eth_estimateGas
method to check the gas estimate for calling the setName
and sayHello
functions on each network. To do so, you'll need the bytecode for each transaction, which includes the function selector, and for the setName
function, the name to be set. This example bytecode sets the name to "Chloe":
0xc47f00270000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000543686c6f65000000000000000000000000000000000000000000000000000000
0xef5fb05b
Now, you can use the following curl commands on Moonbase Alpha to return the gas estimate:
curl https://rpc.api.moonbase.moonbeam.network -H "Content-Type:application/json;charset=utf-8" -d \
'{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_estimateGas",
"params":[{
"to": "0xDFF8E772A9B212dc4FbA19fa650B440C5c7fd7fd",
"data": "0xc47f00270000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000543686c6f65000000000000000000000000000000000000000000000000000000"
}]
}'
curl https://rpc.api.moonbase.moonbeam.network -H "Content-Type:application/json;charset=utf-8" -d \
'{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_estimateGas",
"params":[{
"to": "0xDFF8E772A9B212dc4FbA19fa650B440C5c7fd7fd",
"data": "0xef5fb05b"
}]
}'
Then on Sepolia, you can use the same bytecode for the data
and modify the RPC URL and contract address to target the contract deployed to Sepolia:
curl https://sepolia.publicgoods.network -H "Content-Type:application/json;charset=utf-8" -d \
'{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_estimateGas",
"params":[{
"to": "0x8D0C059d191011E90b963156569A8299d7fE777d",
"data": "0xc47f00270000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000543686c6f65000000000000000000000000000000000000000000000000000000"
}]
}'
curl https://sepolia.publicgoods.network -H "Content-Type:application/json;charset=utf-8" -d \
'{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_estimateGas",
"params":[{
"to": "0x8D0C059d191011E90b963156569A8299d7fE777d",
"data": "0xef5fb05b"
}]
}'
At the time of writing, the gas estimates for both networks are as follows:
Method | Gas Estimate |
---|---|
setName | 45977 |
sayHello | 25938 |
Method | Gas Estimate |
---|---|
setName | 21520 |
sayHello | 21064 |
You'll see that on Sepolia, the gas estimates for both calls are very similar, whereas on Moonbase Alpha, there is a noticeable difference between the calls and that the setName
call, which modifies the storage, uses more gas than the sayHello
call.
Ethereum API Transaction Fees¶
To calculate the fee incurred on a Moonbeam transaction sent via the Ethereum API, the following formula can be used:
GasPrice = BaseFee + MaxPriorityFeePerGas < MaxFeePerGas ?
BaseFee + MaxPriorityFeePerGas :
MaxFeePerGas;
Transaction Fee = (GasPrice * TransactionWeight) / 25000
Transaction Fee = (GasPrice * TransactionWeight) / 25000
Transaction Fee = (GasPrice * TransactionWeight) / 25000
Note
EIP-1559 transaction fees on Moonbeam are calculated using the previous block's base fee.
The following sections describe in more detail each of the components needed to calculate the transaction fee.
Base Fee¶
The BaseFee
is the minimum amount charged to send a transaction and is a value set by the network itself. It was introduced in EIP-1559. Moonbeam has its own dynamic fee mechanism for calculating the base fee, which is adjusted based on block congestion. As of runtime 2300, the dynamic fee mechanism has been rolled out to all of the Moonbeam-based networks.
The minimum gas price for each network is as follows:
Variable | Value |
---|---|
Minimum Gas Price | 125 Gwei |
Variable | Value |
---|---|
Minimum Gas Price | 1.25 Gwei |
Variable | Value |
---|---|
Minimum Gas Price | 0.125 Gwei |
To calculate the dynamic base fee, the following calculation is used:
BaseFee = NextFeeMultiplier * 125000000000 / 10^18
BaseFee = NextFeeMultiplier * 1250000000 / 10^18
BaseFee = NextFeeMultiplier * 125000000 / 10^18
The value of NextFeeMultiplier
can be retrieved from the Substrate Sidecar API, via the following endpoint:
GET /pallets/transaction-payment/storage/nextFeeMultiplier?at={blockId}
The pallets endpoints for Sidecar returns data relevant to a pallet, such as data in a pallet's storage. You can read more about the pallets endpoint in the official Sidecar documentation. The data at hand that's required from storage is the nextFeeMultiplier
, which can be found in the transaction-payment
pallet. The stored nextFeeMultiplier
value can be read directly from the Sidecar storage schema. Read as a JSON object, the relevant nesting structure is as follows:
RESPONSE JSON Storage Object:
|--at
|--hash
|--height
|--pallet
|--palletIndex
|--storageItem
|--keys
|--value
The relevant data will be stored in the value
key of the JSON object. This value is a fixed point data type, hence the real value is found by dividing the value
by 10^18
. This is why the calculation of BaseFee
includes such an operation.
GasPrice, MaxFeePerGas, and MaxPriorityFeePerGas¶
The GasPrice
is used to specify the gas price of legacy transactions prior to EIP-1559. The MaxFeePerGas
and MaxPriorityFeePerGas
were both introduced in EIP-1559 alongside the BaseFee
. The MaxFeePerGas
defines the maximum fee permitted to be paid per unit of gas and is the sum of the BaseFee
and the MaxPriorityFeePerGas
. The MaxPriorityFeePerGas
is the maximum priority fee configured by the sender of a transaction that is used to incentivize the prioritization of a transaction in a block.
Although Moonbeam is Ethereum-compatible, it is also a Substrate-based chain at its core, and priorities work differently in Substrate than in Ethereum. In Substrate, transactions are not prioritized by gas price. To address this, Moonbeam uses a modified prioritization system that reprioritizes Substrate transactions using an Ethereum-first solution. A Substrate transaction still goes through the validity process, where it is assigned transaction tags, longevity, and a priority. The original priority is then overwritten with a new priority based on the transaction's fee per gas, which is derived from the transaction's tip and weight. If the transaction is an Ethereum transaction, the priority is set according to the priority fee.
It's important to note that priority is not the sole component responsible for determining the order of transactions in a block. Other components, such as the longevity of a transaction, also play a role in the sorting process.
The values of GasPrice
, MaxFeePerGas
and MaxPriorityFeePerGas
for the applicable transaction types can be read from the block JSON object according to the structure described in the Sidecar API page.
The data for an Ethereum transaction in a particular block can be extracted from the following block endpoint:
GET /blocks/{blockId}
The paths to the relevant values have also been truncated and reproduced below:
EVM Field | Block JSON Field |
---|---|
MaxFeePerGas | extrinsics[extrinsic_number].args.transaction.eip1559.maxFeePerGas |
MaxPriorityFeePerGas | extrinsics[extrinsic_number].args.transaction.eip1559.maxPriorityFeePerGas |
EVM Field | Block JSON Field |
---|---|
GasPrice | extrinsics[extrinsic_number].args.transaction.legacy.gasPrice |
EVM Field | Block JSON Field |
---|---|
GasPrice | extrinsics[extrinsic_number].args.transaction.eip2930.gasPrice |
Transaction Weight¶
TransactionWeight
is a Substrate mechanism used to measure the execution time a given transaction takes to be executed within a block. A transaction's weight is a vector of two components: refTime
and proofSize
. refTime
refers to the amount of computational time that can be used for execution. proofSize
refers to the size of the PoV (Proof of Validity) of the Moonbeam block that gets submitted to the Polkadot Relay Chain for validation. Since both refTime
and proofSize
are integral components of determining a weight, it is impossible to obtain an accurate weight value with just one of these values.
For all transactions types, TransactionWeight
can be retrieved under the event of the relevant extrinsic where the method
field is set to:
pallet: "system", method: "ExtrinsicSuccess"
And then TransactionWeight
is mapped to the following two fields of the block JSON object. proofSize
is mapped as follows:
extrinsics[extrinsic_number].events[event_number].data[0].weight.proof_size
And refTime
is mapped as follows:
extrinsics[extrinsic_number].events[event_number].data[0].weight.ref_time
Fee History Endpoint¶
Moonbeam networks implement the eth_feeHistory
JSON-RPC endpoint as a part of the support for EIP-1559.
eth_feeHistory
returns a collection of historical gas information from which you can reference and calculate what to set for the MaxFeePerGas
and MaxPriorityFeePerGas
fields when submitting EIP-1559 transactions.
The following curl example will return the gas information of the last 10 blocks starting from the latest block on the respective Moonbeam network using eth_feeHistory
:
curl --location \
--request POST 'INSERT_RPC_API_ENDPOINT' \
--header 'Content-Type: application/json' \
--data-raw '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_feeHistory",
"params": ["0xa", "latest"]
}'
curl --location \
--request POST 'INSERT_RPC_API_ENDPOINT' \
--header 'Content-Type: application/json' \
--data-raw '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_feeHistory",
"params": ["0xa", "latest"]
}'
curl --location \
--request POST 'https://rpc.api.moonbase.moonbeam.network' \
--header 'Content-Type: application/json' \
--data-raw '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_feeHistory",
"params": ["0xa", "latest"]
}'
curl --location \
--request POST 'http://127.0.0.1:9944' \
--header 'Content-Type: application/json' \
--data-raw '{
"jsonrpc": "2.0",
"id": 1,
"method": "eth_feeHistory",
"params": ["0xa", "latest"]
}'
Sample Code for Calculating Transaction Fees¶
The following code snippet uses the Axios HTTP client to query the Sidecar endpoint /blocks/head
for the latest finalized block. It then calculates the transaction fees of all transactions in the block according to the transaction type (for Ethereum API: legacy, EIP-1559 or EIP-2930 standards, and for Substrate API), as well as calculating the total transaction fees in the block.
Note
EIP-1559 transaction fees on Moonbeam are calculated using the previous block's base fee.
The following code sample is for demo purposes only and should not be used without modification and further testing in a production environment.
You can use the following snippet for any Moonbeam-based network, but you'll need to modify the baseFee
accordingly. You can refer back to the Base Fee section to get the calculation for each network.
import axios from 'axios';
// This script calculates the transaction fees of all transactions in a block
// according to the transaction type (for Ethereum API: legacy, EIP-1559 or
// EIP-2930 standards, and Substrate API) using the dynamic fee mechanism.
// It also calculates the total fees in the block
// Endpoint to retrieve the latest block
const endpointBlock = 'http://127.0.0.1:8080/blocks/head';
// Endpoint to retrieve the latest nextFeeMultiplier
const endpointPallet =
'http://127.0.0.1:8080/pallets/transaction-payment/storage/nextFeeMultiplier?at=';
// Endpoint to retrieve the node client's information
const endpointNodeVersion = 'http://127.0.0.1:8080/node/version';
// Define the minimum base fee for each network
const baseFee = {
moonbeam: 125000000000n,
moonriver: 1250000000n,
moonbase: 125000000n,
};
async function main() {
try {
// Create a variable to sum the transaction fees in the whole block
let totalFees = 0n;
// Find which Moonbeam network the Sidecar is pointing to
const responseClient = await axios.get(endpointNodeVersion);
const network = responseClient.data.clientImplName;
// Retrieve the block from the Sidecar endpoint
const responseBlock = await axios.get(endpointBlock);
// Retrieve the block height of the current block
console.log('Block Height: ' + responseBlock.data.number);
// Use the previous block's base fee to match the on-chain data
// Find the block's nextFeeMultiplier
const prevBlock = Number(responseBlock.data.number) - 1;
const responsePallet = await axios.get(endpointPallet + prevBlock);
// Iterate through all extrinsics in the block
responseBlock.data.extrinsics.forEach((extrinsic) => {
// Create an object to store transaction information
let transactionData = new Object();
// Set the network field
transactionData['network'] = network;
// Filter for Ethereum Transfers
if (
extrinsic.method.pallet === 'ethereum' &&
extrinsic.method.method === 'transact'
) {
// Iterate through the events to get non type specific parameters
extrinsic.events.forEach((event) => {
if (
event.method.pallet === 'ethereum' &&
event.method.method === 'Executed'
) {
// Get Transaction Hash
transactionData['hash'] = event.data[2];
}
if (
event.method.pallet === 'system' &&
event.method.method === 'ExtrinsicSuccess'
) {
// Add correction weight if needed to Transaction Weight!
transactionData['weight'] = BigInt(event.data[0].weight.refTime);
}
});
// Get the transaction type and type specific parameters and compute the
// transaction fee
if (extrinsic.args.transaction.legacy) {
transactionData['txType'] = 'legacy';
transactionData['gasPrice'] = BigInt(
extrinsic.args.transaction.legacy.gasPrice
);
transactionData['txFee'] =
(transactionData['gasPrice'] * transactionData['weight']) / 25000n;
} else if (extrinsic.args.transaction.eip1559) {
transactionData['txType'] = 'eip1599';
transactionData['maxFeePerGas'] = BigInt(
extrinsic.args.transaction.eip1559.maxFeePerGas
);
transactionData['maxPriorityFeePerGas'] = BigInt(
extrinsic.args.transaction.eip1559.maxPriorityFeePerGas
);
// Update based on the network you're getting tx fees for
transactionData['baseFee'] =
(BigInt(responsePallet.data.value) * baseFee.moonbeam) /
BigInt('1000000000000000000');
// Gas price dependes on the MaxFeePerGas and MaxPriorityFeePerGas set
transactionData['gasPrice'] =
transactionData['baseFee'] +
transactionData['maxPriorityFeePerGas'] <
transactionData['maxFeePerGas']
? transactionData['baseFee'] +
transactionData['maxPriorityFeePerGas']
: transactionData['maxFeePerGas'];
transactionData['txFee'] =
(transactionData['gasPrice'] * transactionData['weight']) / 25000n;
} else if (extrinsic.args.transaction.eip2930) {
transactionData['txType'] = 'eip2930';
transactionData['gasPrice'] = BigInt(
extrinsic.args.transaction.eip2930.gasPrice
);
transactionData['txFee'] =
(transactionData['gasPrice'] * transactionData['weight']) / 25000n;
}
// Increment totalFees
totalFees += transactionData['txFee'];
// Display the tx information to console
console.log(transactionData);
}
// Filter for Substrate transactions, check if the extrinsic has a
// 'TransactionFeePaid' event
else {
extrinsic.events.forEach((event) => {
if (
event.method.pallet === 'transactionPayment' &&
event.method.method === 'TransactionFeePaid'
) {
transactionData['txType'] = 'substrate';
transactionData['txFee'] = event.data[1];
transactionData['tip'] = event.data[1];
}
if (
event.method.pallet === 'system' &&
event.method.method === 'ExtrinsicSuccess'
) {
transactionData['weight'] = event.data[0].weight.refTime;
}
});
}
});
// Output the total amount of fees in the block
console.log('Total fees in block: ' + totalFees);
} catch (err) {
console.log(err);
}
}
main();
Substrate API Transaction Fees¶
This section of the guide assumes you are interacting with Moonbeam blocks via the Substrate API Sidecar service. There are other ways of interacting with Moonbeam blocks, such as using the Polkadot.js API library. The logic is identical once the blocks are retrieved.
You can reference the Substrate API Sidecar page for information on installing and running your own Sidecar service instance, as well as more details on how to decode Sidecar blocks for Moonbeam transactions.
Note that the information in this section assumes you are running version 19.2.0 of the Substrate Sidecar REST API.
All the information around fee data for transactions sent via the Substrate API can be extracted from the following block endpoint:
GET /blocks/{blockId}
The block endpoints will return data relevant to one or more blocks. You can read more about the block endpoints on the official Sidecar documentation. Read as a JSON object, the relevant nesting structure is as follows:
RESPONSE JSON Block Object:
...
|--number
|--extrinsics
|--{extrinsic_number}
|--method
|--signature
|--nonce
|--args
|--tip
|--hash
|--info
|--era
|--events
|--{event_number}
|--method
|--pallet: "transactionPayment"
|--method: "TransactionFeePaid"
|--data
|--0
|--1
|--2
...
The object mappings are summarized as follows:
Tx Information | Block JSON Field |
---|---|
Fee paying account | extrinsics[extrinsic_number].events[event_number].data[0] |
Total fees paid | extrinsics[extrinsic_number].events[event_number].data[1] |
Tip | extrinsics[extrinsic_number].events[event_number].data[2] |
The transaction fee related information can be retrieved under the event of the relevant extrinsic where the method
field is set to:
pallet: "transactionPayment", method: "TransactionFeePaid"
And then the total transaction fee paid for this extrinsic is mapped to the following field of the block JSON object:
extrinsics[extrinsic_number].events[event_number].data[1]
| Created: September 6, 2022