Skip to content
This repository has been archived by the owner on Sep 30, 2024. It is now read-only.

Commit

Permalink
Merge pull request #185 from getwax/fee-measurer
Browse files Browse the repository at this point in the history
Fee measurer
  • Loading branch information
voltrevo authored Mar 18, 2024
2 parents 0f9d76c + c15c754 commit 7aec340
Show file tree
Hide file tree
Showing 20 changed files with 6,410 additions and 0 deletions.
4 changes: 4 additions & 0 deletions tools/fee-measurer/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
artifacts
cache
node_modules
typechain-types
11 changes: 11 additions & 0 deletions tools/fee-measurer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
node_modules
.env
coverage
coverage.json
typechain
typechain-types

# Hardhat files
cache
artifacts

14 changes: 14 additions & 0 deletions tools/fee-measurer/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"editor.rulers": [80],
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"eslint.rules.customizations": [
{
"rule": "*",
"severity": "warn"
}
],
"editor.tabSize": 2
}

169 changes: 169 additions & 0 deletions tools/fee-measurer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# Fee Measurer

This tool facilitates direct measurement of L2 fees.

Every L2 needs to extend Ethereum's system of charging fees to account for their
L1 costs. The method tends to be different for each L2, which creates a lot of
confusion and makes it difficult to compare L2s to each other.

This tool helps unify L2 fees by approximating them with this model:

```
l1GasUsed = fixedL1Gas + l1GasPerEffectiveDataByte * effectiveDataBytes
l2GasUsed = (ordinary gas defined by protocol / same on L1 and local dev)
fee = l1GasUsed * l1GasPrice + l2GasUsed * l2GasPrice
```

To start using this model for an L2, we need to find the values of `fixedL1Gas`
and `l1GasPerEffectiveDataByte` for the L2. Once these are measured, the same
values should continue to accurately predict fees (<5% error) as L1 and L2 gas
prices change. Reassessment should only be needed periodically to stay up to
date with network upgrades.

## How to Measure the Model Parameters

```sh
yarn
```

The main script is `scripts/measure.ts`. You can try this out in the test
environment with:

```sh
yarn hardhat run scripts/measure.ts
```

However, because the test environment is not an L2, it should measure the L2's
extra fees as zero. Gas prices can also fluctuate dramatically in the test
environment, which throws off the calculations.

For a real measurement, you'll need:
1. Choose the network you want to measure, and find its network name (or add it)
in `hardhat.config.ts`
1. Set up an account with about 0.002 ETH and have a mnemonic phrase for it (the
exact amount spent should be closer to 0.001 ETH but you'll want a buffer to
avoid needing to re-run the test from running out of funds)

Optionally, you can also consider changing this hardcoded number:

```ts
const maxPriorityFeePerGas = 100_000n; // 0.0001 gwei
```

then:

```sh
# Tip: Include a leading space to reduce the chance the command is written to
# history somewhere.
MNEMONIC="(your mnemonic)" yarn hardhat \
--network "(network name)" \
run scripts/measure.ts
```

This generates significant output, but the key values of interest are
`baselineExtraWei` and `extraWeiPerByte`. Also of interest are the
`weiUsedRelErrors` results (there are 2 sets). These should be as close to zero
as possible, ideally below 0.05 (5% error). If they are higher, it could be due
to significant gas price movements during the test, or because the L2's pricing
model is too far outside the assumptions of this model. You'll need to see
errors that you consider acceptable for future estimation to proceed, which
might be resolved by re-running the test at a later time. (Gas changes are only
an issue during the test - you can still use the resulting model to do
estimations when gas prices change later.)

