EIP-4626 Compatibility Study

Summary

There are currently 566 contracts implementing EIP-4626 Tokenized Vaults standard on Ethereum mainnet.

Of them, 69 contracts, including some notable ones like Savings DAI and Staked Yearn Ether, don't emit a Transfer event on mint/deposit/burn/redeem.

This could affect tokens being picked up by explorers and wallets, as well as require changes to data analytics pipelines to correctly account for all balance changes.

The results can be found here (.csv).

How it happened

The EIP-4626 standard defines Deposit and Withdraw events that contain all the needed info. The Transfer event comes from ERC-20 Token Standard, which says:

A token contract which creates new tokens SHOULD trigger a Transfer event with the _from address set to 0x0 when tokens are created.

It doesn’t contain a strict requirement (and auditors agree) as it doesn’t say anything about burning, but every data analyst hates it when a token doesn’t follow an implicit convention of Transfer(0x, receiver, amount) on mint and Transfer(owner, 0x, amount) on burn.

This problem can be traced to EIP-4626 example repo and most likely stems from it.

The problem doesn’t exist in other implementation like Snekmate and Solady.

What can be done

If you operate an explorer or a wallet, you can do the following:

  1. For Deposit(sender, owner, assets, shares) event check if there is an adjacent Transfer(0x0, owner, shares) and if it’s missing, insert it.

  2. For Withdraw(sender, receiver, owner, assets, shares) check for Transfer(owner, 0x0, shares) and add it if it’s missing.

Methodology

To find all EIP-4626 compatible tokens you can start from all the Deposit events. As often happens with events, there would be collisions with unrelated contracts that happen to share the same selector. The code here uses Ape and some portions could be shortened or omitted for brevity.

sdai = Contract('0x83F20F44975D03b1b09e64809B757c47f942BEeA')
log_filter = LogFilter.from_event(sdai.Deposit)
deposit_logs = list(chain.provider.get_contract_logs(log_filter))

This would pull all Deposit events emitted by all addresses. Do the same for Withdraw events. Now we need to filter out the incompatible contracts. EIP-4626 doesn’t require implementing EIP-165 supportsInterface method, so let’s do this by comparing a contract interface against the reference ABI.

abi = requests.get('https://raw.githubusercontent.com/fubuloubu/ERC4626/main/contracts/ERC4626.json').json()
erc4626 = ContractType.parse_obj({'abi': abi})

def is_erc4626(c: ContractType):
    events = all(e in c.events for e in erc4626.events)
    methods = all(m in c.methods for m in erc4626.methods)
    return events and methods

Next up we pull all the Transfer logs for found contracts.

log_filter = LogFilter.from_event(sdai.Transfer, addresses=erc4626_compatible_addrs)
transfer_logs = list(chain.provider.get_contract_logs(log_filter))

The only remaining thing is to find whether there was a matching Transfer event for each Deposit and Withdraw event and annotate the results.

def check_compat(addr):
    transfer_tuples = {tuple(log.event_arguments.values()) for log in transfer_logs_by_addr[addr]}

    try:    
        for log in deposit_logs_by_addr[addr]:
            assert len(log.event_arguments) == 4
            transfer_args = ZERO_ADDRESS, log['owner'], log['shares']
            assert transfer_args in transfer_tuples
        
        for log in withdraw_logs_by_addr[addr]:
            assert len(log.event_arguments) == 5
            transfer_args = log['owner'], ZERO_ADDRESS, log['shares']
            assert transfer_args in transfer_tuples
    except AssertionError:
        return False

    return True
Subscribe to banteg
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.