cgewecke / hardhat-gas-reporter

Gas Usage Analytics for Hardhat
MIT License
414 stars 58 forks source link

Gas estimation mismatch #248

Open MC12024 opened 2 days ago

MC12024 commented 2 days ago

When i run the test in my hardhat 2.22.15 environment, the gas report it shows is for specific function i'm interested in, is 289237 and when calling the same function on chain (holesky and sepolia testnets) its almost 700000 gas. why is that? i tried to replicate the exact state of the on-chain contract in my test, but still the same gas usage difference. should it be at least a close estimation of gas or am i missing something here? i asked this in the discord of hardhat and also in stackoverflow but no one answered just wanna know what might be the reason for this.

p.s: i use gas report version "2.2.1"

cgewecke commented 2 days ago

Could you provide more information about your test? It's difficult to answer this question without an example transaction and a link to the repo you're running the plugin in.

Are you interacting with other contract systems on-chain? Are you modeling those locally using mock contracts or testing using a forked environment?

MC12024 commented 1 day ago

sure. the function A on contract A, calls function B on contract B. i test the exactly same contract B (its a mock but i populate it with the same info of the one that is already deployed in the testnet).

for the environment, i'm using forked of holesky and ethereum-sepolia which i deployed both of the contacts on. and also locally.

my hardhat config:

require("@nomicfoundation/hardhat-toolbox");
require("@openzeppelin/hardhat-upgrades");
require("@nomiclabs/hardhat-solhint");
require("dotenv").config();

module.exports = {
  solidity: {
    version: "0.8.28",
    settings: {
      optimizer: {
        enabled: true,
        runs: 100_000,
      },
      viaIR: true,
    },
  },
  gasReporter: {
    enabled: true,
    mode: "full",
    onlyCalledMethods: true,
  },
  networks: {
    hardhat: {
      allowBlocksWithSameTimestamp: true,
      forking: {
        url: process.env.ALCHEMY_RPC_ETH_HOLSKEY,
        blockNumber: 2649864,
      },
    },
    sepolia: {
      url: process.env.ALCHEMY_RPC_ETH_SEPOLIA,
      accounts: [process.env.PRIVATE_KEY],
    },
    holesky: {
      url: process.env.ALCHEMY_RPC_ETH_HOLSKEY,
      accounts: [process.env.PRIVATE_KEY, process.env.PRIVATE_KEY],
    },
  },
  etherscan: {
    apiKey: {
      sepolia: process.env.ETHERSCAN_TOKEN,
      holesky: process.env.ETHERSCAN_TOKEN,
    },
  },
};

all mocked contract are exactly the same as the deployed ones, implementing all functions and populate with all the same info that those deployed on chain, have. i also did against the deployed contract on holesky, but the gas estimation it shows is exactly the same as the one on my local.

this is the test:

const { expect } = require("chai");
const { ethers } = require("hardhat");
const { StandardMerkleTree } = require("@openzeppelin/merkle-tree");

