// SPDX-License-Identifier: MIT pragma solidity 0.8.26; /** * Varve — an ERC-4626 vault that writes its own price history into the log. * * WHY THIS EXISTS. ERC-4626 defines exactly two events, `Deposit` and * `Withdraw`, and both of them are about what a HOLDER did. Nothing in the * standard emits when the vault's own value changes: income arrives as a * silent increase in `totalAssets()`, a loss as a silent decrease. The share * price lives in state, and state is the one thing a chain is allowed to * forget. * * On Robinhood Chain it forgets it in about ten minutes. `eth_getLogs` answers * from block 1; `eth_getBalance`, `eth_getStorageAt` and `eth_call` at a block * more than ~6,200 behind head answer `metadata is not found`. So on this * chain the question "what was my position worth an hour ago" is not * expensive, it is UNANSWERABLE — unless somebody wrote the answer into a log * while it was still true. * * So this vault writes a LAYER on every mutation that can move the share * price, including the two the standard has no event for at all — income * arriving and value being marked down. A varve is the couplet of sediment a * lake lays down in one year: the reason a varve chronology can date something * 50,000 years old is that nothing reconstructs a layer afterwards. It had to * be laid down at the time. * * `chronology` is the part that makes "reconstructible from the log" checkable * rather than asserted: one storage word, a running commitment to every layer * ever written. A reader replays the `Layer` logs, recomputes the accumulator, * and compares it to the word the contract holds NOW. A missing layer, a * reordered one, or a fabricated one all give a different word. Ten minutes of * state is enough to audit an unbounded history, because one word is enough. * * WHAT IS DELIBERATELY NOT HERE. There is no strategy address, no upgrade * path, no owner who can move the assets, and no fee. Assets sit in this * contract. Income arrives by somebody transferring the asset in — which is * how a fee router or a keeper actually pays a vault — and anyone may call * `settle()` to book it. `report()` is the one trusted entry point and it is * disabled outright when `steward` is the zero address, which is what the * canonical deployment uses. */ interface IERC20 { function transfer(address to, uint256 amount) external returns (bool); function transferFrom(address from, address to, uint256 amount) external returns (bool); function balanceOf(address who) external view returns (uint256); function decimals() external view returns (uint8); } contract Varve { /* ------------------------------------------------------------------ * * The layer * ------------------------------------------------------------------ */ /** Every mutation that can move the share price writes one of these. `assets` and `supply` together ARE the share price at this block, to the last unit, with no oracle and no division performed by the writer. `kind` says which of the four things happened. */ event Layer(uint64 indexed n, uint8 indexed kind, uint128 assets, uint128 supply); uint8 public constant GENESIS = 0; uint8 public constant DEPOSIT = 1; uint8 public constant WITHDRAW = 2; uint8 public constant ACCRUE = 3; /* income — the standard has no event for this */ uint8 public constant MARKDOWN = 4; /* loss — the standard has no event for this */ /** How many layers have been written. */ uint64 public laminae; /** A running commitment to every layer, in order. One word, readable now, that pins a history the chain's state can no longer answer for. */ bytes32 public chronology; /* ------------------------------------------------------------------ * * ERC-20 * ------------------------------------------------------------------ */ string public name; string public symbol; uint8 public immutable decimals; uint256 public totalSupply; mapping(address => uint256) public balanceOf; mapping(address => mapping(address => uint256)) public allowance; event Transfer(address indexed from, address indexed to, uint256 value); event Approval(address indexed owner, address indexed spender, uint256 value); /* ------------------------------------------------------------------ * * ERC-4626 * ------------------------------------------------------------------ */ address public immutable asset; event Deposit(address indexed sender, address indexed owner, uint256 assets, uint256 shares); event Withdraw( address indexed sender, address indexed receiver, address indexed owner, uint256 assets, uint256 shares ); /** Virtual share offset. This is the SECOND lock on the inflation attack, not the first — `_assets` below is tracked, so a donation cannot move the share price at all and the attack is already closed. The offset is kept because it also sets the width of the rounding step, and a vault seeded small drifts into a coarse one whether or not anybody is attacking it. */ uint8 public immutable offset; /** TRACKED ACCOUNTING, not `asset.balanceOf(this)`. A vault that reads its own balance moves its share price when a stranger sends it a token. */ uint256 internal _assets; /** Value a steward has written off. It is subtracted from what `settle()` is willing to book, and it never comes back. THE FUZZER FOUND THIS AND IT WAS A REAL HOLE. A markdown reduces `_assets` while the tokens stay physically in this contract, so without this counter the surplus a markdown creates looks exactly like income that has not been booked yet — and the very next `settle()`, from anybody, re-books it and silently undoes the loss. The property suite could not see it: it takes a markdown FOLLOWED BY a settle, and no property did those two in that order. A randomised run of forty operations did it twice in four seeds. */ uint256 internal _writtenOff; /** May mark the position up or down, within the bounds below. Zero means nobody can, and the vault has no trusted party at all. */ address public immutable steward; /** Hard bound on one report, in parts per million of current assets. */ uint32 public immutable reportCapPpm; /** Minimum blocks between two reports. */ uint32 public immutable reportGap; uint64 public lastReport; error NotSteward(); error TooSoon(); error TooLarge(); error Zero(); error Insufficient(); error Reentrant(); /* A mutex is required BECAUSE deposit measures the balance either side of `transferFrom` to credit what actually arrived. That is the right way to handle a fee-on-transfer asset and it is exactly what a token with a transfer hook exploits: a reentrant deposit lands between the two measurements and the outer caller is credited the inner caller's tokens. Before that change this contract genuinely needed no mutex. After it, it did. */ uint256 private _lock = 1; modifier lock() { if (_lock != 1) revert Reentrant(); _lock = 2; _; _lock = 1; } constructor( address asset_, string memory name_, string memory symbol_, uint8 offset_, address steward_, uint32 reportCapPpm_, uint32 reportGap_ ) { asset = asset_; name = name_; symbol = symbol_; /* The share token is `offset` decimals finer than the asset, so one whole share is one whole asset at a price of one. Quoting a price in SHARE UNITS instead makes every price view return the integer 1 forever. */ decimals = IERC20(asset_).decimals() + offset_; offset = offset_; steward = steward_; reportCapPpm = reportCapPpm_; reportGap = reportGap_; lastReport = uint64(block.number); /* The genesis layer exists so that a reader replaying the log has the vault's starting point rather than inferring it from the first deposit. An empty vault is a fact about the price series. */ _layer(GENESIS); } /* ------------------------------------------------------------------ * * Writing the record * ------------------------------------------------------------------ */ function _layer(uint8 kind) internal { uint64 n = laminae; uint128 a = uint128(_assets); uint128 s = uint128(totalSupply); chronology = keccak256(abi.encode(chronology, n, kind, a, s, uint64(block.number))); emit Layer(n, kind, a, s); unchecked { laminae = n + 1; } } /** Recompute this off the logs and compare. `n` must be `laminae`. */ function fold(bytes32 prev, uint64 n, uint8 kind, uint128 a, uint128 s, uint64 blk) external pure returns (bytes32) { return keccak256(abi.encode(prev, n, kind, a, s, blk)); } /* ------------------------------------------------------------------ * * Accounting * ------------------------------------------------------------------ */ function totalAssets() public view returns (uint256) { return _assets; } function _toShares(uint256 a, bool up) internal view returns (uint256) { uint256 num = a * (totalSupply + 10 ** offset); uint256 den = _assets + 1; return up ? (num + den - 1) / den : num / den; } function _toAssets(uint256 s, bool up) internal view returns (uint256) { uint256 num = s * (_assets + 1); uint256 den = totalSupply + 10 ** offset; return up ? (num + den - 1) / den : num / den; } function convertToShares(uint256 a) external view returns (uint256) { return _toShares(a, false); } function convertToAssets(uint256 s) external view returns (uint256) { return _toAssets(s, false); } function previewDeposit(uint256 a) public view returns (uint256) { return _toShares(a, false); } function previewMint(uint256 s) public view returns (uint256) { return _toAssets(s, true); } function previewWithdraw(uint256 a) public view returns (uint256) { return _toShares(a, true); } function previewRedeem(uint256 s) public view returns (uint256) { return _toAssets(s, false); } function maxDeposit(address) external pure returns (uint256) { return type(uint256).max; } function maxMint(address) external pure returns (uint256) { return type(uint256).max; } function maxWithdraw(address o) external view returns (uint256) { return _toAssets(balanceOf[o], false); } function maxRedeem(address o) external view returns (uint256) { return balanceOf[o]; } /** The share price, quoted per WHOLE share so it has resolution. */ function pricePerShare() external view returns (uint256) { return _toAssets(10 ** decimals, false); } /* ------------------------------------------------------------------ * * Entering and leaving * ------------------------------------------------------------------ */ function deposit(uint256 assets_, address receiver) external lock returns (uint256 shares) { if (assets_ == 0) revert Zero(); uint256 got = _pull(assets_); shares = _toShares(got, false); if (shares == 0) revert Zero(); _assets += got; _mint(receiver, shares); emit Deposit(msg.sender, receiver, got, shares); _layer(DEPOSIT); } function mint(uint256 shares, address receiver) external lock returns (uint256 assets_) { if (shares == 0) revert Zero(); assets_ = _toAssets(shares, true); uint256 got = _pull(assets_); /* What ARRIVED is what is credited. A fee-on-transfer asset means the caller gets the shares they asked for only if the tokens turned up; otherwise the mint is short and everyone else would pay for it. */ if (got < assets_) revert Insufficient(); _assets += got; _mint(receiver, shares); emit Deposit(msg.sender, receiver, got, shares); _layer(DEPOSIT); } function withdraw(uint256 assets_, address receiver, address owner) external lock returns (uint256 shares) { shares = _toShares(assets_, true); _spend(owner, shares); _assets -= assets_; _burn(owner, shares); IERC20(asset).transfer(receiver, assets_); emit Withdraw(msg.sender, receiver, owner, assets_, shares); _layer(WITHDRAW); } function redeem(uint256 shares, address receiver, address owner) external lock returns (uint256 assets_) { assets_ = _toAssets(shares, false); _spend(owner, shares); _assets -= assets_; _burn(owner, shares); IERC20(asset).transfer(receiver, assets_); emit Withdraw(msg.sender, receiver, owner, assets_, shares); _layer(WITHDRAW); } /* ------------------------------------------------------------------ * * The two the standard has no event for * ------------------------------------------------------------------ */ /** Book whatever asset has turned up since the last time anybody looked. Permissionless: there is no way to abuse it, because it can only ever move the share price UP and it moves it by exactly what arrived. */ function settle() external lock returns (uint256 income) { uint256 held = IERC20(asset).balanceOf(address(this)); /* The floor is what is claimed PLUS what has been written off. Tokens behind a write-off are stranded here on purpose: they were declared lost, and letting the next caller book them as income would reverse a loss the holders have already taken. */ uint256 floor = _assets + _writtenOff; if (held <= floor) return 0; unchecked { income = held - floor; } _assets = held - _writtenOff; _layer(ACCRUE); } /** Tokens held here that no longer back a share. */ function writtenOff() external view returns (uint256) { return _writtenOff; } /** Mark the position, for a vault whose steward reports a position held somewhere this contract cannot see. Bounded in size and in cadence, and unavailable at all when `steward` is zero. */ function report(int256 delta) external lock { if (steward == address(0) || msg.sender != steward) revert NotSteward(); if (block.number < lastReport + reportGap) revert TooSoon(); uint256 mag = delta < 0 ? uint256(-delta) : uint256(delta); if (mag == 0) revert Zero(); if (mag > (_assets * reportCapPpm) / 1_000_000) revert TooLarge(); lastReport = uint64(block.number); if (delta > 0) { _assets += mag; _layer(ACCRUE); } else { _assets -= mag; _writtenOff += mag; _layer(MARKDOWN); } } /* ------------------------------------------------------------------ * * Plumbing * ------------------------------------------------------------------ */ function _pull(uint256 want) internal returns (uint256 got) { uint256 before = IERC20(asset).balanceOf(address(this)); IERC20(asset).transferFrom(msg.sender, address(this), want); got = IERC20(asset).balanceOf(address(this)) - before; } function _spend(address owner, uint256 shares) internal { if (msg.sender == owner) return; uint256 a = allowance[owner][msg.sender]; if (a == type(uint256).max) return; if (a < shares) revert Insufficient(); unchecked { allowance[owner][msg.sender] = a - shares; } } function _mint(address to, uint256 s) internal { totalSupply += s; unchecked { balanceOf[to] += s; } emit Transfer(address(0), to, s); } function _burn(address from, uint256 s) internal { uint256 b = balanceOf[from]; if (b < s) revert Insufficient(); unchecked { balanceOf[from] = b - s; totalSupply -= s; } emit Transfer(from, address(0), s); } function transfer(address to, uint256 v) external returns (bool) { uint256 b = balanceOf[msg.sender]; if (b < v) revert Insufficient(); unchecked { balanceOf[msg.sender] = b - v; balanceOf[to] += v; } emit Transfer(msg.sender, to, v); return true; } function transferFrom(address from, address to, uint256 v) external returns (bool) { if (msg.sender != from) { uint256 a = allowance[from][msg.sender]; if (a != type(uint256).max) { if (a < v) revert Insufficient(); unchecked { allowance[from][msg.sender] = a - v; } } } uint256 b = balanceOf[from]; if (b < v) revert Insufficient(); unchecked { balanceOf[from] = b - v; balanceOf[to] += v; } emit Transfer(from, to, v); return true; } function approve(address spender, uint256 v) external returns (bool) { allowance[msg.sender][spender] = v; emit Approval(msg.sender, spender, v); return true; } }