← Back to Blog

How to Audit a Diamond-Pattern Stablecoin Protocol

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.

✍ 0xTheBlackPanther 📅 Mar 2026 ⏱ 18 min read 🏷 Solidity · EIP-2535 · Stablecoin · ERC-4626 · Audit

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.


Understanding the Architecture First

Before looking at a single function, I spent time mapping the architecture. The Diamond Pattern means:

EIP-2535 Diamond Pattern — How It Works Single proxy contract routes all calls via delegatecall to "facets" (logic modules)
All state lives in the Diamond, not in the facets
Two storage namespaces: one for Diamond metadata, one for business logic
Functions can be added, replaced, or removed at any time via 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 Dual-Token Model

The protocol has two user-facing tokens:

Two tokens, one collateral pool Stablecoin — Minted by depositing LSTs as collateral
Leverage Token — Represents the equity buffer (TVL minus stablecoin liabilities), giving leveraged exposure to the underlying asset

Key formula: Collateral Ratio (CR) = TVL of all LSTs / stablecoin supply

CR > 100% → healthy. CR drops below threshold (~130%) → "Stability Mode" triggers: stablecoins burned from stability pool and converted to leverage tokens to restore CR.

This forced conversion during stability mode is where some of the most interesting bugs lived.


Part 1: The High Severity Findings

🔴 HIGH ERC-4626 First Depositor Attack on Stability Vault

The stability vault (where users stake stablecoins to earn yield) inherits ERC-4626 logic but was missing the well-known first depositor defense.

⚡ Classic ERC-4626 Inflation Attack
1 Attacker deposits 1 wei of stablecoin → receives 1 share
2 Attacker directly transfers D stablecoins to the vault (bypassing deposit())
3 Victim deposits V stablecoins where D < V < 2×(D+1) — say D=100, V=199
4 Victim receives shares = 199 × 1 / 101 = 1 (integer division rounds down)
5 Attacker redeems their 1 share for 150 stablecoins. Profit: 49 stablecoins

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.

🔴 HIGH Stale External Withdrawal Index — The Swap-and-Pop Trap

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:

// Router — stores index at request time uint256 externalIdx = existingRequests.length;
withdrawalRequests.push(UserWithdrawalRequest({ externalIdx: externalIdx, ... }));

But the external staking manager's claim function uses swap-and-pop deletion:

// External staking manager — reorders array on claim userRequests[_idx] = userRequests[userRequests.length - 1];
userRequests.pop();

// Every successful claim moves the last element into the claimed slot
// All previously stored indices are now INVALID

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.

🔴 HIGH Multi-Asset Vault Bypass — Stealing Leverage Tokens from Stakers

After a stability mode triggers, the vault holds both stablecoins and leverage tokens. The contract correctly blocks redeem() and withdraw() during this state:

// Guard present on redeem() and withdraw() if (leverageToken.balanceOf(address(this)) > 0) revert Errors.UseRedeemMulti();

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.

⚡ Flash Loan → Trigger Stability → Drain Leverage Tokens
1 Borrow LSTs via flash loan, mint stablecoins to push CR below threshold
2 Trigger stability mode — vault's stablecoins burned, leverage tokens deposited
3 deposit() into vault at discounted share price (no guard blocks this)
4 redeemMulti() for proportional stablecoins + leverage tokens → profit
5 Repay flash loan. Entire attack is atomic — single block.

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.


Part 2: The Medium Findings — Subtle but Dangerous

🟠 MEDIUM Fee Tier Timing Inconsistency

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).

🟠 MEDIUM Malformed DEX Swap Path Enables Arbitrary External Calls

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.

// The fix — validate path structure if (path.length < 43 || (path.length - 43) % 23 != 0) revert InvalidPath();

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.

🟠 MEDIUM The 1 Wei DoS — Griefing Through Uninvited Token Transfers

The stability vault blocks redeem() and withdraw() whenever it holds any leverage tokens:

if (leverageToken.balanceOf(address(this)) > 0) revert Errors.UseRedeemMulti();

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.

🟠 MEDIUM MEV Protection Bypass via Direct Vault Access

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.

// The fix — gate vault entry points modifier onlyDiamond() {
    if (!hasRole(DIAMOND_ROLE, msg.sender)) revert Errors.Unauthorized();
    _;
}

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.

🟠 MEDIUM Missing Yield Harvest Before Collateral Increase

The protocol's yield formula multiplies collateralAmount by the exchange rate increase since last harvest:

// Yield computation uint256 rateIncrease = _currentExchangeRate - oldRate;
uint256 yieldAmount = Math.mulDiv(_collateralAmount, rateIncrease, PRECISION);

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.


Part 3: Select Low Findings

🟡 LOW Missing Function Registration in Diamond

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.

🟡 LOW Configuration Flag Inconsistency Across Price Functions

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.

🟡 LOW Unbounded Array Copy DoS

The router copies an external staking manager's entire withdrawal request array into memory just to read its .length:

// Copies ENTIRE array into memory just to get length WithdrawalRequest[] memory existingRequests =
    stakingManager.getUserWithdrawalRequests(address(this));
uint256 nextIdx = existingRequests.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.


Cross-Cutting Patterns for Diamond + DeFi Audits

PATTERN 01
Map All Entry Points Before Hunting Bugs

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.

PATTERN 02
Check Symmetry Between Paired Operations

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.

PATTERN 03
External Protocol Assumptions Are Fragile

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.

PATTERN 04
balanceOf Is Not Your Friend

Three 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.

PATTERN 05
Recovery Paths Need Auditing Too

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.


Closing Thought

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.

Protocol type: Diamond Pattern (EIP-2535) stablecoin + leverage token
Stack: Solidity · Diamond proxy · 8+ facets · ERC-4626 vault · Uniswap V3 router · LST wrapper
Findings: 3 High · 8 Medium · 9 Low · 6 Enhancements
Key patterns: Symmetry diffing · Entry point mapping · balanceOf distrust · Recovery path audit
Author: @thepantherplus · pantheraudits.com
← Back to Blog