The contracts
Two contracts, both deliberately boring, and the six decisions worth knowing about them.
| Contract | What it is |
|---|---|
DeedVault | OpenZeppelin ERC4626 over USDG. Deposit cap, pausable deposits, no admin withdrawal path. |
RollRegistry | Append-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 —maxDepositreturns 0 rather than reverting or underflowing, and no holder balance moves. - The pause blocks deposits only. Redemption, withdrawal, share transfers and
addCapitalall 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
approvethendeposit. Checkallowancefirst and skip the approve when it is already sufficient. - A redemption is one
redeemcall. 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.