Once you're happy with your numbers, you need to find the applicable L1 gas
price. You can get a ballpark number by just independently looking at what L1
recently charged for gas, but for best results you need to use a method specific
to your L2. For example:
- Arbitrum: There are precompiles available ([more info](
https://hackmd.io/@voltrevo/H15SBijOa#Calculating-the-Actual-L1-Gas))
- Optimism: The applicable L1 gas price is provided on [optimism's block
explorer](https://optimistic.etherscan.io/)

With the L1 gas price value (in wei (if you have gwei, multiply by 10^9 to get
the wei value)), you can calculate:

```
fixedL1Gas = baselineExtraWei / l1GasPrice
l1GasPerEffectiveDataByte = extraWeiPerByte / l1GasPrice
```

Example results (measured Jan 2024):

| | Arbitrum One | Optimism |
| --------------------------- | ------------ | -------- |
| `fixedL1Gas` | 1816 | 1302 |
| `l1GasPerEffectiveDataByte` | 16.2 | 11.0 |

## How to Apply the Model

**Step 1: Measure L2 gas**

Use a test environment like hardhat to measure the amount of ordinary gas (ie
the L2's gas when run on the L2) for the transaction you're interested in.
Depending on the complexity of your transaction, some care might be needed to
set up the right kind of state so that your transaction follows the
relevant/'normal' code path. This value is often higher for the first instance
of the transaction, because it writes to new storage. It's up to you to figure
out whether this first-time cost or ongoing cost is the number you need (or you
might be interested in both).

You can also get this value by running transactions directly on the target
chain, but be careful to interpret L2 gas correctly. This can be tricky because
L2s can report gas in unexpected ways, [for example on Arbitrum](
https://hackmd.io/@voltrevo/H15SBijOa).

**Step 2: Measure Effective Data Bytes**

This is about the number of bytes in your transaction's `data` field and how
they are treated by your specific L2. If your transaction data is a custom and
efficient format that is mostly random and incompressible, then you can use the
actual length of the data field as its effective length.

If you have a large data field based on the solidity ABI, the effective length
is probably about 40% of the actual length (based on Jan 2024 experiments).

Otherwise, you'll need to do some investigation for your transaction and L2 to
find the right value. For example:
- Arbitrum: Uses a complex system based on brotli, and appears to charge for
small data fields (eg ERC20 transfer) as though they were incompressible. The
most practical way to measure effective bytes is probably to send your actual
bytes to the chain and find the length that generates the correct fee when you
plug it into the model.
- Optimism: Ultimately uses gzip to post L1 data, but for fee calculation
purposes it uses the number of non-zero bytes plus 1/4 * the number of
zero-bytes.

**Step 3: Final Calculation**

From previous steps, you should have `l2Gas` and `effectiveDataBytes` values for
your transaction, as well as the `fixedL1Gas` and `l1GasPerEffectiveDataByte`
values that are built into the model.

With those values, you can calculate:

```
l1GasUsed = fixedL1Gas + l1GasPerEffectiveDataByte * effectiveDataBytes
l2GasUsed = (the number measured in step 1)
fee = l1GasUsed * l1GasPrice + l2GasUsed * l2GasPrice
```

This gives a number in wei, which you can divide by 10^18 for a value in ETH,
and multiply by the price of ETH to get a value in your preferred currency.

## Median Gas Price

To give an objective estimate that isn't tied exactly to the current gas price
conditions, the median gas price can be used.

This script will measure the median basefee:

```
yarn hardhat run --network "(your network)" scripts/estimateMedianBasefee.ts
```

Add a modest/applicable priority fee to get an overall gas price (eg 0.0001
gwei). This depends on the L2 (eg Arbitrum doesn't take a priority fee even if
you offer it).
36 changes: 36 additions & 0 deletions tools/fee-measurer/contracts/FeeMeasurer.sol
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

contract FeeMeasurer {
// Note: Linter wants to restrict this to pure but that prevents calling this
// in an actual transaction.
function useGas(uint8 size) external {
require(size != 0, "Size zero works differently due to zero-byte");

uint256 iterations = 100 * uint256(size);

for (uint256 i = 0; i < iterations; i++) {}
}

function useGasOrdinaryGasUsed(uint8 size) public pure returns (uint256) {
require(size != 0, "Size zero works differently due to zero-byte");

return 21629 + 11100 * uint256(size);
}

function fallbackOrdinaryGasUsed(
uint256 nonZeroDataLen
) public pure returns (uint256) {
uint256 result = 21064 + 16 * nonZeroDataLen;

if (nonZeroDataLen >= 4) {
// I think this is because a different branch is used if the data
// might match a method id. Less than 4 bytes and it can't match.
result += 78;
}

return result;
}

fallback() external {}
}
59 changes: 59 additions & 0 deletions tools/fee-measurer/hardhat.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { HardhatUserConfig, task, types } from 'hardhat/config';
import '@nomicfoundation/hardhat-toolbox';

const config: HardhatUserConfig = {
solidity: {
compilers: [
{
version: '0.8.21',
settings: {
optimizer: {
enabled: true,
runs: 1_000_000,
},
},
},
],
},

networks: {
hardhat: {
gasPrice: 20_000_000_000,
},

mainnet: {
url: 'https://rpc.ankr.com/eth'
},

arbitrumOne: {
url: 'https://arb1.arbitrum.io/rpc',
},

optimism: {
url: 'https://mainnet.optimism.io',
},
},
};

task('sendEth', 'Sends ETH to an address')
.addParam('address', 'Address to send ETH to', undefined, types.string)
.addOptionalParam('amount', 'Amount of ETH to send', '1.0')
.setAction(
async ({ address, amount }: { address: string; amount: string }, hre) => {
const wallet = hre.ethers.Wallet.fromPhrase(
`${'test '.repeat(11)}junk`,
hre.ethers.provider,
);

console.log(`${wallet.address} -> ${address} ${amount} ETH`);

const txnRes = await wallet.sendTransaction({
to: address,
value: hre.ethers.parseEther(amount),
});

await txnRes.wait();
},
);

export default config;
31 changes: 31 additions & 0 deletions tools/fee-measurer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
{
"name": "fee-measurer",
"version": "0.0.0",
"main": "index.js",
"private": true,
"dependencies": {
"account-abstraction": "github:eth-infinitism/account-abstraction#0.6.0",
"hardhat": "^2.17.3",
"openzeppelin-contracts": "github:openzeppelin/openzeppelin-contracts#v4.9.3",
"safe-contracts": "github:safe-global/safe-contracts#v1.4.1"
},
"devDependencies": {
"@nomicfoundation/hardhat-chai-matchers": "^2.0.0",
"@nomicfoundation/hardhat-ethers": "^3.0.0",
"@nomicfoundation/hardhat-network-helpers": "^1.0.0",
"@nomicfoundation/hardhat-toolbox": "^3.0.0",
"@nomicfoundation/hardhat-verify": "^1.0.0",
"@typechain/ethers-v6": "^0.4.0",
"@typechain/hardhat": "^8.0.0",
"@types/chai": "^4.2.0",
"@types/mocha": ">=9.1.0",
"@types/node": ">=16.0.0",
"chai": "^4.2.0",
"ethers": "^6.4.0",
"hardhat-gas-reporter": "^1.0.8",
"solidity-coverage": "^0.8.0",
"ts-node": ">=8.0.0",
"typechain": "^8.1.0",
"typescript": ">=4.5.0"
}
}
28 changes: 28 additions & 0 deletions tools/fee-measurer/scripts/deploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/* eslint-disable no-console */

import { ethers } from 'hardhat';
import { FeeMeasurer__factory } from '../typechain-types';
import SafeSingletonFactory from '../src/SafeSingletonFactory';
import { Signer } from 'ethers';

async function main() {
let signer: Signer;

if (process.env.MNEMONIC) {
signer = ethers.Wallet.fromPhrase(process.env.MNEMONIC, ethers.provider);
} else {
signer = (await ethers.getSigners())[0];
}

const ssf = await SafeSingletonFactory.init(signer);
const feeMeasurer = await ssf.connectOrDeploy(FeeMeasurer__factory, []);

console.log('FeeMeasurer deployed to:', await feeMeasurer.getAddress());
}

// We recommend this pattern to be able to use async/await everywhere
// and properly handle errors.
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Loading

0 comments on commit 7aec340

Please sign in to comment.