ERC20 Snapshot

ERC20 Snapshot solves the problem of double voting.If votes are weighed by the number of tokens someone holds, then a malicious actor can use their tokens to vote, then transfer the tokens to another address, vote with that, and so forth. If each address is a smart contract, then the hacker can accomplish all these votes in one transaction. A related attack is to use a flashloan to obtain a bunch of governance tokens, vote, then return the flash loan.A similar problem exists for claiming airdrops. One could use their ERC20 tokens to claim an airdrop, then transfer their tokens to another address, then claim the airdrop again.Fundamentally, ERC20 snapshot provides a mechanism to defend against users transferring tokens and re-using token utility in the same transaction.At first, snapshotting might seem like an intractable problem. The brute force, or naive, solution to this is to iterate through every address in the “balances” mapping of ERC20, then copy these to another mapping. It isn’t possible to natively iterate through a mapping in ERC20, so the coder would have to use an enumerable map — a map with an array that keeps track of all the keys.As one can imagine, this O(n) operation would be extremely gas intensive.There is a saying in computer science that “Every problem in computer science can be solved with another level of indirection” and that’s how ERC20 snapshots solves it.

Efficient but Naive Solution

Let’s take the example of the balances mapping. Here is a buggy solidity solution, but a step in the right direction.
balances[snapshotNumber][user]
In this case, snapshotNumber is a counter that starts at zero and increments by one every time a snapshot is done.Going back to our voting example, we create a snapshot at a particular point in time, let everyone go about their business, then create another snapshot. At the time of voting, we use the previous snapshot since the current snapshot can still be changed by transferring tokens.This way, we can query someone’s balance by supplying both the snapshotNumber and their address at the snapshot we care about. Since we know the current snapshot, balanceOf is simply the balances at the most recent snapshot.Ah, but there is a problem! Every time we do a snapshot, everyone’s balances are set to zero! It is possible to solve this with some accounting — just track the last snapshot the user transacted at, but this quickly gets complicated as the engineer tries to cover all the corner cases.

Openzeppelin solution

This is how OpenZeppelin accomplishes it. codeEach balance stores a struct
struct Snapshots {
    uint256[] ids;
    uint256[] values;
}

mapping(address => Snapshots) private _accountBalanceSnapshots;

function balanceOfAt(address account, uint256 snapshotId) public view virtual returns (uint256) {
    (bool snapshotted, uint256 value) = _valueAt(snapshotId, _accountBalanceSnapshots[account]);

    return snapshotted ? value : balanceOf(account);
}
Inside the user balances, we store a struct which has an array of ids and values. The array of ids is a monotonically increasing snapshot id, and the values are the balance when that id was the active snapshot.

Taking a snapshot

Here is the snapshot function. It simply increments the current snapshot id.
function _snapshot() internal virtual returns (uint256) {
    _currentSnapshotId.increment();

    uint256 currentId = _getCurrentSnapshotId();
    emit Snapshot(currentId);
    return currentId;
}
When a user makes a transfer in the new snapshot, the _beforeTokenTransfer hook is called, which has the following code.Both the receiver and the sender have _updateAccountSnapshot called.
// Update balance and/or total supply snapshots before the values are modified. This is implemented
// in the _beforeTokenTransfer hook, which is executed for _mint, _burn, and _transfer operations.
function _beforeTokenTransfer(address from, address to, uint256 amount) internal virtual override {
    super._beforeTokenTransfer(from, to, amount);

    if (from == address(0)) {
        // mint
        _updateAccountSnapshot(to);
        _updateTotalSupplySnapshot();
    } else if (to == address(0)) {
        // burn
        _updateAccountSnapshot(from);
        _updateTotalSupplySnapshot();
    } else {
        // transfer
        _updateAccountSnapshot(from);
        _updateAccountSnapshot(to);
    }
}
This is the _updateAccountSnapshot function that gets called
function _updateAccountSnapshot(address account) private {
    _updateSnapshot(_accountBalanceSnapshots[account], balanceOf(account));
}
Which in turn calls _updateSnapshot . The definition is below
function _updateSnapshot(Snapshots storage snapshots, uint256 currentValue) private {
    uint256 currentId = _getCurrentSnapshotId();
    if (_lastSnapshotId(snapshots.ids) < currentId) {
        snapshots.ids.push(currentId);
        snapshots.values.push(currentValue);
    }
}
Because the currentId was just incremented, the if statement will be true. Inside the snapshots array, the current balance will be appended. Because this was called in the _beforeTokenTransfer hook, this reflects the balance before it gets changed.Hence, once the snapshot ID increases, any transfers that happen after the snapshot transaction will store the balances before the transaction takes place and store that into the array. This effectively “freezes” everyone’s current balance because any transfer that happens after the snapshot causes the “old” value to get stored. What happens if two snapshots happen, but an address does not transact during those snapshots? In that case, the snapshot ids will not be contiguous.Because of this, we cannot access an accounts balance at a snapshot by doing “ids[snapshotId]”. Instead, binary search is used to find the snapshot id the user is requesting. If the id is not found, then we use the previous adjacent snapshot value. For example, if we want to know a user’s balance at snapshot 5, but they didn’t transfer tokens during snapshots 3 and 4, we would look at snapshot 2.

