Security · the review

Found, fixed, and pinned by a test.

The contracts went through two review passes. In the second, seven attacker lenses read the code independently, and every finding then had to survive three reviewers whose only job was to refute it. Here is what they found, what changed, the review's own bug, and what the contract still does not protect you from.

Illustration not to scalea finding counts only when it survives all three

No external audit yet. The review on this page is internal, and a small surface with a green test suite is not an audit.

2
review passesthe first writtenwith the contract
7
attacker lenseseach read the codeon its own
3
refuters per findingtheir only job:refute it
318
offline tests16 suitesas of 23 Sep 2026

How the review ran

Two passes, and the second one was trying to break it.

The first pass was written alongside the contract. The second was adversarial: it read the code the way attackers would, and it found things that 248 green tests had not. Nothing counted as a finding until it had survived an attempt to knock it down.

  1. 01

    First pass

    Written alongside the contract: inflation, taxed coins, the swap's price check, pauses, keys, reentrancy. Each finding a test.

  2. 02

    Seven attacker lenses

    The second pass read the code as seven different attackers would, each lens on its own, none seeing the others' notes.

  3. 03

    Three refuters

    Every finding then went to three reviewers whose only job was to refute it. A finding that survived all three was real.

  4. 04

    Fix, then a test

    What survived was fixed and pinned by a test. “Red before” means that test was run against the code before its fix, and failed.

The review's own bug

A crashed refuter was counted as a refutation.

The refutation round hit a session limit at 59 of 145 agents. The run then counted every finding whose refuters had crashed as refuted: 28 of its 46 findings had no vote at all, and the report said “41 refuted”.

On 22 Sep 2026 those 28 were taken one by one, each with a test. Every one of them is in the table below, with its verdict.

See the 28, one by one
Refutation agents145
ran · 59never ran: the session limit
Findings of that round46
had a voteno vote, counted as refuted · 28re-examined 22 Sep 2026, each with a test

one square per agent, one per finding · the run's own counts

Findings

Five findings, each with the number it was measured at.

Every figure below comes from a test, and carries the label of where it was measured. Where a picture is an illustration rather than a measurement, it says so.

01Second passfixed by vesting

The harvest was an instant, and one address owned that instant.

The keeper is the only address that can make the rate move. While a harvest's gain was credited the moment it landed, the keeper could deposit in one block, harvest in the next and redeem in a third: no flash loan, no contract, no atomicity. It walked off with whatever share of the harvest its own capital bought, and every documented guard held throughout.

What changed. A harvest books its gain into a lock, and totalAssets() is the balance minus the part still locked. At the instant of a harvest both rise together, so the rate does not move at all; over the next six hours the lock falls to zero in a straight line. Capture is now bounded by time held, not by capital.

A lock that is running keeps its own start and end. Shortening the window cannot pull a gain forward and lengthening it cannot push principal down; the first half of that was the first fix's own hole, caught by a later pass. The harvest throttle gained a floor, so it can be lowered but never switched off.

The testA holder has 1,000,000 USDG in the vault. The keeper deposits 99,000,000 twelve seconds before its own harvest of 20,700 and redeems twelve seconds after it.

Test
test/unit/Vesting.t.sol · test_keeperCannotStandInFrontOfItsOwnHarvest
Asserts
the keeper's take stays under 0.1% of the gain; the value itself is logged
Also
test_shorteningTheWindowCannotPullAnOutstandingLockForward · test_harvestIntervalCannotBeSwitchedOff

Before the gain lands in one instant

timeharvest 

the whole step falls inside the keeper's 24 seconds

Now the gain is released over six hours

timeharvest+6 h

the same 24 seconds now hold a sliver of the line

the keeper's window, in and outshare rateIllustration not to scale

Before the fixpre-fix

20,493of 20,700 to the keeper · 99%

keeper took 20,493holder kept 207

measured on the pre-fix contract (one-instant harvest), unit-test replay

After the fixtoday

11.38of 20,700 to the keeper · 0.055%

keeper's take: 11.38the rest vests into the rate

unit test, test/unit/Vesting.t.sol

