Found Academy useful? A $5 donation by May 14 helps us ship more, faster. Every donor counts (QF matching).

Donate

Section 3 of 18

Build
+10 Lynx

Vault: Share Token (ERC-20 + Permit)

What You Are Building

The vault IS its own ERC-20. The share token is the receipt for a pool stake; users hold it instead of the underlying. Section 4 will build the math that gives those shares value. This section builds the surface that lets users move them: transfer, transferFrom, approve, plus the internal primitives _mint, _burn, _transfer that later sections call from deposit and withdraw. We add EIP-2612 permit so users can approve via a signed message instead of a separate transaction.

We build ERC-20 from scratch, not by inheriting OpenZeppelin. Same choice Uniswap V2 made (UniswapV2ERC20 is hand-written) and Compound V2 made. The reason is pedagogical: every state mutation is visible in this file, no abstraction hides what _mint actually does.

The Surface

balanceOf, allowance, totalSupply, name, symbol, decimals, nonces were declared as public mappings or fields in section 2. Solidity auto-generates view functions for public state, so we get the ERC-20 read interface for free. This section adds the mutating functions only.

transfer(address to, uint256 amount) is the simple case: move shares from msg.sender to to, return true. Internally it calls _transfer.

transferFrom(address from, address to, uint256 amount) adds the allowance dance. We read allowance[from][msg.sender], require it covers amount, decrement (unless it equals type(uint256).max, which we treat as infinite), then _transfer.

function transferFrom(address from, address to, uint256 amount) external returns (bool) {
    uint256 allowed = allowance[from][msg.sender];
    if (allowed != type(uint256).max) {
        require(allowed >= amount, "Vault/insufficient-allowance");
        unchecked {
            allowance[from][msg.sender] = allowed - amount;
        }
        emit Approval(from, msg.sender, allowance[from][msg.sender]);
    }
    _transfer(from, to, amount);
    return true;
}

The infinite-allowance shortcut matters for gas: many DeFi integrations approve type(uint256).max once and never re-approve. Decrementing on every transferFrom is a wasted SSTORE for those flows.

approve(address spender, uint256 amount) overwrites the allowance (no increase/decrease helper). Callers should approve to 0 before re-approving for safety, or use permit.

The Internal Primitives

_transfer, _mint, _burn are internal. They are the only places that touch balanceOf and totalSupply. Sections 5 (deposit -> _mint), 14 (withdraw -> _burn), and 16 (fee minting -> _mint) call them.

_transfer requires to != address(0) and to != address(this) (sending shares to the vault itself locks them forever; better to refuse than to silently brick). The unchecked block is safe because we check fromBalance >= amount first.

_mint requires to != address(0) and increments totalSupply and balanceOf[to]. The Transfer(address(0), to, amount) event is the ERC-20 convention for mint.

_burn decrements balanceOf[from] and totalSupply. The Transfer(from, address(0), amount) event is the ERC-20 convention for burn.

Each function is short, but they are the load-bearing surface. Every share that enters or leaves circulation passes through one of them.

EIP-2612 Permit

permit lets a user approve a spender via an off-chain signed message, then the spender (or anyone) submits the signature on-chain. This avoids a separate approve transaction, which matters for gas-conscious UX and for protocols that want to compose approval into the same transaction as the consumption.

The mechanism is EIP-712 typed-data signing. The signed payload is a struct with the typehash, owner, spender, value, nonce, and deadline. Recover the signer from (v, r, s); if it equals owner and the deadline has not passed, set the allowance.

function permit(
    address owner, address spender, uint256 value, uint256 deadline,
    uint8 v, bytes32 r, bytes32 s
) external {
    require(deadline >= block.timestamp, "Vault/permit-expired");
    require(owner != address(0), "Vault/zero-owner");

    bytes32 structHash = keccak256(
        abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline)
    );
    bytes32 digest = keccak256(
        abi.encodePacked("\x19\x01", DOMAIN_SEPARATOR(), structHash)
    );
    address recovered = ecrecover(digest, v, r, s);
    require(recovered == owner, "Vault/invalid-signature");

    allowance[owner][spender] = value;
    emit Approval(owner, spender, value);
}

PERMIT_TYPEHASH was declared as a constant in section 2. It is keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)"). The exact string matters; signers compute the typehash from this string when building the signature. Get it wrong by a single character and every permit signature fails.

nonces[owner]++ is the replay defense. Each owner has a counter that increments on every successful permit. A signature for nonce 0 cannot be used twice; the second attempt fails because the recovered signer no longer matches when the nonce is 1.

DOMAIN_SEPARATOR

EIP-712 requires a domain separator to scope signatures to a specific (chain, contract) pair. Without it, a signature valid for vault A on chain X could be replayed against vault B on chain Y if they shared name. The domain separator includes chainid and address(this), eliminating that risk.

function DOMAIN_SEPARATOR() public view returns (bytes32) {
    return keccak256(
        abi.encode(
            keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)"),
            keccak256(bytes(name)),
            keccak256(bytes("1")),
            block.chainid,
            address(this)
        )
    );
}

Yearn V2 caches DOMAIN_SEPARATOR at deployment. We compute it lazily on every permit call. The trade-off is a few thousand gas per permit in exchange for chain-fork safety: if the chain forks (think: a hard fork that creates two chains with different chain IDs), the cached separator on one fork would no longer match chainid, and signatures could be replayed across forks. Recomputing eliminates that. The gas cost is small in the context of an already-ecrecover-heavy operation.

Comparison to Uniswap V2

Uniswap V2 inherited from UniswapV2ERC20, also self-built, also no OpenZeppelin. Same _mint / _burn pattern. Same EIP-2612 permit. The differences are cosmetic: Uniswap uses transferFrom without the infinite-allowance shortcut (was less common in 2020); Uniswap caches the domain separator.

The architectural pattern is identical: the AMM contract is also its own ERC-20 (the LP token). The vault contract is also its own ERC-20 (the share token). The receipt token IS the contract.

Your Task

The starter has the function signatures, NatSpec, and the storage already declared (from section 2). Implement:

  1. transfer, call _transfer and return true.
  2. transferFrom, handle the infinite-allowance shortcut, decrement otherwise, emit Approval, call _transfer.
  3. approve, overwrite allowance, emit Approval.
  4. permit, build the EIP-712 digest, ecrecover, set allowance.
  5. DOMAIN_SEPARATOR, return the EIP-712 domain hash including block.chainid and address(this).
  6. _transfer, check to != 0 and to != address(this), decrement from, increment to, emit Transfer.
  7. _mint, check to != 0, increment totalSupply and balance, emit Transfer from address(0).
  8. _burn, check balance, decrement balance and totalSupply, emit Transfer to address(0).

Section 4 builds the share-math that decides how many shares get minted on deposit and how many tokens get returned on withdraw. The math you build there will call the _mint and _burn you build here.

Your Code

Solution.sol
Solidity
Loading editor...

Requirements

Write your implementation, then click Run Tests. Tests execute on the server.