The vault
24 properties, run on this chain
There is no toolchain here, no testnet and no funded account. An eth_call may carry a state override, so the compiled suite is placed at a scratch address and a public Robinhood Chain node executes the whole thing — constructors, reverts, transfers and all — for free, on the same EVM the app deploys to. Last run at block 55,462,976.
Every property
| # | Property | Result |
|---|---|---|
| P1 | the first deposit mints, and redeeming it all returns exactly what went in | held |
| P2 | rounding never favours the holder, at every size | held |
| P3 | a stray transfer moves no price at all | held |
| P4 | the donation attack is closed by the accounting, with the offset switched off entirely | held |
| P5 | deposit mints exactly previewDeposit, and redeem pays exactly previewRedeem | held |
| P6 | mint costs exactly previewMint, and withdraw burns exactly previewWithdraw | held |
| P7 | totalAssets tracks the accounting rather than the token balance | held |
| P8 | a cut above the cap reverts — from the owner and from the constructor — and one at the cap is accepted | held |
| P9 | a stranger reaches none of the three owner-only functions | held |
| P10 | harvest charges exactly the cut on the income and no more | held |
| P11 | accrued cut sits outside every holder's claim and survives a full exit | held |
| P12 | collect cannot move a wei more than has been accrued, and never touches principal | held |
| P13 | another holder arriving or leaving moves the share price by nothing | held |
| P14 | transferring a share moves the claim and moves no price | held |
| P15 | a zero deposit, a zero redemption and the zero address all revert | held |
| P16 | allowance is respected, and an infinite one does not decay | held |
| P17 | everybody leaving empties the supply and strands no principal | held |
| P18 | THE FOLD IS EXACT: dust() mints zero, and one wei more mints one | held |
| P19 | THE STEP IS EXACT: both ends of step() mint the same shares, and one wei outside each does not | held |
| P20 | foldLoss() matches a real deposit followed by a real redemption, to the wei | held |
| P21 | a larger offset strictly NARROWS the dead zone | held |
| P22 | an offset outside the bound reverts rather than being clamped | held |
| P23 | a fee-on-transfer token credits only what actually arrived | held |
| P24 | the ERC-4626 view surface is complete and its limits are honoured | held |
Sabotage
A suite that passes is evidence of nothing until you have watched it bite
11 copies of the contract are compiled with one deliberate defect each and put through the same properties. Each must come back with a hole, and the runner names which property found it. One is expected to survive every property, and is in the file for exactly that reason: without it, a suite with one permanently-red property would appear to catch everything.
totalAssets is read from the token balance
the classic donation vector, and the reason the accounting is tracked at all.
Found by P1, P3, P4, P7, P10(revert), P11(revert), P12(revert), P13, P17, P18(revert), P19(revert), P20(revert), P21(revert), P23.
previewDeposit rounds up instead of down
the direction of a single floor is the whole of who absorbs the remainder. Rounded up, the depositor is issued shares worth more than they paid, and the wei comes out of everybody already in the vault.
Found by P18, P19, P20.
previewRedeem rounds up instead of down
the same wei, taken on the way out. A round trip then returns more than it cost, which is a slow drain rather than a theft, and is invisible per trade.
Found by P20.
dust() is off by one
the site's headline number. Off by one it is not a dead zone but a claim that the first share is free, and only a real deposit can tell the difference.
Found by P18.
step() reports a half-open interval as a closed one
the missing −1 makes `hi` the first size that mints one share MORE, so the app would tell a depositor they are getting a share they are not.
Found by P19.
the cut cap is removed from setCut
the cap is the only thing standing between a holder and a 100% fee on income. The constructor still checks, so a reader auditing deployment sees a capped vault.
Found by P8.
collect saturates instead of reverting
asking for more than has accrued silently takes everything accrued and pushes the requested amount, which reaches principal. A revert and a clamp look the same from outside until the amount is wrong.
Found by P12.
the offset bound is removed from the constructor
the app lets a deployer choose the offset. Unbounded, a large one pushes the conversion multiplications toward overflow and makes the vault unusable in a way that only shows up at size.
Found by P22.
the accounting credits what was asked for, not what arrived
a fee-on-transfer token delivers less than the request. Crediting the request mints shares against assets the vault never received, and the shortfall is paid silently by everyone already in it.
Found by P23.
maxRedeem reports more than the holder owns
a limit that does not match what the vault will actually do is worse than no limit: an integrator sizes a redemption on it and the transaction reverts.
Found by P24.
an owner-only rescue() is added
it breaks NO property. Every invariant in the suite still holds, and the owner can take the principal. Only a mechanical read of the compiled ABI against the written-down surface can see a function that was added rather than changed.
Found by no property at all and by the ABI surface check.
The attack, four times
The offset is a second lock on a door the accounting already shut
The textbook ERC-4626 inflation attack: the attacker takes the first share for one wei, transfers a large amount straight to the vault, and the victim deposits afterwards — deliberately less than the donation. Run against Cusp and against a vault identical to it except that totalAssets() reads balanceOf, at two offsets.
| Accounting | Offset | Victim put in | Victim can take out | Kept |
|---|---|---|---|---|
| Tracked in storage Cusp | 0 | 500 tokens | 500.0000 tokens | 100.0000% |
| Tracked in storage Cusp | 6 | 500 tokens | 500.0000 tokens | 100.0000% |
| Read from balanceOf | 0 | 500 tokens | 0.0000 tokens | 0.0000% |
| Read from balanceOf | 6 | 500 tokens | 499.9997 tokens | 99.9999% |
The comparison vault is not a straw man built to lose: it is Cusp's arithmetic with one function changed, and its source is in contracts/Attack.sol next to the result. What the run says is that either defence alone is sufficient, and that the one with a cost — the fold — is the one that is optional.
The written-down surface
The only check that can see a function that was added
Every state-changing function the vault is allowed to expose is written down; the check is that the compiled ABI contains no others. A hand-written list of "functions the owner can call" would test the list. This is what catches an owner-only rescue(), which breaks no invariant that exists and hands the owner the principal.
- deposit(uint256,address)
- mint(uint256,address)
- redeem(uint256,address,address)
- withdraw(uint256,address,address)
- harvest(uint256)
- setCut(uint16)
- collect(address,uint256)
- transfer(address,uint256)
- transferFrom(address,address,uint256)
- approve(address,uint256)
Unexpected on the last run: none.
Where it runs
And what is not deployed
contract
Compiled and executedCusp.sol compiles with solc 0.8.26 and its properties run on Robinhood Chain's own EVM through an eth_call state override on every build. It is not deployed to a fixed address and has not been audited.
app
Deploys from your walletThe app deploys the contract from your own address. Every write is an ordinary signed transaction; no key is handled anywhere in this repo.
measurement
Read from chainAsset list, decimals and prices are read from Robinhood Chain with no API key and no proxy. The fold figures are computed from those.
token
Does not existThere is no Cusp token and no contract address to copy.