02Second passfixed: the floor comes from the pool

The keeper set the price floor it was meant to be held to.

Until this round the keeper supplied the minimum a harvest had to return. Zero was refused, but 1 was not, so a hostile keeper could push the pool off-price in the same block and sell the whole reward inventory into it.

What changed. The floor comes from the pool the swap goes through: its time-weighted price over the last 30 minutes, less the pool's own fee, less a 1% tolerance, checked on what the vault actually gained. A stricter keeper floor binds; a looser one is ignored. A pool without 30 minutes of history cannot price a harvest, so the harvest reverts with TwapUnavailable: the keeper's number never stands in for a missing price.

harvestFloor(swapIn) = 30-min pool price × (1 − pool fee) × (1 − 1%)defaults · window 10 min – 2 h · tolerance at most 10%
Test
test/fork/LiveRouter.t.sol · test_fork_manipulatedSpot_keeperFloorOfOne_isRefused
Asserts
the floor does not move in a same-block dump, and the harvest reverts; the prices are logged
Weight
removing the check turns 19 of the 318 offline tests red
2,477.780.9 WETH quote before tolerance · pool fee + 1% 2,450.62floor · unchanged 30-min pool average same block
1,500 WETH dump
2,369.67quote after the dump harvest(1)reverted · the reward stays in the vault

USDG for 0.9 WETH · price axis drawn to scale, time schematic

fork test against live state, 22 Sep 2026

03Second passfixed: rescue checks the effect

Rescue compared names, and a token can have two of them.

Rescue sweeps out tokens that were sent to the vault by mistake. It refused three addresses: the coin, the reward and the share token. A token with two contract addresses over one ledger, a shape real tokens have shipped with, passes that check under its second face and moves the first face's balance anyway.

What changed. Rescue now snapshots what the vault holds and reverts if the coin or reward balance moved, whatever the token was called. The admin can still sweep an unrelated token; it cannot reduce the coin or the reward.

Test
test/unit/Vesting.t.sol · test_rescueRefusesASecondEntryPointOntoTheAsset
Also
test_rescueStillSweepsAnUnrelatedToken · CannotRescueCore · src/StashVault.sol:626
Athe coin's address Ba second address, same token
one ledger the vault's coin lives here
Before
rescue(B) checks the name: B is not the coin sent 100% of principal out, every share still outstanding
Now
rescue(B) checks the effect: did the coin's balance move? reverts CannotRescueCore · nothing leaves

measured by the review on the pre-fix rescue

04Second passfixed: under 4 decimals refused

Coins too coarse to divide.

The vault carries one virtual unit of the coin against a thousand virtual shares; that pair is what blunts donation attacks. On a coin with no decimals, one unit is a whole coin, and a single-unit deposit stranded half of every harvest in a share nobody could redeem.

What changed. MIN_ASSET_DECIMALS = 4: below it, the constructor reverts, and above 18 it reverts too. Every share carries three more decimals than its coin, so one share still reads as about one coin in a wallet.

Test
test/unit/Vesting.t.sol · test_constructorRefusesAnAssetCoarserThanFourDecimals · test_constructorAcceptsExactlyFourDecimals
Code
UnsupportedDecimals · src/StashVault.sol:39, :223

Decimals of the coin, and whether it can get a vault

refused · UnsupportedDecimalsaccepted

Before the fix, on a coin with no decimals: one harvest

one unit is a whole coin, so a single-unit deposit split every harvest in two

05First passmeasured, not assumed

Donation inflation: the victim is not always made whole.

The classic attack: be the first depositor, then send coin straight to the vault so the next deposit rounds down to almost nothing. The vault's thousand virtual shares make it cost the attacker about half of what they send, at every size, and a 256-run fuzz asserts they never come out ahead.

At large donation sizes the victim is not made whole: at 1000× they recover 50%. What holds unconditionally is minSharesOut. Pass it and the deposit reverts with SlippageShares instead of minting dust; the app sets it on every deposit from the vault's own preview. And every vault is seeded before it opens.