Total supply is tracked the same way

The reader may note that the struct Snapshots has seemingly overgeneric variable names, like ids and values. Shouldn’t it just be named “balance” to be more precise? ERC20 Snapshot keeps track of the total supply using the same strategy, so the variable names capture the fact that the same struct is used both for tracking user balances and the total supply.Only mint and burn change the total supply, so when these functions are invoked, the struct storing the total supply is checked to see if the snapshot has changed before updating these values.Note that historic allowance values are not snapshotted.

Added gas cost

Regular transfers are more expensive because we check if the last id in the ids of the user matches the current snapshot and add a new id if that is not the case. Appending to the array of ids and values will incur two extra SSTOREs. When a new snapshot occurs, the first transfer to or from an address will be more expensive. But the second transaction will cost approximately the same as a transfer in a regular ERC20 token.

Getting hacked

If someone takes out a flashloan and creates a snapshot in the same transaction, they can artificially inflate their voting power. If the tokens can be borrowed at a low interest rate, and an attacker knows when the next snapshot will occur, they can borrow tokens leading up to the snapshot to accomplish something similar. However, a flashloan will not be a viable way to inflate voting power, since they will need the balance to stay high during a separate snapshot transaction.

Tallying votes

This is simply the balance of an address divided by the total supply, all at a particular snapshot.Originally Published Feb 22, 2023

20 Common Solidity Beginner Mistakes

20 Common Solidity Beginner Mistakes Our intent is not to be patronizing towards developers early in their journey with this article. Having reviewed code from numerous Solidity developers, we’ve seen some mistakes occur more frequently and we list those here. By no means is this an exhaustive list of mistakes a Solidity developer can make. […]

Smart Contract Foundry Upgrades with the OpenZeppelin Plugin

Smart Contract Foundry Upgrades with the OpenZeppelin Plugin Upgrading a smart contract is a multistep and error-prone process, so to minimize the chances of human error, it is desirable to use a tool that automates the procedure as much as possible. Therefore, the OpenZeppelin Upgrade Plugin streamlines deploying, upgrading and managing smart contracts built with Foundry or […]

UUPS: Universal Upgradeable Proxy Standard (ERC-1822)

UUPS: Universal Upgradeable Proxy Standard (ERC-1822) The UUPS pattern is a proxy pattern where the upgrade function resides in the implementation contract, but changes the implementation address stored in the proxy contract via a delegatecall from the proxy. The high level mechanism is shown in the animation below: Similar to the Transparent Upgradeable Proxy, the […]

Try Catch and all the ways Solidity can revert

Try Catch and all the ways Solidity can revert This article describes all the kinds of errors that can happen when a smart contract is called, and how the Solidity Try / Catch block responds (or fails to respond) to each of them. To understand how Try / Catch works in Solidity, we must understand […]