Purpose: Comprehensive review guide for human and AI reviewers. For high-level philosophy and principles, see copilot-instructions.md.
- ALWAYS use
requirewith custom errors (Solidity 0.8.26+) - NEVER use
if + revertpattern
- Deposit/mint: Round DOWN shares (favors vault)
- Withdraw/redeem: Round UP shares (favors vault)
- Use
Math.mulDivwith explicitRounding.DownorRounding.Up - ALWAYS ROUND IN FAVOR OF THE PROTOCOL
- Contracts:
PascalCase - Functions:
mixedCase,_privateInternal() - Variables:
mixedCase,_privateState - Constants:
ALL_CAPS - Errors:
PascalCase, module-prefixed:ModuleName__SpecificError
- ❌ Unchecked
callwithout error handling - ❌ Missing access control on privileged functions
- ❌ Silent asset movement (no events)
- ❌ Direct
tx.originauthorization - ❌ Infinite/unbounded loops
- ❌ State updates after external calls (CEI violation)
- ❌ Re-initialization vulnerabilities (missing
initializedchecks) - ❌ Conversion operations before burns/transfers (incorrect order)
- ❌ Asymmetric conversion functions (deposit vs withdraw math mismatch)
- ❌ Time-based accrual logic that skips updates on zero amounts
- ❌ Accounting variables not updated after direct asset transfers (bypassing share mechanism)
- ❌ Multi-step decimal conversions (e.g., 27→18→27 decimals loses precision)
- ❌ Parameters used for checks differ from parameters used for execution
- ❌ Operations that fail when external protocols pause/revert (no graceful handling)
- ❌ Same asset serving multiple roles (stake token == reward token enables privilege escalation)
- Vaults/strategies CANNOT transfer, burn, or misaccount user assets outside audited flows
- All asset movements MUST emit indexed events
- Track balance changes in state BEFORE external calls (CEI pattern)
- ALL privileged functions use explicit role checks
- Use:
Ownable2Step,AccessControl, Safe modules, or Hats - NEVER bare
msg.senderchecks for admin functions - Role bitmask pattern for multiple roles
- Use ONLY
SafeERC20for token transfers - ALL unchecked low-level calls are vulnerabilities
- Always check return values
- NEVER use raw
.transfer()or.send()
- REQUIRE
ReentrancyGuardon all external flows moving assets - Order: Checks → Effects → Interactions
- NEVER send funds before updating storage
- Protect against strategy callback reentrancy (strategies can give callbacks to attackers)
- Update debt/state BEFORE external calls, not after
- Validate ALL transitions in vault/round/strategy logic
- Require explicit state checks before state changes
- No implicit state assumptions
- Validate edge cases: zero deposit/withdraw, max values, first depositor
- Correct rounding (deposit: DOWN, withdraw: UP)
- Check precision loss in multi-step decimal conversions (avoid 27→18→27)
- CRITICAL: Ensure
_convertToShares()and_convertToAssets()are mathematical inverses - Avoid asymmetric conversion logic that creates arbitrage opportunities
- Account for idle assets in
totalAssetscalculations - Never compare exchange rates with mismatched decimals
- Edge cases with accumulated state: Check
totalAssets + lossAmountnot justtotalAssets - Update
totalAssetsimmediately when bypassing share mechanism (direct transfers)
- All yield harvest/rebalance/allocation verifies limits
- Emit events for significant state changes
- Slippage protection on all swaps
- Test edge cases: zero/max deposit, withdrawal
- Fuzz test all user-facing functions
- Test admin function access control
- Test external call failures (protocol paused, reverts)
- Test with accumulated state (non-zero loss/debt)
- Test parameter mismatches (check vs execution)
- Test cross-chain timing issues (blocks vs timestamps)
- Test underflow scenarios (loss + fees > totalAssets)
- Check CI for coverage targets
- ❌ Using
if + revertinstead ofrequirewith custom errors - ❌ Wrong rounding direction in ERC-4626 conversions
- ❌ Duplicating NatSpec instead of using
@inheritdoc - ❌ Specifying units for standard ERC-4626 parameters
- ❌ Skipping validation on user inputs
- ❌ Using blocklists instead of allowlists for critical operations
- ❌ Hardcoding addresses instead of immutables/constants
- ❌ State updates after external calls (CEI violation)
- ❌ Missing events on asset movements or admin actions
- ❌ Unchecked external calls
- ❌ Mocking the system under test (only mock dependencies)
- ❌ Missing re-initialization protection in
initialize()functions - ❌ Burning/transferring before converting (convert first, then burn/transfer)
- ❌ Conditional state updates that skip time elapsed (update time even if amount is zero)
- ❌ Forgetting to account for idle assets in harvest/report functions
- ❌ Using
transferFrom()whenfrom == address(this)(usetransfer()instead) - ❌ Applying configuration changes retroactively to already-earned fees
- ❌ Off-by-one errors in time-based state transitions (voting periods, etc.)
- ❌ Sandwich-vulnerable admin functions without private mempool protection
- ❌ Loss tracking that doesn't account for new depositors diluting losses
- ❌ Not updating accounting variables (
totalAssets) after direct asset transfers - ❌ Multi-step decimal conversions that lose precision (convert directly, not via intermediate precision)
- ❌ Checking parameters that differ from execution parameters (array mismatch, flag mismatch)
- ❌ Operations failing when external protocols pause (add paused state checks, graceful degradation)
- ❌ Zero checks without accounting for accumulated state (
totalAssets == 0butlossAmount > 0) - ❌ Same asset used for multiple purposes without isolation (stake == reward enables theft)
- ❌ Allowing operations on unreported state (sync/report before dependent operations)
- ❌ Block-based timing on multi-chain deployments (use timestamps for cross-chain consistency)
- ❌ Arithmetic operations without underflow protection (e.g.,
loss + fees > totalAssets)
- Validate:
asset != address(0),shares != 0,feeBps <= MAX_BPS
- Use
Math.mulDivfor precision (OpenZeppelin) - ALWAYS ROUND IN FAVOR OF THE PROTOCOL
- Use
Clones.cloneDeterministic(EIP-1167) - Salt:
keccak256(abi.encodePacked(deployer, params))
- Files:
ContractName.t.sol,ContractNameIntegration.t.sol - Functions:
test_scenario(),testFuzz_scenario(uint x),test_RevertWhen_condition()
See CONTRIBUTING.md for full standards.
- Contracts:
@title,@author,@custom:security-contact - Functions:
@notice,@param,@return - Modified code:
@custom:origin - Security-sensitive:
@custom:security
- Use
@inheritdocfor overrides (DON'T duplicate) - Document WHY, not WHAT
- Units only when ambiguous:
@param feeBps Fee in basis points (10000 = 100%) - NO units for standard ERC-4626:
@param assets Amount of assets✓ - Errors are self-documenting (NO
@notice)
- Safe/Dragon Modules: ALL privileged execution verifies sender/module context. Module functions have full Safe permissions.
- Factories: ALL deployments transfer control to Safe/multisig (NEVER EOA)
- Yield/Donation: Assets redirected MUST update accounting
- ALL
initialize()functions MUST prevent re-initialization - Require
s.initialized == falsebefore setting state - Example:
require(!s.initialized, "Already initialized");
All strategies MUST implement:
_deployFunds(uint256 amount)- Deploy assets to protocol_freeFunds(uint256 amount)- Withdraw assets from protocol_harvestAndReport()returns(uint256 profit, uint256 loss)- Report performance
CRITICAL _harvestAndReport() Requirements:
- MUST account for idle assets (not just deployed assets)
- Include
balanceOf(asset)in total assets calculation - Handle emergency withdrawal scenarios where funds are freed but not yet redeployed
- Handle external protocol paused/unavailable states gracefully (check paused before deposits)
- Example:
totalAssets = deployedAssets + IERC20(asset).balanceOf(address(this))
- Use immutable proxy pattern
- Store
ITokenizedStrategy public immutable TokenizedStrategy - Delegatecall to TokenizedStrategy for all ERC-4626 operations
- Implement strategy-specific logic in override functions
- Use bitmask pattern for roles
- Enum for role types:
ADD_STRATEGY_MANAGER,DEBT_MANAGER,EMERGENCY_MANAGER, etc. mapping(address => uint256) public roles- Store as bitmask- Check:
(roles[account] & (1 << uint256(role))) != 0 - Grant:
roles[account] |= (1 << uint256(role)) - Revoke:
roles[account] &= ~(1 << uint256(role))
- Track
StrategyParams:activation,lastReport,currentDebt,maxDebt - Update debt atomically (either deploy or withdraw, never both)
- Update
currentDebtstate BEFORE external calls, not after (prevents reentrancy exploits) - Protect against strategy callback reentrancy with ReentrancyGuard
- Always use basis points:
10000 = 100% - Constant:
MAX_BPS = 10_000 - Calculate:
fee = (amount * feeBps) / MAX_BPS - Protect against underflow: if
loss + fees > totalAssets, cap fees or handle gracefully
- Avoid multi-step conversions that lose precision (e.g., 27→18→27 decimals)
- Convert directly to target precision in single operation
- Document expected decimal bases for all rate/exchange calculations
- Ensure parameters used in checks match parameters used in execution
- Example: if checking
availableWithdrawLimit(strategiesA), execute with samestrategiesA - Validate flags are consistent: if
useDefaultQueue == false, don't use default queue
- Implement
isShutdownflag shutdown()function for emergency stopsnotShutdownmodifier on critical functions
All strategies MUST implement:
_deployFunds(uint256 amount)- Deploy to yield source_freeFunds(uint256 amount)- Withdraw from yield source_harvestAndReport()returns(uint256 profit, uint256 loss)- Harvest and report
- ALL yield goes to beneficiary
- Track
baselineAssets(principal) - On harvest: if
currentBalance > baselineAssets, profit = excess - Transfer profit to beneficiary
- On deploy:
baselineAssets += amount - On free:
baselineAssets -= amount
- Retain baseline yield rate, donate excess
- Track:
baselineYieldBps(e.g., 400 = 4% APY),lastHarvestTimestamp,baselineAssets - Calculate expected baseline growth based on elapsed time
- If
currentBalance > expectedBaseline, skim excess profit - Keep baseline profit in strategy, donate excess
- Update
lastHarvestTimestampafter harvest
- Use protocol constants (addresses) as
constantorimmutable - Implement
_getProtocolBalance()to query actual balance - Handle protocol-specific precision (e.g., RAY for DSR)
- Handle paused/reverted states gracefully
- Claim rewards from protocol distributor
- Swap to asset using DEX (Uniswap V3)
- ALWAYS use slippage protection (
amountOutMinimum) - Use oracle or TWAP for min-out calculation
- Return swapped amount as profit
- Implement
isPausedandisShutdownflags pause()/unpause()functionsshutdown()withdraws all funds from protocol (irreversible)notPaused/notShutdownmodifiers on deposit/mint
MODULE DANGER: Module functions execute arbitrary code through Safe with full permissions. Validate ALL inputs rigorously.
- Store
address public immutable safe onlySafemodifier checksmsg.sender == safe- Execute via
IAvatar(safe).execTransactionFromModule(to, value, data, operation) - Always check return value, revert if false
- Enum:
AccessMode { Allowlist, Blocklist, Open } - Allowlist: Only listed addresses can call
- Blocklist: All except listed addresses can call
- Open: Anyone can call
- CRITICAL: Prefer Allowlist for high-value operations
- Track:
isActive,expiresAt,hasPermissionmapping - Validate: passport active, not expired, has required permission
- Grant permissions via admin function
- Track:
shareslocked,unlockTimestamp - On deposit: Lock shares, set
unlockTimestamp = now + LOCKUP_PERIOD - On redeem: Check
now >= unlockTimestamp, check sufficient unlocked shares - Decrement locked shares after redemption
- Non-transferable: Block transfers via
_beforeTokenTransfer(only mint/burn allowed)
- Constant penalty:
RAGE_QUIT_PENALTY_BPS = 1000(10%) - Convert shares to assets
- Apply penalty:
netAssets = assets - (assets * penalty / MAX_BPS) - Burn shares, transfer penalty to treasury, transfer net to user
mapping(bytes4 => address) public adapters- Function selector → adapter- Execute: Extract selector from calldata, lookup adapter, delegatecall
- Register: Only Safe can register new adapters
- CRITICAL: Adapters execute via delegatecall with full Safe context
- Accept array of:
target,value,data,operation(0=Call, 1=DelegateCall) - Execute all operations in loop
- Revert entire batch if any operation fails (atomic)
- ALL operations validate target against allowlist
- Implement rate limiting for value transfers
- Use multi-sig threshold for adapter registration
- Log all executions with indexed events
- Test with Safe deployment in fork environment
- Module calls through Safe can reenter
- Follow CEI pattern strictly
- Update state BEFORE calling
execTransactionFromModule
- Layer 1: Passport check (if using passports)
- Layer 2: Allowlist check
- Layer 3: Rate limit check
- Layer 4: Value limit check
- Multiple layers provide defense in depth
- Files:
ContractName.t.sol,ContractNameIntegration.t.sol,ContractNameInvariants.t.sol - Functions:
test_scenario(),testFuzz_scenario(uint256 x),test_RevertWhen_condition()
- Test each function with valid inputs
- Test edge cases: zero values, max values, boundary conditions
- Test access control with
vm.prank() - Test expected reverts with custom errors
- Test events are emitted correctly
- Use assertion messages for all checks
- Re-initialization attempts (should revert)
- Operations with
totalAssets == 0buttotalSupply > 0(loss scenarios) - Conversion symmetry: deposit amount → shares → assets should preserve value within rounding
- Share burns/transfers: verify conversion happens BEFORE state changes
- Time-based accrual with very small rates (test rounding to zero doesn't break accounting)
- Race conditions on time boundaries: cooldown periods, voting periods (off-by-one errors)
- Decimal mismatches: never compare values with different decimal bases
- Idle asset accounting: harvest/report must include both deployed and idle assets
- Reentrancy via strategy callbacks during deposits/withdrawals
- Loss dilution: new depositors shouldn't dilute losses meant for existing users
- Sandwich attacks on admin functions that change share price
- Direct asset transfers bypassing shares: verify
totalAssetsupdated immediately - Multi-step decimal conversions: test precision loss (e.g., 27→18→27)
- Parameter mismatches: what's checked vs what's executed (strategies array, flags)
- External protocol paused/reverted: operations must handle gracefully
- Accumulated state edge cases:
totalAssets + lossAmount == 0not justtotalAssets == 0 - Same asset multiple roles: if stake token == reward token, test privilege boundaries
- Unreported state exploitation: deposit into strategy with unreported losses
- Cross-chain timing: block-based logic behaves differently on different chains
- Underflow scenarios:
loss + fees > totalAssetsshould not revert entire operation
- Bound inputs to valid ranges using
bound() - Test invariants hold across random inputs
- Avoid magic numbers - use named constants
- Use Handler pattern to manage test state
- Track ghost variables for cumulative operations
- Target handler, not vault directly
- Test critical invariants: no loss of funds, accounting consistency
- Use mainnet fork for protocol integration tests
- Test with actual protocol contracts at specific blocks
- Use known token holders via
vm.prank()for realistic scenarios - Test harvest after time progression (
vm.warp) - Verify profit calculations
- Test emergency shutdown withdraws all funds
- Run
yarn coveragebefore submitting PRs - Check uncovered branches and add tests for critical paths
- View detailed reports with
yarn coverage:genhtml - Focus on security-critical functions (access control, asset handling, state transitions)
- ❌ Mocking the system under test - Never mock what you're testing, only dependencies
- ❌ Not using
vm.prank()for access control tests - ❌ Testing multiple unrelated things in one test
- ❌ Using magic numbers instead of named constants
- ❌ Skipping edge cases (0, max, boundary values)
- ❌ Missing assertion messages
- ❌ Not testing with different token decimals
- ❌ Skipping mainnet fork tests for protocol integrations
Rule: If testing Contract X, deploy real Contract X. Only mock external contracts that X depends on.
- ❌ Wrong rounding direction in ERC-4626 conversions
- ❌ Not validating strategy addresses before adding
- ❌ Forgetting to update debt accounting after deploy/withdraw
- ❌ Skipping emergency shutdown mechanisms
- ❌ Hardcoding fee recipients instead of making them configurable
- ❌ Missing re-initialization protection in
initialize()functions - ❌ Asymmetric
_convertToShares()/_convertToAssets()implementations - ❌ Ignoring idle assets in
_harvestAndReport()calculations - ❌ Burning/transferring shares before converting to assets (convert first!)
- ❌ Comparing exchange rates with mismatched decimals
- ❌ Using
transferFrom()whenfrom == address(this)(usetransfer()) - ❌ Updating debt after external calls instead of before
- ❌ Applying fee configuration changes retroactively to earned fees
- ❌ Not updating
totalAssetswhen assets transferred directly (bypassing shares) - ❌ Multi-step decimal conversions losing precision (27→18→27)
- ❌ Parameters for checks differ from parameters for execution
- ❌ Operations failing when external protocols pause (no graceful fallback)
- ❌ Zero checks ignoring accumulated state (
totalAssets == 0butlossAmount > 0) - ❌ Same asset as stake and reward without privilege isolation
- ❌ Allowing operations on unreported losses (report before allowing new deposits)
- ❌ No underflow protection when
loss + fees > totalAssets
- ❌ Skipping slippage protection on swaps
- ❌ Not tracking baseline assets correctly
- ❌ Forgetting to update timestamps in harvest
- ❌ Hardcoding protocol addresses (use immutables with comments)
- ❌ Skipping emergency shutdown mechanism
- ❌ Not handling different token decimals
- ❌ Assuming protocol solvency (always verify actual balances)
- ❌ Using blocklist instead of allowlist for critical operations
- ❌ Skipping rate limiting on high-value operations
- ❌ Not validating operation targets against allowlist
- ❌ Forgetting reentrancy protection (module calls can reenter)
- ❌ Allowing adapter registration without multi-sig approval
- ❌ Not implementing emergency pause/shutdown