Tests
test/unit/Inflation.t.sol · test_donationAttack_attackerNotProfitable_atDonation1x, …10x, …1000x
Asserts
the attacker never profits (testFuzz_donationAttack_attackerNeverProfitable, 256 runs); the deposit reverts when a donation front-runs it and minSharesOut is set
1,0001×
999.749938 99.97%
500.125031 of 1,000.000001
10,00010×
995.452478 99.54%
5,002.273761 of 10,000.000001
1,000,0001000×
500.249875 50.02% · not made whole
500,249.875063 of 1,000,000.000001

The protection that holds at every size: deposit(assets, receiver, minSharesOut) reverts with SlippageShares(got, wanted) rather than mint fewer shares than you asked for.

unit test, logged · the victim deposits 1,000 USDG

Re-examined 22 Sep 2026

The findings nobody refuted, taken one by one.

Every finding whose refuters had crashed, with its verdict and the test that now holds it. Some were real and are fixed here; some were already closed by vesting or by the pool's price floor, and are now proved; one is bounded, not removed. Two rows about our own build tooling are left out; they are in the repository's README.

The findings the review run reported as refuted without a vote, with their verdicts and tests
#FindingVerdictProof
0A donation into an empty vault pins the rate highcontractsitereal · no lossdepositors get exactly their coin back; the site said a harvest is the only thing that moves the rate, now correctedtest_0_donationIntoAnEmptyVault_movesTheRate_butCostsDepositorsNothing
1A 0-decimal coin strands half the yieldcontractfixed earlierunder 4 decimals refusedtest_constructorRefusesAnAssetCoarserThanFourDecimals
2exchangeRateRay overflows above ~1.16e47 wei and bricks the harvestcontractreal, fixed512-bit mulDivtest_2_harvestStillWorks_aboveTheOld256BitRateCeilingred before
3Deposit and redeem overflow above ~1.08e37 weicontractreal, fixed512-bit mulDiv in all four conversionstest_3_depositAndRedeem_aboveTheOld256BitCeilingred before
4The Harvest event's fee does not reconcilecontracthalfit already reconciled for a receiver-side tax; feeTaken is now the measured outflow, so it reconciles for any tax shapetest_4_harvestEvent_feeTaken_isTheMeasuredOutflow_andReconciles
5, 24, 31A keeper floor of 1 sells everythingcontractkeeperreal, fixedby the pool floorTwapFloor.t.sol · 19 tests red with the check removed
6, 22A bot's just-in-time deposit takes the harvestcontractfixed by vestingprovedtest_6_22_jitDepositAroundAnHonestHarvest_capturesOnlyTheVestedSlice · under 2 of 2,070
7, 11The keeper script collapses the floor to 1 wei on bad inputkeeperfixed earlier, now testedreal in the first versiontest-keeper-harvest.sh · 8 of 13 missed on the first version
8, 26A griefed or wrong-tier vault is the coin's vault forevercontractreal, fixedretireVault unlists it and holders keep every right; the salt carries the indextest_8_26_aRetiredVault_canBeReplaced_andItsHoldersCanStillLeave
9The vault's own address as the coin or the rewardcontractreal, fixedfor a plain deploy; unreachable through the factory, which cannot name an address it has not created yettest_9_theVaultsOwnAddressAsRewardOrAsset_isRefusedred before
10A price quoter accepted as the routercontractreal, fixedvault creation requires the configured SwapRouter02 on the configured chaintest_10_quoterPastedAsTheRouter_isRefused
12An empty chain answer shown as rate 0, labelled as a live readsitefixed earlier, now testedreal in the first versiontest-stash-js.mjs
13A testnet deploy labelled 4663 and linked to mainnetsitereal, fixedboth scripts assert the expected chain id; the site labels and links by network, the badge by configtest_13_wrongChain_isRefused · test-stash-js.mjsred before
14A failed log read drawn as “0 harvests”; logs read from genesissitereal, fixedno chart when unread, no query without a deploy block, and the address sync refuses an incomplete vault entrytest-stash-js.mjsred before
15The raw keeper key on the command linekeepermitigated earlierit needs an explicit opt-in and warns; the keystore path passes no key. A stolen key can no longer sell for 1test-keeper-harvest.sh
16A token's symbol() written into the page as HTMLsitefixed earlier, now testedreal in the first versiontest-stash-js.mjs
17Live addresses copied into four fileskeeperreal, fixedthe fork test and the keeper read config/addresses.jsonfork run 4 of 4 on the config values
20formatUnits prints “.0” for 0 decimalssiteunreachable, fixed anywayunder 4 decimals is refused; one linetest-stash-js.mjsred before
21A flash loan around the keeper's own harvestcontractfixed by vestingone transaction releases nothingtest_21_depositHarvestRedeem_inOneTransaction_capturesNothing
23Reward arriving after the quote is sold unpricedcontractfixed by the pool floorwhich prices what is actually soldtest_23_rewardArrivingAfterTheQuote_isPricedByThePoolNotTheStaleFloor
25The keeper's tolerance is extractablecontractkeeperbounded, not removedat most the 1% tolerance plus the pool fee; see “Still on you”test_spotWithinSlippage_harvests
27No deadline on harvest()contractkeepernot real any morethe binding floor is read when the transaction executes, so a stale keeper floor can only be the stricter onetest_27_delayedHarvest_isPricedAtExecution_notAtSubmission

