Diamond proxy, 8+ facets, ERC-4626 stability vault, Uniswap V3 router, custom LST wrapper — 3 Highs, 8 Mediums, 9 Lows, and the patterns that found them.
I recently audited a DeFi stablecoin protocol built on the Diamond Pattern (EIP-2535). The protocol lets users mint stablecoins and leveraged tokens against Liquid Staking Token (LST) collateral. It features dynamic fee tiers based on collateral ratio, yield distribution to stability pool stakers, and emergency stability modes for market stress events.
This was a dense engagement. The codebase spans a Diamond proxy, 8+ facets, an ERC-4626 stability vault, a periphery router with Uniswap V3-style DEX integration, and a custom wrapper for a non-transferable LST. I found 3 High, 8 Medium, and 9 Low severity issues, plus 6 enhancement opportunities.
Here's what I learned, and the patterns I'd recommend any auditor internalize before touching a protocol like this.
Before looking at a single function, I spent time mapping the architecture. The Diamond Pattern means:
delegatecall to "facets" (logic modules)diamondCut()
This architecture choice has massive security implications. Every facet shares the same storage slot space, which means a storage collision in any facet can corrupt the entire protocol's state. And since diamondCut() can swap logic instantly with no timelock or governance, the Diamond Owner is effectively a god-mode key.
First check on any Diamond: Is diamondCut() behind a timelock? A multisig? Or can a single EOA rewrite the entire protocol in one transaction?
The protocol has two user-facing tokens:
This forced conversion during stability mode is where some of the most interesting bugs lived.
The stability vault (where users stake stablecoins to earn yield) inherits ERC-4626 logic but was missing the well-known first depositor defense.
shares = 199 × 1 / 101 = 1 (integer division rounds down)
The vault had a if (shares == 0) revert check, which prevents the most extreme case (victim gets zero shares), but doesn't prevent the attacker from forcing 1-share outcomes and absorbing the rounding remainder.
The fix is two-layered: First, use internal accounting (dedicated state variables instead of raw balanceOf) so direct transfers can't inflate the exchange rate. Second, implement a virtual asset/share offset in _convertToShares and _convertToAssets following OpenZeppelin's ERC-4626 implementation.
Audit pattern: Whenever you see an ERC-4626 vault, immediately check for dead shares, virtual offsets, and internal accounting vs balanceOf. If none are present, you likely have a finding.
This was one of my favorite findings because it involves a cross-protocol assumption that silently breaks.
The protocol's router lets users redeem tokens and immediately initiate an unstake request through an external staking manager. The router stores the external withdrawal array index at request creation time:
But the external staking manager's claim function uses swap-and-pop deletion:
With 2 requests, the second user's claim reverts (index out of bounds). With 3+ requests, a stale index can silently point to a different user's withdrawal — meaning one user claims another user's funds.
The fix: Store the external system's stable unique identifier (a monotonically increasing uuid) instead of the mutable array index, and resolve the current index at claim time.
Audit pattern: Whenever a protocol stores an external array index, check if that external system uses swap-and-pop, FIFO deletion, or any other reordering mechanism. The index you stored at time T may be garbage at time T+1.
After a stability mode triggers, the vault holds both stablecoins and leverage tokens. The contract correctly blocks redeem() and withdraw() during this state:
But deposit() and mint() have no equivalent guard. Since stability mode burned stablecoins from the vault, totalAssets is reduced, making shares artificially cheap. An attacker deposits stablecoins at the discounted share price (which ignores the leverage tokens), then redeems via a multi-asset function for both stablecoins and leverage tokens proportionally.
deposit() into vault at discounted share price (no guard blocks this)
redeemMulti() for proportional stablecoins + leverage tokens → profit
The fix: Apply the same balance check guard to deposit() and mint().
Audit pattern: When a contract has multiple entry points that share state, verify that security guards are applied consistently across all of them. Three out of four protected is not protected.
The dynamic fee system rewards actions that improve the collateral ratio (lower fees) and penalizes actions that worsen it (higher fees). The fee tier is selected based on the current CR.
The problem: when the CR is checked varies across functions. Three of four core functions check CR before modifying supply. But one checks CR after.
Minting stablecoins adds equal TVL and supply, which worsens the CR (pushes it toward 100%). If CR is checked before the state change, the user pays fees against the pre-mint, healthier CR. A large mint can push the protocol from a healthy mode into a stressed mode while paying fees computed against the healthier, pre-mint state — exactly the behavior the tier system was designed to discourage.
Audit pattern: In any system with state-dependent fee tiers, check whether fees are computed before or after the state-changing operation, and whether the timing is consistent across symmetric operations (mint/redeem, deposit/withdraw).
Uniswap V3-style DEXes encode swap routes as tightly packed byte sequences: [token0 | fee | token1 | fee | token2 | ...]. A single-hop swap is 43 bytes (20 + 3 + 20), each additional hop adds 23 bytes.
The router extracts outputToken by reading the last 20 bytes of the path using assembly. But the only validation checks the first token, not the path structure. A malformed path with extra trailing bytes causes the router to extract the wrong output token address entirely.
Audit pattern: Any time you see raw byte manipulation with assembly for DEX path encoding, verify that path length and structure are validated. The DEX may silently handle malformed inputs differently than the calling contract expects.
The stability vault blocks redeem() and withdraw() whenever it holds any leverage tokens:
The leverage token is a standard ERC-20. Anyone can transfer 1 wei to the vault address at any time. Once they do, both redeem() and withdraw() are permanently bricked. The functions that could drain the leverage token are the very functions being blocked.
The fix: Switch to internal accounting. Track expected token balances in state variables, updated on every deposit/mint/withdraw/redeem. Ignore unsolicited transfers. Optionally add a sweepExcess admin function to clear griefing dust.
Audit pattern: Any time a contract uses balanceOf(address(this)) as a security-critical condition, check if an external actor can manipulate that balance. ERC-20 transfers are permissionless — you can't stop someone from sending tokens to your contract.
The stability pool facet enforces a minDepositBlocks lock to prevent yield sandwiching. But the underlying ERC-4626 vault's deposit(), mint(), redeem(), withdraw(), and redeemMulti() are all fully public. No role-based access check. Anyone can call them directly, completely bypassing the Diamond proxy and its MEV protection.
An attacker can sandwich yield harvests in a single block: deposit directly into the vault before harvest, then redeem after, stealing accumulated yield from legitimate stakers.
Audit pattern: When a protocol enforces security invariants at a proxy/router layer, always check if the underlying contracts can be called directly. If the vault is public and the guard is only in the proxy, the guard doesn't exist.
The protocol's yield formula multiplies collateralAmount by the exchange rate increase since last harvest:
The redeem functions correctly call _harvestAllYield() before modifying collateral. But the mint functions do not — they increase collateralAmount first, then the next harvest applies the accumulated rate increase to the inflated amount, generating phantom yield.
An attacker deposits a large LST amount without prior harvest, waits for the next harvest trigger, and extracts the phantom yield as unbacked stablecoins minted to the stability pool.
Audit pattern: In any yield-bearing system, map out every code path that modifies the yield base (collateral, shares, principal) and verify that yield is settled/harvested before the base changes. Asymmetry between deposit and withdrawal paths is a common source of phantom yield.
The protocol had a recovery function for extreme price drops — it allows users to burn worthless leverage tokens when the underlying asset crashes. The function existed in the facet code but was never registered in the Diamond's selector array during deployment. Deployed but completely unreachable.
When the underlying asset drops enough that leverage tokens become worthless (price = 0), this recovery function is the only path to burn the worthless supply and let the protocol recover. Without it registered, the protocol would be stuck in a degraded state permanently.
Audit pattern: For Diamond Pattern contracts, always cross-reference the deployed facet's function selectors against what's registered in diamondCut(). Missing selectors mean unreachable code — which could mean unreachable recovery paths.
Two internal functions query LST exchange rates: one for USD pricing, one for raw exchange rates. The first correctly checks both a configuration flag AND the provider address before using the intrinsic rate. The second only checks the provider address, ignoring the flag entirely.
When an admin emergency-switches away from a broken exchange rate provider by toggling the flag, minting resumes (via the USD price function), but all redemptions remain frozen because the exchange rate function still calls the broken provider during yield harvesting.
One flag checked in one function, not checked in another. The admin thinks they've fixed the outage. Minting works. But no one can redeem.
Audit pattern: When two functions serve similar purposes (fetching price data), verify they respect the same configuration flags. Copy-paste with slight modifications is a breeding ground for inconsistency bugs.
The router copies an external staking manager's entire withdrawal request array into memory just to read its .length:
Since all users' unstake requests go through the router as msg.sender, this array grows monotonically and never shrinks. After approximately 50-100k entries, the returndatacopy exceeds the block gas limit and the function permanently reverts.
The fix: Track the index locally with a counter (uint256 public nextIdx) instead of querying the external array.
Audit pattern: Any time a contract copies an external array into memory, check if that array can grow unboundedly. Memory allocation cost is quadratic in the EVM — O(n) reads become O(n²) gas once the array is large enough.
In a Diamond protocol, calls arrive via the Diamond's fallback(), direct calls to external contracts (vault, router, tokens), and admin calls (diamondCut, config setters). I found bugs in all three categories. You have to map every entry point before you can reason about invariants.
The highest-yield technique for this engagement: mint vs redeem — harvest timing differs. Leverage vs stablecoin mint fee timing — pre vs post state check. deposit() vs redeem() guards — 3 of 4 protected. USD price vs exchange rate function — flag handling differs. Diff every pair line by line. Any asymmetry is either intentional or a bug.
Three findings came from external assumptions: staking manager uses swap-and-pop (breaks stored indices), external array grows unboundedly (breaks gas), Chainlink feeds have different heartbeat intervals (single global maxPriceAge doesn't work). Never assume external protocol behavior is stable. Read their code.
balanceOf Is Not Your FriendThree separate findings involved reliance on balanceOf(address(this)): first depositor attack via direct transfer, 1 wei DoS to permanently brick the vault, phantom yield from inflated collateral. Internal accounting is almost always safer. Track what you expect to hold, sweep the difference.
A critical recovery function was unreachable (unregistered selector). An admin emergency switch only partially worked (flag inconsistency). A first depositor defense just deferred the capture. Always ask: what happens when things go wrong? Audit every emergency mechanism, admin override, and recovery function.
The most dangerous bugs in this protocol weren't in complex math or exotic DeFi mechanics. They were in gaps between components: the vault that checked a token balance on 3 of 4 entry points, the two functions that read exchange rates but handled the same flag differently, the router that stored an external index without knowing the external array could reorder.
Diamond Pattern protocols are particularly prone to these gaps because the modularity that makes them powerful — independent facets sharing storage — also makes it easy for invariants to fall through the cracks between modules. When you audit a Diamond, you're not just auditing individual functions. You're auditing the interfaces between facets, the consistency of guards across entry points, and the assumptions each module makes about shared state.
Diff every pair of symmetric functions, and verify that every security guard covers every entry point to the state it's protecting. 💎
📝 The findings shared in this post are for educational purposes only. All issues were reported responsibly and the team was receptive to remediation. If you spot any technical inaccuracies, outdated Solidity patterns, or anything that needs correcting, please DM me on X so I can fix it.
balanceOf distrust · Recovery path audit