Security review
A full internal review of the DOSS contracts: the trust model, what the code enforces, every finding we know about, and exactly what is tested. Published in full, including the uncomfortable parts, because that is the only kind of security document worth reading.
What this is, and what it is not
This is an internal security review, prepared by the DOSS development team. It is a serious line-by-line review, and every claim in it is checkable against the verified source code.
September 2026 · Solidity 0.8.26 · OpenZeppelin Contracts v5 · Uniswap v3 libraries
Scope
Four contracts, all deployed on Robinhood Chain and all in scope. Source is verified on the explorer so you can diff every line against this review.
| Contract | Role | Address |
|---|---|---|
| DossVault | ERC-4626 vault, strategy registry, deposit cap, withdrawal cascade | 0x600a…cbDC |
| StrategyCL | One per stock/USDG pool: holds the LP position, oracle-priced valuation, harvests | stamped per pair by the factory |
| StrategyFactory | Deploys a StrategyCL per pair, reading pool fee, token order and decimals from chain | 0xaB0b…40c1 |
| FeeCollector | Receives performance fees; owner can sweep to the treasury address only | 0x1759…e637 |
Out of scope: the Uniswap v3 contracts and USDG itself (external, widely deployed), the off-chain keeper (it holds a scoped key and is covered by the trust model below), and the website.
Trust model · read this section if you read nothing else
Three keys can influence the system. What each one can and cannot do is the core of DOSS security:
| Key | Can | Cannot |
|---|---|---|
| Owner (founder) | Add/remove strategies, raise the deposit cap, pause deposits, set the agent key, point a strategy at a different price feed, set fees up to a hard 20% cap, set risk parameters | Lower the cap, pause withdrawals, or transfer user funds to itself directly. But see S-1: feed control is indirect price control. |
| Agent (keeper) | Move capital between the vault and registered strategies; open, move, close ranges (ticks must bracket the oracle price); harvest. Rate-limited to 6 actions per hour per strategy. | Send funds anywhere except the vault, a registered strategy, or the fee collector. Every destination is hardcoded. This is fuzz-tested as an invariant (see coverage). |
| Price feeds | Define the value of every stock position: share price, swap bounds, and harvest gains all follow the feed. | Move pool prices or touch balances directly. |
What the contracts enforce
Properties that hold on-chain regardless of what any key does:
- Withdrawals can never be paused. The pause flag stops deposits and agent actions only. The withdrawal path has no pause check, and the vault pulls from strategies automatically to cover redemptions.
- The deposit cap can only go up.
setDepositCapreverts on any decrease, so the owner cannot trap depositors behind a shrinking cap. - Share price is oracle-priced, never pool-priced. Positions are
valued at the feed-implied price. Wash trading or sandwiching the pool
does not move
totalAssets(). - Every range must bracket the oracle.
rebalanceandredeployrevert unless the oracle price sits strictly inside the ticks, so the agent cannot park liquidity somewhere nonsensical. - Internal swaps are oracle-bounded. Minimum output is derived from the feed price minus the pool fee and a 1% deviation allowance. A manipulated pool makes the swap revert instead of eating the loss.
- Performance fee is capped at 20% in the bytecode, charged on gains above a high-water mark only, and deposits can never be skimmed as "profit": the mark rises with every deployment and shrinks pro-rata with every withdrawal.
- First-depositor share inflation is mitigated with an ERC-4626 virtual-offset of 6, making the classic donation attack cost about a million times its payoff.
- Ownership moves in two steps (Ownable2Step) on every contract, so a fat-fingered transfer cannot brick admin.
Findings
Everything we know about, ranked. "Acknowledged" means it is a deliberate design trade-off that is disclosed and mitigated rather than fixed in code.
| ID | Sev | Finding | Status |
|---|---|---|---|
| S-1 | High | Feed control is valuation control | Acknowledged, mitigated by caps |
| S-2 | Medium | A dead feed can temporarily freeze vault flows | Acknowledged, owner-recoverable |
| S-3 | Medium | Owner powers act instantly, no timelock yet | Acknowledged, timelock planned |
| S-4 | Low | 18-decimal stock assumption is not enforced by the factory | Operational guard |
| S-5 | Low | Harvested event always reports 0 compounded | Cosmetic, fix in next revision |
| S-6 | Info | Position mints use zero slippage minimums | Bounded by prior oracle-checked swaps |
| S-7 | Info | A leaked agent key can waste money on churn | Bounded by rate limit and brackets |
S-1 · Feed control is valuation control High · acknowledged
Strategies currently read keeper-tended StaticFeeds, not decentralized oracles (no Chainlink network exists for these assets on this chain yet). Whoever owns a feed can set its price, and the share price follows the feed. A malicious feed owner could inflate the price and redeem shares against other depositors' USDG. The contracts cannot distinguish an honest feed from a dishonest one; this is the single largest trust point in the system. Mitigations today: the feed updater is a different key from the contract owner path users interact with, every feed write is a public on-chain transaction you can watch, the keeper cross-checks feed against official market prices and pool prices and goes flat on divergence, and the global plus per-wallet deposit caps bound the total at risk. The exit from this finding is an independent price source or a feed guarded by a timelock plus sanity bounds; until then the caps stay small.
S-2 · A dead feed can freeze flows Medium · acknowledged
totalAssets() sums every strategy's oracle-priced value, so
if a feed goes stale (24h+1h tolerance) or breaks while its strategy holds
stock, valuation reverts and deposits and share-priced withdrawals revert
with it until the owner re-points the feed or relaxes staleness. No funds
are lost and the LP position is untouched; this is an availability risk,
not a solvency one. The keeper tends feeds continuously and alarms loudly
when one goes quiet. A future revision should let withdrawals fall back to
a last-known price band rather than reverting.
S-3 · Owner powers act instantly Medium · acknowledged
setFeed, setConfig, setPerformanceFee
(≤20%), addStrategy and setAgent all take effect
in the same block. Users get no reaction window to a hostile or mistaken
owner action. A public timelock in front of the owner key is the planned
fix and a precondition for raising the caps meaningfully. Until then, you
are trusting the founder's keys, and the site says so.
S-4 · 18-decimal assumption Low
StrategyCL's valuation math assumes the stock token has 18 decimals.
The factory reads decimals() from the token but does not
revert when it is not 18; a strategy created for a non-18-decimal token
would mis-value badly. Creation is owner-only and every Robinhood
tokenized stock observed so far is 18 decimals, so this is guarded
operationally; the next factory revision should enforce it in code.
S-5 · Harvested event under-reports Low
The Harvested event's compounded field is
hardcoded to zero by a degenerate ternary. Funds are unaffected; only the
event log is less useful than intended. Off-chain accounting reads
balances, not this field. Will be fixed in the next contract revision (the
deployed bytecode is immutable).
S-6 · Zero-min mints Info
mint passes amount0Min = amount1Min = 0. This
is normally a sandwich risk, but here the token ratio being minted was
just set by oracle-bounded swaps, and an attacker moving the pool against
the mint mostly changes which residual token stays idle in the strategy
rather than extracting value. Tightening this is still worthwhile in a
future revision.
S-7 · Leaked agent key Info
A stolen agent key cannot move funds out (see the invariant tests) but
could churn ranges to bleed swap fees and pool friction. The damage rate
is capped by the 6-actions-per-hour limit and the oracle bracket
requirement, and the owner can rotate the key with setAgent
at any time.
Test coverage
The full Foundry suite runs on every change: 56 tests, all passing at the reviewed commit.
- Invariant fuzzing, 128,000 randomized calls per invariant:
invariant_agentNeverHoldsFundsandinvariant_collectorReceivesOnlyUsdgdrive rebalance, redeploy, de-risk, harvest, oracle moves and time passage in random interleavings and assert the agent path never accumulates funds and the collector only ever receives USDG. - Fuzz tests (512+ runs each):
agentCannotExtractacross arbitrary tick ranges; full deposit/withdraw round-trips across arbitrary amounts. - Principal accounting: deposits never skimmed as profit, the high-water mark rises with deployments and shrinks pro-rata with withdrawals, harvest charges gains only.
- Multi-strategy: registry add/remove, the withdrawal cascade across strategies, cap and pause behavior, dust-tolerant removal.
- A fork test replays the full daily cycle (enter, earn, de-risk, gap, redeploy, harvest) against real Uniswap v3 bytecode.
The keeper has its own suite (24 tests) covering the state machine: pre-bell de-risk, re-anchor gates, divergence folds, rotation brakes, and harvest guards.
forge test is the whole
command.The path to a real audit
In order, and honestly: verified source on the explorer (done), this public review (you are reading it), a timelock in front of the owner key, hardened price feeds (independent source or bounded and delayed updates), then an independent audit by a named firm with the report linked here. The deposit caps rise after those things, not before. Anything faster would be asking for trust the system has not earned yet.