red before the test was run against the code before its fix, and failed · test names from test/unit/ReviewRound2.t.sol, Scripts.t.sol, TwapFloor.t.sol and the site and keeper test scripts

From the first pass

Written alongside the contract, each with its own test.

The first pass came before the adversarial one, and everything it found is in the contract today. None of it needs you to trust a key: each is a line of code that refuses.

The coin paying itself

If the coin and its reward were the same token, payouts not yet harvested would count as principal and anyone could move the rate. Refused in the constructor.

AssetIsReward

Donation inflation

A thousand virtual shares, and a minimum on every deposit. Measured above at three donation sizes, including the one where the victim is not made whole.

VIRTUAL_SHARES 1_000 · finding 05

Coins with a transfer tax

Counting what was asked for would mint against coin that never arrived. Deposit and harvest both count the measured change in balance; tested with a 3% tax.

measured balance delta

The price check in the wrong place

The router's amount out is not what a taxed vault gains. The router is given a minimum of 0 and the vault checks its own measured gain. A failed harvest reverts whole and does not use up the throttle.

amountOutMinimum 0 · vault-side floor

An unpriced harvest is a free sandwich

First fixed by refusing a floor of 0, which a keeper passing 1 walked straight through; now superseded by the pool's own floor. A harvest too small to be worth one unit of the coin reverts.

UnpricedHarvest

No standing router allowance

The approval is set to the exact swap amount and goes back to 0 in the same call. The fork test against the real router asserts it is zero afterwards.

allowance → 0 each harvest

Rug surfaces removed

No mint entrypoint, no blacklist, no transfer hook, no tax setter, no proxy. The coin, the reward, the router and the fee tier are fixed when the vault is created.

immutable asset · reward · router · poolFee

Pauses that cannot trap you

The guardian pauses deposits and harvests only. The admin's redemption pause lasts 72 hours at most, then 72 hours without one; lifting it early does not shorten that lockout.

MAX_REDEEM_PAUSE 72 h · REDEEM_PAUSE_COOLDOWN 72 h

Four keys at creation

Two-step admin handover, and zero-address checks on every role setter. The factory refuses to create a vault unless admin, guardian, treasury and keeper are four different addresses; afterwards the admin can reassign the last three. The deploy scripts refuse the treasury key, a signer that is not the factory's admin, another chain and any router but the configured one; the signer check was found by the dry run, not by a unit test.

DuplicateRole · two-step admin

Reentrancy

Deposit, redeem and harvest are non-reentrant, so a token with a callback cannot read a half-updated balance. The guard lives in storage, not transient storage: Orbit chains are not guaranteed to be Cancun.

nonReentrant · storage guard

Redemption can never overdraw

Conversions round down, and the virtual shares keep the maths under the balance. A 256-run fuzz proves it, and that the safety clamp inside redeem never has to engage.

