Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ERC20 _update customization is not practical. #4967

Closed
BillSchumacher opened this issue Mar 21, 2024 · 17 comments
Closed

ERC20 _update customization is not practical. #4967

BillSchumacher opened this issue Mar 21, 2024 · 17 comments

Comments

@BillSchumacher
Copy link

You can't actually modify behavior due to the variables being private.

/**
 * @dev Transfers a `value` amount of tokens from `from` to `to`, or alternatively mints (or burns) if `from`
 * (or `to`) is the zero address. All customizations to transfers, mints, and burns should be done by overriding
 * this function.
 *
 * Emits a {Transfer} event.
 */
function _update(address from, address to, uint256 value) internal virtual {

Let's say I want to customize the burn to increment the balance of the 0 adresss and decrement the total supply, It's not currently possible.

💻 Environment

OpenZeppelin Contracts (last updated v5.0.0) (token/ERC20/ERC20.sol)

📝 Details

🔢 Code to reproduce bug

@BillSchumacher
Copy link
Author

BillSchumacher commented Mar 21, 2024

Another example is the capped implementation:

function _update(address from, address to, uint256 value) internal virtual override {
    super._update(from, to, value);

    if (from == address(0)) {
        uint256 maxSupply = cap();
        uint256 supply = totalSupply();
        if (supply > maxSupply) {
            revert ERC20ExceededCap(supply, maxSupply);
        }
    }
}

This could be implemented slightly more efficiently, by being located before the super in this instance, but also by being able to add this check before the total supply increment.

@BillSchumacher
Copy link
Author

image
image
image

@ernestognw
Copy link
Member

ernestognw commented Mar 21, 2024

Hi @BillSchumacher,

Let's say I want to customize the burn to increment the balance of the 0 adresss and decrement the total supply, It's not currently possible.

Can you elaborate what would be the problem with the following approach?

abstract contract MyERC20 is ERC20 {
    uint256 addressZeroBalance;

    function balanceOf(address account) public view virtual returns (uint256) {
        if(account === address(0)) return addressZeroBalance;
        return _balances[account];
    }

    function _update(address from, address to, uint256 value) internal virtual override {
        super._update(from, to, value);

        if (to == address(0)) {
            addressZeroBalance++;
        }
    }
}

The variables are private intentionally so users DO NOT BREAK the invariants the contract assumes.

This could be implemented slightly more efficiently, by being located before the super in this instance, but also by being able to add this check before the total supply increment.

Could you elaborate on how it's more efficient? At most you'll be saving one if statement, which is about a couple of gas units (JUMPI costs 10 units). The contract is optimized to avoid the most possible SLOADs, which is one of the most expensive operations.

@BillSchumacher
Copy link
Author

BillSchumacher commented Mar 21, 2024

Hi @BillSchumacher,

Let's say I want to customize the burn to increment the balance of the 0 adresss and decrement the total supply, It's not currently possible.

Can you elaborate what would be the problem with the following approach?

abstract contract MyERC20 is ERC20 {
    uint256 addressZeroBalance;

    function balanceOf(address account) public view virtual returns (uint256) {
        if(account === address(0)) return addressZeroBalance;
        return _balances[account];
    }

This is broken because _balances is private and unavailable. A super to balanceOf does workaround that though.
image

function _update(address from, address to, uint256 value) internal virtual override {
    super._update(from, to, value);

    if (to == address(0)) {
        addressZeroBalance++;
    }
}

}


The variables are private intentionally so users DO NOT BREAK the invariants the contract assumes.

> This could be implemented slightly more efficiently, by being located before the super in this instance, but also by being able to add this check before the total supply increment.

Could you elaborate on how it's more efficient? At most you'll be saving one `if` statement, which is about a couple of gas units (JUMPI costs 10 units). The contract is optimized to avoid the most possible SLOADs, which is one of the most expensive operations.

In the scenario that we are at the cap two state writes are avoided saving quite a bit of gas, as displayed in the gas remaining screenshots.

With the suggested workaround fixed:
image

~10000 more gas than required.

image

Anyhow, I can submit a PR to fix capped if you like with the addition of course so we don't go over the cap before capping,

I appreciate the insight.

@ernestognw
Copy link
Member

This is broken because _balances is private and unavailable. A super to balanceOf does workaround that though.

Right, this is what I meant:

abstract contract MyERC20 is ERC20 {
  uint256 addressZeroBalance;
  
  function balanceOf(address account) public view virtual returns (uint256) {
      if(account === address(0)) return addressZeroBalance;
      return super.balanceOf(account);
  }
  
  ...
}

A super to balanceOf does workaround that though.

I assume should be fine now.

~10000 more gas than required.

This is straight false, you're measuring the gas just before executing the _update function, which of course will change dramatically the gas consumption.

Please, share a detailed gas comparison using either Foundry or the Hardhat gas reporter for the whole function execution, including the optimization settings and other stuff.

Without optimizations, I'd expect to save only 1 JUMPI on average for your use case. With optimizations enabled and via-ir I'd expect the difference to be waaay smaller. Note that 10 gas units saved is around 0.003 cents in mainnet at 100 gwei, that's the kind of optimizations we won't implement because it compromises other security guarantees of the contract. Variables are private for a reason.

@BillSchumacher
Copy link
Author

BillSchumacher commented Mar 21, 2024

Why would you execute the update function if you are going to go over the cap?

@ernestognw
Copy link
Member

Why would you execute the update function if you are going to go over the cap?

Gas measurement should take the full execution of the function into account. Why would you optimize for reverts if you can avoid it with a gas estimation?

@BillSchumacher
Copy link
Author

To avoid the state writes

@ernestognw
Copy link
Member

There are no state writes if you revert, sir.

@ernestognw
Copy link
Member

The current implementation of the ERC20 already tracks the balance of the zero address. Also, I prepared a Foundry project with both the current implementation and another custom implementation having the cap check before the super._update(...) call.

You can see both implementations here.

After running the following gas measurement:

forge snapshot --via-ir --optimizer-runs 1000000000 --use 0.8.24 --match-test testTransfer -vvvv

The result is exactly the same for both implementations:

MyERC20CappedModifiedTest:testTransfer() (gas: 10316)
MyERC20CappedTest:testTransfer() (gas: 10316)

Closing

@BillSchumacher
Copy link
Author

Indeed, thanks again.

Ran 1 test for test/HelloWorld.t.sol:OverCapTest
[PASS] testHelloOverCap() (gas: 19073)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 920.50µs (139.40µs CPU time)

Ran 1 test for test/WorldHello.t.sol:OverCapAfterTest
[PASS] testWorldOverCap() (gas: 19065)
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 807.10µs (117.40µs CPU time)

@BillSchumacher
Copy link
Author

BillSchumacher commented Mar 21, 2024

Actually I forgot the addition, it does save gas.

image

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;

import "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Burnable.sol";
import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Capped.sol";
error ERC20ExceededCap(uint256, uint256);


abstract contract MyERC20 is ERC20, ERC20Burnable, ERC20Capped {
    function _update(address from, address to, uint256 value) internal virtual override(ERC20Capped, ERC20) {
        if (from == address(0)) {
            uint256 maxSupply = cap();
            uint256 supply = totalSupply();
            if (supply + value > maxSupply) {
                revert ERC20ExceededCap(supply, maxSupply);
            }
        }
        ERC20._update(from, to, value);
    }
}

abstract contract MyERC20After is ERC20, ERC20Burnable, ERC20Capped {
    function _update(address from, address to, uint256 value) internal virtual override(ERC20Capped, ERC20) {
        ERC20._update(from, to, value);
        if (from == address(0)) {
            uint256 maxSupply = cap();
            uint256 supply = totalSupply();
            if (supply > maxSupply) {
                revert ERC20ExceededCap(supply, maxSupply);
            }
        }
    }
}

contract HelloWorld is MyERC20 {
    constructor() payable MyERC20() ERC20("hello", "wrld") ERC20Capped(42)  {
    }

    function mint(uint256 value) public {
        _mint(msg.sender, value);
    }
}

contract WorldHello is MyERC20After {
    constructor() payable MyERC20After() ERC20("hello", "wrld") ERC20Capped(42)  {
    }

    function mint(uint256 value) public {
        _mint(msg.sender, value);
    }
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../contracts/HelloWorld.sol";

contract OverCapTest is Test {
    HelloWorld public helloWorld;
    function setUp() public {
        helloWorld = new HelloWorld();
    }
    function testHelloOverCap() public {
        try helloWorld.mint(1000000 * 10 ** helloWorld.decimals()) {
        } catch {
        }
    }
}
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Test.sol";
import "../contracts/HelloWorld.sol";

contract OverCapAfterTest is Test {
    WorldHello public worldHello;

    function setUp() public {
        worldHello = new WorldHello();
    }
    function testWorldOverCap() public {
        try worldHello.mint(1000000 * 10 ** worldHello.decimals()) {
        } catch {
        }
    }
}

@BillSchumacher
Copy link
Author

this output seems super unreliable...

@BillSchumacher
Copy link
Author

Is there a setting that I should have a certain way?

@BillSchumacher
Copy link
Author

Here's the result with 2 million runs:
image

@BillSchumacher
Copy link
Author

If your test doesn't fail to a revert I don't think you're hitting the condition btw.

@BillSchumacher
Copy link
Author

I matched your 1 billion runs, which seems absurd.
image

BillSchumacher added a commit to BillSchumacher/openzeppelin-contracts that referenced this issue Mar 21, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging a pull request may close this issue.

2 participants