describe("Staker Contract", function () {
  let Staker, staker;
  let Token, token;
  let StakeContract, stakeContract;
  let owner, admin, relayer, user1, user2, user3, user4;
  let merkleTree;
  let merkleRoot;
  let airdropData;
  let DEFAULT_ADMIN_ROLE, UPGRADER_ROLE, RELAYER_ROLE, STAKER_ROLE;
  let stakeContractAddress;
  let stakerAddress;

  beforeEach(async function () {
    // Get signers
    [owner, admin, relayer, user1, user2, user3, user4] =
      await ethers.getSigners();

    // Deploy Token
    Token = await ethers.getContractFactory("ERC20Mock");
    token = await Token.deploy(
      "Test Token",
      "TTK",
      ethers.parseEther("1000000")
    );
    await token.waitForDeployment();

    // Deploy Mock StakeContract
    StakeContract = await ethers.getContractFactory("StakeContractMock");
    const tokenAddress = await token.getAddress();

    stakeContract = await upgrades.deployProxy(
      StakeContract,
      [tokenAddress, tokenAddress, admin.address, admin.address],
      { initializer: "initialize" }
    );
    await stakeContract.waitForDeployment();

    stakeContractAddress = await stakeContract.getAddress();

    // config pool for staker contract
    // Configure the SIX months pool
    const SIX = 0; // LockingPeriodsEnum.SIX
    const rewardDuration = 60 * 60 * 24 * 30 * 6; // 6 months in seconds
    const duration = 60 * 60 * 24 * 30 * 6; // 6 months in seconds

    // As admin, configure the pool
    await stakeContract
      .connect(admin)
      .configPool(SIX, rewardDuration, duration);

    airdropData = [];
    for (let i = 0; i < 25000; i++) {
      airdropData.push([user1.address, ethers.parseEther("100").toString()]);
      airdropData.push([user2.address, ethers.parseEther("200").toString()]);
      airdropData.push([user3.address, ethers.parseEther("150").toString()]);
      airdropData.push([user4.address, ethers.parseEther("250").toString()]);
    }

    // Create Merkle Tree
    merkleTree = StandardMerkleTree.of(airdropData, ["address", "uint256"]);
    merkleRoot = merkleTree.root;

    // Deploy Staker contract
    Staker = await ethers.getContractFactory("Staker");
    staker = await Staker.deploy();
    await staker.waitForDeployment();
    stakerAddress = await staker.getAddress();
    const fee = 1;

    // Initialize the Staker contract
    await staker.initialize(
      admin.address,
      tokenAddress,
      stakeContractAddress,
      merkleRoot
    );

    // Transfer tokens to Staker contract
    await token.transfer(stakerAddress, ethers.parseEther("1000"));

    // Get role IDs
    DEFAULT_ADMIN_ROLE = await staker.DEFAULT_ADMIN_ROLE();
    UPGRADER_ROLE = await staker.UPGRADER_ROLE();
    RELAYER_ROLE = await staker.RELAYER_ROLE();
    STAKER_ROLE = await stakeContract.STAKER_ROLE();

    // Grant RELAYER_ROLE to relayer
    await staker.connect(admin).grantRole(RELAYER_ROLE, relayer.address);
    await stakeContract.connect(admin).grantRole(STAKER_ROLE, staker.target);
  });

  /**
   * Helper function to get the Merkle proof for a user
   * @param {string} userAddress - The address of the user
   * @param {BigNumber} amount - The amount allocated to the user
   * @returns {string[]} - The Merkle proof for the user
   */
  function getProofForUser(userAddress, amount) {
    for (const [i, v] of merkleTree.entries()) {
      if (
        v[0].toLowerCase() === userAddress.toLowerCase() &&
        BigInt(v[1]) === amount
      ) {
        return merkleTree.getProof(i);
      }
    }
    throw new Error("User not found in Merkle Tree");
  }

  describe("Claim and Stake", function () {
    it("should allow a relayer to claim and stake tokens for a user", async function () {
      const user = user1;
      const amounts = [ethers.parseEther("50"), ethers.parseEther("50")];

      const poolIndices = [0, 0];
      const totalAmount = ethers.parseEther("100");
      const proof = getProofForUser(user.address, totalAmount);

      const chainId = (await ethers.provider.getNetwork()).chainId;
      const domain = {
        name: "Staker",
        version: "1",
        chainId: 31337,
        verifyingContract: stakerAddress,
      };

      const types = {
        StakeData: [
          { name: "totalAmount", type: "uint256" },
          { name: "poolCount", type: "uint256" },
        ],
      };

      const value = {
        totalAmount: totalAmount.toString(),
        poolCount: poolIndices.length.toString(),
      };

      // Compute the digest
      const domainSeparator = ethers.TypedDataEncoder.hashDomain(domain);
      const contractDomainSeparator = await staker.eip712Domain();
      const digest = ethers.TypedDataEncoder.hash(domain, types, value);
      const signature = await user.signTypedData(domain, types, value);

      await expect(
        staker
          .connect(relayer)
          .claimAndStake(user.address, amounts, poolIndices, proof, signature)
      )
        .to.emit(staker, "Staked")
        .withArgs(user.address, totalAmount, amounts, poolIndices);

      expect(await staker.hasClaimed(user.address)).to.be.true;
    });

    it("should not allow non-relayer to claim and stake", async function () {
      const user = user1;
      const amounts = [ethers.parseEther("100")];
      const poolIndices = [0];
      const totalAmount = ethers.parseEther("100");
      const proof = getProofForUser(user.address, totalAmount);

      const domain = {
        name: "Staker",
        version: "1",
        chainId: 31337, // Replace with the actual chain ID
        verifyingContract: stakerAddress, // Replace with your contract address
      };

      const types = {
        StakeData: [
          { name: "totalAmount", type: "uint256" },
          { name: "poolCount", type: "uint256" },
        ],
      };

      const value = {
        totalAmount: amounts[0],
        poolCount: poolIndices.length.toString(),
      };
      const signature = await user.signTypedData(domain, types, value);

      await expect(
        staker
          .connect(user)
          .claimAndStake(user.address, amounts, poolIndices, proof, signature)
      ).to.be.revertedWithCustomError(
        staker,
        "AccessControlUnauthorizedAccount"
      );
    });

    it("should not allow claiming with invalid signature", async function () {
      const user = user1;
      const amounts = [ethers.parseEther("100")];
      const poolIndices = [0];
      const totalAmount = ethers.parseEther("100");
      const proof = getProofForUser(user.address, totalAmount);

      const domain = {
        name: "Staker",
        version: "1",
        chainId: 31337, 
        verifyingContract: stakerAddress, 
      };

      const types = {
        StakeData: [
          { name: "totalAmount", type: "uint256" },
          { name: "poolCount", type: "uint256" },
        ],
      };

      const value = {
        totalAmount: amounts[0],
        poolCount: poolIndices.length.toString(),
      };
      const signature = await user2.signTypedData(domain, types, value);

      await expect(
        staker
          .connect(relayer)
          .claimAndStake(user.address, amounts, poolIndices, proof, signature)
      ).to.be.revertedWithCustomError(staker, "InvalidSignature");
    });

    it("should validate total amount matches sum of individual amounts", async function () {
      const user = user1;
      const amounts = [ethers.parseEther("60"), ethers.parseEther("60")]; // Total 120 > 100
      const poolIndices = [0, 1];
      const totalAmount = ethers.parseEther("100");
      const proof = getProofForUser(user.address, totalAmount);

      const domain = {
        name: "Staker",
        version: "1",
        chainId: 31337, 
        verifyingContract: stakerAddress, 
      };

      const types = {
        StakeData: [
          { name: "totalAmount", type: "uint256" },
          { name: "poolCount", type: "uint256" },
        ],
      };

      const value = {
        totalAmount: totalAmount,
        poolCount: poolIndices.length.toString(),
      };
      const signature = await user.signTypedData(domain, types, value);

      await expect(
        staker
          .connect(relayer)
          .claimAndStake(user.address, amounts, poolIndices, proof, signature)
      ).to.be.revertedWithCustomError(staker, "InvalidProof");
    });

    it("should validate amounts and poolIndices arrays have same length", async function () {
      const user = user1;
      const amounts = [ethers.parseEther("50"), ethers.parseEther("50")];
      const poolIndices = [0]; // Missing one index
      const totalAmount = ethers.parseEther("100");
      const proof = getProofForUser(user.address, totalAmount);

      const domain = {
        name: "Staker",
        version: "1",
        chainId: 31337, 
        verifyingContract: stakerAddress, 
      };

      const types = {
        StakeData: [
          { name: "totalAmount", type: "uint256" },
          { name: "poolCount", type: "uint256" },
        ],
      };

      const value = {
        totalAmount: totalAmount.toString(),
        poolCount: poolIndices.length.toString(),
      };

      const signature = await user.signTypedData(domain, types, value);

      await expect(
        staker
          .connect(relayer)
          .claimAndStake(user.address, amounts, poolIndices, proof, signature)
      ).to.be.revertedWithCustomError(staker, "LengthMismatch");
    });
  });
});

this is a transaction on testnet with almost 2 times the gas usage: https://holesky.etherscan.io/tx/0x4c5c5aa012649fe02bfe816abc7ea186cf366ffa110f8410cee78f91bf6030b2

cgewecke commented 1 day ago

@MC12024

One difference between your local config and the contract on-chain are the solidity optimizer settings. Above you have:

optimizer: {
  enabled: true,
  runs: 100_000,
},
viaIR: true,

For the contract on holesky, etherscan is reporting it was compiled with:

"optimizer": {
  "enabled": true,
  "runs": 200
},

Does making those the same fix the discrepancy in gas measurement?

In advance, I haven't seen this gas measurement mismatch in any other projects. For example, OpenZeppelin uses the plugin, tracks gas usage changes across pull requests and their measurements have stayed constant. There are no other reports of mismatch in the issues and I have not seen this happen on my own either. It's likely the discrepancy is flagging a difference between your local environment and your on-chain deployment.

There's an independent study of gas measurement accuracy across ecosystem tooling here fwiw:

https://github.com/0xpolarzero/gas-metering-comparison

If changing optimizer settings doesn't resolve this, you'll have to create a simple reproduction case for the bug that I can investigate.