The contracts

Two contracts, both deliberately boring, and the six decisions worth knowing about them.

ContractWhat it is
DeedVaultOpenZeppelin ERC4626 over USDG. Deposit cap, pausable deposits, no admin withdrawal path.
RollRegistryAppend-only record of each monthly Roll: docHash, navPerShare, uri.

contracts/INTERFACE.md in the repository is the contract of record. Change it first, then the code.

DeedVault

asset()                                  -> USDG
totalAssets()                            -> USDG.balanceOf(vault)
decimals()                               -> 18
deposit(uint256 assets, address to)      -> uint256 shares
mint(uint256 shares, address to)         -> uint256 assets
redeem(uint256 shares, address to, address owner)    -> uint256 assets
withdraw(uint256 assets, address to, address owner)  -> uint256 shares
convertToAssets(uint256 shares)          -> uint256 assets
maxDeposit(address)                      -> uint256
cap()                                    -> uint256
addCapital(uint256 amount)               -- anyone
pause() / unpause() / setCap(uint256)    -- owner only

totalAssets is the balance, unmodified

totalAssets() is USDG.balanceOf(vault) — the OpenZeppelin default, not overridden. Nothing pushes a valuation on chain, so yield arrives as a plain ERC-20 transfer and the share price is whatever the vault holds.

addCapital is therefore not privileged: anyone can raise the share price by sending USDG to the vault, and there is nothing to trust about it.

The inflation defence

_decimalsOffset() is 12, which is one decision doing two jobs: vDEED gets 6 + 12 = 18 decimals, and the vault runs with 1e12 virtual shares against one virtual asset.

That matters here more than in most vaults. Because yield arrives as a donation, the classic first-depositor / inflation attack is live by construction, not hypothetical. Virtual shares are mandatory rather than optional, and test/InflationAttack.t.sol is the proof.

The cap and the pause live in maxDeposit

Both are enforced through maxDeposit and maxMint, not a bare require, so deposit and mint revert with the standard ERC4626ExceededMaxDeposit and ERC4626ExceededMaxMint errors and a front end learns the truth before it sends a transaction.

  • The cap counts everything the vault holds, donations included: a donation raises totalAssets() and shrinks the room left.
  • Lowering the cap below totalAssets() is safe — maxDeposit returns 0 rather than reverting or underflowing, and no holder balance moves.
  • The pause blocks deposits only. Redemption, withdrawal, share transfers and addCapital all keep working, and there is a test asserting it.

There is no way out for the owner

No rescue, no sweep, no emergency withdraw. The owner can pause, change the cap, and transfer ownership — that is the complete list. The test suite scans the deployed runtime bytecode for a list of admin-withdrawal selectors, so adding one later fails a test rather than a code review.

renounceOwnership() is overridden to revert on both contracts. Renouncing would freeze the cap and the pause forever, which is a worse outcome than having an owner.

There is no exit fee

There was a retained 0.25% one, carried over from copy written before any contract existed. Because the fee stayed in the vault, each slice of a staggered exit repriced its own fee onto the leaver's remaining shares — so 500,000 USDG paid 1,250.000000 in one call and 1.273604 across a thousand.

It was deleted rather than patched, because it bought nothing. The vault holds only USDG and pays out in the same transaction, so there is no liquidity cost to recover; the first-depositor defence is the decimals offset; and the one thing a fee could have deterred — depositing just before rent lands and leaving just after — 25 bps did not cover, because a month moves the price by more than that.

previewRedeem is now plain convertToAssets, so a quote and a conversion agree.

RollRegistry

publish(uint16 n, bytes32 docHash, uint256 navPerShare, string uri)  -- owner only
rolls(uint16 n) -> (bytes32 docHash, uint256 navPerShare, string uri, uint64 at)
latest()        -> uint16
event RollPublished(uint16 indexed n, bytes32 docHash, uint256 navPerShare, string uri, uint64 at)

docHash is the SHA-256 of the canonical CSV described in The published file format — the same bytes the report page hashes in the browser and the same bytes the download contains. navPerShare is carried at 12 decimals.

Publishing a roll number that already exists reverts. Rolls are append-only: no update, no delete. A mistyped figure is permanent and a correction is a later Roll saying so.

What a front end may assume

  • The share price shown to a reader is convertToAssets(1e18), read from the chain. Do not compute it from a ledger.
  • A deposit is approve then deposit. Check allowance first and skip the approve when it is already sufficient.
  • A redemption is one redeem call. No queue, no request, no cancel.
  • Design for every state: not connected, wrong chain, below minimum, insufficient balance, approval pending, transaction pending, confirmed, reverted, cap reached, paused.