256-run fuzz

No external audit

A small surface and a green suite are not an audit. The review on this page is internal: two passes and the tests below.

not yet

Still on you

What the contract still does not protect you from.

Everything above narrows what a key or an attacker can do. This is what is left, said as plainly as we can.

30-min pool average ≈ 1% + pool fee
by default
floor the keeper can pick this moment
Illustration not to scalea spot price wandering inside the tolerance
  • The keeper still picks the moment. It can harvest whenever the spot price sits anywhere inside the tolerance: up to about 1% plus the pool fee under the 30-minute price, by default. That costs part of a harvest, never principal. Dragging the 30-minute price itself means holding the pool off-price for the whole window, and paying every arbitrageur for that long.
  • The coin can fall to zero. The share rate counts coin, not dollars. More of a coin that is worth less is still worth less.
  • The coin's own contract still applies. The vault holds what the coin lets it hold. A coin that can freeze, seize or rebase balances can do that to the vault's balance too.
  • A taxed coin needs room. A transfer-taxed coin loses its tax on every fill, so its vault needs a tolerance that covers the tax, or every harvest reverts. Loudly, never silently.

The admin's levers, and where each one stops.

The admin can loosen settings, never past these limits. Time rails are on a log scale; the dot is the default.

Fee on each payoutnever on principal
010%cap 30%
Vesting windowa running lock keeps its dates
10 min6 h7 days
Price windowthe pool average the floor uses
10 min30 min2 h
Tolerance under the averageloosened, never switched off
01%cap 10%
Harvest throttlenever above the vesting window
60 s2 min7 days
Redemption pauseadmin only; the guardian never
paused · up to 72 hno new pause · 72 h
  • Reassign the guardian, the treasury and the keeper, to any non-zero address.
  • Retire a coin's vault from the factory, so a replacement can open; the retired vault is untouched and its holders keep every right.
  • Rescue a stray token, but never reduce the coin or the reward.
  • Never: mint shares, lower the rate, change the coin, reward, router or fee tier, or pause redemption past 72 hours.

No external audit yet.

The review on this page is internal: two passes and the test suite below. A small surface and a green suite are not an audit. Deposit only what you can afford to lose.

The test suite

Every claim above is a test you can run.

Counts carry a date, because they change. Where a finding quotes a number, the test logged it; what the test asserts is written next to it.

tests · as of 23 Sep 2026all green
$ forge test --no-match-path 'test/fork/*'
Ran 16 test suites in 1.81s (20.34s CPU time): 318 tests passed, 0 failed, 0 skipped (318 total tests)
$ forge test --match-path 'test/fork/*' -vv
4 tests against live chain state: a real swap, the allowance back to 0, the floor under a same-block dump
318
offline tests in 16 suites
4
tests against live chain state
256
fuzz runs per property
AccountingErc4626SurfaceFactoryHarvestInflationInvariantsMetadataPause ReentrancyRescueReviewRound2RolesScriptsTaxedTwapFloorVesting fork · LiveRouter

suites that hold the findings on this pagetest/unit/*.t.sol · test/fork/

The 28, re-examined

Each finding became a test.

Offline tests went from 303 to 318 with the commit of 22 Sep 2026.

fork · 4 of 4keeper script · 13 of 13

offline tests before and after the commit of 22 Sep 2026

What the numbers are
  • Asserted: the keeper's take stays under 0.1% of the gain; the attacker never profits; the floor does not move in a same-block dump and the harvest reverts; the rate never falls.
  • Logged, not asserted to the digit: 11.38, the inflation table, and the fork prices. The fork tests run against live state, so they assert relations, not these exact figures.
  • Replayed on the pre-fix contract: 20,493 of 20,700.
Run it yourself · from the repository root
forge test --no-match-path 'test/fork/*'    # offline, deterministic
forge test --match-path 'test/fork/*' -vv   # against live chain state; -vv prints the logged figures
bash scripts/check-all.sh                   # every gate, one command

Check it yourself.

The chain checks run in your browser. The contract reference lists every function, event and error.

Reading config/addresses.json…