How to Build a Vault — Commercial & Technical Guide
Fee models, ERC-4626 implementation, Aave strategy integration, security attack vectors, invariant testing, and deployment architecture
The Vault as a Business
Before writing a single line of Solidity, a vault needs a commercial rationale. The most technically sound ERC-4626 implementation will fail if the yield strategy is undifferentiated, the fee structure makes it uncompetitive, or the target market has no clear path to TVL. Commercial design and technical design must be done in parallel.
The first question is what problem the vault solves for a specific depositor. There are three viable positions: (1) yield maximisation — aggregate and auto-compound yield from multiple sources better than depositors can manually; (2) risk reduction — diversify across protocols with automated rebalancing and circuit breakers; (3) access — allow smaller depositors to access institutional yield sources with minimum deposit requirements above their means. Each position implies a different strategy, fee structure, and target investor.
Fee models vary by vault type and competitive positioning. The two standard fees are the management fee — typically 0.5–2% annually, charged on AUM regardless of performance — and the performance fee — typically 10–20% of profits above a high-water mark. A third model, the entry/exit fee (0.1–0.5%), is less common but protects existing holders from sandwich attacks around large deposits. The most competitive publicly accessible vaults (Yearn Finance, Beefy) charge 0%–2% management + 10–20% performance. Institutional vaults (Gauntlet-managed, Morpho curated vaults) often charge more on smaller AUM to cover operational costs.
| Vault type | Management fee | Performance fee | Min TVL to be viable | Primary differentiator |
|---|---|---|---|---|
| Yield aggregator | 0–1% | 10–20% | $1M | Best net APY across chains |
| Risk-adjusted yield | 0.5–2% | 15–20% | $5M | Lower volatility, managed drawdowns |
| Institutional access | 1–2% | 20% | $10M | Whitelist, compliance, reporting |
| RWA / off-chain yield | 0.5–1% | 10% | $20M | Access to T-bill / credit yield |
| Strategy-specific | 1–2% | 20–30% | $2M | Single-strategy specialist alpha |
TVL flywheel is the core growth dynamic. More TVL allows the vault to access better rates on institutional lending markets, reduces per-unit gas cost for rebalancing, and attracts integration from aggregators (DeFi Saver, Instadapp, Yearn). Getting onto these aggregators is one of the highest-leverage growth actions available since they route capital automatically to highest-yield vaults. The path is typically: deploy → manual depositors → audit completion → aggregator listing → TVL flywheel.
Morpho's MetaMorpho and ERC-4626 curation frameworks allow vault operators to act as curators rather than strategy developers — curating risk parameters and capital allocation across pre-audited lending markets, rather than writing custom yield strategies. This dramatically reduces the audit surface and time-to-market for new vault operators. Consider whether the business goal requires a custom strategy or whether a curated vault on existing infrastructure is sufficient.
The ERC-4626 Interface
ERC-4626 standardises the interface for tokenised vaults — any contract that accepts an ERC-20 token and issues shares representing proportional ownership of a pool. Understanding every function in the standard is essential before building on top of it.
interface IERC4626 is IERC20, IERC20Metadata {
// ── Core accounting ──────────────────────────────────────────
function asset() external view returns (address);
function totalAssets() external view returns (uint256);
// ── Conversion (view only, no state changes) ─────────────────
function convertToShares(uint256 assets) external view returns (uint256);
function convertToAssets(uint256 shares) external view returns (uint256);
// ── Deposit flow: assets in, shares out ──────────────────────
function maxDeposit(address receiver) external view returns (uint256);
function previewDeposit(uint256 assets) external view returns (uint256);
function deposit(uint256 assets, address receiver) external returns (uint256 shares);
// ── Mint flow: specify shares to receive, pay assets ─────────
function maxMint(address receiver) external view returns (uint256);
function previewMint(uint256 shares) external view returns (uint256 assets);
function mint(uint256 shares, address receiver) external returns (uint256 assets);
// ── Withdraw flow: specify assets out, burn calculated shares ─
function maxWithdraw(address owner) external view returns (uint256);
function previewWithdraw(uint256 assets) external view returns (uint256 shares);
function withdraw(uint256 assets, address receiver, address owner)
external returns (uint256 shares);
// ── Redeem flow: specify shares in, receive calculated assets ─
function maxRedeem(address owner) external view returns (uint256);
function previewRedeem(uint256 shares) external view returns (uint256 assets);
function redeem(uint256 shares, address receiver, address owner)
external returns (uint256 assets);
}The deposit/mint and withdraw/redeem pairs serve the same economic purpose but from different input perspectives. deposit(assets) is used when the user knows how many tokens they want to put in. mint(shares)is used when the user wants to receive a specific number of shares (for example, to meet a minimum share threshold). Similarly, withdraw(assets)lets a user request a specific dollar amount out, while redeem(shares) burns a specific number of shares and returns whatever assets that represents.
The max* functions are often overlooked but are critical for safe integration. They signal whether the vault currently has capacity to accept deposits or process withdrawals — for example, a vault may return maxWithdraw = 0 if all assets are locked in a strategy with a redemption queue. Integrators who ignore these functions and call withdraw directly may revert unexpectedly.
Vault Architecture — Layer Stack
auto-cycling flowsDepositors
ETH, USDC, wBTC holders
ERC-4626 Vault
Core contract layer
Strategy Router
Capital allocation logic
Yield Protocols
Where capital is deployed
Core Implementation
The minimal ERC-4626 vault is surprisingly small — OpenZeppelin's base implementation handles share minting, rounding, and conversion arithmetic correctly. The vault-specific work is in totalAssets(), the fee hooks, and the strategy integration.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {ERC4626} from "@openzeppelin/contracts/token/ERC20/extensions/ERC4626.sol";
import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";
import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
import {Pausable} from "@openzeppelin/contracts/utils/Pausable.sol";
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SimpleVault is ERC4626, Ownable, Pausable, ReentrancyGuard {
uint256 public deployedAssets; // capital held in strategy
address public feeRecipient;
constructor(
IERC20 asset_,
string memory name_,
string memory symbol_,
address feeRecipient_
)
ERC4626(asset_)
ERC20(name_, symbol_)
Ownable(msg.sender)
{
feeRecipient = feeRecipient_;
}
// ── Inflation attack protection ───────────────────────────────
// Adds 1e6 virtual shares/assets to share price calculations.
// An attacker needs 1,000,000x more capital to manipulate the
// initial share price.
function _decimalsOffset() internal pure override returns (uint8) {
return 6;
}
// ── Core accounting ───────────────────────────────────────────
// totalAssets = idle assets in vault + capital deployed to strategy
function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this)) + deployedAssets;
}
// ── Deposit / redeem gates ────────────────────────────────────
function deposit(uint256 assets, address receiver)
public override nonReentrant whenNotPaused returns (uint256)
{
_accrueManagementFee();
return super.deposit(assets, receiver);
}
function redeem(uint256 shares, address receiver, address owner)
public override nonReentrant whenNotPaused returns (uint256)
{
_accrueManagementFee();
return super.redeem(shares, receiver, owner);
}
// ── Admin ─────────────────────────────────────────────────────
function pause() external onlyOwner { _pause(); }
function unpause() external onlyOwner { _unpause(); }
function setFeeRecipient(address r) external onlyOwner {
require(r != address(0));
feeRecipient = r;
}
// ── Fee stub (see Fee Architecture section) ───────────────────
function _accrueManagementFee() internal virtual {}
}Fee Architecture
The cleanest way to implement fees in an ERC-4626 vault is by minting shares to the fee recipient rather than transferring assets. This approach requires no special accounting, is gas-efficient, and naturally dilutes all existing holders proportionally.
Management fee accrues continuously as a fraction of total supply. Every time the vault is touched (deposit, redeem, harvest), a small number of new shares are minted to the fee recipient proportional to time elapsed. The fee recipient's shares represent their claim on a growing fraction of the pool.
// Management fee: mint shares to fee recipient over time.
// 1% annual management fee on $10M AUM = ~$100K/year in fee shares.
uint256 public managementFeeBps = 100; // 1.00% annual
uint256 public constant MAX_MGMT_FEE = 500; // hard cap at 5%
uint256 public lastFeeTimestamp;
function _accrueManagementFee() internal override {
if (managementFeeBps == 0 || feeRecipient == address(0)) return;
uint256 elapsed = block.timestamp - lastFeeTimestamp;
if (elapsed == 0) return;
lastFeeTimestamp = block.timestamp;
uint256 supply = totalSupply();
if (supply == 0) return;
// shares_to_mint = supply × fee_rate × elapsed / 1 year
// Derived from: new_ratio = fee_rate × elapsed/year
// new_supply = supply / (1 - ratio) ≈ supply × (1 + ratio)
uint256 sharesToMint = (supply * managementFeeBps * elapsed)
/ (10_000 * 365 days);
if (sharesToMint > 0) {
_mint(feeRecipient, sharesToMint);
}
}
function setManagementFee(uint256 feeBps) external onlyOwner {
require(feeBps <= MAX_MGMT_FEE, "Exceeds cap");
_accrueManagementFee(); // settle existing fee before changing rate
managementFeeBps = feeBps;
}Performance fee is charged on profits above a high-water mark (HWM). The HWM tracks the highest share price the vault has ever reached. Performance fees are only charged on new profit — if the vault loses money and then recovers, the recovery back to the HWM is fee-free. This aligns the vault operator with depositors: no fees until you've recovered previous losses.
// Performance fee with high-water mark.
// 20% of profits above HWM on $10M AUM earning 8% gross = $160K/year.
uint256 public performanceFeeBps = 2000; // 20%
uint256 public highWaterMarkPerShare; // assets per 1e18 shares
uint256 public constant MAX_PERF_FEE = 3000; // hard cap at 30%
// Call this after yield is harvested / totalAssets() increases.
function _accruePerformanceFee() internal {
if (performanceFeeBps == 0 || totalSupply() == 0) return;
// Price = assets per 1e18 shares (using 1e18 as fixed-point unit)
uint256 currentPrice = convertToAssets(1e18);
if (currentPrice <= highWaterMarkPerShare) return; // no new profit
uint256 gainPerShare = currentPrice - highWaterMarkPerShare;
uint256 feePerShare = (gainPerShare * performanceFeeBps) / 10_000;
// Total fee in assets = feePerShare × totalSupply / 1e18
uint256 totalFeeAssets = (feePerShare * totalSupply()) / 1e18;
// Move HWM up by the net gain (after fee)
highWaterMarkPerShare = currentPrice - feePerShare;
// Convert fee to shares at current price and mint to recipient
uint256 feeShares = convertToShares(totalFeeAssets);
if (feeShares > 0) {
_mint(feeRecipient, feeShares);
emit PerformanceFeeMinted(feeShares, totalFeeAssets);
}
}
event PerformanceFeeMinted(uint256 shares, uint256 assets);Fee Impact Calculator
See how management and performance fees compound over 5 years
Gross APY
8.0%
Net APY
5.20%
Fee drag
2.80% p.a.
5-yr fees
$18.1K
Strategy Integration
The strategy layer is where the vault's competitive differentiation lives. The vault contract itself should be minimal and well-audited; the strategy logic can be upgraded separately. The most common pattern connects the vault to one or more external yield protocols via a strategy contract that the vault calls.
For a straightforward single-strategy vault lending USDC to Aave v3, the integration is compact. The vault overrides _withdraw to pull from Aave when the vault has insufficient idle assets, and a keeper-callable harvest() function re-supplies idle capital.
import {IPool} from "@aave/core-v3/contracts/interfaces/IPool.sol";
import {IAToken} from "@aave/core-v3/contracts/interfaces/IAToken.sol";
contract AaveVault is SimpleVault {
IPool public immutable aavePool;
IAToken public immutable aToken; // e.g. aUSDC
constructor(
IERC20 asset_,
IPool pool_,
IAToken aToken_,
address feeRecipient_
)
SimpleVault(asset_, "Aave USDC Vault", "avUSDC", feeRecipient_)
{
aavePool = pool_;
aToken = aToken_;
// Approve Aave pool once — use SafeERC20 to handle non-standard tokens
SafeERC20.forceApprove(IERC20(asset()), address(aavePool), type(uint256).max);
}
// ── Accounting ────────────────────────────────────────────────
function totalAssets() public view override returns (uint256) {
return IERC20(asset()).balanceOf(address(this)) // idle
+ aToken.balanceOf(address(this)); // deployed to Aave
}
// ── Harvest: deposit idle capital into Aave ───────────────────
function harvest() external onlyOwner {
_accrueManagementFee();
_accruePerformanceFee(); // charges fee on new Aave yield
uint256 idle = IERC20(asset()).balanceOf(address(this));
if (idle > 0) {
aavePool.supply(asset(), idle, address(this), 0);
}
}
// ── Withdraw path: pull from Aave if vault is short ──────────
function _withdraw(
address caller,
address receiver,
address owner,
uint256 assets,
uint256 shares
) internal override {
uint256 idle = IERC20(asset()).balanceOf(address(this));
if (idle < assets) {
// Withdraw exact shortfall from Aave; Aave may revert if illiquid
aavePool.withdraw(asset(), assets - idle, address(this));
}
super._withdraw(caller, receiver, owner, assets, shares);
}
}For vaults with multiple strategies, use a router pattern with target allocation weights (e.g. 60% Aave, 30% Morpho, 10% idle buffer). The router rebalances toward targets on each harvest. A minimum idle buffer (typically 5–10%) ensures small withdrawals don't always trigger an Aave withdrawal transaction. Track each strategy's deployed balance separately to avoid oracle manipulation through the totalAssets() calculation.
Security & Attack Vectors
ERC-4626 vaults have a well-documented set of attack vectors that every implementation must address. Several vaults have been exploited due to overlooking one or more of these — understanding the full threat model before deployment is not optional.
Vault Security Vectors
Beyond the individual vectors above, vault security depends on the security of everything the vault interacts with. If the vault deploys to Aave v3 and Aave suffers a price oracle manipulation, the vault's funds are at risk even if the vault contract itself is flawless. Evaluate the vault's security as the composition of its own code security plus the security of every protocol it touches.
Testing for Production
A vault managing external assets requires three layers of testing: unit tests for individual functions, integration tests against forked mainnet, and invariant (property-based) tests that fuzz hundreds of thousands of random inputs looking for invariant violations.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import {Test} from "forge-std/Test.sol";
import {MockERC20} from "./mocks/MockERC20.sol";
import {SimpleVault} from "../src/SimpleVault.sol";
contract VaultTest is Test {
MockERC20 asset;
SimpleVault vault;
address alice = makeAddr("alice");
address bob = makeAddr("bob");
address fees = makeAddr("fees");
function setUp() public {
asset = new MockERC20("USD Coin", "USDC", 6);
vault = new SimpleVault(asset, "Test Vault", "tvUSDC", fees);
asset.mint(alice, 10_000e6);
asset.mint(bob, 10_000e6);
vm.prank(alice); asset.approve(address(vault), type(uint256).max);
vm.prank(bob); asset.approve(address(vault), type(uint256).max);
}
function test_depositMintsSharesToReceiver() public {
vm.prank(alice);
uint256 shares = vault.deposit(1_000e6, alice);
assertGt(shares, 0, "No shares minted");
assertEq(vault.balanceOf(alice), shares);
}
function test_redeemBurnsSharesAndReturnsAssets() public {
vm.prank(alice); vault.deposit(1_000e6, alice);
uint256 shares = vault.balanceOf(alice);
vm.prank(alice);
uint256 assets = vault.redeem(shares, alice, alice);
assertApproxEqRel(assets, 1_000e6, 1e14); // within 0.01%
assertEq(vault.balanceOf(alice), 0);
}
function test_sharePriceRisesWithYield() public {
vm.prank(alice); vault.deposit(1_000e6, alice);
uint256 priceBefore = vault.convertToAssets(1e6); // 1 share in assets
// Simulate yield: donate 100 USDC directly to vault
asset.mint(address(vault), 100e6);
uint256 priceAfter = vault.convertToAssets(1e6);
assertGt(priceAfter, priceBefore, "Share price didn't rise");
}
// Fuzz: deposit any amount and immediately redeem — should get >= assets back
function testFuzz_depositRedeem(uint256 assets) public {
assets = bound(assets, 1, 1_000_000e6);
asset.mint(alice, assets);
vm.startPrank(alice);
asset.approve(address(vault), assets);
uint256 shares = vault.deposit(assets, alice);
uint256 out = vault.redeem(shares, alice, alice);
vm.stopPrank();
// After fees, output may be slightly less — but check no free money
assertLe(out, assets + 1, "Got more than deposited");
}
}// Invariant tests: forge runs hundreds of random sequences of calls
// and checks these properties hold after every sequence.
import {StdInvariant} from "forge-std/StdInvariant.sol";
import {VaultHandler} from "./handlers/VaultHandler.sol";
contract VaultInvariantTest is StdInvariant, Test {
SimpleVault vault;
VaultHandler handler;
function setUp() public {
// ... setup vault ...
handler = new VaultHandler(vault, asset);
targetContract(address(handler));
}
// 1. Share price never falls (unless fees were charged)
function invariant_sharePriceMonotone() public view {
if (vault.totalSupply() == 0) return;
assertGe(
vault.convertToAssets(1e6),
handler.lowestSharePrice(),
"Share price fell unexpectedly"
);
}
// 2. Total assets >= user-redeemable assets (vault is solvent)
function invariant_vaultSolvent() public view {
assertGe(
vault.totalAssets(),
vault.convertToAssets(vault.totalSupply()),
"Vault insolvent"
);
}
// 3. maxWithdraw is always achievable
function invariant_maxWithdrawRespected() public {
address user = handler.lastDepositor();
uint256 max = vault.maxWithdraw(user);
if (max == 0 || vault.balanceOf(user) == 0) return;
vm.prank(user);
vault.withdraw(max, user, user); // must not revert
}
}Deployment Architecture
Production vault deployment requires upgradability for strategy changes, governance controls for parameter updates, and emergency mechanisms that don't require a full redeployment to activate.
- 1Proxy pattern (UUPS or Transparent)
Deploy vault logic behind an OpenZeppelin UUPS or TransparentUpgradeableProxy. UUPS is more gas-efficient (upgrade logic in the implementation); Transparent Proxy is simpler to reason about. Store the proxy address in all integrations — never the implementation address.
- 2Multisig ownership
Transfer vault ownership to a Gnosis Safe multisig (3-of-5 or 4-of-7) immediately after deployment. No single key should control fee changes, strategy updates, or emergency pause. The multisig signers should be documented and ideally have on-chain reputation (ENS names, prior vault history).
- 3Timelock for sensitive operations
Wrap the multisig with an OpenZeppelin TimelockController (48–72 hour delay) for all non-emergency operations: fee changes, strategy swaps, deposit caps. Emergency pause should bypass the timelock to allow instant response to exploits — but unpause should require the full timelock delay.
- 4Audit before mainnet
At minimum, a single focused audit of the vault contract plus the strategy. Two independent audits are strongly recommended for vaults targeting >$1M TVL. Competitive audits (Code4rena, Sherlock, Cantina) are cost-effective for novel strategies. Publish the audit report — it is one of the strongest signals of credibility to depositors.
- 5Bug bounty from day one
Deploy an Immunefi or Cantina bug bounty on the same day as mainnet launch. A credible bounty (10–50% of TVL for critical vulnerabilities) attracts whitehats to review your code continuously. It is far cheaper than a successful exploit.
// Foundry deployment script with proxy + timelock
import {Script} from "forge-std/Script.sol";
import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol";
import {TimelockController} from "@openzeppelin/contracts/governance/TimelockController.sol";
contract DeployVault is Script {
function run() external {
address multisig = vm.envAddress("MULTISIG");
address feeRecipient = vm.envAddress("FEE_RECIPIENT");
address asset = vm.envAddress("USDC");
vm.startBroadcast();
// 1. Deploy implementation
AaveVault impl = new AaveVault(
IERC20(asset), IPool(AAVE_POOL), IAToken(A_USDC), feeRecipient
);
// 2. Deploy proxy pointing to implementation
bytes memory initData = abi.encodeCall(impl.initialize, (multisig));
ERC1967Proxy proxy = new ERC1967Proxy(address(impl), initData);
// 3. Deploy timelock: 48h delay, multisig as proposer + executor
address[] memory proposers = new address[](1);
address[] memory executors = new address[](1);
proposers[0] = multisig;
executors[0] = multisig;
TimelockController timelock = new TimelockController(
48 hours, proposers, executors, address(0)
);
// 4. Transfer vault ownership to timelock
AaveVault(address(proxy)).transferOwnership(address(timelock));
vm.stopBroadcast();
}
}Operations & Monitoring
A vault in production requires continuous monitoring, regular harvesting, and clear emergency procedures. Most vault failures are operational rather than contractual — capital left idle, yield not harvested, positions not rebalanced after rate changes.
| Metric | Measure | Alert threshold | Action |
|---|---|---|---|
| Share price | convertToAssets(1e18) | Drop > 0.1% in 1 block | Pause vault; investigate totalAssets() |
| Idle ratio | balanceOf(vault) / totalAssets() | > 15% for > 1 hour | Run harvest() to deploy idle capital |
| Strategy APY | aToken interest rate index | Drop > 20% in 24h | Evaluate rebalance to higher-yield protocol |
| Oracle price | External price vs totalAssets | Deviation > 2% | Check for strategy token price manipulation |
| Gas cost | harvest() gas × gas price | > 5% of weekly yield | Increase harvest interval or batch harvests |
| Protocol health | Aave utilisation rate | > 95% | Prepare to withdraw; utilisation may prevent exit |
APY calculation for display to users should use a time-weighted method over a rolling window (typically 7 days) rather than annualising the most recent harvest — single harvests can be misleadingly high or low due to timing. The formula is: APY = (currentSharePrice / priceNDaysAgo)^(365/N) - 1. Display gross APY (before fees) and net APY (after fees) separately so depositors understand the fee impact.
Emergency runbook should be written before launch. The minimum runbook covers: (1) how to pause the vault (who has the key, what the multisig threshold is, how long the timelock is — emergency pause should bypass timelock); (2) how to withdraw all funds from the strategy; (3) how to communicate with depositors; (4) contact list for protocol teams whose contracts the vault uses. Vaults that have been exploited without a runbook have consistently made worse decisions under pressure.
Go-to-Market & Governance
Technical completion is necessary but not sufficient for vault success. TVL growth follows a consistent pattern: seed liquidity → credibility signals → aggregator listings → institutional inflows. Planning this sequence before launch determines whether the vault achieves sustainable TVL or stays stuck below the minimum viable operating level.
Seed liquidity from the vault operator or early partners establishes a realistic share price history and gives potential depositors confidence that the vault is operational. $100K–$1M of seed capital is typical depending on the target market. Some vault operators structure seed deposits with a 90-day lockup to signal long-term commitment.
Credibility signals that depositors look for: a completed audit from a known firm; a live bug bounty; a public address or entity behind the vault (anon teams face higher scrutiny); a governance forum with proposal history; Dune Analytics dashboards showing TVL, APY, and fee history transparently. These are not marketing materials — they are due diligence inputs for institutional depositors who will allocate the largest positions.
Aggregator listing is the highest-leverage growth action. Being listed as a yield source on Yearn, DeFi Saver, Instadapp, or Alchemy automates capital routing from thousands of users who actively optimise their yield. Each aggregator has its own listing requirements — typically: working ERC-4626 interface, completed audit, minimum $500K TVL, and public documentation of the strategy risk.
Vault governance is the mechanism for parameter changes over time. The minimum governance setup is: a multisig for emergency operations, a timelock for parameter changes (fee adjustments, strategy upgrades, deposit caps), and a public forum for community input. More sophisticated vaults use token governance (veToken models, conviction voting) to decentralise parameter control and align vault curators with depositors through fee-sharing tokens.
The most common causes of vault failure are not smart contract exploits — they are operational: (1) idle capital not harvested for weeks due to no keeper automation, dragging net APY below competitors; (2) strategy obsolescence when the underlying protocol changes rates or introduces a new, superior pool that the vault doesn't migrate to; (3) fee recipient key loss locking accrued fee shares permanently; (4) emergency pause without a clear unpause plan, causing depositors to exit at losses. Operational discipline is as important as code quality.