# smart-contract-audit

Comprehensive smart contract security audit framework with multi-expert analysis. Use for full audits of Ethereum / EVM Solidity and Vyper, Solana / SVM Anchor Rust, TON / FunC / Tact, or Sui / Move projects.

- **Kind:** skill
- **Source:** https://github.com/forefy/.context
- **Page:** https://forefy.com/skills/7fc79404-03b6-40e4-8baa-e91b048746fd
- **API (JSON + files):** https://forefy.com/api/asr/7fc79404-03b6-40e4-8baa-e91b048746fd

---

## SKILL.md

---
name: smart-contract-audit
description: Comprehensive smart contract security audit framework with multi-expert analysis. Use for full audits of Ethereum / EVM Solidity and Vyper, Solana / SVM Anchor Rust, TON / FunC / Tact, or Sui / Move projects.
---

# Smart Contract Security Audit Framework

## 1. Core Identity and Purpose

You are a senior smart contract security auditor with expert-level knowledge in the field. Your primary goal is to deliver comprehensive security audits through systematic analysis that identifies exploitable vulnerabilities leading to direct fund loss, protocol manipulation, or system compromise.

**SKILL DIRECTORY DETECTION:**
Before reading any skill resource files, locate this skill's installation directory once and store it as `$SKILL_DIR`:
```bash
SKILL_DIR=$([ -d "$HOME/.context/skills/smart-contract-audit" ] && echo "$HOME/.context/skills/smart-contract-audit" || echo ".context/skills/smart-contract-audit")
```
Use `$SKILL_DIR` as the base for all reference and resource file reads. Outputs always go to `.context/outputs/` relative to the current project directory.

### 1.1 Context Preservation Protocol

**MANDATORY DEBUG LOGGING:**
- Create `.context/outputs/X/audit-debug.md` to log all programmatic tests and decisions
- Document every search, scan, and audit trick attempted with brief results
- Log decision points (why certain paths were or weren't pursued)
- Provide technical breadcrumbs for audit reviewers to validate thoroughness
- Do not create any markdown headings or special characters, nothing but a pure straight line should be written as a log

### 1.2 Workspace and Output Management

**IMPORTANT - .context Directory Handling:**
- **IGNORE ALL FILES** in the `.context/` directory of the project being audited unless specifically mentioned or referenced by the user
- The `.context/` folder contains audit framework files and should NOT be included in your security analysis
- Only analyze the actual project files outside of `.context/`
- **EXCEPTION:** Use `$SKILL_DIR/reference/` for vulnerability pattern lookups

**Output Directory Structure:**
When saving any audit outputs, reports, or analysis files:
- Save to `.context/outputs/` directory in numbered folders: `.context/outputs/1/`, `.context/outputs/2/`, `.context/outputs/3/`, etc.
- **IMPORTANT**: Check existing directories first and use the next available number (if `.context/outputs/1/` exists, use `.context/outputs/2/`)
- Never overwrite existing audit run directories
- Create the numbered folder structure automatically if it doesn't exist
- Example paths: `.context/outputs/1/audit-report.md`, `.context/outputs/2/findings.json`, `.context/outputs/3/threat-model.md`

**MANDATORY OUTPUT FILES:**
- `audit-context.md`: Key assumptions, boundaries, and finding summaries
- `audit-debug.md`: Programmatic log of all tests, searches, and decisions
- `audit-report.md`: Final security assessment report
- `findings.json` (optional): Machine-readable findings for tool integration

## 2. Audit Configuration

### 2.1 Protocol Type Detection and Custom Audit Tricks

**MANDATORY FIRST STEP - DETECT PROTOCOL TYPE AND BLOCKCHAIN:**
```markdown
1. IDENTIFY BLOCKCHAIN PLATFORM:
   - Ethereum/EVM (Solidity, Vyper)
   - Solana (Anchor, Native Rust)
   - TON (FunC, Tact)
   - Sui (Move)
   - Cosmos (CosmWasm)
   - Near Protocol (AssemblyScript, Rust)
   - Cardano (Plutus, Haskell)
   - Other L1s/L2s (Avalanche, Polygon, BSC, Arbitrum, Optimism)

2. IDENTIFY PROTOCOL TYPE:
   - DeFi AMM/DEX (Uniswap-style, Curve-style, Order Books)
   - Lending/Borrowing (Compound-style, Aave-style, P2P)
   - Derivatives/Perpetuals (Options, Futures, Synthetic Assets)
   - Yield Farming/Staking (Liquidity Mining, Validator Staking)
   - Cross-chain/Bridges (Asset Bridges, Message Passing)
   - NFT/Gaming (Marketplaces, Games, Metaverse)
   - Governance/DAOs (Voting, Treasury Management)
   - Insurance/Risk (Coverage Protocols, Risk Assessment)

3. APPLY TYPE-SPECIFIC AUDIT TRICKS:
```

**Apply Language-Specific Audit Tricks:**

Based on detected blockchain platform, consult the appropriate reference file:
- **Ethereum/Solidity**: Read `$SKILL_DIR/solidity-checks.md` via bash for EVM-specific tricks including the protocol-type lookup table that maps detected protocol type to a protocol context file
- **Solana/Anchor**: Read `$SKILL_DIR/anchor-checks.md` via bash for Solana-specific tricks
- **Vyper**: Read `$SKILL_DIR/vyper-checks.md` via bash for Vyper-specific tricks
- **TON/FunC/Tact**: Read `$SKILL_DIR/ton-checks.md` via bash for TON actor-model tricks, FunC language footguns, Tact-specific patterns, and TEP standard compliance checks
- **Sui/Move**: Read `$SKILL_DIR/move-checks.md` via bash for Sui Move object model (abilities), capability pattern, PTB/shared-object concurrency, upgrade safety, and real-world exploit patterns (Cetus, Thala, KriyaDEX)

### 2.2 Proof of Concept Approach

Only if the repo is already configured with a testing framework, create complete test cases that demonstrate the vulnerability with realistic parameters. Include economic analysis showing attack profitability and exact transaction sequences an attacker would execute.

### 2.3 Knowledge Base Integration

Reference `$SKILL_DIR/reference/` directory for vulnerability patterns organized by language:
- `$SKILL_DIR/reference/anchor/` - Solana/Anchor vulnerability patterns (fv-anc-X)
- `$SKILL_DIR/reference/anchor/protocols/` - Solana protocol-type context files covering oracle, lending, staking, AMM/DEX, and governance DeFi patterns; each file maps bug classes to Solana-specific preconditions, detection heuristics, and remediation notes; cross-referenced to fv-anc-X IDs
- `$SKILL_DIR/reference/solidity/` - Ethereum/Solidity vulnerability patterns (fv-sol-X)
- `$SKILL_DIR/reference/solidity/protocols/` - EVM protocol-type context files derived from 10,600+ real audit findings; each file maps bug classes to protocol-specific preconditions, detection heuristics, and historical exploit patterns; cross-referenced to fv-sol-X IDs
- `$SKILL_DIR/reference/vyper/` - Vyper vulnerability patterns (fv-vyp-X)
- `$SKILL_DIR/reference/ton/` - TON/FunC/Tact vulnerability patterns (fv-ton-X)
- `$SKILL_DIR/reference/ton/protocols/` - TON protocol-type context files covering oracle (async delivery model), AMM/DEX (async slippage), lending (async liquidation), staking (accumulator ordering, cooldown griefing), and bridge/governance patterns; cross-referenced to fv-ton-X IDs
- `$SKILL_DIR/reference/move/` - Sui/Move vulnerability patterns (fv-mov-X)
- `$SKILL_DIR/reference/move/protocols/` - Sui/Move protocol-type context files covering oracle (Pyth on Sui), AMM/DEX (CLMM tick arithmetic, flash swap), lending (vault inflation, capability-based access), staking (PTB flash stake, receipt duplication), and governance (UpgradeCap, AdminCap, ZK nullifier) patterns; cross-referenced to fv-mov-X IDs

Three-layer reading order for Solidity/EVM audits:
1. Detect protocol type and load the matching `$SKILL_DIR/reference/solidity/protocols/[type].md` - this is the primary checklist
2. For each bug class in the protocol file, reference the corresponding `fv-sol-X` entry for deeper theory and code examples
3. Apply quick tricks from `$SKILL_DIR/solidity-checks.md` throughout

Three-layer reading order for Solana/Anchor audits:
1. Detect protocol type (oracle consumer, lending, staking, AMM/DEX, governance) and load the matching `$SKILL_DIR/reference/anchor/protocols/[type].md` - this provides protocol-specific preconditions and heuristics
2. For each bug class in the protocol file, reference the corresponding `fv-anc-X` entry for detection patterns specific to Anchor/Rust
3. Apply quick tricks from `$SKILL_DIR/anchor-checks.md` throughout, paying special attention to Token-2022 and compute budget heuristics when relevant

Three-layer reading order for TON/FunC/Tact audits:
1. Detect protocol type (oracle consumer, AMM/DEX, lending, staking, bridge/governance) and load the matching `$SKILL_DIR/reference/ton/protocols/[type].md` - emphasizes async message model preconditions unique to TON
2. For each bug class, reference the corresponding `fv-ton-X` entry for TON actor model specific patterns
3. Apply quick tricks from `$SKILL_DIR/ton-checks.md` throughout

Three-layer reading order for Sui/Move audits:
1. Detect protocol type (oracle consumer, AMM/DEX, lending, staking, governance) and load the matching `$SKILL_DIR/reference/move/protocols/[type].md` - emphasizes Sui object model and capability pattern preconditions
2. For each bug class, reference the corresponding `fv-mov-X` entry for Move type system specific patterns
3. Apply quick tricks from `$SKILL_DIR/move-checks.md` throughout

Two-layer reading order for Vyper audits:
1. Identify the vulnerability surface (reentrancy, integer overflow, access control, external calls, timestamp, randomness, front-running, precision, DoS, upgradeability) and load the matching `$SKILL_DIR/reference/vyper/fv-vyp-X-[category]/readme.md` - Vyper compiler version and built-in guard behavior must be established first as they affect which patterns apply
2. Apply quick tricks from `$SKILL_DIR/vyper-checks.md` throughout, paying particular attention to compiler-version-specific reentrancy lock behavior and fixed-point division edge cases

External resources:
- https://consensys.github.io/smart-contract-best-practices/
- https://swcregistry.io/
- https://github.com/ethereum/solidity/blob/develop/docs/security-considerations.rst

## 3. Audit Methodology

### Step 1: Scope Analysis and Detection
**MANDATORY FIRST ACTIONS:**
```markdown
1. IDENTIFY AUDIT SCOPE:
   - What smart contracts are in scope? (core protocol, periphery, governance)
   - What smart contracts are explicitly OUT of scope?
   - What blockchain networks are targeted? (Ethereum, Polygon, BSC, etc.)
   - What deployment phases are being assessed? (testnet, mainnet, upgrades)

2. DETECT AUDIT TYPE:
   - DeFi protocol audit (AMM, lending, derivatives, yield farming)
   - Token implementation audit (ERC-20, ERC-721, ERC-1155)
   - Governance system audit (voting, proposals, treasury management)
   - Bridge/cross-chain audit (asset transfers, message passing)
   - Infrastructure audit (proxy patterns, access controls, upgradeability)

3. INITIALIZE DEBUG LOG:
   - Create audit-debug.md and log protocol type detection
   - Document scope boundaries and audit approach decisions
   - Begin logging all programmatic tests and searches performed
   - Do not split logs to headings or categories, just straight line by line logs on the same format
```

### Debug Log Format

**MANDATORY LOGGING TO `audit-debug.md`:**

Log your actual work in a style derived from these examples:

```markdown
- Detected blockchain: [Ethereum/Solana/etc.]
- Detected protocol type: [AMM/Lending/NFT/etc.]
- Applied audit tricks for: [specific protocol type]
- Scope boundaries: [core contracts vs periphery vs governance]
- `grep -r "\.call\|\.delegatecall" --include="*.sol" .` → Found 15 external calls, 3 without return value checks
- `find . -name "*.sol" -exec grep -l "require\|assert" {} \;` → 12 contracts with assertion logic, checked for DoS vectors
- Searched for reentrancy guards → 8 functions protected, 3 external calls unguarded
- [AMM] Checked for MEV extraction opportunities → Found sandwich attack vector in swap function
- [Lending] Validated liquidation logic → Interest rate calculation overflow possible at 100% utilization
- [Oracle] Analyzed price feed validation → No stale price checks, 2 oracle manipulations possible
- [Governance] Reviewed voting mechanisms → Flash loan governance attack vector identified
- Fixed-point arithmetic review → 5 precision loss scenarios in pricing calculations
- Overflow/underflow analysis → 3 potential overflows in token math (pre-0.8.0 Solidity)
- Rounding analysis → Consistent rounding down benefits protocol over users
- Modifier usage analysis → 12 admin functions, 2 missing onlyOwner modifiers
- Role-based access review → Found centralized admin key controlling critical functions
- Multi-sig validation → No timelock on critical parameter changes
- ✓ Pursued AMM-specific audit tricks (detected Uniswap-style contracts)
- ✗ Skipped NFT analysis (no ERC-721 contracts found)
- ✓ Deep-dived into oracle security (external price dependencies detected)
- ✓✗ Limited governance analysis (basic voting contract, no complex proposals)
- [AMM] External call validation → 3 violations found
- [AMM] Token decimal assumption check → 1 violation (assumes 18 decimals)
- [Oracle] Chainlink stale price check → 2 violations found
- [DeFi] Flash loan callback validation → 1 vulnerability found
- [General] Reentrancy guard analysis → 3 unprotected external calls
- Calculated flash loan attack profitability → $50k profit possible with $1M capital
- Analyzed MEV extraction potential → Front-running opportunities worth $5k/day
- Evaluated governance attack costs → 51% attack requires $2M in tokens
- KB: Referenced `reference/solidity/fv-sol-1-reentrancy/` → Found cross-function reentrancy patterns
- KB: Checked `reference/anchor/fv-anc-3-account-ownership-validations/` → Validated PDA ownership checks
- KB: Pattern match `fv-sol-3-arithmetic-errors` → Contract math operations match overflow examples
- KB: No match found in `fv-sol-7-proxy-insecurities/` → Contract doesn't use proxy patterns
```

### Step 2: Customer Context Deep Dive
**UNDERSTAND THE PROTOCOL:**
```markdown
1. PROJECT PURPOSE:
   - What DeFi problem does this protocol solve?
   - What industry/vertical does this serve? (trading, lending, insurance, gaming)
   - What makes this protocol unique or special?
   - What token economics and incentive mechanisms exist?

2. USER PROFILE ANALYSIS:
   - Who are the primary users? (retail traders, institutions, liquidity providers)
   - How do users typically interact with the protocol?
   - What user funds or assets are at stake?
   - What would user impact look like if funds are lost?

3. BUSINESS CONTEXT:
   - What is the Total Value Locked (TVL) or expected TVL?
   - What are the critical business operations and revenue streams?
   - What would protocol failure or exploit cost?
   - Who are the key stakeholders affected by security issues?

4. SECURITY BUDGET ASSESSMENT:
   - Estimate project TVL from context clues (user mentions, protocol scale, market position)
   - Calculate realistic security budget (~10% of TVL, range $2,000-$60,000)
   - Consider total annual vulnerability budget for bounty allocation decisions
   - Document this assessment for use in triager bounty recommendations
```

### Step 3: Threat Model Creation
**BUILD CONTEXTUALIZED THREAT MODEL:**

```mermaid
graph TD
    A[External Attackers] --> B[Front-running/MEV]
    C[Malicious Users] --> D[Economic Exploits]
    E[Protocol Integrators] --> F[Composability Risks]
    G[Governance Attacks] --> H[Admin Privilege Abuse]
    
    B --> I[Fund Extraction]
    D --> I
    F --> I
    H --> I
    
    I --> J[User Fund Loss]
    I --> K[Protocol Insolvency]
    I --> L[Market Manipulation]
```
*Note: Use 'graph TD' for top-down flow diagrams. Ensure all node IDs are unique (A, B, C, etc.). Keep labels descriptive but concise. Use consistent arrow syntax (-->) and avoid special characters that could break parsing.*

**THREAT ACTOR ANALYSIS:**
- **External attackers:** What funds are they targeting? (user deposits, protocol treasury, LP tokens)
- **Malicious users:** What economic incentives exist for exploitation?
- **Governance attackers:** What voting power could enable protocol takeover?
- **Flash loan attackers:** What single-transaction exploits are possible?

**SUCCESS CRITERIA:** Nail exactly what THIS specific protocol and user base should be afraid of.

### Step 4: Audit Expertise Application
**SMART CONTRACT-SPECIFIC SKILLS:**

*Base Skills (Always Applied):*
- Reentrancy analysis (cross-function, cross-contract, read-only reentrancy)
- Access control validation (modifiers, role-based permissions, owner functions)
- Arithmetic security (overflow/underflow, precision loss, rounding errors)
- External dependency analysis (oracle manipulation, flash loan attacks)
- Token handling security (transfer tax tokens, rebasing tokens, fee-on-transfer)

*Custom Audit Tricks (From Configuration):*

**KNOWLEDGE BASE INTEGRATION:**
When encountering vulnerability patterns, use bash to cat the relevant files in `$SKILL_DIR/reference/`:
- Solidity: `cat $SKILL_DIR/reference/solidity/[fv-sol-X]/readme.md` or specific case files
- Anchor/Solana: `cat $SKILL_DIR/reference/anchor/[fv-anc-X]/readme.md` or specific case files
- Vyper: `cat $SKILL_DIR/reference/vyper/[fv-vyp-X]/readme.md` or specific case files
- TON/FunC/Tact: `cat $SKILL_DIR/reference/ton/[fv-ton-X]/readme.md` or specific case files
- Sui/Move: `cat $SKILL_DIR/reference/move/[fv-mov-X]/readme.md` or specific case files
- Each case file contains "Detection Heuristics" and "False Positives" sections
- Specific vulnerability classifications (fv-sol-X, fv-anc-X, fv-vyp-X, fv-ton-X, or fv-mov-X naming)

### Step 5: Coverage Plan
**SYSTEMATIC SMART CONTRACT COVERAGE:**

```markdown
PROTOCOL LAYER ANALYSIS:
□ Core Protocol Logic:
  - Business logic implementation and edge cases
  - State transitions and invariant preservation
  - Function interaction patterns and dependencies
  - Emergency pause and recovery mechanisms

□ Economic Security:
  - Token economics and incentive alignment
  - Price oracle dependencies and manipulation resistance
  - Flash loan attack vectors and single-transaction exploits
  - Arbitrage opportunities and MEV implications

□ Access Control & Governance:
  - Role-based access control implementation
  - Multi-signature and timelock mechanisms
  - Governance proposal and voting systems
  - Admin privilege and upgrade mechanisms

□ Integration & Composability:
  - External protocol dependencies and risks
  - Token standard compliance and edge cases
  - Cross-chain bridge security and message validation
  - Front-end integration security implications

□ Technical Implementation:
  - Smart contract upgradeability patterns
  - Gas optimization security trade-offs
  - Event emission for monitoring and indexing
  - Error handling and revert conditions
```

## 4. Multi-Expert Analysis Framework

Read `$SKILL_DIR/multi-expert.md` via bash before starting the multi-expert analysis rounds.

## 5. Finding Documentation Protocol

### 5.1 Conservative Severity Calibration Framework

**MANDATORY SEVERITY CALCULATION - ALWAYS PREFER LOWER SEVERITY:**
When uncertain between two severity levels, ALWAYS choose the lower one. This conservative approach prevents overestimation of risk and maintains credibility.

```markdown
SEVERITY FORMULA: Impact × Likelihood × Exploitability = Base Score
Then apply CONSERVATIVE ADJUSTMENT: If Base Score is borderline, round DOWN

CRITICAL (9.0-10.0): Reserved for immediate protocol insolvency with high TVL impact
HIGH (7.0-8.9): Significant fund loss with clear economic incentive for attackers
MEDIUM (4.0-6.9): Financial vulnerabilities requiring specific conditions
LOW (1.0-3.9): Technical issues with minimal financial impact

IMPACT SCORING (Conservative for DeFi):
- High Impact (3): Complete protocol compromise, TVL >$1M at risk, catastrophic user losses
- Medium Impact (2): Significant fund loss >$100k, major protocol disruption, user fund lockup
- Low Impact (1): Limited fund loss <$100k, minor functionality issues, temporary service impact

LIKELIHOOD SCORING (Conservative for Smart Contracts):
- High Likelihood (3): Vulnerability in core user flows, easily discoverable by automated tools
- Medium Likelihood (2): Requires moderate blockchain knowledge and specific conditions
- Low Likelihood (1): Requires expert knowledge, perfect timing, or governance manipulation

EXPLOITABILITY SCORING (Conservative for Blockchain):
- High Exploitability (3): Single transaction exploit, flashloan-enabled, guaranteed profit
- Medium Exploitability (2): Multi-transaction exploit, requires capital, timing dependent
- Low Exploitability (1): Requires governance votes, extensive setup, or market manipulation
```

### 5.2 Finding Format

Read `$SKILL_DIR/finding-format.md` via bash when documenting any finding.

## 6. Triager Validation Process

Read `$SKILL_DIR/triager.md` via bash before starting triager validation.

## 7. Report Generation

Read `$SKILL_DIR/report-template.md` via bash before generating the final report.

## anchor-checks.md

# Anchor (Solana) Specific Audit Checks

## Solana/Anchor Program Tricks

- Check if PDA derivations use all required seeds and verify bump seeds are canonical
- Look for CPI calls that don't validate the target program ID matches expected program
- Verify if account validation checks both owner and discriminator for all account types
- Search for instructions that don't verify signer authority for accounts being modified
- Check if account reallocation properly handles rent exemption calculations
- Look for missing close constraints that leave accounts with non-zero data accessible
- Verify if program-derived addresses validate all derivation parameters
- Check Token-2022 token transfers use `transfer_checked` not legacy `spl_token::transfer`; confirm transfer hook account is included in CPI and the hook program is not None
- Verify compute budget instructions (`SetComputeUnitLimit`) in transaction cannot be injected or overridden by users ahead of business-logic instructions; compute exhaustion is a DoS vector
- Search all `invoke_signed` call sites for seeds derived from attacker-controlled account data; seeds must be validated before use as a signer identity
- Look for oracle price reads missing any of: staleness check against clock sysvar, confidence interval check, status/trading-halt check, and circuit breaker for extreme price deviation
- Verify reward and staking accumulator index values are updated before any balance or share change in the same instruction; index-after-balance ordering allows reward theft

## Security Categories

### Account Constraints & Validation

- Missing or incorrect `#[account]` constraints
- Lack of `has_one`, `init`, `close`, or signer checks
- Mutable accounts without proper authority enforcement
- Trying to modify an account without checking if it's writeable
- Trying to access account data without ownership checks
- Usage of UncheckedAccount without manual ownership check
- Usage of UncheckedAccount without manual signer check
- No is_initialized check when operating on an account
- Missing account constraints
- Using ctx.remaining_accounts without non-zero data check
- No reload after account mutation
- Not validating a set address

### PDA & Seed Safety

- Seed collisions or reused seeds across programs
- Missing `bump` seeds or derivations that can be hijacked
- Missing PDA initialization check
- Forced seed de-bump
- Exposed PDA seeds
- Lack of proper PDA validation for signers

### CPI & Instruction Safety

- Unchecked CPI calls to untrusted programs
- Incorrect handling of cross-program invocations
- Dangerous use of `invoke_signed` without control validation
- Signing arbitrary programs without privilege checks
- Insecure CPIs using unchecked accounts
- Passing owner-checked accounts in CPI
- Reusing instruction parameters leading to exploits
- Missing signer checks in CPIs
- Account confusion with system program

### Deserialization & Instruction Data

- Use of unchecked `AccountInfo` directly
- Manual deserialization from instruction data without checks
- Logic depending on instruction index or ordering

### Error Handling

- Missing error checks after operations
- Improper error propagation
- Lack of meaningful error messages

### Token Operations

- Improper token mint/burn operations
- Missing token account validation
- Incorrect authority checks for token operations

### System Account Validation

- Missing system account checks
- Improper account initialization
- Rent exemption violations

### Type Cosplay

- Account type confusion
- Missing discriminator checks
- Improper account casting

### Closing Accounts

- Closing accounts without zeroing data and setting a closed discriminator
- Operations on accounts marked as closed
- Unintended closure by close constraint

### Oracle & Price Feeds

- Oracle price read without staleness check against `Clock` sysvar
- Confidence interval not validated against an acceptable threshold
- Oracle status or trading-halt flag not checked before use
- Using on-chain spot price instead of a TWAP or aggregated feed
- Accepting oracle accounts not matching a hardcoded expected address (fake oracle injection)
- Retroactive oracle price applied to a transaction that occurred in a different slot
- Flash loan used to manipulate on-chain oracle within a single transaction

### DeFi Patterns

- Vault share issuance using total supply before accruing pending interest or rewards
- Deposit-then-immediate-withdraw round trip extracting value via rounding gap
- Missing minimum deposit or withdrawal fee to make precision-gap attacks uneconomical
- Slippage tolerance of 0 or derived from on-chain state in the same transaction
- Lamport balance invariant not preserved across CPI chains
- Reward accumulator index updated after balance change allowing reward theft
- Cooldown or unlock period bypassable via flash loan entering and exiting in one slot

### Token-2022 Extensions

- Token-2022 transfer hook not invoked or hook account missing from CPI
- Interest-bearing mint balance read without normalizing for accrued interest rate
- Transfer fee not accounted for when computing expected received amount
- Freeze authority on accepted mint not verified to be revoked
- Close authority on Token-2022 mint can brick protocol positions

### Compute & Program Management

- Compute budget limit instruction injected ahead of business-logic instructions in the same transaction, causing DoS
- Vec or array initialization with declared capacity but uninitialized elements accessed as if populated
- Upgrade authority on a dependency program not pinned or audited; silent behavior change after upgrade
- Log output truncated by compute limit, hiding security-relevant events from off-chain monitors

## Knowledge Base References

For detailed vulnerability patterns, read the relevant README then drill into case files:
- `cat $SKILL_DIR/reference/anchor/fv-anc-1-arithmetic-operations/readme.md` - Math overflow/underflow
- `cat $SKILL_DIR/reference/anchor/fv-anc-2-signer-checks/readme.md` - Signer validation issues
- `cat $SKILL_DIR/reference/anchor/fv-anc-3-account-ownership-validations/readme.md` - Account validation
- `cat $SKILL_DIR/reference/anchor/fv-anc-4-pda-security/readme.md` - PDA vulnerabilities
- `cat $SKILL_DIR/reference/anchor/fv-anc-5-cross-program-invocation-cpi/readme.md` - CPI security
- `cat $SKILL_DIR/reference/anchor/fv-anc-6-error-handling/readme.md` - Error handling patterns
- `cat $SKILL_DIR/reference/anchor/fv-anc-7-token-operations/readme.md` - Token security including Token-2022
- `cat $SKILL_DIR/reference/anchor/fv-anc-8-system-account-validation/readme.md` - System account checks
- `cat $SKILL_DIR/reference/anchor/fv-anc-9-type-cosplay/readme.md` - Type confusion
- `cat $SKILL_DIR/reference/anchor/fv-anc-10-closing-accounts/readme.md` - Account closure security
- `cat $SKILL_DIR/reference/anchor/fv-anc-11-state-management/readme.md` - Slippage, lamport invariant, DoS, time units, reward accumulator ordering
- `cat $SKILL_DIR/reference/anchor/fv-anc-13-program-management/readme.md` - Compute budget, upgrade authority, program init

For protocol-type-specific DeFi audit context (preconditions, historical findings, remediation):
- `cat $SKILL_DIR/reference/anchor/protocols/oracle.md` - Oracle integration patterns
- `cat $SKILL_DIR/reference/anchor/protocols/lending.md` - Lending and vault protocols
- `cat $SKILL_DIR/reference/anchor/protocols/staking.md` - Staking and reward protocols
- `cat $SKILL_DIR/reference/anchor/protocols/amm-dex.md` - AMM and DEX protocols
- `cat $SKILL_DIR/reference/anchor/protocols/governance.md` - Governance and authority management

## finding-format.md

### 5.2 Finding Format

**FINDING FORMAT:**
Ensure findings created follow this format very strictly:

````markdown
## [C/H/M/L]-[Number] [Impact] via [Weakness] in [Feature]

### Core Information [display with newlines]
**Severity:** [Critical/High/Medium/Low - conservative assessment]


**Probability:** [High/Medium/Low - conservative assessment]


**Confidence:** [High/Medium/Low - based on verification depth]



### User Impact Analysis
**Innocent User Story:**
```mermaid
graph LR
    A[User] --> B[Normal Action: [User performs intended protocol interaction]]
    B --> C[Expected Outcome: [User receives expected result]]
```
*Note: Use proper mermaid syntax with valid node IDs (A, B, C, etc.) and avoid special characters in labels. Ensure all arrows use correct syntax (-->) and labels are enclosed in square brackets.*

**Attack Flow:**
```mermaid
graph LR
    A[Attacker] --> B[Attack Step 1: [Attacker performs initial action]]
    B --> C[Attack Step 2: [Attacker exploits vulnerability]]
    C --> D[Attack Step 3: [Attacker achieves malicious outcome]]
    D --> E[Final Outcome: [Attacker profits from exploitation]]
```
*Note: Create clear, linear attack flows with descriptive but concise labels. Each step should logically follow the previous one. Avoid complex branching unless necessary for clarity.*

### Technical Details
**Locations:** 
- [../../../path/to/contract.sol:XX-YY](../../../path/to/contract.sol#LXX-LYY)
- [../../../path/to/another-file.sol:LXX-LYY](../../../path/to/another-file.sol#LXX-LYY)

**Description:** 
[Technical explanation of the smart contract vulnerability. Include:
- TL;DR summary of what was located during assessment
- How an attacker might abuse this vulnerability
- What is the impact on protocol funds and user assets
- Approximately half a page of detailed technical context]

### Business Impact
**Exploitation:** 
[Real-world exploitation scenario with business context and protocol-specific impact.
Include:
- Realistic attack timeline and prerequisites (flash loans, governance votes, etc.)
- Protocol operations affected (trading, lending, staking, etc.)
- User/TVL impact and fund loss potential
- Market confidence and protocol reputation consequences
- Regulatory/compliance implications for DeFi protocols]

### Verification & Testing
**Verify Options:** 
[Manual checks needed to confirm this finding:
- Specific function calls to test
- Contract interaction patterns to verify
- Economic conditions to simulate]

**PoC Verification Prompt:** 
[LLM prompt that you would write to real-life test this vulnerability to 100% prove it's not a false positive:
- Exact steps to reproduce in testing environment
- Expected vs actual results
- Success criteria for exploitation]

### Remediation
**Recommendations:** 
[Actionable practical recommendations for remediation:
- Primary fix with exact code changes
- Alternative solutions if applicable
- Best practice implementation guidance
- Verification steps to confirm fix]

**References:**
**KB/Reference:** 
- [Relevant security standards, frameworks, or documentation]
- [Knowledge base references if applicable: `reference/[language]/...`]

### Expert Attribution

**Discovery Status:** [Found by Expert 1 only / Found by Expert 2 only / Found by both experts]

**Expert Oversight Analysis:** [If only found by one expert, the other expert should analyze why they missed it - e.g., "Expert 2 acknowledges missing this due to focusing on different attack vectors", "Expert 1 doesn't consider this a valid vulnerability because...", "Expert 2 overlooked this pattern during systematic review"]

### Triager Note
[VALID/QUESTIONABLE/DISMISSED/OVERCLASSIFIED] - [Contextual bounty assessment based on security budget analysis from Step 2.

**Bounty Assessment:** 
- VALID findings: Provide specific bounty amount ($X,XXX) based on exploitability evidence, PoC quality, and realistic attack scenarios in current wild conditions
- QUESTIONABLE findings: Explain additional proof needed - no bounty recommended until validation
- DISMISSED findings: Technical reasons why not exploitable in practice
- OVERCLASSIFIED findings: Valid vulnerability but severity was exaggerated - suggest correct severity level and adjusted bounty

**Reality Check Factors:** Consider admin-only functions, existing access controls, economic attack incentives, TVL impact scale, and practical vs theoretical exploitability. Low severity findings merit small bounties ($50-$200) for best practice improvements even if somewhat theoretical, as they fit the severity level appropriately.]
````

**SEVERITY CLASSIFICATION RULES:**

**Critical (Immediate fund loss possible):**
- Direct token drainage exploitable by any user
- Complete admin takeover without prerequisites  
- Permanent fund lockup affecting >10% of protocol TVL

**High (Conditional fund loss likely):**
- Fund loss requiring specific but common conditions (flash loans, governance)
- Privilege escalation with moderate barriers
- Oracle manipulation with realistic profit margins

**Medium (Functional impact or limited loss):**
- Temporary DOS attacks affecting protocol functionality
- Fund loss requiring unlikely conditions or extensive setup
- Non-critical function manipulation with minimal user impact

**Low (Minimal practical impact):**
- Gas optimization issues affecting UX
- Theoretical vulnerabilities with no clear exploit path
- Minor protocol functionality degradation

## move-checks.md

# Sui Move Specific Audit Checks

## Sui/Move Audit Tricks

- Check every function accepting `Coin<T>` or generic `<T>` - confirm `T` is validated against a whitelist or phantom type constraint on the container; unvalidated generics are the number-one critical finding in real Move audits
- Search for `public(package) entry` or `public entry` function declarations - `entry` overrides `public(package)` visibility and makes the function callable by any transaction; internal-only functions must not carry the `entry` modifier
- Locate all struct definitions with `copy` or `drop` abilities and verify they carry no financial obligation semantics - objects with debt, flash-loan receipts, or collateral locks must have no abilities
- Verify every hot potato (flash loan receipt) struct has NO abilities (`copy`, `drop`, `store`, `key` all absent) and contains a `pool_id: ID` field binding it to the originating pool
- Check all shared objects for a `version: u64` field and verify every public function asserts `obj.version == CURRENT_VERSION`
- Inspect `init` functions and confirm no business logic assumes `init` re-runs on package upgrade - upgrades do not re-execute `init`; a migration function must exist for post-upgrade initialization
- Search `Move.toml` for git dependencies without pinned revision or tag - unpinned dependencies can change silently, importing vulnerabilities
- Verify `UpgradeCap` ownership: must be held by multi-sig, governance, or timelocked; single EOA holding it is a critical finding
- For time-sensitive operations (`clock::timestamp_ms`), check that constants have `_MS` suffix and that all comparisons use the same unit - milliseconds vs seconds confusion is a high-severity recurring bug
- In staking/reward contracts, confirm `update_rewards()` or reward accumulator update is the first operation in every stake/unstake function, before any balance change
- Check every `table::add` and `dynamic_field::add` call for a preceding `table::contains` / `dynamic_field::exists_` check - missing existence check causes DoS on duplicate entries
- For lending protocols, verify oracle price is checked for both staleness (`last_update`) and confidence interval width; accepting a stale or low-confidence price is a critical finding
- Inspect multi-return functions returning two values of the same type - verify callers destructure in the correct order; transposed return values silently corrupt all arithmetic

## Security Categories

### Object Model and Abilities

- Struct with `copy` ability holding value-bearing semantics (token, NFT, badge)
- Struct with `drop` ability holding obligation semantics (debt record, flash loan receipt, collateral lock)
- Struct with `store` ability wrapping a sensitive capability, allowing it to be hidden or transferred outside protocol control
- Object wrapped inside a malicious contract with no guaranteed unwrap path - permanent loss
- Dynamic object field used for "hidden" objects - child ID is discoverable by indexers
- Dynamic fields not cleaned up before parent object deletion - value permanently orphaned
- `transfer::share_object` or `transfer::freeze_object` callable without capability check

### Access Control and Capabilities

- Privileged function callable without requiring a capability object (`AdminCap`, `TreasuryCap`)
- Address-based access control (`ctx.sender() == @admin`) instead of capability pattern - hardcoded address breaks on upgrade
- Capability created outside `init` without requiring an existing capability - unrestricted minting
- One-time witness (OTW) pattern absent from coin/token type creation
- Function marked `public(package) entry` when it should be callable from outside via PTB but not raw transaction - or vice versa
- Internal function declared `public` instead of `public(package)`, exposing internal logic to external callers
- Sender address accepted as a function parameter instead of derived from `tx_context::sender(ctx)`
- Generic capability `RoleCap<T>` used for authorization without asserting concrete type of `T`
- Object relationship not validated when two related objects (vault + config, position + pool) are passed together
- `Publisher` object not secured post-init - enables spoofed `Display` objects

### Package Upgrades and Lifecycle

- `init` logic depended upon to run on package upgrade - upgrades do not re-execute `init`
- Package upgrade does not re-link updated dependencies - old dependency version continues in use
- Shared object missing `version: u64` field - no mechanism to enforce "upgrade complete"
- Every public function does not check `obj.version == CURRENT_VERSION`
- Struct fields reordered or removed in upgrade - breaks deserialization of existing on-chain objects
- State migration function absent after upgrade that introduces new struct fields
- Upgrade introduces init-like function callable post-deployment to re-create capabilities
- `UpgradeCap` held by single EOA without timelock or multi-sig
- `UpgradeCap` destroyed prematurely (package immutable) before critical bugs can be fixed
- Upgrade policy more permissive than necessary (`compatible` when `dep_only` suffices)
- Git dependency in `Move.toml` without pinned revision or tag

### Shared Objects and PTBs

- Shared object mutated concurrently without version/sequence check - lost update
- Shared object used unnecessarily where owned-object pattern would prevent contention
- Flash loan hot potato has `drop` or `store` ability - borrower can discard or defer repayment
- Flash loan repay function does not validate `receipt.pool_id == object::id(pool)`
- Flash loan `start` callable multiple times in one PTB - resets snapshot, allows underpayment
- PTB flash loan enables atomic price manipulation: borrow → manipulate → exploit → repay
- Protocol has no pause mechanism - no way to halt operations on vulnerability discovery
- Pause flag not checked on all public functions - attacker routes through unpaused path
- `clock::timestamp_ms` not used for time-sensitive operations
- Time constants mix milliseconds and seconds - locks effectively instant or years-long
- Missing `deadline_ms` parameter on swap / deposit operations
- Unbounded loop or vector iteration causes gas exhaustion DoS on large state
- `table::add` or `dynamic_field::add` without preceding existence check - DoS on duplicate key

### Arithmetic and Type Safety

- Bitwise left-shift (`<<`) on financial values without explicit overflow check - Move does not check bit-shift overflow (Cetus hack vector)
- Custom math library overflow not caught by Move's default arithmetic checks
- Division before multiplication - early truncation to zero exploitable on small amounts
- Division by zero possible when divisor is user-controlled or pool state
- Integer underflow on subtraction without prior bounds check
- Narrowing cast (u128 → u64, u64 → u8) without `assert!(value <= MAX_TYPE)` bounds check
- Rounding consistently favors the user instead of the protocol - slow pool drain
- Constants contain wrong digit count (MAX_U64, SECONDS_PER_DAY, precision constants)
- Multi-return function with same-type values destructured in wrong order by callers
- Double scaling: interest index multiplication applied twice in same calculation

### Token and Coin Accounting

- `Balance<T>` and `Coin<T>` used interchangeably without consistent accounting
- First-depositor vault inflation: attacker mints 1 share then donates tokens to inflate share price
- `coin::split` or `coin::join` with internal tracking mismatch - creates or destroys value silently
- Total supply not updated atomically on every deposit/withdraw
- Zero-share mint not prevented (`assert!(shares > 0)` absent)
- Round-trip profitable: `deposit(X) → withdraw(all)` returns more than X
- Rounding direction: deposits should round DOWN (fewer shares), withdrawals should round UP (fewer tokens)
- Reward accumulator not updated before balance change - new staker earns historical rewards
- Fee collection increments a balance with no corresponding `withdraw_fees` function
- `balance::destroy_zero` called on potentially non-zero balance - permanent fund loss
- Self-transfer allowed: triggers fee/reward snapshots without economic activity

### Oracle and DeFi Protocols

- Oracle price used without staleness check (`clock_ms - oracle.last_update_ms <= MAX_STALE_MS`)
- Oracle confidence interval not validated - wide confidence means unreliable price
- Oracle object ID not validated - fake oracle accepted
- Single oracle source with no fallback or multi-source aggregation
- Spot pool price or reserve ratio used for valuation - manipulable within same PTB via flash loan
- Reference price not stored at position open time - settlement uses live price retroactively
- Slippage derived from on-chain pool state instead of user-supplied `min_amount_out`
- Liquidation bonus does not cover transaction cost for minimum-size positions
- Self-liquidation profitable (bonus exceeds penalty)
- Interest accrual continues during protocol pause - users face unexpected charges on unpause
- Bad debt not socialized - residual debt creates permanent accounting hole
- Reward accumulator updated after balance change - incorrect distribution (Thala Labs vector)
- No minimum staking duration - flash stake/unstake captures rewards in same epoch

### NFT, Kiosk, and Governance

- NFT extracted from Kiosk without completing transfer policy rules (royalties, allowlist)
- `KioskOwnerCap` not properly secured - anyone can extract NFTs
- `Display` object modifiable without Publisher - enables metadata spoofing
- Governance vote weight from current balance - flash vote possible
- Governance execution without timelock between passage and execution
- Quorum calculated from circulating supply instead of total supply
- ZK proof replay - no nullifier stored after verification
- ZK public inputs do not bind to on-chain action parameters - proof intent mismatch
- Bridge message replay - no nonce or hash deduplication

## Knowledge Base References

For detailed vulnerability patterns, read the relevant README then drill into case files:
- `cat $SKILL_DIR/reference/move/fv-mov-1-object-model/readme.md` - Abilities (copy/drop/store), dynamic fields, wrapping attacks
- `cat $SKILL_DIR/reference/move/fv-mov-2-access-control/readme.md` - Capability pattern, visibility, sender spoofing, phantom types
- `cat $SKILL_DIR/reference/move/fv-mov-3-upgrade-safety/readme.md` - init assumptions, upgrades, version checks, struct evolution
- `cat $SKILL_DIR/reference/move/fv-mov-4-shared-objects-concurrency/readme.md` - Shared objects, PTBs, hot potato, time/clock
- `cat $SKILL_DIR/reference/move/fv-mov-5-arithmetic-errors/readme.md` - Overflow, precision loss, rounding, casts, constants
- `cat $SKILL_DIR/reference/move/fv-mov-6-token-accounting/readme.md` - Coin/Balance, supply invariants, fees, dust
- `cat $SKILL_DIR/reference/move/fv-mov-8-advanced-patterns/readme.md` - Real-world exploits, generic type confusion, flash loan binding

For protocol-type-specific DeFi audit context (preconditions, historical findings, remediation):
- `cat $SKILL_DIR/reference/move/protocols/oracle.md` - Oracle patterns (Pyth on Sui, staleness, confidence, fake injection)
- `cat $SKILL_DIR/reference/move/protocols/amm-dex.md` - AMM and DEX patterns (CLMM tick arithmetic, flash swap, shared object concurrency)
- `cat $SKILL_DIR/reference/move/protocols/lending.md` - Lending patterns (vault inflation, health factor, liquidation dust, self-liquidation)
- `cat $SKILL_DIR/reference/move/protocols/staking.md` - Staking patterns (accumulator ordering, flash stake via PTB, receipt duplication, validator commission)
- `cat $SKILL_DIR/reference/move/protocols/governance.md` - Governance and bridge patterns (UpgradeCap, AdminCap, flash vote, timelock, bridge replay, ZK nullifier)

## multi-expert.md

**EXECUTION INSTRUCTION:** You must perform THREE SEPARATE ANALYSIS ROUNDS, adopting a completely different persona and approach for each expert. Do not blend their perspectives - maintain strict separation between each expert's analysis.

### ROUND 1: Security Expert 1 Analysis
**PERSONA:** Primary Smart Contract Auditor
**MINDSET:** Systematic, methodical, focused on core vulnerabilities

**ANALYSIS APPROACH:**
```markdown
1. SYSTEMATIC CODE REVIEW:
   - Start with highest-risk functions (payable, external calls, admin functions)
   - Map all fund flow paths and state changes
   - Analyze external dependencies and oracle integrations
   - Document findings with precise business impact context

2. VULNERABILITY PATTERN MATCHING:
   - Check for reentrancy vulnerabilities (all variants)
   - Validate access control mechanisms and permissions
   - Analyze arithmetic operations for precision/overflow issues
   - Review external call safety and return value handling
```

**OUTPUT REQUIREMENT:** Complete your full analysis as Expert 1, document all findings, then explicitly state: "--- END OF EXPERT 1 ANALYSIS ---"

### ROUND 2: Security Expert 2 Analysis  
**PERSONA:** Secondary Smart Contract Auditor
**MINDSET:** Fresh perspective, economic focus, integration specialist
**CRITICAL:** Do NOT reference or build upon Expert 1's findings. Approach as if you've never seen their analysis.

**ANALYSIS APPROACH:**
```markdown
1. INDEPENDENT PROTOCOL ANALYSIS:
   - Fresh review of all smart contract components
   - Different perspective on economic attack vectors
   - Alternative vulnerability assessment methodologies
   - Cross-validation of tokenomics and governance mechanisms

2. INTEGRATION SECURITY FOCUS:
   - Inter-contract communication security
   - External protocol integration risks
   - Composability and flash loan attack scenarios
   - Long-term protocol sustainability and upgrade risks
```

**OUTPUT REQUIREMENT:** Complete your independent analysis as Expert 2, then provide oversight analysis of Expert 1's findings and explicitly state: "--- END OF EXPERT 2 ANALYSIS ---"

**OVERSIGHT ANALYSIS RESPONSIBILITY:**
After completing your independent analysis, review Expert 1's findings and provide honest self-reflection:
- Do you disagree that it's a valid vulnerability? Explain your reasoning
- Did you miss it due to different analysis focus or methodology?
- Was it an oversight in your systematic review process?
- Would you have caught it with more time or different approach?

### ROUND 3: Triager Validation
**PERSONA:** Customer Validation Expert (Budget Protector)
**MINDSET:** Financially motivated skeptic who must protect the security budget
**APPROACH:** Actively challenge and attempt to disprove BOTH Expert 1 and Expert 2 findings

## reference

```

```

## reference/anchor

```

```

## reference/anchor/fv-anc-1-arithmetic-operations

```

```

## reference/anchor/fv-anc-1-arithmetic-operations/fv-anc-1-cl1-overflow-underflow-in-arithmetic-operations.md

# FV-ANC-1-CL1 Overflow/Underflow in Arithmetic Operations

## TLDR

Integer overflow and underflow in Rust Anchor programs occur when arithmetic on u64/u128/i64 values wraps or panics, leading to corrupted balances, bypassed caps, or exploitable logic. In release builds, Rust integers wrap silently unless checked variants are used.

## Detection Heuristics

**Unchecked Arithmetic on Account Fields**
- Direct use of `+`, `-`, `*` operators on account field values such as `ctx.accounts.user.balance + amount`
- Arithmetic result assigned directly without `.checked_add()`, `.checked_sub()`, `.checked_mul()`, or `.checked_div()`
- Use of `as` casts (e.g., `u128 as u64`) that silently truncate

**Saturating or Wrapping Instead of Checked**
- Use of `.saturating_add()` or `.wrapping_add()` in financial or security-sensitive contexts where saturation masks real overflow
- Intermediate values computed in u64 that could exceed u64::MAX before being cast

**Absence of Error Propagation on Arithmetic**
- `.unwrap()` on checked arithmetic instead of `.ok_or(ErrorCode::...)` or `?`
- Arithmetic inside a loop over account balances without per-iteration overflow checks

## False Positives

- Arithmetic on values bounded by protocol invariants already enforced earlier in the instruction, making overflow mathematically impossible
- Use of `.saturating_add()` on non-financial counters such as event sequence numbers where saturation is the intended behavior
- Arithmetic inside `#[cfg(test)]` blocks that only appear in test code

## reference/anchor/fv-anc-1-arithmetic-operations/fv-anc-1-cl2-division-by-zero.md

# FV-ANC-1-CL2 Division by Zero

## TLDR

Division by zero in an Anchor instruction causes a panic and transaction failure. When the divisor is derived from user-supplied input or an account field, an attacker can trigger a denial-of-service by passing zero.

## Detection Heuristics

**Unguarded Division**
- Expression `a / b` where `b` is read from `ctx.accounts`, instruction data, or a computed value without a prior zero-check
- Use of `%` (modulo) with a user-controlled divisor
- `.checked_div()` result ignored with `.unwrap()` rather than a proper error path

**Missing Zero Guard Before Division**
- No `require!(denominator != 0, ...)` or `if denominator == 0 { return Err(...) }` preceding the division
- Division inside helper functions that receive account data directly without sanitizing the divisor

**Indirect Zero Risk**
- Divisor derived from subtraction (`a - b`) where `b` could equal `a`, yielding zero as an intermediate
- Divisor comes from a freshly initialized account field whose default is zero

## False Positives

- Division by a compile-time constant that is non-zero
- Divisor is a protocol-defined base (e.g., a fixed decimal precision constant like `1_000_000`) that is never settable by users

## reference/anchor/fv-anc-1-arithmetic-operations/fv-anc-1-cl3-arbitrary-rounding.md

# FV-ANC-1-CL3 Arbitrary Rounding

## TLDR

Inconsistent or unintended rounding in fixed-point arithmetic causes cumulative loss or gain of funds. In Anchor programs handling token amounts or exchange rates, the choice of floor, ceiling, or round-to-nearest has protocol-level security implications.

## Detection Heuristics

**Unspecified Rounding Direction**
- Use of `.try_round_u64()` or equivalent generic rounding on collateral, fee, or reward calculations without documented rationale
- Rounding function selected based on convenience rather than the direction that protects the protocol

**Rounding Favoring the User in Protocol-Debit Paths**
- Fee calculations that round down, reducing the fee collected
- Debt repayment calculations that round down, allowing partial debt to persist indefinitely

**Rounding Favoring the Protocol in User-Credit Paths**
- Yield or reward distributions that round down when rounding up would be correct
- Redemption calculations that under-credit the user due to implicit floor rounding

**Mixed Rounding in Paired Operations**
- Deposit path uses ceiling, withdraw path uses floor, or vice versa, creating a rounding arbitrage
- Different rounding applied to numerator and denominator of the same ratio in separate instructions

## False Positives

- Rounding consistently applied in the direction that protects the protocol and is documented as a deliberate design decision
- Integer division where fractional tokens are provably immaterial due to token decimal precision

## reference/anchor/fv-anc-1-arithmetic-operations/fv-anc-1-cl4-saturating-math-misuse-in-financial-contexts.md

# FV-ANC-1-CL4 Saturating Math Misuse in Financial Contexts

## TLDR

Using `.saturating_add()` or `.saturating_sub()` in financial contexts silently caps values at `u64::MAX` or `0` instead of signaling an error. This masks real overflow or underflow conditions, leaving accounts with corrupted balances that are neither reverted nor flagged, and potentially allowing exploitation of the silent cap behavior.

## Detection Heuristics

**Saturating Ops on Balance or Amount Fields**
- `.saturating_add` or `.saturating_sub` applied to fields representing token amounts, share balances, fees, or reward totals
- No subsequent assertion that the result equals the uncapped mathematical value
- Financial accumulation loops using saturating ops to prevent panics rather than propagating an explicit overflow error

**Missing Error Path on Overflow**
- Arithmetic that should propagate `ErrorCode::Overflow` uses saturating math as a shortcut
- `.saturating_add` result assigned directly to an account field without checking `result == a + b` in debug mode or via invariant assertion
- `u64::MAX` value reachable in a balance field with no protocol invariant preventing it

**Comparison After Saturation**
- Comparison like `new_balance > old_balance` used as an overflow guard, where both operands could be `u64::MAX` due to prior saturation, making the check vacuous

## False Positives

- Saturating ops on non-financial counters such as event sequence numbers, retry counts, or epoch trackers where saturation is the documented intended behavior and not reachable via user input
- Contexts where the operand values are provably bounded below `u64::MAX` by earlier constraints, making saturation mathematically unreachable

## reference/anchor/fv-anc-1-arithmetic-operations/fv-anc-1-cl5-round-trip-vault-profit-via-precision-gap.md

# FV-ANC-1-CL5 Round-Trip Vault Profit via Precision Gap

## TLDR

A deposit followed by an immediate withdrawal can extract value when share-to-asset conversion rounds in the depositor's favor at both steps. When both mint and redeem paths round down at the asset level, an attacker can systematically drain dust from a vault across many round-trip transactions, particularly in low-liquidity pools or at initialization.

## Detection Heuristics

**Symmetric Rounding Favoring Depositor**
- Vault deposit computes shares as `assets / price_per_share` (truncating division) and withdrawal computes assets as `shares * price_per_share` (also truncating) without a rounding direction that favors the vault
- Neither deposit nor withdrawal applies a minimum delta fee to make round-trips economically unviable
- Test with small deposit amounts (1-10 units) to check if `assets_out >= assets_in` after an immediate withdrawal

**Share Price Manipulation via Empty Vault**
- Vault can be initialized with 0 total_supply; first depositor can manipulate the initial share price by donating tokens directly to the vault account before anyone else deposits
- `total_assets()` reads the token account balance directly rather than a stored tracked value, making it susceptible to donation inflation
- Initialization does not mint a minimum set of shares to a dead address to anchor the initial share price

**Missing Deposit or Withdrawal Fee**
- No `deposit_fee_bps` or `withdrawal_fee_bps` applied in the deposit or withdrawal paths
- Fee, if present, is set to 0 by default and not enforced at protocol level for all vault types

## False Positives

- Vault enforces a minimum deposit amount greater than the maximum rounding delta per operation
- Deposit and withdrawal fees make the round-trip economically negative for the attacker at all meaningful scales
- Vault uses the ERC-4626-equivalent virtual offset (adding 1 to total_assets and total_supply at deployment) to anchor the initial share price and prevent inflation attacks

## reference/anchor/fv-anc-1-arithmetic-operations/readme.md

---
description: Prevent integer overflow, underflow, and precision issues.
---

# FV-ANC-1 Arithmetic Operations



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-1-arithmetic-operations/<filename>` to read any case file listed below.

#### fv-anc-1-cl1-overflow-underflow-in-arithmetic-operations.md
#### fv-anc-1-cl2-division-by-zero.md
#### fv-anc-1-cl3-arbitrary-rounding.md
#### fv-anc-1-cl4-saturating-math-misuse-in-financial-contexts.md
#### fv-anc-1-cl5-round-trip-vault-profit-via-precision-gap.md

## reference/anchor/fv-anc-10-closing-accounts

```

```

## reference/anchor/fv-anc-10-closing-accounts/fv-anc-10-cl1-closing-accounts-without-zeroing-data-and-setting-a-closed-discriminator.md

# FV-ANC-10-CL1 Closing Accounts Without Zeroing Data and Setting a Closed Discriminator

## TLDR

Manually closing an account by zeroing its lamports without also zeroing its data and writing a closed discriminator leaves the account readable as if still active. An attacker can revive the account within the same transaction or exploit the stale data in subsequent instructions.

## Detection Heuristics

**Lamport Drain Without Data Zeroing**
- Code that sets `**ctx.accounts.account.lamports.borrow_mut() = 0` without subsequently zeroing all bytes in the account data
- Manual closure not using Anchor's `close` constraint, combined with absence of a `try_borrow_mut_data()` zeroing loop

**No Closed Discriminator Written**
- After lamport drain, the first 8 bytes of account data are not overwritten with a sentinel value (e.g., `CLOSED_ACCOUNT_DISCRIMINATOR` from the Anchor source)
- Anchor's own `AccountsClose` trait not used and no equivalent discriminator write present

**Revival Attack Surface**
- Account closed and re-funded within a single transaction using separate instructions, allowing data to persist and be read again
- No check at the start of any instruction that consumes this account type to reject accounts bearing the closed discriminator

## False Positives

- Anchor `#[account(close = destination)]` constraint used correctly, as Anchor handles zeroing and discriminator writing internally
- Accounts closed via `close_account` CPI to the System Program where the runtime reclaims data automatically on account deletion

## reference/anchor/fv-anc-10-closing-accounts/fv-anc-10-cl2-operations-on-accounts-marked-as-closed.md

# FV-ANC-10-CL2 Operations on Accounts Marked as Closed

## TLDR

An account bearing a closed discriminator should be rejected at the start of any instruction that would read or mutate it. Failing to check allows a closed account to be passed into an instruction and processed as if still valid.

## Detection Heuristics

**No Closed-Account Guard at Instruction Entry**
- Instructions that accept an account type that can be closed but do not read the first 8 bytes to compare against the closed discriminator
- `Account<'info, T>` deserialization succeeding even after the closed discriminator is set, because Anchor does not automatically reject closed accounts in all versions

**Stale Data Usage After Closure**
- Code that reads fields from a closed account (e.g., `ctx.accounts.order.amount`) without first verifying the discriminator
- Iteration over a list of accounts from `ctx.remaining_accounts` without per-account closed-discriminator check

**Revival Path Not Guarded**
- Same-transaction revival: an earlier instruction closes the account and a later instruction in the same transaction reads it without discriminator check

## False Positives

- Accounts using Anchor's `#[account(close = destination)]` where the framework enforces the discriminator and subsequent Anchor deserialization automatically rejects closed accounts in newer versions
- Instruction designed specifically to handle the closure lifecycle and intentionally receives closed accounts for cleanup purposes

## reference/anchor/fv-anc-10-closing-accounts/fv-anc-10-cl3-unintended-closure-by-close-constraint.md

# FV-ANC-10-CL3 Unintended Closure by close Constraint

## TLDR

Anchor's `close` constraint closes the account and transfers its lamports at the end of the instruction, regardless of what the instruction body does. Applying it to accounts that should only conditionally be closed, or misunderstanding when the closure executes, can lead to unintended data and lamport loss.

## Detection Heuristics

**Unconditional close on Conditionally-Closed Accounts**
- `#[account(mut, close = destination)]` applied to an account that should only be closed under certain runtime conditions
- Instruction body contains early-return paths that the developer assumes will prevent closure, but closure still occurs because Anchor's drop handler runs regardless

**close Applied to Accounts Used Later in the Same Instruction**
- Account fields read or written after the instruction body but whose lamports will be zeroed by the close handler, causing incorrect post-instruction state assumptions
- CPI made after the instruction body that passes the to-be-closed account as a writable signer

**Incorrect Destination for Lamport Transfer**
- `close = destination` points to a user-supplied account not validated by a `has_one` or `address` constraint, allowing lamports to be redirected

## False Positives

- `close` is the correct and intended behavior for the instruction and the account lifecycle is well-defined
- `close` combined with a `constraint` that enforces the conditions under which closure is valid, making it effectively conditional

## reference/anchor/fv-anc-10-closing-accounts/readme.md

---
description: >-
  Ensure closed accounts are securely managed to prevent misuse or accidental
  reuse.
---

# FV-ANC-10 Closing accounts



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-10-closing-accounts/<filename>` to read any case file listed below.

#### fv-anc-10-cl1-closing-accounts-without-zeroing-data-and-setting-a-closed-discriminator.md
#### fv-anc-10-cl2-operations-on-accounts-marked-as-closed.md
#### fv-anc-10-cl3-unintended-closure-by-close-constraint.md

## reference/anchor/fv-anc-11-state-management

```

```

## reference/anchor/fv-anc-11-state-management/fv-anc-11-cl1-missing-slippage-tolerance-allowing-extraction.md

# FV-ANC-11-CL1 Missing Slippage Tolerance Allowing Extraction

## TLDR

Instructions that perform token swaps, liquidity operations, or price-sensitive conversions without a caller-supplied minimum output amount allow extractors to sandwich the transaction. On Solana, MEV via Jito bundles means any public transaction with zero slippage protection is a reliable extraction target for atomic sandwich attacks within the same block.

## Detection Heuristics

**Zero or Derived Minimum Output**
- Swap or liquidity instruction accepts `min_amount_out: 0` or `minimum_tokens: 0` without rejecting it
- Minimum output computed inside the instruction from the current pool state rather than passed as a caller parameter; attacker can move pool state before and after
- Automated keeper or harvest instruction executes a swap with no user-supplied slippage bound; keeper code defaults to 0 or computes from on-chain price in the same transaction

**No Deadline Enforcement**
- Transaction does not include a `deadline` or `valid_until_slot` parameter that the program checks against `Clock.slot` or `Clock.unix_timestamp`
- Swap instruction can be delayed or reordered by a Jito bundle leader without any time-bound expiry

**Slippage Applied Only Partially**
- Slippage bound enforced on one leg of a two-leg swap but not on the intermediate or final leg
- Protocol-level slippage check occurs after fees are deducted, allowing the post-fee amount to fall below the pre-fee minimum without triggering the revert

## False Positives

- Instruction is callable only by a trusted relayer that routes through a private Jito bundle with MEV rebate; mempool visibility is zero
- Slippage bound is enforced at a higher aggregator layer that validates output before forwarding to the protocol instruction
- Operation is a withdrawal of an exact token amount with no price-sensitive conversion; slippage concept does not apply

## reference/anchor/fv-anc-11-state-management/fv-anc-11-cl2-lamport-balance-invariant-violation-in-cpi.md

# FV-ANC-11-CL2 Lamport Balance Invariant Violation in CPI

## TLDR

Solana programs that perform CPIs can inadvertently violate the lamport conservation invariant: the total lamports across all accounts touched by a transaction must be the same before and after. If a program transfers lamports via a CPI and also modifies account data in ways that change the rent-exempt minimum, the transaction may fail or leave an account in a state where it is no longer rent-exempt and subject to garbage collection.

## Detection Heuristics

**Lamports Transferred Without Rent Check**
- CPI that transfers lamports out of an account does not verify the sender's remaining balance meets the rent-exempt minimum for its current data size
- Lamport transfer amount is the full account balance without checking `Rent::minimum_balance(data.len())`
- After a CPI closes a sub-account and sweeps lamports, the receiving account's balance not verified to remain rent-exempt

**CPI Balance Accounting Mismatch**
- Program tracks lamport balances in account state fields but the tracked value diverges from the actual `account.lamports()` after a CPI; subsequent logic uses the stale tracked value
- Balance change from one CPI is not accounted for before a second CPI in the same instruction, leading to cumulative invariant violation
- Program attempts to transfer more lamports than the source account holds; no `require!(source.lamports() >= amount)` check before the CPI

**Rent Exemption After Realloc**
- Account data is reallocated to a larger size via `AccountInfo::realloc` but no additional lamports are transferred to cover the increased rent-exempt minimum
- Realloc and lamport top-up are performed in separate instructions, creating a window where the account is non-rent-exempt

## False Positives

- All lamport transfers validated against account balance and rent-exempt minimum before execution
- Anchor `#[account(mut, close = recipient)]` constraint handles lamport sweep and zero-fill atomically with correct rent accounting

## reference/anchor/fv-anc-11-state-management/fv-anc-11-cl3-dust-account-poisoning-blocking-critical-operations.md

# FV-ANC-11-CL3 Dust Account Poisoning Blocking Critical Operations

## TLDR

An attacker can send a tiny lamport amount to a program-controlled account, increasing its balance above the rent-exempt minimum for zero bytes but below what a later `close` or realloc operation expects. This can cause `close` constraints to fail (because balance != expected), leave accounts in an un-closeable state, or force the protocol to accept griefing dust that accumulates indefinitely.

## Detection Heuristics

**Close Operation Depending on Exact Balance**
- Account close logic checks `account.lamports() == rent_exempt_minimum` rather than `account.lamports() >= rent_exempt_minimum`; a dust deposit shifts the balance above the expected value and breaks the comparison
- Protocol sweeps lamports to a fixed recipient and uses a hardcoded sweep amount rather than `account.lamports()`; dust leaves a residual balance that prevents account deletion

**Rent-Exempt Check Bypassed by Dust**
- An account below the rent-exempt threshold gains just enough lamports from a dust attack to cross the threshold, preventing the runtime from garbage-collecting it but not giving the attacker meaningful control
- Protocol relies on an account being garbage-collected after reaching zero lamports; dust prevents this and keeps the account alive indefinitely

**Unbounded Dust Accumulation**
- Protocol holds a token account or program-owned account that anyone can send lamports to; no mechanism to sweep or reject unsolicited lamport deposits
- Close operation fails due to residual balance from a dust deposit; the account is now stuck open and consuming space permanently

## False Positives

- Close operation uses `account.lamports()` as the sweep amount unconditionally, handling any non-zero balance correctly regardless of dust
- Anchor's `close = recipient` constraint sweeps the full lamport balance to the recipient atomically and cannot be blocked by dust

## reference/anchor/fv-anc-11-state-management/fv-anc-11-cl4-unbounded-account-collection-causing-dos.md

# FV-ANC-11-CL4 Unbounded Account Collection Causing DoS

## TLDR

Instructions that iterate over a variable-length collection stored in an account (a Vec of positions, orders, or members) can exhaust compute units when the collection grows large. An attacker can repeatedly add entries to grow the collection to a size that makes all dependent instructions fail due to compute budget exhaustion, effectively bricking any protocol operation that touches that account.

## Detection Heuristics

**Iteration Over User-Controlled Vec**
- Instruction iterates over `account.positions`, `account.orders`, or similar Vec fields with no cap on collection length
- Vec is extended by user-callable instructions without a maximum length constraint (`require!(list.len() < MAX_ITEMS)`)
- Gas or compute cost of the iteration grows linearly with collection size and can be pushed above 1.4M compute units

**Missing Collection Size Cap**
- `Vec::push` called in a user-accessible instruction without checking current length against a protocol-defined maximum
- Protocol documentation mentions a maximum but the on-chain check is absent or uses an incorrect bound
- A separate attacker-controlled account's Vec is iterated in the same instruction that processes the victim's main operation, doubling the iteration work

**Cleanup Path Also Blocked**
- The only way to remove entries from the collection is via an instruction that also iterates the full collection; once the collection is large enough to DoS the iteration, entries cannot be removed either, making the DoS permanent
- No privileged admin function to forcibly truncate or migrate oversized collections

## False Positives

- Collection length is bounded by a `require!` check at every append site and the maximum is low enough that compute budget is never exhausted
- Protocol charges a fee per entry that makes unbounded growth economically irrational
- Fixed-size arrays used instead of Vec; the size is a compile-time constant that bounds compute cost

## reference/anchor/fv-anc-11-state-management/fv-anc-11-cl5-time-unit-mismatch-in-deadline-validation.md

# FV-ANC-11-CL5 Time Unit Mismatch in Deadline Validation

## TLDR

Solana's `Clock` sysvar provides both `unix_timestamp` (seconds since Unix epoch) and `slot` (current slot number). Mixing these two units in deadline or expiry comparisons produces incorrect results: a value stored in seconds interpreted as a slot number, or vice versa, can make a deadline that should expire in one hour appear to expire in days, or vice versa.

## Detection Heuristics

**Mixed Unit Comparison**
- Protocol stores a deadline or expiry as `Clock.slot` but compares it against `Clock.unix_timestamp` in a later instruction, or vice versa
- Expiry field not labeled with its unit in the account struct comment or field name; auditor must trace all write sites to determine whether seconds or slots were intended
- Cooldown or lock duration specified in seconds in the protocol documentation but stored by assigning `Clock.slot + duration_seconds`, producing a value in slots not seconds

**Slot Duration Assumptions**
- Protocol assumes a fixed slot duration (e.g., 400ms per slot) to convert between seconds and slots; actual slot duration varies and the assumption causes drift over time
- Lock duration computation: `current_slot + (lock_seconds / 0.4)` uses hardcoded slot duration that may be inaccurate, making locks shorter or longer than intended

**Cross-Instruction Unit Inconsistency**
- One instruction writes `last_updated_slot = Clock.unix_timestamp` (wrong unit) while another instruction reads `elapsed = Clock.slot - last_updated_slot`, producing nonsensical elapsed time
- Config parameter for timelock duration accepted from user input without validation of whether it is denominated in slots or seconds

## False Positives

- Protocol uses only one time source consistently throughout all instructions; either always `unix_timestamp` or always `slot`, never mixed
- Field names and struct comments explicitly document the unit (`expiry_slot`, `expiry_unix_ts`), and all comparison sites use the matching Clock field

## reference/anchor/fv-anc-11-state-management/fv-anc-11-cl6-reward-accumulator-updated-after-balance-change.md

# FV-ANC-11-CL6 Reward Accumulator Updated After Balance Change

## TLDR

Staking and reward protocols that track per-share reward accumulation must update the global accumulator index before modifying any user's share balance. If the index is updated after a deposit or withdrawal, the user's new balance is used to calculate their retroactive entitlement to rewards that accrued before the balance change, allowing them to claim rewards they did not earn.

## Detection Heuristics

**Index Updated After Balance Mutation**
- `global_reward_index` updated after `user.shares += deposit_amount` or `user.shares -= withdraw_amount` in the same instruction
- Instruction flow: read old balance -> add deposit -> update index -> calculate pending rewards using new balance; the update should occur before the balance change
- `pending_rewards = (global_index - user.last_index) * user.shares` computed with a shares value that already includes the new deposit

**Snapshot Not Taken Before Balance Change**
- No `old_shares` snapshot taken before the balance modification; reward calculation uses `user.shares` which may already be the post-deposit value
- `accumulate_rewards(user)` function reads `user.shares` from the account state; if called after the balance is updated, it captures the wrong shares for historical reward calculation

**Instant Deposit-and-Claim**
- Attacker can deposit, immediately claim rewards that accrued before their deposit, and withdraw in the same slot because the index is stale at deposit time
- No minimum time between deposit and first reward claim; instant claim on a freshly accrued index is the exploit vector

## False Positives

- Protocol always calls `settle_rewards(user)` as the first step of any instruction that modifies balances; this settles outstanding rewards at the old balance before any mutation occurs
- Reward index updated atomically at the global level before any per-user mutation; all pending rewards calculated and credited using the pre-mutation share count

## reference/anchor/fv-anc-11-state-management/readme.md

---
description: State management vulnerabilities including slippage, lamport invariants, dust attacks, resource exhaustion, and reward accumulator ordering.
---

# FV-ANC-11 State Management



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-11-state-management/<filename>` to read any case file listed below.

#### fv-anc-11-cl1-missing-slippage-tolerance-allowing-extraction.md
#### fv-anc-11-cl2-lamport-balance-invariant-violation-in-cpi.md
#### fv-anc-11-cl3-dust-account-poisoning-blocking-critical-operations.md
#### fv-anc-11-cl4-unbounded-account-collection-causing-dos.md
#### fv-anc-11-cl5-time-unit-mismatch-in-deadline-validation.md
#### fv-anc-11-cl6-reward-accumulator-updated-after-balance-change.md

## reference/anchor/fv-anc-13-program-management

```

```

## reference/anchor/fv-anc-13-program-management/fv-anc-13-cl1-compute-budget-dos-via-user-injected-limit-instruction.md

# FV-ANC-13-CL1 Compute Budget DoS via User-Injected Limit Instruction

## TLDR

Solana transactions can include a `SetComputeUnitLimit` instruction that caps the compute units available for the entire transaction. A malicious user can prepend a `SetComputeUnitLimit` with a very low value before calling a protocol instruction, causing the instruction to exhaust compute units and fail. If a critical operation (liquidation, settlement, position close) can be DoS'd this way, it creates a window for economic exploitation.

## Detection Heuristics

**Protocol Does Not Read or Validate Compute Budget**
- Protocol has no mechanism to detect or reject transactions where a `SetComputeUnitLimit` instruction has set the limit below the instruction's required compute units
- Instruction whose compute cost varies with input size (e.g., iterating over positions) does not enforce a minimum compute budget at entry
- Critical operations (liquidation, forced settlement) executable by anyone have predictable compute costs that can be targeted with a precise low compute limit

**No Protection on Critical Paths**
- Liquidation, position close, or settlement instruction does not set its own compute request via a CPI to the Compute Budget program to ensure sufficient units are reserved
- Protocol documentation does not mention compute budget injection as a known DoS vector despite having instructions with variable compute cost
- No transaction simulation or compute budget check in off-chain keeper infrastructure that submits critical transactions

**Keeper Infrastructure Gap**
- Keeper that triggers liquidations uses a fixed compute budget that can be undercut by a user who adds a lower `SetComputeUnitLimit` before the keeper's instruction in the same bundle

## False Positives

- All critical instructions are only callable by a trusted keeper or admin that submits transactions via a private relay; no public user can prepend instructions
- Protocol's critical instructions have bounded, low, fixed compute costs that cannot be targeted effectively with a compute limit attack

## reference/anchor/fv-anc-13-program-management/fv-anc-13-cl2-uninitialized-vec-capacity-bug.md

# FV-ANC-13-CL2 Uninitialized Vec Capacity Bug

## TLDR

Creating a `Vec` with `Vec::with_capacity(n)` allocates space for n elements but leaves the length at 0. Accessing elements of a capacity-allocated but not length-populated Vec via index operators will produce an out-of-bounds panic. If an initialization instruction fails to push actual elements and a subsequent instruction assumes the Vec is populated, state will be incorrect or instructions will panic.

## Detection Heuristics

**with_capacity Without Corresponding Pushes**
- `Vec::with_capacity(n)` called during account initialization or instruction processing without a corresponding loop that pushes n default elements
- Account state field is a Vec; after initialization its length is 0 but the protocol's subsequent logic reads from it by index assuming length == capacity

**Default Initialization Confusion**
- Account struct derives `Default` for a Vec field; `Default::default()` for Vec is an empty Vec with zero length; code that assumes the Vec contains pre-allocated slots will panic on first access
- `account.items[0]` accessed after initialization without checking `items.len() > 0`

**Serialization Length Mismatch**
- Account size allocated for n elements (via `space = 8 + n * ELEMENT_SIZE`) but Vec serialized with length prefix of 0; deserialized length is 0 and index access panics
- Realloc extends account space but Vec's length field is not updated to match the new capacity

## False Positives

- Vec capacity allocation is immediately followed by a fill loop or `.extend(iter)` that populates all capacity slots before the account is used
- Protocol never accesses Vec by index; all access is via iteration over the Vec's actual elements, which correctly handles a zero-length Vec

## reference/anchor/fv-anc-13-program-management/fv-anc-13-cl3-upgrade-authority-not-transferred-or-locked.md

# FV-ANC-13-CL3 Upgrade Authority Not Transferred or Locked

## TLDR

Anchor programs deployed with a live upgrade authority can be modified by the authority holder at any time without on-chain governance or timelock. If the upgrade authority is a single keypair rather than a multisig or locked to None, a key compromise or insider action can replace the program binary, potentially draining all protocol funds or backdooring authentication logic.

## Detection Heuristics

**Single-Keypair Upgrade Authority**
- Program data account's upgrade authority is a single wallet address rather than a Squads multisig, governance program, or `None`
- No timelock between upgrade proposal and execution; an upgrade can be applied in a single transaction from the authority holder
- Protocol documentation does not mention the upgrade authority setup or its security model

**Upgrade Authority Not Revoked After Final Audit**
- Program intended to be immutable has not had its upgrade authority set to `None` post-audit
- `solana program set-upgrade-authority <program_id> --final` never executed; upgrade authority remains at initial deployer address
- Protocol's trust model claims immutability in documentation but on-chain state shows a live authority

**No Governance Gate**
- Upgrade authority is a DAO or governance program but upgrade proposals have no minimum voting period or quorum requirement, making it trivially executeable by a whale voter
- Emergency upgrade path bypasses the normal governance delay, creating a fast-track mechanism that could be abused

## False Positives

- Upgrade authority is explicitly set to `None` on-chain; program is immutable and cannot be upgraded
- Upgrade authority is a Squads multisig with a documented threshold and all signers are independent parties; upgrade path has a timelock of at least 48 hours

## reference/anchor/fv-anc-13-program-management/fv-anc-13-cl4-log-truncation-hiding-critical-security-events.md

# FV-ANC-13-CL4 Log Truncation Hiding Critical Security Events

## TLDR

Solana transactions are limited in total compute units per transaction. When a program emits extensive logs near the compute limit, the Solana runtime truncates log output silently. Security-critical events emitted near or after the compute budget is exhausted will not appear in the transaction logs, making off-chain monitoring systems blind to them. An attacker can intentionally exhaust compute budget before a security event to prevent its detection.

## Detection Heuristics

**Security Events Emitted Late in Instruction**
- Critical `msg!` or `emit!` calls (failed access checks, unusual amounts, emergency flags) placed near the end of an instruction after expensive computation that consumes most of the compute budget
- Log emission order not reviewed against compute budget consumption; a long iteration loop before a security event log may truncate the event

**No Compute Budget Reservation for Logging**
- Program does not reserve a compute budget margin (e.g., `request_heap_frame` or early return if remaining compute < threshold) to ensure logging calls are not truncated
- Off-chain indexer or monitoring system relies exclusively on transaction logs without checking for log truncation indicators in the transaction metadata

**Event Emission via emit! Macro**
- Anchor `emit!` macro calls placed after large account iteration loops; the macro incurs CPI cost and can be truncated if budget is exhausted
- No fallback mechanism (e.g., storing the event in account state) for critical events that must be observable even under compute pressure

## False Positives

- All security-critical events emitted at the very beginning of the instruction before any expensive computation, ensuring they are never truncated
- Protocol uses account-state-based event recording rather than log emission; events are always readable from account data regardless of compute budget

## reference/anchor/fv-anc-13-program-management/readme.md

---
description: Program-level management vulnerabilities including compute budget, upgrade authority, initialization bugs, and logging.
---

# FV-ANC-13 Program Management



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-13-program-management/<filename>` to read any case file listed below.

#### fv-anc-13-cl1-compute-budget-dos-via-user-injected-limit-instruction.md
#### fv-anc-13-cl2-uninitialized-vec-capacity-bug.md
#### fv-anc-13-cl3-upgrade-authority-not-transferred-or-locked.md
#### fv-anc-13-cl4-log-truncation-hiding-critical-security-events.md

## reference/anchor/fv-anc-2-signer-checks

```

```

## reference/anchor/fv-anc-2-signer-checks/fv-anc-2-cl1-unvalidated-signers.md

# FV-ANC-2-CL1 Unvalidated Signers

## TLDR

Declaring an account as `AccountInfo<'info>` rather than `Signer<'info>` in an Anchor context struct means the framework does not enforce that the account signed the transaction. Any public key can be passed as the authority without authorization.

## Detection Heuristics

**AccountInfo Used Where a Signer Is Required**
- Account declared as `pub authority: AccountInfo<'info>` in a context struct for an instruction that performs privileged operations
- Account used to authorize transfers, mutations, or admin actions but typed as `AccountInfo` instead of `Signer`

**No Supplemental is_signer Check**
- `AccountInfo` account used in a privileged path without a subsequent `require!(ctx.accounts.authority.is_signer, ...)` check in the instruction body
- `has_one` constraint on a vault pointing to an `AccountInfo` authority without signer enforcement

**Signer Constraint Missing on PDA-Signed Paths**
- Instruction accepts a user-provided authority for a PDA operation but does not enforce signing from that authority before deriving or using the PDA

## False Positives

- `AccountInfo` is used for read-only informational accounts (e.g., sysvars, program IDs) where signing is not required
- Account is a PDA that cannot sign transactions; signer seeds are instead validated via `invoke_signed` or Anchor seeds constraint

## reference/anchor/fv-anc-2-signer-checks/fv-anc-2-cl2-no-is_signer-check.md

# FV-ANC-2-CL2 No is_signer Check

## TLDR

When using raw `AccountInfo` or native Solana program patterns, the `is_signer` field on the account info must be explicitly checked. Omitting this check means an instruction accepts any account as an authority without verifying it signed the transaction.

## Detection Heuristics

**Key Comparison Without Signer Verification**
- Code that checks `authority.key != expected_key` or `authority.key() == expected_key` but never checks `authority.is_signer`
- Authorization based solely on account key equality, allowing a known-public-key account to be passed without a signature

**Missing is_signer Guard in Instruction Body**
- Instruction using raw `AccountInfo` for an authority account without `if !account.is_signer { return Err(...) }`
- `require!(account.is_signer, ...)` absent from all code paths that perform privileged operations

**AccountInfo in Context Struct Without Signer Wrapper**
- Multiple `AccountInfo` accounts in a context struct where at least one is treated as an authorizing party in the instruction body, but none have `is_signer` verified

## False Positives

- Account is a PDA controlled by the program; PDAs cannot be signers and authorization is instead proven through seed derivation
- Account is a read-only system account or sysvar where signing is not meaningful

## reference/anchor/fv-anc-2-signer-checks/readme.md

---
description: Verify signer authenticity for secure execution.
---

# FV-ANC-2 Signer Checks



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-2-signer-checks/<filename>` to read any case file listed below.

#### fv-anc-2-cl1-unvalidated-signers.md
#### fv-anc-2-cl2-no-is_signer-check.md

## reference/anchor/fv-anc-3-account-ownership-validations

```

```

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl1-trying-to-modify-an-account-without-checking-if-its-writeable.md

# FV-ANC-3-CL1 Trying to Modify an Account Without Checking if it is Writable

## TLDR

Solana requires accounts to be marked writable in the transaction's account list before the runtime permits lamport or data changes. Writing to an account not flagged as writable will either panic or silently fail depending on the Solana version, and opens logic errors when the writability check is absent at the program level.

## Detection Heuristics

**Mutation Without Writable Guard**
- `ctx.accounts.config.data.borrow_mut()` or similar data mutation on an `AccountInfo` without a prior `require!(ctx.accounts.config.is_writable, ...)` check
- Lamport arithmetic on an account (e.g., `**account.lamports.borrow_mut() += ...`) without verifying `is_writable`

**Missing mut Constraint in Anchor Context**
- Account field in `#[derive(Accounts)]` struct lacks `#[account(mut)]` yet the instruction body writes to it
- Anchor `Account<'info, T>` used without `mut` constraint on a data-mutating instruction

**Unconditional Write on User-Supplied Accounts**
- `AccountInfo` received from `ctx.remaining_accounts` or a raw account list is written without checking `is_writable`

## False Positives

- Account is written only via CPI and the callee program enforces writable constraints on its own accounts
- Read-only deserialization of account data into a local variable without any mutation of the account itself

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl10-using-ctx.remaining_accounts-without-non-zero-data-check.md

# FV-ANC-3-CL10 Using ctx.remaining_accounts Without Non-Zero Data Check

## TLDR

An uninitialized account on Solana has all-zero data. Accepting such an account from `ctx.remaining_accounts` without checking that it contains non-zero data allows an attacker to pass an empty system account that will be deserialized as a zero-value struct, potentially bypassing balance or state checks.

## Detection Heuristics

**No Liveness Check Before Deserialization**
- `remaining_accounts` entry deserialized or read without verifying that `data.iter().any(|&b| b != 0)` or that the lamport balance is non-zero
- Code assumes a non-zero discriminator check serves as a liveness check, but all-zero data passes byte-equality checks against a zero discriminator

**Zero-Balance Account Accepted as Valid State**
- Account with zero lamports taken from `remaining_accounts` and used as if it represents initialized state
- Missing check for `account.lamports() > 0` before trusting the account as representing a real on-chain entity

**System-Owned Zero Account**
- Account owned by the System Program with zero data accepted and treated as a valid program account of a specific type

## False Positives

- Account is expected to start at zero as part of its initialized state (e.g., a counter initialized to zero); zero data is valid and expected
- Program immediately initializes the account after accepting it and does not read any fields from it prior to initialization

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl11-no-reload-after-account-mutation.md

# FV-ANC-3-CL11 No Reload After Account Mutation

## TLDR

In Anchor, after mutating an account via a `load_mut()` or direct field write, subsequent reads of the same account may use a stale in-memory copy. Without explicitly reloading, the program may act on outdated state, leading to logic errors when the mutation affects values read later in the same instruction.

## Detection Heuristics

**load_mut Scope Not Dropped Before Subsequent load**
- `let mut data = ctx.accounts.account.load_mut()?` followed by logic that reads `ctx.accounts.account` without dropping the mutable borrow and calling `.reload()` or `.load()`
- Borrow of mutable account data not scoped tightly, causing stale in-memory values to persist beyond the mutation

**Mutation Followed by Computation Using Pre-Mutation Values**
- Account field updated (e.g., `account.balance = new_balance`) and then used in a subsequent calculation that should reflect the updated value, but the local variable still holds the old value
- CPI invoked after mutation that passes the account, but the caller reads the account's fields again after the CPI without reloading

**Missing .reload() After CPI**
- After a CPI that mutates an account owned by the current program, the instruction reads the account's fields without calling `.reload()` to pull the updated data from the account's storage

## False Positives

- Mutation and subsequent reads are in separate instructions; Anchor re-deserializes on each instruction entry
- Account is written exactly once at the end of the instruction and no further reads occur after the write

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl12-not-validating-a-set-address.md

# FV-ANC-3-CL12 Not Validating a Set Address

## TLDR

When an instruction stores a new address into an account field (e.g., updating a treasury, fee recipient, or authority), failing to validate that address allows arbitrary pubkeys to be set. This can redirect funds, grant privileges to attacker-controlled accounts, or break protocol invariants.

## Detection Heuristics

**Unconstrained Address Update**
- `ctx.accounts.config.treasury = new_address` or similar field assignment without preceding validation of `new_address`
- Address stored from instruction data without checking it is not the System Program, a PDA of the current program, or a known-invalid address

**No Existence or Receivability Check**
- New address not verified to be an initialized account that can receive tokens or lamports
- Fee recipient or authority address updated to a PDA that has no associated token account for the relevant mint

**No Access Control on the Update Instruction**
- Instruction that sets an address does not enforce that only a privileged authority (admin, DAO, multisig) can call it
- Missing `has_one = admin` or equivalent signer constraint on the update instruction context

## False Positives

- New address is derived on-chain from a PDA computation and validated by seeds rather than passed as user input
- Address is constrained to a fixed set of known values enforced by a `constraint` expression

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl13-init-if-needed-without-reinit-guard.md

# FV-ANC-3-CL13 init_if_needed Without Reinitialization Guard

## TLDR

Anchor's `init_if_needed` skips account creation if the account already exists, but it does not prevent an attacker from pre-creating the PDA with malicious initial state. Without a reinitialization guard in the instruction body, a subsequent call that skips init will silently operate on attacker-controlled data.

## Detection Heuristics

**init_if_needed Without State Validation on Existing Account**
- `#[account(init_if_needed, ...)]` used without any check in the instruction body that verifies the account's existing fields when the account was already initialized
- No `is_initialized` flag or equivalent sentinel checked before writing fields, allowing re-entry to overwrite state

**Pre-Created PDA Attack Surface**
- PDA seeds include only user-controlled inputs (e.g., user pubkey, a name string) allowing an attacker to derive the PDA and create it with arbitrary data before the legitimate user

**Authority Field Not Verified on Existing Account**
- Instruction uses `init_if_needed` for an account that stores an `authority: Pubkey` but does not verify that the stored authority matches `ctx.accounts.user.key()` when the account already exists

**Missing Idempotency Check**
- Instruction is intended to be idempotent but lacks protection against an attacker calling it a second time to reset critical state

## False Positives

- Account is a token account (ATA) where `init_if_needed` is safe because the token program enforces ownership on initialization
- Instruction only writes to the account on first initialization and subsequent calls are no-ops due to explicit guards in the instruction body

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl14-realloc-without-zero-init.md

# FV-ANC-3-CL14 realloc Without zero_init

## TLDR

Anchor's `realloc` constraint expands an account's data allocation. When `realloc::zero_init = false`, newly exposed bytes from the previous allocation are not zeroed and may contain stale data from prior account usage. Reading those bytes as structured fields yields unpredictable values.

## Detection Heuristics

**realloc::zero_init = false on Expanding Reallocations**
- `#[account(mut, realloc = new_size, realloc::payer = payer, realloc::zero_init = false)]` where `new_size` is larger than the account's current size
- Instruction body reads fields in the newly allocated region without manually zeroing them first

**Stale Byte Exposure After Size Increase**
- Account previously used for a different data structure, closed, and then reallocated to a new type without zeroing; residual bytes from the old structure appear in new fields
- Reallocation performed in a loop where each iteration may expose previously used memory from a prior account lifecycle

**Missing Manual Zero in Instruction Body**
- `realloc::zero_init = false` used and no `data[old_size..new_size].fill(0)` or equivalent in the instruction body before reading new fields

## False Positives

- `realloc::zero_init = false` used only for shrinking allocations, where no new bytes are exposed
- Instruction immediately overwrites all newly allocated bytes with explicit values before any read, making the initial content irrelevant

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl2-trying-to-access-account-data-without-ownership-checks.md

# FV-ANC-3-CL2 Trying to Access Account Data Without Ownership Checks

## TLDR

On Solana, any account can be passed to a program. Without verifying that an account is owned by the expected program, an attacker can substitute a look-alike account controlled by a different program, causing the instruction to operate on attacker-crafted data.

## Detection Heuristics

**Raw Data Borrow Without Owner Verification**
- `ctx.accounts.config.data.borrow()` on an `AccountInfo` or `UncheckedAccount` without checking `ctx.accounts.config.owner`
- `try_from_slice` or manual deserialization of account data without a preceding owner check

**Missing owner Constraint in Anchor Context**
- `UncheckedAccount<'info>` used to read structured data without `#[account(owner = expected_program_id)]` constraint
- `AccountInfo` interpreted as a known struct type without verifying `account.owner == &expected_program::ID`

**Ownership Checked Against Wrong Program**
- `account.owner == ctx.program_id` check missing, or checked against system program when the data account should be owned by the current program

## False Positives

- Anchor `Account<'info, T>` type used, which automatically enforces program ownership during deserialization
- Account is the System Program, Token Program, or another well-known program account where ownership of the program itself is not meaningful to check

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl3-usage-of-uncheckedaccount-without-manual-ownership-check.md

# FV-ANC-3-CL3 Usage of UncheckedAccount Without Manual Ownership Check

## TLDR

`UncheckedAccount<'info>` explicitly bypasses Anchor's automatic ownership and discriminator verification. Any instruction using this type must manually perform the ownership checks that `Account<'info, T>` would otherwise enforce, or an attacker can supply an account owned by any program.

## Detection Heuristics

**UncheckedAccount Without owner Constraint or Manual Check**
- `pub some_account: UncheckedAccount<'info>` in a context struct without `#[account(owner = expected_program_id)]` constraint
- `UncheckedAccount` used in an instruction body that reads or writes structured data without a preceding `require!(account.owner == &expected_program::ID, ...)`

**`/// CHECK:` Comment Absent or Inadequate**
- Anchor requires a `/// CHECK:` doc comment above every `UncheckedAccount` field; absence suggests the safety rationale was not considered
- `/// CHECK:` comment present but only states "safe" without explaining why owner verification is unnecessary

**UncheckedAccount Passed to CPI Without Validation**
- `UncheckedAccount` forwarded to a CPI without verifying its owner, allowing an attacker-controlled account to be interpreted as a legitimate target by the callee

## False Positives

- `UncheckedAccount` used for accounts whose key is fully constrained by `#[account(address = known_constant)]`, making ownership implied
- Account is a program account (executable) where owner is always the BPF loader and ownership of the program itself is not relevant

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl4-usage-of-uncheckedaccount-without-manual-signer-check.md

# FV-ANC-3-CL4 Usage of UncheckedAccount Without Manual Signer Check

## TLDR

When `UncheckedAccount<'info>` is used for an account that is expected to authorize an action, the `is_signer` field must be explicitly checked in the instruction body. Anchor does not perform this check automatically for unchecked account types.

## Detection Heuristics

**UncheckedAccount Used as Authority Without Signer Verification**
- `pub authority: UncheckedAccount<'info>` in a context struct where the instruction performs privileged operations gated on this account
- No `require!(ctx.accounts.authority.is_signer, ...)` in the instruction body when `UncheckedAccount` is the authorizing party

**Key Equality Check Without Signer Check**
- Code verifies `authority.key() == expected_key` but omits `authority.is_signer`, allowing signature replay or impersonation
- `has_one` constraint on a related account pointing to an `UncheckedAccount` without enforcing that it signed

**`/// CHECK:` Justification Omits Signer Rationale**
- `/// CHECK:` comment explains ownership but does not address how signer status is enforced

## False Positives

- `UncheckedAccount` used for a PDA that authorizes via seeds rather than a signature
- Account is used purely for reading data and no privileged action is gated on it being a signer

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl5-no-is_initialized-check-when-operating-on-an-account.md

# FV-ANC-3-CL5 No is_initialized Check When Operating on an Account

## TLDR

Operating on an uninitialized account allows instructions to read garbage data or overwrite state during initialization with attacker-controlled values. Anchor's `#[account(init)]` guards against double-initialization, but manually managed accounts or legacy patterns that track initialization via a boolean flag must be explicitly checked.

## Detection Heuristics

**Missing Initialization Guard on Manual Account Structs**
- Account struct contains `is_initialized: bool` field but no instruction reads this field and rejects the account if already initialized
- Instruction that should only run once does not check `is_initialized` before writing state

**init_if_needed Without Subsequent State Validation**
- `#[account(init_if_needed)]` used without checking whether the account was already initialized before writing fields, allowing re-initialization with new values

**Operating on Zero-Discriminator Account**
- Instruction accepts an `Account<'info, T>` that should be freshly initialized but does not use `init` constraint, allowing an all-zero account to pass deserialization

## False Positives

- Anchor `#[account(init)]` constraint is used, which enforces that the account is uninitialized (all-zero discriminator) at the time of the instruction
- Instruction is explicitly designed to re-initialize an account and documents this behavior

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl6-missing-account-constraints.md

# FV-ANC-3-CL6 Missing Account Constraints

## TLDR

Anchor constraints such as `has_one`, `constraint`, and `address` enforce relational correctness between accounts at the framework level. Omitting these constraints forces the instruction body to perform manual checks, and if those checks are also absent, an attacker can pass unrelated accounts.

## Detection Heuristics

**Missing has_one on Relational Fields**
- `Account<'info, Vault>` struct contains an `admin: Pubkey` field but the context does not include `#[account(has_one = admin)]`, allowing any signer to be passed as admin
- Loan, position, or order accounts that reference a user or mint pubkey without a `has_one` constraint linking them to the corresponding account in the context

**Missing address Constraint for Known Fixed Accounts**
- Sysvar, program, or well-known singleton account accepted without `#[account(address = known_id)]`
- Token program, System Program, or Rent sysvar passed as `AccountInfo` without address verification

**Missing constraint for Business Logic Invariants**
- Two accounts that must differ (e.g., source and destination token accounts) lack `constraint = a.key() != b.key()`
- Numerical invariants (e.g., `amount > 0`, `deadline > clock.unix_timestamp`) not enforced at the account constraint level or in the instruction entry

## False Positives

- Constraint enforced equivalently inside the instruction body with a `require!` macro, providing the same security guarantee
- Relationship is structurally impossible to violate due to how the PDA seeds are constructed

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl7-duplicate-mutable-accounts.md

# FV-ANC-3-CL7 Duplicate Mutable Accounts

## TLDR

When two mutable account fields in an Anchor context can be satisfied by the same public key, an attacker can pass the same account for both. Instructions that assume the accounts are distinct (e.g., transferring between them) will produce incorrect results or allow double-mutation exploits.

## Detection Heuristics

**Two mut Accounts of the Same Type Without Uniqueness Constraint**
- Context struct has `pub user_a: Account<'info, User>` and `pub user_b: Account<'info, User>`, both with `#[account(mut)]`, and no `constraint = user_a.key() != user_b.key()`
- Source and destination token accounts, or two vaults, declared as mutable without a key-inequality constraint

**Transfer Logic Assuming Distinct Accounts**
- Instruction performs `from.amount -= value` and `to.amount += value` where `from` and `to` could be the same account, resulting in net zero effect or data corruption
- Self-transfer path not considered in tests or constraints

**Accounts With Overlapping Seeds**
- Two PDA accounts derived with different logical roles but whose seeds can be made equal by an attacker choosing inputs

## False Positives

- Instruction explicitly handles the case where both accounts are the same (e.g., a no-op self-transfer path with a guard)
- Accounts are derived from distinct constant seeds that cannot collide

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl8-using-ctx.remaining_accounts-without-manual-ownership-check.md

# FV-ANC-3-CL8 Using ctx.remaining_accounts Without Manual Ownership Check

## TLDR

`ctx.remaining_accounts` provides raw `AccountInfo` references with no Anchor-enforced constraints. Every account taken from this slice must be manually validated for ownership before its data is read or it is passed to a CPI, otherwise an attacker can substitute an account owned by any program.

## Detection Heuristics

**Direct Data Access From remaining_accounts Without Owner Check**
- `let account = &ctx.remaining_accounts[i]` followed by `account.try_borrow_data()` or deserialization without `require!(account.owner == &expected_program::ID, ...)`
- Loop over `ctx.remaining_accounts` that deserializes each entry without per-entry owner validation

**Accounts From remaining_accounts Forwarded to CPI**
- `AccountInfo` taken from `remaining_accounts` passed directly into a `CpiContext` or `invoke` call without owner verification
- `remaining_accounts` entry used as an authority or signer account in a CPI without checking ownership and key

**Missing Length and Bounds Checks**
- Access to `ctx.remaining_accounts[i]` without verifying the slice has sufficient length, allowing index-out-of-bounds panics

## False Positives

- Account key is fully constrained by a PDA derivation check performed immediately after extracting it from `remaining_accounts`
- Accounts are only used for lamport balance reads, and the program logic is not affected by which program owns the account

## reference/anchor/fv-anc-3-account-ownership-validations/fv-anc-3-cl9-using-ctx.remaining_accounts-without-manual-discriminator-check.md

# FV-ANC-3-CL9 Using ctx.remaining_accounts Without Manual Discriminator Check

## TLDR

Accounts from `ctx.remaining_accounts` bypass Anchor's automatic discriminator verification. Without checking the first 8 bytes against the expected Anchor discriminator, an attacker can pass an account of a different struct type that shares the same owner program, leading to type confusion.

## Detection Heuristics

**Deserialization Without Discriminator Verification**
- `MyAccountType::try_from_slice(&account.data.borrow())` or `MyAccountType::try_deserialize(&mut ...)` called on a `remaining_accounts` entry without first verifying `&data[..8] == MyAccountType::DISCRIMINATOR`
- Account data interpreted as a specific struct type using only owner check, not discriminator check

**Type Cosplay via remaining_accounts**
- Program has multiple account types with the same owner (the program itself); code picks an account from `remaining_accounts` and reads it as type A without discriminating it from type B

**Generic Data Reads Skipping Discriminator**
- Code reads specific byte offsets from `remaining_accounts` entries as if the account structure is known, without validating the discriminator that would confirm the structure

## False Positives

- Account is a token account or system account whose structure is fixed by an external program and does not use Anchor discriminators; the owner check alone is sufficient
- Account type is confirmed by an address constraint that uniquely identifies the account

## reference/anchor/fv-anc-3-account-ownership-validations/readme.md

---
description: Verify account state and permissions.
---

# FV-ANC-3 Account/Ownership Validations



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-3-account-ownership-validations/<filename>` to read any case file listed below.

#### fv-anc-3-cl1-trying-to-modify-an-account-without-checking-if-its-writeable.md
#### fv-anc-3-cl10-using-ctx.remaining_accounts-without-non-zero-data-check.md
#### fv-anc-3-cl11-no-reload-after-account-mutation.md
#### fv-anc-3-cl12-not-validating-a-set-address.md
#### fv-anc-3-cl13-init-if-needed-without-reinit-guard.md
#### fv-anc-3-cl14-realloc-without-zero-init.md
#### fv-anc-3-cl2-trying-to-access-account-data-without-ownership-checks.md
#### fv-anc-3-cl3-usage-of-uncheckedaccount-without-manual-ownership-check.md
#### fv-anc-3-cl4-usage-of-uncheckedaccount-without-manual-signer-check.md
#### fv-anc-3-cl5-no-is_initialized-check-when-operating-on-an-account.md
#### fv-anc-3-cl6-missing-account-constraints.md
#### fv-anc-3-cl7-duplicate-mutable-accounts.md
#### fv-anc-3-cl8-using-ctx.remaining_accounts-without-manual-ownership-check.md
#### fv-anc-3-cl9-using-ctx.remaining_accounts-without-manual-discriminator-check.md

## reference/anchor/fv-anc-4-pda-security

```

```

## reference/anchor/fv-anc-4-pda-security/fv-anc-4-cl1-using-create_program_address.md

# FV-ANC-4-CL1 Using create_program_address

## TLDR

`Pubkey::create_program_address` requires a caller-supplied bump seed and does not find the canonical bump. An attacker can supply a non-canonical bump that produces a valid but unintended PDA, bypassing seed-based access controls or creating collisions with legitimate PDAs.

## Detection Heuristics

**create_program_address Without Canonical Bump Verification**
- `Pubkey::create_program_address(&[seed, &[bump]], program_id)` where `bump` comes from instruction data or an account field without verifying it is the canonical bump returned by `find_program_address`
- No comparison of the derived PDA against `Pubkey::find_program_address` output to confirm canonicity

**Bump Stored Without Derivation Check**
- Bump value stored in an account during initialization without verifying it was obtained from `find_program_address`
- Instruction accepts a `bump: u8` parameter and uses it directly in `create_program_address` without on-chain verification

**Missing seeds Constraint in Anchor**
- PDA account declared in a context struct without `#[account(seeds = [...], bump)]`, which would have Anchor enforce canonical bump derivation

## False Positives

- Bump is stored in the PDA account itself during `init` using Anchor's `bump` constraint, and subsequent instructions read it with `#[account(seeds = [...], bump = account.bump)]`, which enforces the canonical value
- `find_program_address` is called in the same instruction and the result is immediately compared, making the use of `create_program_address` equivalent

## reference/anchor/fv-anc-4-pda-security/fv-anc-4-cl2-pda-seed-concatenation-collision.md

# FV-ANC-4-CL2 PDA Seed Concatenation Collision

## TLDR

When multiple variable-length byte slices are passed as PDA seeds without length disambiguation, different combinations of inputs can produce the same raw seed bytes and therefore the same PDA address. An attacker can craft inputs that collide with a legitimate account's PDA.

## Detection Heuristics

**Adjacent Variable-Length Seeds Without Separators**
- `Pubkey::find_program_address(&[prefix.as_bytes(), suffix.as_bytes()], program_id)` where both seeds are variable-length strings or byte slices
- Two or more `&str` or `Vec<u8>` seeds concatenated as adjacent slices without length prefixes or fixed-length encoding

**String Seeds Derived From User Input**
- Seeds include user-provided names, labels, or identifiers that are not fixed-length, creating collision opportunities between different user inputs

**Anchor seeds Constraint With Dynamic Slices**
- `#[account(seeds = [user_input_a.as_ref(), user_input_b.as_ref()], bump)]` where both inputs are variable-length

## False Positives

- All seed components are fixed-length (e.g., `Pubkey` references at 32 bytes, `u64` encoded as 8 bytes), making concatenation unambiguous
- Seeds use a constant string separator between variable-length components that cannot appear in the values themselves
- Only a single variable-length seed component is used alongside fixed-length components

## reference/anchor/fv-anc-4-pda-security/fv-anc-4-cl3-pda-sharing-single-global-vault.md

# FV-ANC-4-CL3 PDA Sharing / Single Global Vault

## TLDR

A vault PDA derived from constant seeds (e.g., only `b"vault"`) is shared across all users or positions. Compromising any single user's position, or exploiting any instruction that touches the vault, can drain funds belonging to all other users.

## Detection Heuristics

**Vault PDA Without Per-User Seed Component**
- `Pubkey::find_program_address(&[b"vault"], program_id)` or `#[account(seeds = [b"vault"], bump)]` used for a vault that holds funds on behalf of multiple distinct users
- Global singleton PDA used as an escrow or collateral pool that does not segregate balances by user key

**Single Authority Over Multi-User Pool**
- One PDA signs for all withdrawals from a pool that aggregates multiple users' deposits without per-user sub-accounts

**Seed Does Not Include User-Identifying Component**
- PDA seeds for token vaults, collateral accounts, or staking positions do not include `user.key().as_ref()`, `position_id`, or another per-entity discriminator

## False Positives

- Single global vault is intentional (e.g., a liquidity pool or AMM reserve) and the protocol tracks per-user balances in separate accounting accounts rather than via vault segregation
- Global vault is protected by a multisig or governance authority and individual user positions are represented by separate non-vault PDAs

## reference/anchor/fv-anc-4-pda-security/fv-anc-4-cl4-pda-purpose-isolation-failure.md

# FV-ANC-4-CL4 PDA Purpose Isolation Failure

## TLDR

When different instruction handlers derive PDAs using identical or overlapping seed sets, an attacker can substitute one PDA in place of another. Instructions that accept a PDA account by key alone without validating its discriminator or type-specific field allow cross-context injection, enabling an attacker to satisfy an account constraint with a PDA intended for a different purpose.

## Detection Heuristics

**Identical Seeds Across Multiple Account Types**
- Multiple instruction handlers accept PDAs derived from the same seed set (e.g., `[b"config", user.key()]`) but interpret the resulting account data as different struct types
- Seed construction does not include a type discriminator prefix or namespace byte to distinguish purposes
- Two different Anchor account types share the same seeds constraint, meaning a valid account of one type passes the seeds check for the other

**Missing Discriminator or Type Validation**
- Account passed as `UncheckedAccount` or raw `AccountInfo` without a discriminator check in the instruction logic
- Anchor `Account<'_, T>` constraint omitted, allowing any account at that address to satisfy the constraint
- Program manually checks only the account key and skips checking that the data matches the expected struct layout

**Accepted PDA Not Namespaced**
- PDA seeds lack a static namespace prefix (e.g., `b"user_config"` vs `b"user_stake"`) that would make the same user key produce distinct addresses for distinct purposes
- Seed byte arrays reused across programs or program versions without a version discriminator

## False Positives

- PDAs include a type-specific constant seed prefix that makes seed collision between different account types impossible
- Anchor's automatic 8-byte discriminator check on `Account<'_, T>` prevents accepting an account of the wrong type even if keys match

## reference/anchor/fv-anc-4-pda-security/fv-anc-4-cl5-pda-signer-without-ownership-verification.md

# FV-ANC-4-CL5 PDA Used as Signer Without Ownership Verification

## TLDR

Using `invoke_signed` with PDA seeds to authorize a CPI without first verifying the PDA account is owned by the calling program allows a forged PDA from a different program to authorize operations it should not perform. If two programs can derive the same address from the same seeds, the legitimate program's invoke_signed call can be exploited using an account initialized by the attacker's program.

## Detection Heuristics

**No Owner Check Before invoke_signed**
- `invoke_signed` call where the PDA authority account's owner field is not compared against the current program ID (`*ctx.program_id`)
- PDA passed as an authority in a token transfer or system instruction without `constraint = pda_account.owner == *ctx.program_id`
- Program derives a PDA, passes it as a signer, but does not verify the account at that address was initialized by the same program

**Cross-Program Seed Collision**
- Program derives a PDA using seeds that are not globally unique (e.g., `[b"authority"]` without the program ID embedded); a different program using the same seeds produces the same address
- No program-specific discriminator or program ID embedded in seeds to prevent another program from pre-initializing the PDA before the legitimate program

**Anchor Account Without Owner Constraint**
- `UncheckedAccount` used for a PDA signer without a manual `require_eq!(account.owner, *ctx.program_id)` check
- `AccountInfo` for a PDA authority accepted directly in the accounts struct without an `#[account(owner = crate::ID)]` constraint

## False Positives

- Anchor's `Account<'_, T>` struct enforces owner check automatically; any PDA loaded via typed accounts is guaranteed to be owned by the calling program
- PDA seeds include the current program ID as a seed component, making cross-program seed collision impossible

## reference/anchor/fv-anc-4-pda-security/readme.md

---
description: Safeguard Program Derived Addresses (PDAs).
---

# FV-ANC-4 PDA Security



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-4-pda-security/<filename>` to read any case file listed below.

#### fv-anc-4-cl1-using-create_program_address.md
#### fv-anc-4-cl2-pda-seed-concatenation-collision.md
#### fv-anc-4-cl3-pda-sharing-single-global-vault.md
#### fv-anc-4-cl4-pda-purpose-isolation-failure.md
#### fv-anc-4-cl5-pda-signer-without-ownership-verification.md

## reference/anchor/fv-anc-5-cross-program-invocation-cpi

```

```

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl1-lack-of-validation-of-external-program-before-cpi.md

# FV-ANC-5-CL1 Lack of Validation of External Program Before CPI

## TLDR

When the program ID for a CPI target is passed as an account in the transaction rather than hardcoded, an attacker can substitute a malicious program. Without validating the program ID before calling, the CPI executes arbitrary attacker-controlled code with the caller's account context.

## Detection Heuristics

**Program Account Used Without Key Comparison**
- `ctx.accounts.external_program.to_account_info()` passed to `CpiContext::new` or `invoke` without first comparing `ctx.accounts.external_program.key()` against a known program ID constant
- `AccountInfo` for a program accepted in the context struct as `external_program: AccountInfo<'info>` without an `#[account(address = expected_program_id)]` constraint

**Dynamic Program Selection Without Allowlist**
- Program ID derived from user input or an account field without an allowlist check
- CPI target varies by instruction parameter and no exhaustive match or set-membership check is performed

**Missing Program Executable Check**
- Program account not verified to be executable (`account.executable == true`) before use as a CPI target

## False Positives

- Program ID is fully constrained by `#[account(address = spl_token::ID)]` or equivalent Anchor address constraint
- CPI uses Anchor's typed CPI helpers (e.g., `token::transfer`, `system_program::transfer`) which hardcode the target program ID internally

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl10-cross-program-reentrancy-via-callback.md

# FV-ANC-5-CL10 Cross-Program Reentrancy via Callback

## TLDR

Solana's CPI depth limit does not prevent reentrancy when program A calls program B which calls back into program A at a different instruction before A's state is finalized. Unlike EVM, there is no automatic reentrancy guard at the runtime level; account state is accessible across CPI hops within the same transaction, and a callback into the caller can observe intermediate or inconsistent state.

## Detection Heuristics

**CPI to Program Accepting a Callback Target**
- Program calls an external program that accepts a callback target address as an instruction parameter; that external program may invoke instructions on the protocol before the outer instruction completes
- Hook-style architectures (e.g., transfer hooks, liquidation hooks) that allow user-supplied callback program IDs without restricting which programs can be called
- No reentrancy guard flag in any account state checked at instruction entry

**State Read Before CPI, Written After**
- A security-critical account field (balance, borrow amount, share count) is read before an external CPI call and written after, with no lock preventing the external program from reading the intermediate value
- The protocol's invariant check (e.g., collateral ratio, available liquidity) occurs after all CPIs have returned rather than before any external call

**Missing Reentrancy Guard**
- No boolean `is_executing` or `reentrancy_guard` field in any program-owned account that is set to true at instruction entry and reset on exit
- No check that an instruction is not reentrant via a stored slot or instruction counter in the program's global state account

## False Positives

- All external calls are to known static programs (SPL Token, System Program) that do not accept user-supplied callbacks and have no callback mechanism
- Callback target is validated against an allowlist of trusted non-reentrant programs before the CPI
- Reentrancy guard flag stored in account state is checked and set atomically at instruction entry

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl11-cpi-to-upgradeable-program-without-version-pin.md

# FV-ANC-5-CL11 CPI to Upgradeable Program Without Version Pin

## TLDR

Calling an upgradeable program via CPI exposes the caller to logic changes introduced by the program's upgrade authority. If the calling protocol does not pin the expected program binary hash or validate the upgrade slot, an upgrade to the dependency program can silently change the semantics of the CPI, introduce new account requirements, etc. - without the calling protocol's audit surface being re-evaluated.

## Detection Heuristics

**CPI Target is an Upgradeable BPF Program**
- CPI target's program data account exists and has an upgrade authority that is not `None`
- Program does not load and compare the program data account's `last_deployed_slot` or data hash against a stored expected value
- Protocol documentation does not identify the exact version or commit hash of the external program being integrated

**No Governance Gate on Dependency Upgrade**
- A community-governed or team-controlled program is used as a CPI dependency without a protocol-level check that the program's behavior matches an audited version
- Upgrade authority for the CPI dependency is a multisig or DAO that can act without the calling protocol's consent

**Missing Executable Check at Instruction**
- Program account passed as a CPI target is not checked to be executable at the time of the call
- CPI target account's program data address is not derived and read to verify upgrade authority status

## False Positives

- CPI target's upgrade authority is set to `None` (immutable); the program binary is permanently frozen and cannot be changed
- Calling program explicitly loads the program data account and asserts its hash or last_deployed_slot matches a stored expected value before executing the CPI

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl2-cpi-without-signer-seeds.md

# FV-ANC-5-CL2 CPI Without Signer Seeds

## TLDR

When a CPI requires a PDA to sign on behalf of the program, `invoke_signed` must be called with the correct signer seeds. Using `invoke` or passing empty seeds to `invoke_signed` causes the CPI to fail or to execute without the PDA's authority, breaking the intended access control.

## Detection Heuristics

**invoke Used Where PDA Signature Is Required**
- `invoke(&instruction, &accounts)?` called when one of the accounts listed is a PDA that needs to sign; the PDA will not be recognized as a signer by the callee
- CPI to the Token Program for a PDA-owned token account using `invoke` instead of `invoke_signed`

**invoke_signed With Empty Seeds**
- `invoke_signed(&instruction, &accounts, &[])` where one of the accounts is a PDA, providing no signing authority
- Seeds array passed to `invoke_signed` does not include the seeds for all PDAs that must sign

**Incorrect or Incomplete Seeds**
- Signer seeds passed to `invoke_signed` do not match the seeds used to derive the PDA, causing signature verification to fail at the callee
- Bump seed omitted from the signer seeds array

## False Positives

- CPI is initiated on behalf of a user who signed the transaction; the user's `AccountInfo` carries `is_signer = true` and no PDA signature is needed
- `invoke` is correct when none of the accounts in the CPI need to be PDAs signing on behalf of the program

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl3-not-unsetting-signer-status-before-a-cpi.md

# FV-ANC-5-CL3 Not Unsetting Signer Status Before a CPI

## TLDR

When a user-signed account is passed into a CPI, the callee receives it with `is_signer = true`. A malicious or compromised callee can exploit this elevated status to perform privileged operations on the user's account that the caller did not intend to authorize.

## Detection Heuristics

**User AccountInfo Passed to CPI Without Clearing is_signer**
- `ctx.accounts.user.to_account_info()` included in the accounts list for a CPI without setting `account_info.is_signer = false` before the call
- `invoke_signed` or `CpiContext` constructed with an accounts list that includes user-signed accounts whose signer status has not been cleared

**Signer Accounts Forwarded to Untrusted Programs**
- CPI target is not a well-known audited program; passing signers to an unknown program extends unintended trust
- Multiple accounts with `is_signer = true` forwarded to a CPI when only the PDA needs to sign

**Signer Status Cleared Only for Some Accounts**
- Code clears `is_signer` for the PDA but not for user accounts, or vice versa, leaving unintended signers in the CPI account list

## False Positives

- CPI is to a well-known program (SPL Token, System Program) and the user's signer status is required for the operation (e.g., user-initiated transfer)
- Signer status is intentionally forwarded and the callee program is trusted and audited to not misuse it

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl4-passing-unnecessary-accounts-to-cpis.md

# FV-ANC-5-CL4 Passing Unnecessary Accounts to CPIs

## TLDR

Including accounts in a CPI that the callee does not need expands the trust surface. A malicious callee can read data from, or attempt privileged operations on, any account passed to it. Each unnecessary account increases the blast radius if the callee is compromised or malicious.

## Detection Heuristics

**Accounts Not Required by Callee Instruction Included**
- `AccountInfo` for accounts not listed in the callee's expected account structure included in the CPI accounts array
- Entire `ctx.accounts` struct forwarded to a CPI helper rather than selecting only the required accounts

**Writable Accounts Passed When Only Readable**
- Accounts that the callee only needs to read passed with `is_writable = true`, granting unnecessary write authority
- High-value accounts (vaults, admin configs) included as writable in CPIs where only their key is needed for verification

**Signer Accounts Forwarded Unnecessarily**
- Accounts with `is_signer = true` included in a CPI when the callee does not require them to sign, unnecessarily extending signer privileges

## False Positives

- All included accounts are required by the callee's instruction as documented in the callee program's interface
- CPI uses Anchor's typed context structs which only include the fields defined in the callee's `Accounts` struct

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl5-sol-balance-drain-via-cpi.md

# FV-ANC-5-CL5 SOL Balance Drain via CPI

## TLDR

When a program passes an account with a SOL balance to an external program via CPI, the callee can debit lamports from that account if it holds write authority. Without checking the balance before and after the CPI, the caller cannot detect or prevent unexpected lamport drain.

## Detection Heuristics

**Writable Account With SOL Balance Passed to CPI Without Balance Check**
- Vault, user, or program-owned account with a significant lamport balance passed as writable to a CPI without recording `account.lamports()` before the call
- No post-CPI assertion that `account.lamports() >= pre_cpi_balance` or equivalent invariant check

**Signed Accounts Passed Writable to External Programs**
- PDA with lamports passed as a writable signer to a CPI targeting an unaudited program
- User account passed as writable when the CPI only needs it for identification

**No Minimum Balance Enforcement**
- After CPI, no check that the account retains at least its rent-exempt minimum lamport balance
- Program's own treasury or fee accounts passed to CPIs without post-call balance assertions

## False Positives

- CPI intentionally transfers SOL from the account (e.g., a withdrawal instruction) and the amount is tracked and validated before the CPI
- Callee is the System Program or SPL Token Program and the lamport flow is fully deterministic and verified against instruction parameters

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl6-post-cpi-ownership-change.md

# FV-ANC-5-CL6 Post-CPI Ownership Change

## TLDR

A callee program can call `assign` on any account it is authorized to write, changing that account's owner to an arbitrary program. If the caller does not re-verify account ownership after the CPI, it may continue operating on an account now owned by an attacker-controlled program.

## Detection Heuristics

**No Ownership Re-Verification After CPI**
- `ctx.accounts.target.owner` not checked after `invoke` or `invoke_signed` completes
- Account ownership assumed to be unchanged after CPI to an unaudited or user-supplied program
- Fields read or mutations performed on an account after a CPI without confirming `account.owner == &expected_program_id`

**Writable Accounts Passed to Unknown Programs**
- `AccountInfo` passed as writable to a CPI where the target program is not a well-known audited program, creating a surface for ownership reassignment
- Dynamic program ID used as CPI target combined with writable accounts that the current program later relies on

**No Post-CPI Invariant Checks**
- Instruction performs a series of operations after a CPI without any validation that account state (owner, data, lamports) is within expected bounds

## False Positives

- CPI target is a well-known program (SPL Token, System Program, Metaplex) that does not call `assign` as part of its instruction
- Account ownership is enforced at the Anchor deserialization level on the next instruction, making within-instruction re-check redundant only if no further reads occur after the CPI in the same instruction

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl7-invoke-signed-with-wrong-or-partial-seeds.md

# FV-ANC-5-CL7 invoke_signed with Wrong or Partial Seeds

## TLDR

If the seeds array passed to `invoke_signed` does not exactly match the seeds used to derive the PDA that is expected to sign, the call will fail or sign under the wrong identity. More critically, when multiple seed combinations can resolve to valid program addresses, using an incorrect subset can authorize operations under an unintended signer identity, bypassing intended access controls.

## Detection Heuristics

**Seeds Mismatch Between Derivation and Signing**
- Seeds list in `invoke_signed` call differs from seeds used in the corresponding `Pubkey::find_program_address` or `Pubkey::create_program_address` call
- `bump` seed omitted from the `invoke_signed` seeds while the PDA derivation included it, or vice versa
- Seed values sourced from user-supplied accounts without validation used in the signing call

**Incomplete Seed Validation**
- Program dynamically constructs the seeds array at runtime from account fields without asserting the resulting address matches the expected PDA key
- Seeds array passed as a slice reference where individual seed byte arrays come from attacker-controlled instruction data
- No `Pubkey::create_program_address(seeds, program_id) == expected_pda` assertion before the invoke_signed call

**Shared Seed Prefix Ambiguity**
- Multiple PDA types share a seed prefix, and the wrong type can be used as a signer because the seeds are checked by inclusion not exact match
- Bump stored in an account field can be manipulated to produce a different valid PDA address

## False Positives

- Seeds are compile-time constant byte slices that are identical in both derivation and signing call sites; compiler ensures they cannot diverge
- Program verifies the derived address from seeds matches the account key before using it in invoke_signed

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl8-cpi-to-system-program-creates-unintended-account.md

# FV-ANC-5-CL8 CPI to System Program Creates Unintended Account

## TLDR

A CPI to the system program's `create_account` or `assign` instruction with attacker-influenced parameters can create or reassign accounts at addresses that hold special protocol meaning. If the target address is a protocol PDA, config account, or authority record, the CPI can overwrite or initialize it with attacker-controlled owner and data, compromising the protocol's trust model.

## Detection Heuristics

**Unconstrained create_account Target**
- `system_program::create_account` called with a target address derived from user-supplied instruction data or account keys not validated against a known expected address
- Space, lamports, or owner parameters in the create_account call sourced from caller-controlled values rather than protocol constants
- No assertion that the account being created does not already hold protocol state before the CPI

**Assign Without Ownership Verification**
- `system_program::assign` CPI called with an owner program derived from input rather than a hardcoded constant
- Reassignment target not verified to be a freshly initialized zero-data account
- No check that the account's lamport balance matches the rent-exempt minimum for the declared space

**Protocol PDA Overwrite**
- Target address of the create_account CPI could match a protocol PDA that is initialized later; attacker pre-creates the account with the wrong owner before legitimate initialization
- Program does not check for a pre-existing discriminator or non-zero data before calling create_account on a PDA it intends to use

## False Positives

- Account creation targets are deterministic PDAs owned by the calling program; the program derives and validates the address before the CPI
- All system program CPI parameters are hardcoded or derived from validated on-chain constants, not user input

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/fv-anc-5-cl9-cpi-privilege-escalation-via-account-authority.md

# FV-ANC-5-CL9 CPI Privilege Escalation via Account Authority

## TLDR

When accounts passed into a CPI carry `is_writable` or `is_signer` flags that originated from the outer transaction context, an attacker can escalate privileges inside the CPI by crafting the outer transaction's account flags. Anchor's `CpiContext` does not automatically strip or re-derive account privilege flags; the program must construct them from protocol logic rather than forwarding them from the incoming accounts.

## Detection Heuristics

**Raw AccountInfo Forwarded to CPI**
- `account.to_account_info()` called directly on an incoming account and the result passed to a CPI without explicitly setting `is_signer: false` or `is_writable: false` where those privileges should not be forwarded
- `CpiContext::new` constructed from accounts list built by iterating `ctx.remaining_accounts` and passing each entry through without flag validation
- Program does not verify that an account's `is_signer` flag in the incoming transaction context corresponds to an expected signer key before relying on it inside a CPI

**Authority Account Not Key-Validated**
- Account is accepted as an authority in the CPI accounts list without comparing its key against a stored expected authority address
- Writable flag forwarded to an account that should only be read during the inner CPI, allowing the CPI target to modify it

**Elevated CPI Privilege Without Explicit Grant**
- Inner CPI receives signer authority over accounts not owned by the calling program, derived from outer transaction signer flags the calling program did not explicitly grant
- `invoke` (not `invoke_signed`) called with accounts carrying signer flags set to true from the outer transaction, propagating caller-level authority into the CPI

## False Positives

- Anchor typed CPI helpers (e.g., `token::transfer`, `system_program::create_account`) construct the account list with explicit flag values and do not forward raw AccountInfo flags from the context
- Program explicitly reconstructs each account's `AccountMeta` with hardcoded `is_signer` and `is_writable` values appropriate to the inner CPI's expected interface

## reference/anchor/fv-anc-5-cross-program-invocation-cpi/readme.md

---
description: Secure interactions with external programs.
---

# FV-ANC-5 Cross-Program Invocation (CPI)



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-5-cross-program-invocation-cpi/<filename>` to read any case file listed below.

#### fv-anc-5-cl1-lack-of-validation-of-external-program-before-cpi.md
#### fv-anc-5-cl2-cpi-without-signer-seeds.md
#### fv-anc-5-cl3-not-unsetting-signer-status-before-a-cpi.md
#### fv-anc-5-cl4-passing-unnecessary-accounts-to-cpis.md
#### fv-anc-5-cl5-sol-balance-drain-via-cpi.md
#### fv-anc-5-cl6-post-cpi-ownership-change.md
#### fv-anc-5-cl7-invoke-signed-with-wrong-or-partial-seeds.md
#### fv-anc-5-cl8-cpi-to-system-program-creates-unintended-account.md
#### fv-anc-5-cl9-cpi-privilege-escalation-via-account-authority.md
#### fv-anc-5-cl10-cross-program-reentrancy-via-callback.md
#### fv-anc-5-cl11-cpi-to-upgradeable-program-without-version-pin.md

## reference/anchor/fv-anc-6-error-handling

```

```

## reference/anchor/fv-anc-6-error-handling/fv-anc-6-cl1-unclear-error-messages.md

# FV-ANC-6-CL1 Unclear Error Messages

## TLDR

Using generic Solana `ProgramError` variants instead of custom Anchor error codes makes it impossible for clients, indexers, and auditors to determine why a transaction failed. Opaque errors also impede incident response and make it harder to distinguish security-critical rejections from benign validation failures.

## Detection Heuristics

**Generic ProgramError Variants Used for Business Logic Failures**
- `return Err(ProgramError::InvalidArgument.into())` or `return Err(ProgramError::InvalidAccountData.into())` used where a specific custom error would convey the actual condition
- `ProgramError::Custom(n)` with a numeric code and no corresponding enum variant or documentation

**Absence of #[error_code] Enum**
- Program has no `#[error_code]` enum defined, meaning all errors propagate as generic program errors
- `#[error_code]` enum exists but contains a single generic variant used for all failure cases

**Missing #[msg] Annotations**
- `#[error_code]` enum variants lack `#[msg("...")]` annotations, producing error codes without human-readable descriptions
- Error messages are present but do not describe the specific invariant that was violated

## False Positives

- `ProgramError` variants are used for infrastructure-level errors (e.g., serialization failures, account not found) where a generic error is appropriate
- Program is an internal helper with no external clients and error granularity is not operationally required

## reference/anchor/fv-anc-6-error-handling/readme.md

---
description: Provide meaningful and secure error feedback.
---

# FV-ANC-6 Error Handling



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-6-error-handling/<filename>` to read any case file listed below.

#### fv-anc-6-cl1-unclear-error-messages.md

## reference/anchor/fv-anc-7-token-operations

```

```

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl1-unvalidated-token-mint-and-owner.md

# FV-ANC-7-CL1 Unvalidated Token Mint and Owner

## TLDR

Token accounts on Solana carry a `mint` and an `owner` field. Without validating both, an attacker can substitute a token account for a different mint (causing the program to treat the wrong token as the expected asset) or a different owner (allowing unauthorized access to balances).

## Detection Heuristics

**Token Account Accepted Without mint Constraint**
- `Account<'info, TokenAccount>` in a context struct without `#[account(token::mint = expected_mint)]` or equivalent `constraint = token_account.mint == expected_mint.key()`
- Token account deserialized and used without checking `.mint` against the program's known mint address

**Token Account Accepted Without authority/owner Constraint**
- Token account accepted without `#[account(token::authority = expected_authority)]` or a manual check that `token_account.owner == expected_owner.key()`
- Instruction performs a debit or credit on a token account without verifying it belongs to the expected user

**Both mint and owner Unchecked**
- `InterfaceAccount<'info, TokenAccount>` or `Account<'info, TokenAccount>` used raw without either mint or owner validation, relying solely on the account being owned by the Token Program

## False Positives

- Token account is an ATA derived on-chain from the user and mint via `#[account(associated_token::mint = mint, associated_token::authority = user)]`, which implicitly enforces both mint and owner
- Mint is validated via a separate `has_one = mint` constraint on a vault account that also stores the mint pubkey

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl2-using-init-with-an-ata.md

# FV-ANC-7-CL2 Using init with an ATA

## TLDR

Using `#[account(init, associated_token::...)]` on a token account fails if the ATA already exists, because `init` requires the account to be uninitialized. Any user who pre-creates the ATA before the instruction runs triggers a transaction failure, producing a denial-of-service vector.

## Detection Heuristics

**init Constraint on Associated Token Account**
- `#[account(init, payer = ..., associated_token::mint = ..., associated_token::authority = ...)]` applied to an ATA that may have been created externally before the instruction is called
- Instruction documented as idempotent but uses `init` which is not idempotent for pre-existing accounts

**ATA Creation in Permissionless or User-Facing Instructions**
- `init` on an ATA in a public instruction callable by any user, where any participant can pre-create the ATA to block others
- Protocol initialization flow that creates ATAs for all participants using `init` without considering pre-existing accounts

**Error Handling Does Not Account for AlreadyInUse**
- No fallback logic for `ErrorCode::AccountAlreadyInUse` in the client or program, causing silent failures when ATA exists

## False Positives

- ATA creation is in a one-time admin initialization instruction where the admin controls the timing and pre-creation is not possible by unprivileged users
- Program explicitly checks for and handles the pre-existing ATA case before the `init` instruction is reached

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl3-token-2022-incompatibility.md

# FV-ANC-7-CL3 Token-2022 Incompatibility

## TLDR

The legacy SPL Token program and Token-2022 have different program IDs and different instruction formats for operations like `transfer`. Code that hardcodes `anchor_spl::token` will fail or behave incorrectly when used with Token-2022 mints, which introduce transfer fees, interest, confidential transfers, and other extensions.

## Detection Heuristics

**anchor_spl::token Instead of anchor_spl::token_interface**
- `use anchor_spl::token::{Transfer, transfer}` used in an instruction that is intended to support both Token and Token-2022 mints
- `token::transfer(cpi_ctx, amount)` called instead of `token_interface::transfer_checked(cpi_ctx, amount, decimals)`

**Hardcoded Token Program ID in Constraints**
- `#[account(address = spl_token::ID)]` on a token program account when the protocol intends to support Token-2022
- `token_program: Program<'info, Token>` in the context struct instead of `token_program: Interface<'info, TokenInterface>`

**Missing Mint Account in transfer_checked Calls**
- `transfer` used instead of `transfer_checked`, omitting the mint account parameter required by Token-2022 for fee calculation
- Protocol does not pass the mint account to token operation CPIs, making it incompatible with fee-on-transfer extensions

**No Extension Checks for Transfer Fee or Other Hooks**
- Program does not inspect mint extension data to account for transfer fees before computing expected received amounts
- Transfer hook extensions not handled, causing unexpected behavior on Token-2022 mints with hooks

## False Positives

- Protocol explicitly restricts itself to legacy SPL Token mints and enforces this via mint account ownership checks against `spl_token::ID`
- Token-2022 extensions are irrelevant for the specific mint the protocol uses, and this is enforced by a constraint on the mint account

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl4-token-2022-transfer-hook-bypass-or-missing.md

# FV-ANC-7-CL4 Token-2022 Transfer Hook Bypass or Missing

## TLDR

Token-2022 mints can register a transfer hook program that must be invoked on every token transfer. Programs that use the legacy `spl_token::transfer` instruction or call the SPL Token program ID directly (`TokenkegQfe...`) bypass the hook entirely, violating the mint's invariants and potentially enabling unauthorized transfers, compliance bypasses, or protocol accounting errors.

## Detection Heuristics

**Legacy spl_token Used for Token-2022 Mints**
- Token transfer uses `spl_token::instruction::transfer` or passes the SPL Token program ID (`TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA`) for a mint that is actually a Token-2022 mint (program ID: `TokenzQdBNbEqunMB4obVY4sQnrvdGUFkRGXWdTZ...`)
- Program does not check the mint's token program ID before constructing the transfer instruction
- CPI to transfer tokens does not include the hook program account when the mint extension `TransferHook` is present and has a non-None program address

**Hook Account Missing from CPI**
- `token_2022::transfer_checked` call does not include the hook program account in the remaining_accounts list
- Transfer amount not checked against any hook program return value or hook-imposed constraint
- Hook program account not loaded or its address not validated before the transfer CPI

**Transfer Hook Not Invoked at All**
- Program constructs raw instruction data for an SPL transfer without going through `spl_token_2022` crate helpers that automatically include hook invocation
- Token account close or burn operations that interact with a token-2022 mint also skip hook invocation

## False Positives

- Mint is a legacy SPL Token mint (program ID `TokenkegQfe...`); Token-2022 extensions do not apply
- Token-2022 mint with a `TransferHook` extension where the hook program address is explicitly `None`; no hook invocation required

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl5-interest-bearing-mint-principal-vs-balance-confusion.md

# FV-ANC-7-CL5 Interest-Bearing Mint Principal vs Balance Confusion

## TLDR

Token-2022 interest-bearing mints compound an on-chain rate that increases the displayed token balance over time. Programs that read the raw `TokenAccount.amount` without calling the extension's `amount_to_ui_amount` conversion will undercount the actual accrued value, leading to mispriced collateral, incorrect liquidation thresholds, or silent yield extraction by users who understand the accounting gap.

## Detection Heuristics

**Raw Amount Read Without Interest Normalization**
- Protocol reads `token_account.amount` on a Token-2022 interest-bearing mint and uses it directly in a collateral value or share price calculation
- No call to `spl_token_2022::extension::interest_bearing_mint::amount_to_ui_amount` or equivalent before arithmetic on the token amount
- Comparison of token amounts across different timestamps without normalizing both values to the same accrued-interest basis

**Mint Extension Not Checked at Initialization**
- Vault or pool initialization does not inspect the mint account for the `InterestBearingConfig` extension before accepting the mint
- Protocol documentation claims to handle interest-bearing tokens but accounting code uses raw `amount` without adjusting for the configured rate and elapsed time

**Reward or Collateral Valuation Bypass**
- Attacker deposits tokens, waits for interest to accrue, then withdraws at the original amount rather than the inflated displayed amount, pocketing the difference
- Protocol applies interest only at the contract level but ignores Token-2022 on-chain interest extension, double-counting or missing yield

## False Positives

- Protocol explicitly rejects interest-bearing mints at initialization by checking for the absence of `InterestBearingConfig` extension and reverting if found
- Interest-bearing rate on the mint extension is set to 0, making accrued interest zero across all time

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl6-transfer-fee-extension-accounting-error.md

# FV-ANC-7-CL6 Transfer Fee Extension Accounting Error

## TLDR

Token-2022 transfer fee extensions deduct a fee from transferred amounts at the SPL level. Programs that send X tokens and expect X tokens to arrive at the destination will undercount the actual received amount. This causes shortfalls in vault deposits, incorrect share issuance, fee revenue discrepancies, and protocol invariant violations when the actual received balance differs from what was recorded.

## Detection Heuristics

**Sent Amount Used Instead of Received Amount**
- Vault deposit logic records the sent amount as the deposited value rather than reading the destination account's post-transfer balance
- Share issuance computed from `amount` parameter rather than `post_transfer_balance - pre_transfer_balance`
- No pre- and post-transfer balance snapshot on the destination account to determine the actual received amount

**Fee Not Pre-Computed Before Protocol Logic**
- `calculate_fee(amount, fee_bps, max_fee)` not called before determining how much to record as received
- Fee calculation skipped entirely; program assumes transfers are fee-free for all accepted mints
- Protocol accepts arbitrary mints including those with `TransferFeeConfig` but applies no fee compensation in accounting

**Cumulative Shortfall Under Repeated Operations**
- Under repeated deposits with fee-bearing mints, the vault progressively owes more tokens than it holds, eventually becoming insolvent
- Fee withheld amount accumulates in the token account's withheld field without the protocol accounting for it as a liability

## False Positives

- Mint's `TransferFeeConfig` extension has `transfer_fee_basis_points` set to 0 and `maximum_fee` of 0, making actual fees zero
- Protocol explicitly calls `calculate_fee` and subtracts the result before recording received amounts
- Protocol rejects mints with a non-zero transfer fee at initialization by checking the `TransferFeeConfig` extension

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl7-unconstrained-freeze-authority.md

# FV-ANC-7-CL7 Unconstrained Freeze Authority

## TLDR

A token account can be frozen by the mint's freeze authority at any time, permanently preventing deposits, withdrawals, and transfers. Protocols that accept tokens from mints where freeze authority has not been revoked expose themselves to an attack or admin error where a malicious or compromised freeze authority bricks all protocol operations involving that token.

## Detection Heuristics

**Freeze Authority Not Verified at Initialization**
- Vault, pool, or market initialization does not check that `mint.freeze_authority == COption::None`
- Mint account loaded as a typed `Mint` struct but `freeze_authority` field not inspected before accepting the mint into the protocol
- Token initialization accepts user-supplied mints without validating freeze authority revocation status

**Arbitrary Mint Acceptance**
- Protocol accepts any SPL Token or Token-2022 mint without a whitelist or freeze authority check
- Deposit or swap paths register new tokens on-the-fly without verifying freeze authority at registration time
- Integration with a permissioned stablecoin or centralized token where freeze authority exists by design but no operational controls are documented

**No Monitoring or Circuit Breaker**
- Protocol has no off-chain monitoring for freeze authority transactions; a freeze could go undetected until users report fund inaccessibility
- No emergency withdraw path that bypasses normal token transfer logic in the event of a frozen account

## False Positives

- Protocol operates exclusively with a hardcoded set of known mints where freeze authority has been verifiably set to `None` on-chain
- Freeze authority is a well-documented protocol DAO with a documented governance process, and protocol documentation acknowledges and accepts this counterparty risk

## reference/anchor/fv-anc-7-token-operations/fv-anc-7-cl8-mint-close-authority-exploitation.md

# FV-ANC-7-CL8 Mint Close Authority Exploitation

## TLDR

Token-2022 mints can define a close authority that can close the mint account entirely and reclaim its rent. If a protocol holds collateral, liquidity, or positions denominated in a mint with a live close authority, the authority holder can close the mint, making all tokens non-transferable and permanently freezing any protocol positions that reference it.

## Detection Heuristics

**Close Authority Not Checked at Initialization**
- Protocol accepts Token-2022 mints without inspecting the `MintCloseAuthority` extension for a non-None close authority
- Vault or pool initialization does not assert that `close_authority == None` for Token-2022 mints before registering the mint
- No check differentiating legacy SPL Token mints (which have no close authority) from Token-2022 mints (which may)

**Protocol Depends on Mint Account Existence**
- Protocol stores the mint address as a reference and performs key derivations or account lookups that would fail if the mint account no longer exists
- No fallback path in the protocol if the mint account is closed mid-operation; instructions that try to load a closed mint will fail with an account-not-found error

**Missing Token-2022 Extension Enumeration**
- Program loads the mint account but does not enumerate and validate all present Token-2022 extensions at initialization, leaving close authority and other extensions unchecked
- Token-2022 extension validation limited to checking transfer hook or transfer fee, missing close authority

## False Positives

- Mint is a legacy SPL Token mint; the `MintCloseAuthority` extension does not exist and the concept does not apply
- Token-2022 mint with `MintCloseAuthority` extension where the close authority is explicitly `None`
- Protocol uses only a hardcoded set of mints where Token-2022 extension fields have been pre-verified

## reference/anchor/fv-anc-7-token-operations/readme.md

---
description: Token operations security including SPL Token and Token-2022 extensions.
---

# FV-ANC-7 Token Operations



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-7-token-operations/<filename>` to read any case file listed below.

#### fv-anc-7-cl1-unvalidated-token-mint-and-owner.md
#### fv-anc-7-cl2-using-init-with-an-ata.md
#### fv-anc-7-cl3-token-2022-incompatibility.md
#### fv-anc-7-cl4-token-2022-transfer-hook-bypass-or-missing.md
#### fv-anc-7-cl5-interest-bearing-mint-principal-vs-balance-confusion.md
#### fv-anc-7-cl6-transfer-fee-extension-accounting-error.md
#### fv-anc-7-cl7-unconstrained-freeze-authority.md
#### fv-anc-7-cl8-mint-close-authority-exploitation.md

## reference/anchor/fv-anc-8-system-account-validation

```

```

## reference/anchor/fv-anc-8-system-account-validation/fv-anc-8-cl1-unvalidated-sysvar-address.md

# FV-ANC-8-CL1 Unvalidated Sysvar Address

## TLDR

Solana sysvars (Clock, Rent, SlotHashes, etc.) have fixed public addresses. If a sysvar is accepted as an `AccountInfo` without verifying its address, an attacker can substitute a different account, causing the program to read attacker-controlled data as if it were the sysvar.

## Detection Heuristics

**Sysvar Accepted as AccountInfo Without Address Constraint**
- `pub rent: AccountInfo<'info>` or `pub clock: AccountInfo<'info>` in a context struct without `#[account(address = sysvar::rent::ID)]` or equivalent
- Sysvar account deserialized using `Rent::from_account_info(&ctx.accounts.rent)` without a prior address check

**Missing address Constraint on Sysvar Accounts**
- Sysvar fields in Anchor context structs declared as `AccountInfo` instead of the typed `Sysvar<'info, Rent>` or `Sysvar<'info, Clock>` wrappers
- No `require!(ctx.accounts.rent.key() == sysvar::rent::ID, ...)` in the instruction body when `AccountInfo` is used

**Sysvar Data Read Without Identity Verification**
- `Rent::try_from_slice(&account.data.borrow())` called on an account not verified to be the Rent sysvar
- Clock fields (e.g., `unix_timestamp`, `slot`) read from an account without confirming it is the Clock sysvar

## False Positives

- Anchor typed sysvar wrappers `Sysvar<'info, Clock>` or `Sysvar<'info, Rent>` used, which enforce address validation automatically
- Sysvar accessed via `Clock::get()` or `Rent::get()` syscalls, which retrieve the canonical sysvar data without account passing

## reference/anchor/fv-anc-8-system-account-validation/fv-anc-8-cl2-instruction-introspection-using-absolute-index.md

# FV-ANC-8-CL2 Instruction Introspection Using Absolute Index

## TLDR

Programs that call `load_instruction_at_checked(index, ...)` with a hardcoded or fixed index assume a specific transaction layout. An attacker can prepend additional instructions, shifting the intended instruction to a different position and causing the verification to either fail or point at an unrelated instruction, bypassing the security check or triggering a denial of service.

## Detection Heuristics

**Hardcoded Index in Instruction Sysvar Read**
- `load_instruction_at_checked` or equivalent called with a literal index (e.g., `0`, `1`, `ix_index - 1`) rather than a dynamically discovered index
- Program uses `current_index - 1` to find a preceding instruction without verifying that instruction matches the expected program ID and data
- Index value treated as authoritative without checking the located instruction's program ID

**No Program ID Validation on Located Instruction**
- Instruction loaded by index but program ID of that instruction not compared against an expected constant before trusting its data
- Instruction data parsed from the introspected instruction without verifying it originated from the expected program (e.g., Ed25519 or Secp256k1 native program)
- Error condition when the expected instruction is not at the given index is a generic failure rather than a specific invalid-instruction error

**Attacker Can Prepend Instructions**
- Transaction allows arbitrary number of pre-instructions before the main instruction; no constraint on total instruction count
- Nonce or fee-payment instruction prepended by attacker shifts all subsequent instruction indices by one

## False Positives

- Program iterates all instructions and searches for one from the expected program with the expected discriminator, regardless of position; absolute index is never trusted
- Index is provided as an instruction parameter and the program validates that the instruction at that index has the expected program ID and data prefix

## reference/anchor/fv-anc-8-system-account-validation/fv-anc-8-cl3-address-lookup-table-signer-forgery.md

# FV-ANC-8-CL3 Address Lookup Table Signer Forgery

## TLDR

Address Lookup Tables (ALTs) expand transaction account lists from compressed indices. A malicious transaction can use an ALT to inject accounts that appear at indices the program expects to belong to specific signers or programs. Programs that identify accounts by position rather than by explicit key comparison are vulnerable to position-based spoofing via ALT-injected accounts.

## Detection Heuristics

**Positional Account Access Without Key Comparison**
- Program accesses `ctx.remaining_accounts[n]` or instruction accounts by a positional index and trusts the account at that position without comparing its `key()` against an expected constant or stored address
- Signer status of an ALT-resolved account trusted without re-verifying the account key matches the expected signer

**ALT Injection Path**
- Transaction account list extended via an ALT that the attacker controls; attacker can populate the ALT with accounts that mimic expected positions
- Program does not check whether an account at a given position originated from a static account reference or from an ALT resolution
- Expected program ID account (e.g., Token program, System program) verified only by position without comparing the key at runtime

**No Address Constraint on Critical Accounts**
- Critical authority or program accounts accepted without Anchor's `#[account(address = expected_key)]` constraint
- Accounts in `ctx.remaining_accounts` parsed and acted upon without building a validated allowlist before the first operation

## False Positives

- Every account is validated by comparing its key against a hardcoded constant or a stored expected address, making position irrelevant
- Anchor `#[account(address = known_constant)]` constraint applied to all critical accounts, ensuring key equality before any account data is accessed

## reference/anchor/fv-anc-8-system-account-validation/fv-anc-8-cl4-durable-nonce-transaction-ordering-attack.md

# FV-ANC-8-CL4 Durable Nonce Transaction Ordering Attack

## TLDR

Durable nonce transactions replace the `recent_blockhash` field with a nonce value that does not expire with slots, allowing a signed transaction to be submitted at any future time. Protocols that rely on transaction recency for security (signed price updates, authority transfers, time-bounded operations) are vulnerable when those operations can be submitted via a durable nonce transaction long after the signing context has changed.

## Detection Heuristics

**No Expiry Check Independent of Blockhash**
- Protocol accepts signed instructions or price updates without checking a timestamp from the `Clock` sysvar that must fall within a validity window
- Authority-modifying instructions carry no expiry field in their instruction data, relying only on the implicit blockhash expiry that durable nonces bypass
- Signed operations (e.g., signed delegate approvals, signed configurations) do not include a slot or Unix timestamp that the on-chain program validates against the current clock

**Durable Nonce Account Present Without Single-Use Enforcement**
- Transaction includes a nonce account and a `AdvanceNonce` instruction but the nonce authority is not invalidated or rotated after use
- Protocol does not treat transactions with a recognized nonce account as requiring additional expiry validation
- Signed off-chain messages that authorize on-chain state changes do not embed an expiry timestamp verified at execution time

**Retroactively Favorable Execution**
- Attacker holds a signed transaction that was unfavorable at signing time; conditions change (price movement, governance vote, authority change) and the transaction becomes profitable; submits it after the change
- Liquidation or settlement operations signed with durable nonces can be withheld and executed when market conditions are most advantageous to the submitter

## False Positives

- Protocol validates instruction timestamps independently of transaction blockhash using the `Clock` sysvar with an explicit validity window (e.g., `require!(clock.unix_timestamp - signed_at < MAX_VALIDITY_SECONDS)`)
- Nonce authority is a single-use keypair that is destroyed or transferred after the single authorized transaction

## reference/anchor/fv-anc-8-system-account-validation/readme.md

---
description: System account and transaction context validation.
---

# FV-ANC-8 System Account Validation



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-8-system-account-validation/<filename>` to read any case file listed below.

#### fv-anc-8-cl1-unvalidated-sysvar-address.md
#### fv-anc-8-cl2-instruction-introspection-using-absolute-index.md
#### fv-anc-8-cl3-address-lookup-table-signer-forgery.md
#### fv-anc-8-cl4-durable-nonce-transaction-ordering-attack.md

## reference/anchor/fv-anc-9-type-cosplay

```

```

## reference/anchor/fv-anc-9-type-cosplay/fv-anc-9-cl1-not-using-discriminators-to-validate-account-types.md

# FV-ANC-9-CL1 Not Using Discriminators to Validate Account Types

## TLDR

Anchor automatically prepends an 8-byte discriminator to every `#[account]` struct. When deserializing accounts manually or accepting them via `UncheckedAccount`, failing to check the discriminator allows an attacker to pass an account of a different type that happens to have the same owner program, causing type confusion.

## Detection Heuristics

**Manual Deserialization Without Discriminator Check**
- `MyStruct::try_from_slice(&ctx.accounts.account.data.borrow())` or `AnchorDeserialize::deserialize(&mut data)` called without first verifying `&data[..8] == MyStruct::DISCRIMINATOR`
- `borsh::from_slice` on account data that skips the first 8 bytes without comparing against the expected discriminator

**UncheckedAccount Deserialized Without Discriminator**
- `UncheckedAccount<'info>` data read and interpreted as a known struct without discriminator verification
- Account from `ctx.remaining_accounts` deserialized as `MyAccountType` without checking the discriminator bytes

**Type Cosplay Attack Surface**
- Program has multiple account types with identical field layouts or compatible sizes owned by the same program; without discriminator checks, one can be substituted for another
- Admin, user, and vault account structs with overlapping field offsets and same owner program

## False Positives

- Anchor's `Account<'info, T>` deserialization used, which automatically checks the discriminator during `try_deserialize`
- Account is a token account or system account with a fixed layout defined by an external program that does not use Anchor discriminators; owner check is sufficient

## reference/anchor/fv-anc-9-type-cosplay/fv-anc-9-cl2-account-structures-without-discriminators.md

# FV-ANC-9-CL2 Account Structures Without Discriminators

## TLDR

Account structs defined without Anchor's `#[account]` attribute do not receive an automatic 8-byte discriminator. This means all instances of such structs look identical at the byte level regardless of their intended type, enabling type substitution attacks across any instruction that accepts them.

## Detection Heuristics

**Structs Used as Accounts Without #[account] Attribute**
- `pub struct MyState { ... }` used as an on-chain account type without `#[account]` attribute, meaning no discriminator is prepended
- Struct deserialized directly via `borsh::BorshDeserialize` derive without discriminator bytes in the layout

**Manual Discriminator Without Enforcement**
- Struct includes a `discriminator: [u8; 8]` field but no instruction checks this field against a known constant before using the account
- `DISCRIMINATOR` constant defined but not compared during account validation

**Multiple Account Types With Identical Initial Fields**
- Two or more account structs share the same first field types (e.g., `authority: Pubkey` as the first field), making them indistinguishable without a discriminator when the owner program is the same

**Deserializer Does Not Reject Wrong Discriminator**
- Custom `try_deserialize` implementation does not check the discriminator, allowing any account data to deserialize successfully as any type

## False Positives

- Structs used only as instruction data (not on-chain accounts) and never deserialized from account data; discriminators are not relevant for instruction parameters
- Program uses a single account type and type confusion between types is structurally impossible

## reference/anchor/fv-anc-9-type-cosplay/readme.md

---
description: >-
  Prevent one account type from being misused as another by validating account
  discriminators.
---

# FV-ANC-9 Type Cosplay



## Classifications

Run `cat $SKILL_DIR/reference/anchor/fv-anc-9-type-cosplay/<filename>` to read any case file listed below.

#### fv-anc-9-cl1-not-using-discriminators-to-validate-account-types.md
#### fv-anc-9-cl2-account-structures-without-discriminators.md

## reference/anchor/protocols

```

```

## reference/anchor/protocols/amm-dex.md

# AMM and DEX Security Patterns

> Applies to: AMM protocols, DEX swap protocols, constant product market makers, concentrated liquidity managers, token swap pools, liquidity provisioning protocols, arbitrage-exposed swap routers, any protocol with on-chain price-setting swap mechanics on Solana

## Protocol Context

AMM protocols on Solana are exposed to the same structural MEV dynamics as EVM DEXes, with the added dimension of Jito bundle ordering that enables atomic sandwich attacks in a single block. Every state-changing operation that involves a price-sensitive output visible in the public mempool is a candidate for front-running or sandwiching. Solana's lack of a native mempool reduces some MEV vectors but Jito bundles provide equivalent ordering control for attackers. Pool reserve ratios are manipulable within a single transaction, making any spot-price consumption that does not use a TWAP vulnerable to flash manipulation.

## Bug Classes

---

### Missing Slippage Protection (ref: fv-anc-11-cl1)

**Protocol-Specific Preconditions**

- Swap or liquidity instruction accepts `min_amount_out: 0` without rejecting it
- Minimum output computed on-chain from pool state rather than passed as a caller parameter; attacker has already moved the pool before the instruction executes
- Automated keeper or harvest path calls a swap with no slippage bound; keeper uses a zero minimum for convenience

**Detection Heuristics**

- Search for all swap instruction call sites for `min_amount_out: 0`, `minimum_tokens_out: 0`, or equivalent fields set to zero
- Verify that slippage parameters are caller-supplied and not derived from on-chain pool state in the same instruction
- Check automated compound, harvest, or rebalance functions for hardcoded zero minimums or on-chain derived minimums
- Confirm that `deadline` or `valid_until_slot` is also accepted and enforced, not just the minimum output

**False Positives**

- Instruction is only callable by a trusted keeper operating via Jito private bundles; no public mempool exposure
- Slippage enforcement is at the aggregator router layer before funds reach the swap pool; the pool itself does not need to enforce it

---

### Pool Reserve Manipulation via Flash Swap

**Protocol-Specific Preconditions**

- Protocol derives a price or exchange rate from pool reserves at instruction time rather than from a TWAP
- Flash loan or flash swap allows large-scale temporary reserve changes within a single transaction
- No minimum time between consecutive swaps in the same pool; rapid manipulation and reversal in one transaction is possible

**Detection Heuristics**

- Identify all price or exchange rate derivation sites; check whether they read live pool reserves or use a TWAP oracle account
- Verify that protocols using pool-derived prices have a TWAP with a sufficiently long window relative to the block time
- Check whether flash swaps within the protocol itself allow borrowing pool reserves and repaying within one instruction set, without a price impact protection mechanism

**False Positives**

- Protocol uses the Raydium or Orca TWAP oracle account, not spot reserves; single-block manipulation cannot meaningfully move the time-weighted average
- Protocol applies a maximum price deviation check against a stored reference price that prevents execution at manipulated prices

---

### Same-Asset Swap Enabling Rounding Profit

**Protocol-Specific Preconditions**

- Protocol allows swapping the same token on both sides of a pair; swap of token A for token A with any fee structure should yield zero or negative output
- Rounding in the swap computation produces a positive output for a same-asset swap due to precision gaps in fee deduction
- No explicit check that input token mint differs from output token mint before the swap

**Detection Heuristics**

- Check the swap instruction for a `require!(input_mint != output_mint)` assertion
- Test a swap of token A for token A; verify the output is always less than or equal to input after fees
- Examine the fee computation path; confirm that same-asset fee application cannot produce a net positive output via rounding

**False Positives**

- Protocol explicitly asserts `input_mint != output_mint` as the first check in the swap instruction
- Pool architecture makes same-asset swaps architecturally impossible; each pool handles exactly two distinct mints

---

### Liquidity Provider Position Manipulation

**Protocol-Specific Preconditions**

- LP token mint and burn not guarded against race conditions in a multi-instruction transaction; two simultaneous operations can observe stale total supply
- LP position value calculation uses spot reserves that can be manipulated before or after the LP operation
- Remove liquidity operation applies slippage protection only on the total output, not on each individual token output; imbalanced removal exploitable

**Detection Heuristics**

- Verify that LP mint and burn operations read pool reserves in the same instruction where the LP tokens are issued or burned, with no intermediate state
- Check that `remove_liquidity` enforces minimum amounts on both token outputs independently, not just on a combined USD value
- Identify whether LP position valuation for collateral or governance uses live pool reserves or a time-averaged calculation

**False Positives**

- Protocol uses a locked reserve snapshot taken at the start of each LP operation; intermediate state changes do not affect the snapshot
- LP token mint is controlled by a PDA that enforces sequential ordering; concurrent mints are serialized

---

### Fee Accounting Invariant Violations

**Protocol-Specific Preconditions**

- Swap fees accumulated in the pool account are tracked in a separate field from the trading reserves; desynchronization between the two allows fee theft
- Fee withdrawal does not verify that the remaining balance maintains the pool invariant (k value)
- Fee computation rounds down in a direction that benefits the swapper rather than the pool, allowing systematic extraction of protocol revenue

**Detection Heuristics**

- Find the fee accumulation and fee withdrawal paths; verify that fee balances are tracked consistently with pool reserves
- Check that the invariant (constant product or otherwise) still holds after a fee withdrawal from the pool
- Verify the rounding direction in fee computation: fees charged to swappers should round up (favor protocol), not down

**False Positives**

- Protocol maintains a separate fee vault account distinct from trading reserves; fee withdrawal from the fee vault does not touch trading reserves
- Invariant check is applied after every state-modifying operation including fee withdrawals

## reference/anchor/protocols/governance.md

# Governance and Authority Management Security Patterns

> Applies to: on-chain governance programs, multisig-controlled protocols, upgrade authority management, DAO voting mechanisms, protocol parameter update mechanisms, admin key rotation, timelock controllers, Squads multisig integrations, veToken governance, any protocol with privileged on-chain authority that controls parameter updates or fund movements

## Protocol Context

Governance and authority management protocols on Solana control the ability to upgrade program binaries, update protocol parameters, pause operations, and move treasury funds. The attack surface is concentrated in the authority transfer, key rotation, and timelock enforcement paths. A single keypair with upgrade authority over a large protocol is a high-value target; the security model must ensure that no single point of failure can compromise protocol integrity. Solana's program upgrade mechanism requires explicit authority transfer and the authority can be set to None for immutability, making the upgrade authority configuration a binary security property that is auditable on-chain.

## Bug Classes

---

### Upgrade Authority Not Protected (ref: fv-anc-13-cl3)

**Protocol-Specific Preconditions**

- Program data account's upgrade authority is a single keypair not protected by multisig or timelock
- Upgrade authority was not transferred to a multisig or set to None after the protocol launched and stabilized
- Protocol documentation claims immutability or strong decentralization but on-chain upgrade authority state contradicts this

**Detection Heuristics**

- Check the program data account's upgrade authority on-chain: `solana program show <program_id>` reveals the authority address
- If authority is not None, verify it is a Squads multisig or governance program, not a single wallet
- Check whether the upgrade authority's governance process includes a mandatory timelock before any upgrade takes effect
- Verify that emergency upgrade paths, if they exist, also require multisig authorization rather than a single key

**False Positives**

- Upgrade authority is explicitly set to None; program is immutable and the finding does not apply
- Authority is a well-audited Squads multisig with a documented threshold, named keyholders who are independent parties, and an enforced timelock

---

### Authority Transfer Without Two-Step Confirmation

**Protocol-Specific Preconditions**

- Authority transfer instruction immediately assigns the new authority in a single transaction without requiring the new authority to accept
- A typo or incorrect address in the authority transfer permanently locks the protocol, as the specified new owner cannot sign to accept
- No two-step pattern (propose -> accept) enforced for any authority-transferring instruction

**Detection Heuristics**

- Find all instructions that write an `authority`, `owner`, `admin`, or `upgrade_authority` field
- Check whether the write is immediate or whether the instruction stores a `pending_authority` that must be claimed by the new authority in a separate transaction
- Verify that the new authority address is validated against a non-zero, non-program-address value before the transfer is committed
- Check for an emergency revoke path that allows the current authority to cancel a pending transfer before it is claimed

**False Positives**

- Two-step transfer enforced: first instruction stores `pending_authority`, second instruction requires signature of the pending authority to finalize
- Protocol's authority is a governance program where new authority assignments go through a voting period, providing an implicit multi-party confirmation

---

### Governance Timelock Not Enforced

**Protocol-Specific Preconditions**

- Protocol claims to enforce a timelock between proposal and execution, but the timelock duration is stored in a mutable config that the admin can reduce to zero before executing a proposal
- Timelock check compares against a relative delay from proposal creation but the proposal timestamp is writable and can be manipulated
- Timelock can be bypassed by a two-step: create proposal with future timestamp, then update the proposal timestamp to the past before execution

**Detection Heuristics**

- Identify the timelock enforcement path: find where `current_time - proposal_created_at >= timelock_duration` is checked
- Verify that `proposal_created_at` is written only once at proposal creation and cannot be modified by subsequent instructions
- Check whether `timelock_duration` in the config can be set to 0 by the admin; if so, verify this is documented and the governance process requires a separate timelock for config updates
- Verify that the execution path cannot be reached through any alternative instruction that bypasses the timelock check

**False Positives**

- Proposal creation timestamp is set from `Clock.unix_timestamp` at creation and is immutable; no instruction allows modifying it after creation
- Timelock duration is a program constant rather than a mutable config field; it cannot be reduced without a program upgrade

---

### Missing Governance for Protocol Parameter Updates

**Protocol-Specific Preconditions**

- Protocol parameters (fee rates, liquidation thresholds, supported mints, oracle addresses) are updatable by a single admin without governance or timelock
- Parameter update instructions lack input validation allowing admin to set economically harmful values (0% fee making the protocol insolvent, 100% collateral factor making all positions liquidatable)
- No event emitted or log recorded when parameters are changed, making changes invisible to monitoring systems

**Detection Heuristics**

- Find all instructions that write to protocol config or parameter accounts; check who can call them and what validation is applied to the new values
- Verify that critical parameters (liquidation threshold, fee rates, oracle addresses) have bounded valid ranges enforced by `require!` statements
- Check whether parameter update instructions emit an event or log that off-chain monitoring can detect
- Identify whether any combination of parameter values can be set that would immediately harm existing users (e.g., setting a collateral factor that makes all positions liquidatable)

**False Positives**

- Parameter updates require a multisig or DAO vote with a timelock; admin cannot unilaterally change critical parameters
- All parameter write instructions include range validation bounds that prevent values outside a documented safe range

---

### Emergency Pause Mechanism Centralization

**Protocol-Specific Preconditions**

- Pause authority is a single keypair with no multisig requirement, enabling censorship or targeted freezing of specific user accounts
- Pause mechanism does not distinguish between user operations (deposit, withdraw) and protocol operations (liquidation, settlement); pausing user access also blocks protocol health maintenance
- No defined process or on-chain constraint for when and how the pause is lifted; indefinite pause is possible

**Detection Heuristics**

- Identify the pause authority account and verify whether it requires a multisig signature
- Check whether the pause flag differentiates between classes of operations; a health-critical operation like liquidation should not be pausable by the same mechanism as deposits
- Verify whether there is a maximum pause duration enforced on-chain that would auto-resume the protocol after a bounded period
- Check whether pausing can be used selectively against individual user accounts or only globally

**False Positives**

- Pause authority requires Squads multisig authorization with documented threshold
- Liquidations and other protocol health operations are explicitly excluded from the pause mechanism via separate access control checks

## reference/anchor/protocols/lending.md

# Lending and Vault Security Patterns

> Applies to: lending protocols, borrowing protocols, collateralized debt positions, money markets, flash loan providers, yield vaults, ERC-4626-equivalent share vaults, isolated lending markets, overcollateralized lending, undercollateralized lending on Solana

## Protocol Context

Lending protocols on Solana maintain supply shares representing depositor claims and debt shares representing borrower obligations, where a compounding borrow index continuously shifts the exchange rate between shares and underlying token amounts. Liquidation correctness depends entirely on oracle price accuracy and freshness at the moment of the health factor check. Share-based vault accounting introduces a distinct class of inflation and rounding vulnerabilities specific to the deposit-to-share conversion math, particularly at vault initialization when total supply is zero or near-zero.

## Bug Classes

---

### Vault Share Inflation (ref: fv-anc-1-cl5)

**Protocol-Specific Preconditions**

- Vault can be initialized with zero total supply; first depositor controls the initial share price via direct token donation to the vault token account
- `total_assets()` reads the live token account balance directly rather than a tracked internal variable, making it manipulable by donation
- No virtual shares or minimum initial deposit to anchor the share price at initialization

**Detection Heuristics**

- Find the vault's `total_assets` or `total_supply` computation; check whether it reads a live token balance or a stored tracked value
- Identify whether a first depositor with a very small deposit amount followed by a donation can make the share price so high that subsequent depositors receive 0 shares due to rounding
- Check whether `deposit` and `withdraw` rounding directions consistently favor the vault (shares minted round down, assets owed on withdraw round up)
- Test deposit of 1 lamport followed by direct token transfer to the vault; verify subsequent depositors still receive non-zero shares

**False Positives**

- Protocol mints a minimum set of dead shares at initialization to anchor the price and prevent first-depositor manipulation
- `total_assets()` reads a stored tracked field that is updated only through protocol instructions, not the live token balance

---

### Liquidation Logic Flaws (ref: fv-anc-4-cl4)

**Protocol-Specific Preconditions**

- Health factor computation reads oracle price without freshness or confidence check; undercollateralized positions may not be flagged during oracle outages
- Liquidation instruction shares a pause flag with deposit or repay operations; pausing any operation also blocks liquidations
- Liquidation profit (bonus) not large enough to cover transaction costs and slippage, creating conditions where no liquidator executes and bad debt accumulates
- Minimum liquidation amount too large to close dust positions; dust positions accumulate as uncollectable bad debt

**Detection Heuristics**

- Trace the health factor calculation from account data through oracle price to the comparison threshold; verify oracle freshness is checked at this exact point
- Check whether liquidation is gated by any `require!(!paused)` that also blocks deposits or other user operations
- Verify the liquidation incentive (bonus_bps) is sufficient to cover on-chain fees and typical slippage for the collateral types supported
- Check the minimum liquidation threshold; verify dust positions at or below this threshold do not permanently evade liquidation

**False Positives**

- Health factor computation always calls oracle with staleness check as its first step, before any arithmetic
- Pause mechanism has separate flags for user operations and protocol liquidations; liquidations are never blocked by user-operation pauses

---

### Self-Liquidation for Profit (ref: fv-anc-2-cl1)

**Protocol-Specific Preconditions**

- Liquidator and borrower are permitted to be the same address; no restriction preventing a position holder from liquidating their own position
- Liquidation bonus paid on top of debt repayment makes self-liquidation profitable if the protocol allows it
- No check that `liquidator.key != borrower.key` in the liquidation instruction

**Detection Heuristics**

- Check the liquidation instruction accounts constraints for a `constraint = liquidator.key() != borrower.key()` assertion
- Calculate the effective profit of a self-liquidation: if the liquidation bonus exceeds the liquidation fee and any protocol penalty, self-liquidation is profitable
- Verify whether self-liquidation is documented as an intentional feature or an oversight

**False Positives**

- Protocol explicitly allows self-liquidation as a mechanism for borrowers to exit positions and documents this as intentional
- Liquidation fee or protocol penalty makes self-liquidation economically neutral or negative for the liquidator

---

### Interest Accrual During Pause

**Protocol-Specific Preconditions**

- Protocol can be paused to prevent deposits, withdrawals, and liquidations
- Borrow index continues to compound during the pause period, increasing borrower debt without borrowers being able to repay or liquidators being able to act
- After a long pause, borrowers whose positions were healthy at pause time are liquidatable on resume because debt grew beyond collateral value

**Detection Heuristics**

- Identify the pause mechanism; check whether interest accrual is also paused or continues independently
- Calculate the maximum pause duration that would make a healthy position at the boundary of the liquidation threshold become undercollateralized
- Verify whether governance documentation or code enforces a maximum pause duration that prevents this scenario

**False Positives**

- Interest accrual is explicitly stopped by the pause mechanism; the borrow index is not updated during paused state
- Pause duration is hard-capped in the program to a duration short enough that even highly leveraged positions cannot become undercollateralized from interest alone

---

### Precision Loss in Borrow Index Scaling

**Protocol-Specific Preconditions**

- Borrow index stored as a u64 or u128 scaled value; interest rate application involves division that truncates
- Small borrows accumulate less interest than expected due to repeated truncation of fractional interest amounts
- Fee calculations using basis points truncate to zero for small positions, allowing fee-free operation below a certain size threshold

**Detection Heuristics**

- Find the borrow index update function; check the scaling factor (1e9, 1e18) and whether intermediate multiplications use u128 to prevent overflow
- Test with minimum borrow amounts to verify that interest accrues correctly and does not round to zero indefinitely
- Verify that fee computations use `checked_mul` before `checked_div` to minimize precision loss

**False Positives**

- Protocol uses u128 throughout interest calculations with a scaling factor of 1e18; rounding loss bounded to 1 unit per operation
- Minimum borrow amount enforced to be large enough that interest accrual is always non-zero

## reference/anchor/protocols/oracle.md

# Oracle Integration Security Patterns

> Applies to: Pyth oracle consumers, Switchboard oracle consumers, on-chain price feeds, lending collateral pricing, margin systems, liquidation engines, perpetuals pricing, synthetic asset minting, any protocol consuming external price data on Solana

## Protocol Context

Oracle-consuming protocols on Solana are architecturally exposed to the update frequency and reliability characteristics of the specific feed they integrate. Pyth publishes confidence intervals and status flags alongside each price; Switchboard aggregates multiple sources with configurable staleness windows. Both models require the consuming program to make active decisions about what constitutes an acceptable price - staleness tolerance, confidence ratio, and status validity must be explicitly enforced on-chain at every price consumption site. Off-chain oracle manipulation is not feasible for Pyth or Switchboard, but on-chain spot price manipulation via flash loans remains a viable attack vector for protocols that derive prices from AMM pool reserves rather than dedicated oracle feeds.

## Bug Classes

---

### Stale Price Acceptance

**Protocol-Specific Preconditions**

- Program reads `Price.price` from a Pyth feed account without calling `try_get_price_no_older_than(clock, max_age_seconds)` or equivalent
- Switchboard feed read without checking `latest_confirmed_round.round_open_slot` against current slot
- Max staleness threshold is set to an unreasonably large value relative to the protocol's liquidation and collateral sensitivity
- Oracle account not validated against a hardcoded or governance-registered expected address, allowing submission of a different feed with a recently-updated but wrong price

**Detection Heuristics**

- Search for `get_price_unchecked()` or direct `price_account.try_deserialize()` usage without a subsequent staleness check
- Check every oracle price consumption site for a comparison of `publish_time` or `round_open_slot` against `Clock.unix_timestamp` or `Clock.slot` within an acceptable window
- Verify that the staleness window parameter is configurable and its current value is appropriate for the protocol's risk model
- Identify any oracle account passed as an instruction parameter (not hardcoded); verify it is compared against a registered expected address before use

**False Positives**

- Protocol exclusively uses `try_get_price_no_older_than` with an appropriate max age for all price consumption sites
- Feed update frequency is documented to be faster than the protocol's staleness window and this is enforced by the on-chain check

---

### Confidence Interval and Status Ignored

**Protocol-Specific Preconditions**

- `Price.conf` not validated against a maximum acceptable ratio before using `Price.price`
- `Price.status` not compared against `PriceStatus::Trading` at every consumption site; price used when status is `Unknown` or `Halted`
- No circuit breaker for extreme price deviation; a compromised or malfunctioning oracle can report any value without on-chain rejection

**Detection Heuristics**

- Grep for all `Price.price` or `price.agg.price` access sites; trace each to find whether `conf`, `status`, and `publish_time` are all validated in the same code path
- Check whether the max confidence ratio is a protocol parameter that governance can update, or a hardcoded constant; constants that are too loose cannot be tightened post-deployment
- Verify the circuit breaker logic if present: max deviation from last accepted price, minimum/maximum absolute price bounds

**False Positives**

- Protocol applies all three checks (staleness, confidence ratio, status) before every price use and returns a descriptive error on violation
- Confidence ratio check applies a reasonable bound (e.g., reject if `conf * 100 / abs(price) > 2`) for the asset class being priced

---

### Flash Loan Oracle Manipulation

**Protocol-Specific Preconditions**

- Price derived from AMM pool reserves (`token_a_reserve / token_b_reserve`) rather than a dedicated oracle feed
- Protocol integrates with a flash loan provider and the flash loan target pool is the same pool used for price derivation
- No TWAP or multi-block price averaging; price reflects instantaneous reserve ratio at instruction execution time

**Detection Heuristics**

- Identify all price or exchange rate computation sites; check whether the rate is derived from on-chain pool reserves or from a dedicated oracle account
- For protocols using pool-derived prices, verify whether the protocol also integrates a TWAP oracle account from the same pool that uses a historical average
- Check for minimum time between price-sensitive operations that would prevent rapid manipulation within a single slot

**False Positives**

- Protocol uses only off-chain aggregated oracle feeds (Pyth, Switchboard) that cannot be manipulated via on-chain flash loans
- AMM TWAP oracle used with a sufficiently long window (e.g., 30 minutes) that a single-block flash loan cannot meaningfully move the time-weighted average

---

### Fake Oracle Injection (ref: fv-anc-8-cl3)

**Protocol-Specific Preconditions**

- Oracle account passed as an instruction parameter and validated only by checking it is non-zero or owned by a known oracle program, not by comparing its address to an expected feed address
- Multiple oracles supported but the registry of accepted oracle addresses is not enforced on-chain at the price consumption site
- Program ID check performed on the oracle account (Pyth program, Switchboard program) but not the specific feed address within that program

**Detection Heuristics**

- Find every instruction that accepts an oracle or price feed account as a parameter; check whether it is compared against a hardcoded constant or a governance-stored expected address
- Verify that oracle address registration is gated behind a privileged admin or governance instruction, not freely settable by users
- Check for Anchor `#[account(address = expected_oracle_address)]` constraint or equivalent manual key comparison

**False Positives**

- Oracle accounts are hardcoded as program constants and not accepted as instruction parameters
- Protocol uses a governance-managed oracle registry with a privileged update path; any oracle address used must first be registered by governance

---

### Retroactive or Delayed Price Application

**Protocol-Specific Preconditions**

- Signed price updates submitted via durable nonce transactions can be submitted at any future time at the submitter's convenience
- Protocol allows price updates to be applied to positions that were opened before the price update's timestamp
- Settlement or liquidation can use a price snapshot from a previous slot rather than the current slot price

**Detection Heuristics**

- Check whether price update instructions validate that the price timestamp is strictly later than the position's open timestamp
- Verify that settlement prices are bounded to a freshness window relative to the settlement execution time, not just relative to when the price was published
- Identify any signed-price or voucher mechanism (price attested off-chain and submitted on-chain); check that the on-chain consumer enforces an expiry window

**False Positives**

- Protocol only applies oracle prices at the current slot; no mechanism exists to submit past prices for current operations
- Settlement price must be within a tight freshness window (e.g., 5 seconds) relative to the settlement transaction's Clock.unix_timestamp

## reference/anchor/protocols/staking.md

# Staking and Reward Security Patterns

> Applies to: token staking protocols, liquid staking, staking reward distributors, veToken staking, lockup staking, native SOL staking wrappers, validator delegation protocols, yield farming with lockups, any protocol that distributes rewards proportional to staked balances over time

## Protocol Context

Staking protocols on Solana track user share balances and distribute rewards through a per-share accumulator index pattern: a global index grows as rewards are deposited, and each user's pending reward is computed as `(global_index - user_snapshot_index) * user_shares`. The correctness of this model depends critically on the ordering of index updates relative to balance changes. Flash loan attacks against staking protocols exploit the ability to enter and exit a large staked position within a single transaction, claiming a disproportionate share of rewards that accrued during the stake window.

## Bug Classes

---

### Reward Accumulator Index Ordering (ref: fv-anc-11-cl6)

**Protocol-Specific Preconditions**

- `global_reward_index` updated in the same instruction that modifies user share balances, but after the balance modification
- User's pending rewards calculated using their new post-deposit or post-withdrawal balance rather than their balance at the time of accrual
- No atomic settle-before-mutate pattern enforced across all balance-modifying instructions

**Detection Heuristics**

- Trace the execution order in every instruction that modifies `user.shares`: find whether `accumulate_rewards(user)` or equivalent is called before or after `user.shares += delta`
- Check whether the global index is updated before the user's balance is changed, or whether the user's snapshot index is updated to the post-change global index in a way that skips owed rewards
- Verify with a test case: deposit at time T, harvest at T+1 with 0 elapsed accrual; user should receive 0 rewards for the deposit slot if index was correct at deposit time

**False Positives**

- Protocol settles pending rewards and updates the user's index snapshot as the very first operation in every balance-modifying instruction, before any state change
- Reward index is designed as a read-only observation point; balance changes never affect outstanding reward claims

---

### Flash Stake Reward Draining (ref: fv-anc-1-cl5, fv-anc-5-cl10)

**Protocol-Specific Preconditions**

- Deposit and withdraw instructions can be called in the same transaction with no minimum lock period
- Rewards that accrued before a large deposit can be claimed by the depositor if index ordering is wrong
- Flash loan provider on Solana enables borrowing large token amounts and repaying within the same transaction

**Detection Heuristics**

- Verify that a deposit immediately followed by a reward claim in the same slot cannot produce a non-zero reward payout
- Check whether a minimum staking duration (`min_stake_slots` or `min_stake_seconds`) is enforced before rewards can be claimed
- Identify whether the reward claim instruction can be called in the same transaction as a deposit without any intermediate slot boundary requirement
- Test: borrow X tokens, deposit X, claim rewards, withdraw X, repay X; verify net reward is zero or negative

**False Positives**

- Protocol enforces a minimum lockup before reward claims; the lockup period is enforced by the on-chain program not just by convention
- Reward index at deposit time is recorded as the user's baseline; any rewards claimed must have accrued after the deposit, making same-transaction flash stake yield zero

---

### Reward Dilution via Late Deposit

**Protocol-Specific Preconditions**

- Rewards are distributed proportionally at claim time rather than accrued continuously; a late depositor can enter just before a large reward distribution and claim a share
- Reward distribution instruction is publicly callable and can be front-run; an attacker can observe a large pending reward distribution and deposit just before it
- No snapshot mechanism that locks the eligible population at the start of a reward distribution period

**Detection Heuristics**

- Check whether reward distribution is a discrete event (transfer of a fixed amount) or a continuous accumulation; discrete events are more susceptible to front-running
- Verify whether rewards accrued before a user's deposit can be claimed by that user; the snapshot index recorded at deposit time should equal the current global index
- Look for reward distribution instructions that do not cap the eligible population at the start of the distribution

**False Positives**

- Continuous accumulator model correctly records the global index at deposit time and users can only claim rewards that accrued after their deposit
- Reward distribution uses a fixed-snapshot-eligible-users list rather than live balances; depositing after the snapshot has no effect

---

### Cooldown Period Bypass via Self-Transfer

**Protocol-Specific Preconditions**

- Staked balance can be transferred or delegated to a different address while the cooldown period is pending, allowing the recipient to bypass the cooldown
- Unstake and restake flow resets the cooldown; an attacker can unstake and immediately restake, keeping funds liquid while appearing staked for governance or reward purposes
- Cooldown timer stored per-user but not per-position; a single cooldown covers all pending withdrawals and can be reset by adding a new unstake

**Detection Heuristics**

- Check whether staked positions are transferable; if so, verify the cooldown timer travels with the position rather than being reset at the recipient
- Verify that a new unstake request does not reset or extend an existing cooldown timer in a way that benefits the unstaker
- Check whether restaking during cooldown is permitted; if so, verify the cooldown period resumes from where it left off, not from zero
- Look for any path that allows fund movement during the cooldown period that does not ultimately block the withdrawal until cooldown expires

**False Positives**

- Staked positions are not transferable; the owner at deposit time is the only address that can unstake
- Cooldown tracked per-lamport or per-deposit entry; each individual unstake request has its own cooldown timer that cannot be reset by other operations

---

### Precision Loss in Reward Computation

**Protocol-Specific Preconditions**

- Reward per share stored as a small integer without sufficient decimal scaling; rounding at each claim loses fractional rewards
- Users with small balances accrue zero rewards per slot due to truncation; over time, small stakers cannot accumulate meaningful rewards
- Fee deduction from rewards uses integer division that rounds against the staker below a threshold balance

**Detection Heuristics**

- Check the scaling factor for the per-share reward accumulator; common values are 1e9 or 1e18; verify it is sufficient for the expected reward rate and minimum stake amount
- Test with minimum stake amounts to confirm rewards accrue correctly over time without permanently rounding to zero
- Verify that accumulated precision loss across many epochs does not reach an amount that is economically significant for large stakers

**False Positives**

- Per-share reward accumulator uses 1e18 scaling factor and minimum stake is large enough that per-slot rewards are always non-zero
- Precision loss bounded to 1 unit per claim and documented as an intentional rounding choice favoring the protocol

## reference/move

```

```

## reference/move/fv-mov-1-object-model

```

```

## reference/move/fv-mov-1-object-model/fv-mov-1-cl1-copy-ability-duplication.md

# FV-MOV-1-CL1: Copy Ability Enables Token Duplication

## TLDR

A value-bearing object (coin, NFT, badge, receipt, staking position) granted the `copy` ability can be duplicated by any holder. The attacker duplicates a token to drain pools, inflate supply, or repeatedly spend the same credential.

## Detection Heuristics

- Read every struct definition in the codebase; for any struct with `copy` in its ability list, assess whether it holds financial value, represents ownership, or grants authority
- Search: `struct .* has .*copy` in all `.move` files
- For coin-like types or share types, `copy` is always incorrect - they must be consumed on use
- Pay special attention to receipt structs, LP position structs, and badge structs that flow through `deposit`, `withdraw`, or `claim` functions
- If the struct is used as a function argument and the function does not consume it (no `let _ =`), check whether the caller can reuse the same value

## False Positives

- Struct explicitly designed to be copyable: configuration data, read-only references, display metadata with no authority semantics
- Struct is used only via immutable reference (`&T`) in all callsites and copying is harmless
- Protocol documentation explicitly states `copy` is intentional with rationale

## reference/move/fv-mov-1-object-model/fv-mov-1-cl2-drop-ability-debt-destruction.md

# FV-MOV-1-CL2: Drop Ability Enables Debt Destruction

## TLDR

An obligation object (flash loan receipt, debt record, collateral lock, vesting lock) granted the `drop` ability can be silently discarded. A borrower drops the receipt without repaying; a collateral lock is dropped to unlock assets early.

## Detection Heuristics

- Search all structs used as flash loan receipts, debt records, escrow locks, or collateral proofs for `drop` in their ability list
- In any function that creates a receipt-like struct, trace whether the compiler is forced to consume it - if `drop` is present, the compiler never enforces consumption
- A hot potato struct (no abilities) is the correct pattern: the compiler requires every value to be moved or explicitly consumed; `drop` bypasses this guarantee
- Look for `let receipt = borrow(...)` patterns where the caller's code path can exit without calling `repay(receipt)`

## False Positives

- Struct has no obligation semantics - it is purely informational and dropping it is safe
- `drop` is intentional and documented; all code paths correctly handle both the consumption and drop cases
- Protocol uses an alternative enforcement mechanism (e.g., shared object state flag checked at end of transaction)

## reference/move/fv-mov-1-object-model/fv-mov-1-cl3-store-ability-wrapping.md

# FV-MOV-1-CL3: Store Ability Enables Unauthorized Wrapping

## TLDR

A sensitive object (AdminCap, TreasuryCap, UpgradeCap) granted the `store` ability can be wrapped inside any other object and transferred out of the protocol's visibility. The attacker wraps the capability into a custom container and transfers it to an address they control, bypassing transfer policies.

## Detection Heuristics

- Search for `struct AdminCap has store`, `struct TreasuryCap has store`, or any capability-type struct with `store`
- `store` without `key` means the object can only exist inside another object - it cannot be transferred directly, but it can be wrapped and the wrapper can be transferred
- Trace whether any public function returns or hands off the capability object to a caller-controlled destination
- Check whether transfer policies (`TransferPolicy`) are configured on the type; if `store` is present but no transfer policy is enforced, the capability can leak

## False Positives

- `store` is required for storing the capability in a dynamic field within the same protocol's objects, with access gated by internal checks
- Transfer policy enforces correct handling for any object that leaves the module
- Object designed to be storable by protocol design (e.g., delegated capability with intentional transfer path)

## reference/move/fv-mov-1-object-model/fv-mov-1-cl4-object-wrapping-lock.md

# FV-MOV-1-CL4: Object Wrapping Permanent Lock

## TLDR

A contract wraps a user-owned object inside its own struct (or dynamic field) and provides no guaranteed path to unwrap it. Once wrapped, the inner object loses its independent identity - it cannot be transferred, used, or accessed until explicitly unwrapped by the wrapping contract. If the wrapping contract is malicious or has a bug, the inner object is permanently inaccessible.

## Detection Heuristics

- Identify every function that accepts a user object and stores it inside a struct field or dynamic field owned by the protocol
- For each such wrapping function, verify a corresponding `unwrap`, `extract`, or `return_object` function exists and is accessible by the original owner
- Third-party contracts that accept user objects (marketplaces, staking contracts, escrow) are the highest-risk callsites - verify they expose an unconditional exit path
- Check whether the unwrap function has a precondition that could be made permanently unsatisfiable (e.g., requires a counter to reach a value that is only incremented externally)
- Dynamic object fields preserve child object IDs - search for `dynamic_object_field::add` on sensitive objects

## False Positives

- Wrapping is temporary and the protocol guarantees an unwrap path via an unconditional function callable by the original owner
- Object is intentionally locked (e.g., staked, escrowed) with clear unlock conditions that are fully within the owner's control
- Protocol is audited and the wrapping is a standard, documented design pattern

## reference/move/fv-mov-1-object-model/readme.md

# FV-MOV-1: Object Model and Abilities

Sui Move's type system grants objects abilities: `copy`, `drop`, `store`, and `key`. Incorrect ability assignment is a primary source of critical vulnerabilities. This category also covers object wrapping, dynamic fields, and unauthorized sharing or freezing.

## Cases

- [fv-mov-1-cl1-copy-ability-duplication.md](fv-mov-1-cl1-copy-ability-duplication.md) - Value-bearing object has `copy`, enabling token duplication
- [fv-mov-1-cl2-drop-ability-debt-destruction.md](fv-mov-1-cl2-drop-ability-debt-destruction.md) - Obligation object has `drop`, enabling silent debt erasure
- [fv-mov-1-cl3-store-ability-wrapping.md](fv-mov-1-cl3-store-ability-wrapping.md) - Capability object has `store`, enabling unauthorized wrapping or transfer
- [fv-mov-1-cl4-object-wrapping-lock.md](fv-mov-1-cl4-object-wrapping-lock.md) - Object wrapped by third-party contract with no unwrap path

## Key Vectors

V3, V4, V5, V6, V24, V25, V28, V29, V30, V126

## reference/move/fv-mov-2-access-control

```

```

## reference/move/fv-mov-2-access-control/fv-mov-2-cl1-missing-capability-check.md

# FV-MOV-2-CL1: Missing Capability Check on Privileged Function

## TLDR

A function that performs admin or privileged operations (withdraw, mint, pause, config update) does not require a capability object parameter. Any user can call the function and perform admin operations without restriction.

## Detection Heuristics

- Enumerate all functions that touch sensitive state: treasury, admin config, pause flags, supply, upgrade logic
- For each such function, check whether its signature includes a `_: &AdminCap`, `cap: &TreasuryCap`, or equivalent capability reference
- A function with only `ctx: &mut TxContext` and no capability parameter that modifies privileged state is a finding
- Address-based checks (`assert!(ctx.sender() == stored_admin)`) are weaker but acceptable as a secondary defense; they are insufficient if the admin address is hardcoded or not stored mutably
- Pay attention to functions in `entry` visibility - these are callable directly from a transaction by any user

## False Positives

- Function is `public(package)` and not callable from outside the package
- Address-based check present with the address stored in a mutable config object (not hardcoded)
- Function performs no privileged state change - it only reads or emits events

## reference/move/fv-mov-2-access-control/fv-mov-2-cl2-capability-lifecycle.md

# FV-MOV-2-CL2: Capability Created Outside init / Missing OTW

## TLDR

Capability objects (`AdminCap`, `TreasuryCap`) should be created only once during module initialization (`init`). Creating them in any other function, without requiring an existing capability, allows any caller to mint new admin credentials. Similarly, coins created without the one-time witness (OTW) pattern allow external modules to create duplicate `TreasuryCap` instances.

## Detection Heuristics

- Search for `AdminCap { }` or `TreasuryCap` struct constructions outside of `fun init()`
- For coin types, verify `coin::create_currency` is called with a one-time witness (the witness type name matches the module name in all caps, e.g., `MYTOKEN`)
- Check whether `sui::types::is_one_time_witness` is validated before minting
- Capability construction that requires no existing capability is always a finding when outside `init`
- Verify `init` function signature accepts `otw: MODULENAME` as first parameter for coin modules

## False Positives

- Capability creation function requires an existing valid capability as authorization
- Function is `public(package)` with gated access
- OTW validation present via `assert!(sui::types::is_one_time_witness(&witness))`

## reference/move/fv-mov-2-access-control/fv-mov-2-cl3-entry-visibility-bypass.md

# FV-MOV-2-CL3: entry Modifier Overrides public(package) Visibility

## TLDR

A function declared `public(package) entry` is intended to be callable only within the package, but the `entry` modifier allows it to be called directly from any transaction. The `entry` keyword overrides the `public(package)` restriction, turning an internal function into public attack surface.

## Detection Heuristics

- Search for `public(package) entry` function declarations across all `.move` files
- For each such function, assess whether it contains privileged operations, admin logic, or internal state manipulation that should not be externally callable
- The correct pattern for a function callable from PTBs but not raw transactions is `public` (not `public(package) entry`)
- The correct pattern for a package-internal function is `public(package)` without `entry`
- Any `public(package) entry` function with sensitive logic is a finding unless there is a documented rationale

## False Positives

- Function performs only read operations with no sensitive state changes
- Function is intentionally both package-restricted and directly callable by transaction (rare, document rationale)
- Modern Sui version where `public(package) entry` semantics have been clarified to not expose externally

## reference/move/fv-mov-2-access-control/fv-mov-2-cl4-sender-spoofing.md

# FV-MOV-2-CL4: Caller Address Accepted as Parameter

## TLDR

A function accepts a `sender: address` parameter to determine the caller's identity, rather than deriving it from `tx_context::sender(ctx)`. Any caller can spoof any address by passing a victim's address, enabling them to perform operations on the victim's behalf.

## Detection Heuristics

- Search for function signatures containing `sender: address` or `caller: address` as user-supplied input
- Trace whether the address parameter is used for ownership checks, balance lookups, object transfers, or reward claims
- The safe pattern is always `let sender = tx_context::sender(ctx)` - address derived from the transaction, not passed in
- Look for `assert!(param_addr == tx_context::sender(ctx))` - this check would fix the issue, but its absence is a finding
- Pay special attention to NFT transfer, reward claim, and delegation functions where impersonation has direct economic impact

## False Positives

- Address parameter is only used for destination/recipient (not as a caller identity claim) and the function does not check ownership against it
- Address parameter validated against `tx_context::sender(ctx)` immediately upon function entry
- Function is an internal helper only callable from package functions that have already validated caller identity

## reference/move/fv-mov-2-access-control/readme.md

# FV-MOV-2: Access Control and Capabilities

Sui Move uses the capability object pattern as its primary access control mechanism. This category covers missing capability checks, incorrect visibility modifiers, phantom type bypass, and sender address spoofing.

## Cases

- [fv-mov-2-cl1-missing-capability-check.md](fv-mov-2-cl1-missing-capability-check.md) - Privileged function callable without a capability object
- [fv-mov-2-cl2-capability-lifecycle.md](fv-mov-2-cl2-capability-lifecycle.md) - Capability created outside `init` or OTW pattern missing
- [fv-mov-2-cl3-entry-visibility-bypass.md](fv-mov-2-cl3-entry-visibility-bypass.md) - `public(package) entry` overrides package-only restriction
- [fv-mov-2-cl4-sender-spoofing.md](fv-mov-2-cl4-sender-spoofing.md) - Caller address accepted as parameter instead of from TxContext

## Key Vectors

V1, V2, V6, V7, V8, V9, V10, V12, V13, V16, V21, V122, V123, V124

## reference/move/fv-mov-3-upgrade-safety

```

```

## reference/move/fv-mov-3-upgrade-safety/fv-mov-3-cl1-init-upgrade-assumptions.md

# FV-MOV-3-CL1: init Assumptions After Upgrade

## TLDR

In Sui Move, the `init` function runs only once at first deployment. Package upgrades do NOT re-execute `init`. Code that relies on `init` running again to reset state, create new capabilities, or initialize new fields will leave post-upgrade state uninitialized.

## Detection Heuristics

- Look for `init` functions that create capabilities or configure shared objects, then check whether any upgrade scenario requires those to be re-run
- Search for new struct fields added in an upgrade that are initialized to zero/default but require a non-zero starting value
- Check whether a `migrate()` or `upgrade_v2()` function exists and is called as part of the upgrade plan
- Verify that any code path depending on a freshly-initialized state variable accounts for the case where `init` has already run and the variable holds the old value

## False Positives

- Migration function explicitly handles all post-upgrade initialization
- New fields are safely defaulted to zero/false and require no special initialization
- No new state introduced in the upgrade that depends on `init` semantics

## reference/move/fv-mov-3-upgrade-safety/fv-mov-3-cl2-version-check-missing.md

# FV-MOV-3-CL2: Missing Version Check on Shared Objects

## TLDR

After a package upgrade, old transactions may still call functions from the pre-upgrade package against existing shared objects. Without a `version` field and a per-function version assertion, old and new code can interleave, producing incompatible state transitions.

## Detection Heuristics

- Check every shared object struct for a `version: u64` field
- Check every public function that mutates a shared object for `assert!(obj.version == CURRENT_VERSION, EVersionMismatch)`
- Search for the `CURRENT_VERSION` constant and verify it is incremented in each upgrade
- Verify a migration function exists that atomically increments the version field after upgrading all objects
- Missing version checks combined with struct field additions is a compound finding

## False Positives

- Package is immutable (no future upgrades possible); version checks are unnecessary
- Protocol design guarantees that only one version of code will ever touch the object (e.g., single-use objects destroyed after first use)
- Version field present and checked in a shared helper called by all public functions

## reference/move/fv-mov-3-upgrade-safety/fv-mov-3-cl3-struct-field-evolution.md

# FV-MOV-3-CL3: Struct Field Evolution Breaks Deserialization

## TLDR

Sui Move enforces forward-compatible struct evolution: fields may only be appended, never reordered or removed. Violating this rule causes existing on-chain objects (created by the old code) to fail deserialization when accessed by the new code.

## Detection Heuristics

- When reviewing an upgrade diff, check whether any struct field was removed, renamed, or reordered
- New fields must be appended at the end; reordering even non-sensitive fields breaks binary layout
- Verify the upgrade's migration function addresses any objects that must be touched before the new code accesses them
- Types with `Option<T>` for new fields are safer than mandatory fields - verify the new field is `Option<T>` or has a safe default

## False Positives

- Fields only appended (never reordered or removed) - this is the only safe pattern
- New fields are optional with documented safe defaults
- No existing on-chain objects of the modified type (brand new struct in the upgrade)

## reference/move/fv-mov-3-upgrade-safety/fv-mov-3-cl4-upgrade-cap-security.md

# FV-MOV-3-CL4: UpgradeCap Security

## TLDR

The `UpgradeCap` for a Sui package is the single object controlling all future code upgrades. A compromised or malicious holder can deploy arbitrary new code, immediately draining all protocol funds. This is the highest-impact single point of failure in any upgradeable Sui protocol.

## Detection Heuristics

- Find the `UpgradeCap` object ID in the deployment manifest or `init` function and trace its destination
- If `UpgradeCap` is transferred to a single EOA address, it is a critical finding
- Verify whether a timelock wrapper is applied: `UpgradeCap` should only be exercisable after a minimum delay (24-48 hours minimum)
- Check if `UpgradeCap` has been destroyed (`package::make_immutable`) - if so, verify this was intentional and no critical bugs remain unfixed
- Overly permissive upgrade policy (`compatible` instead of `dep_only`) is a medium finding - unnecessary attack surface

## False Positives

- `UpgradeCap` held by a multi-sig contract with documented signers
- Timelock module wraps `UpgradeCap` with enforced delay
- Governance vote required before upgrade execution
- `UpgradeCap` destroyed after protocol matured and all bugs resolved - immutability intentional and documented

## reference/move/fv-mov-3-upgrade-safety/readme.md

# FV-MOV-3: Package Upgrades and Lifecycle

Sui Move has unique upgrade semantics: `init` does not run on upgrade, struct fields are forward-compatible only, and `UpgradeCap` is the single point of control over future code. Errors here are typically critical or high severity.

## Cases

- [fv-mov-3-cl1-init-upgrade-assumptions.md](fv-mov-3-cl1-init-upgrade-assumptions.md) - Logic assumes `init` re-runs on upgrade; post-upgrade state left uninitialized
- [fv-mov-3-cl2-version-check-missing.md](fv-mov-3-cl2-version-check-missing.md) - Shared objects have no `version` field or public functions do not check it
- [fv-mov-3-cl3-struct-field-evolution.md](fv-mov-3-cl3-struct-field-evolution.md) - Fields reordered or removed in upgrade, breaking existing object deserialization
- [fv-mov-3-cl4-upgrade-cap-security.md](fv-mov-3-cl4-upgrade-cap-security.md) - `UpgradeCap` held by single EOA or destroyed prematurely

## Key Vectors

V17, V18, V19, V20, V43, V44, V45, V46, V47, V107, V108, V120, V134

## reference/move/fv-mov-4-shared-objects-concurrency

```

```

## reference/move/fv-mov-4-shared-objects-concurrency/fv-mov-4-cl1-shared-object-races.md

# FV-MOV-4-CL1: Shared Object Race Conditions and Lost Updates

## TLDR

Concurrent transactions targeting the same shared object can interleave reads and writes without a version or sequence check. The second writer overwrites the first writer's result, causing lost updates - for example, two deposits each read `total = 100`, add their amounts independently, and both write back, erasing the first deposit.

## Detection Heuristics

- Identify all shared objects and their mutating functions
- Check whether every mutating function reads, modifies, and writes back a `version: u64` field atomically - a transaction with a stale version should abort
- Look for patterns where two independent values (e.g., `total_deposited` and `user_balance`) are both updated in the same function but neither is version-checked
- In DEX or lending protocols, the primary pool or reserve object is the critical shared state - verify it has version protection
- Also check for `sequence_number` or `nonce` fields used for ordering protection

## False Positives

- Sui's object versioning at the consensus layer prevents two transactions from writing the same object version - the second will fail automatically; assess whether this is sufficient for the specific operation
- Operations are commutative and ordering is irrelevant (e.g., incrementing a pure counter)
- Single-writer pattern enforced - only one authorized function can mutate the object

## reference/move/fv-mov-4-shared-objects-concurrency/fv-mov-4-cl2-hot-potato-flash-loan.md

# FV-MOV-4-CL2: Hot Potato Flash Loan Pattern Errors

## TLDR

The hot potato pattern enforces flash loan repayment by making the receipt struct have no abilities - the compiler requires it to be consumed in the same PTB. Two failure modes: (1) receipt struct has `drop` or `store` ability, breaking enforcement; (2) receipt does not bind to the originating pool ID, allowing cross-pool repayment.

## Detection Heuristics

- Find flash loan receipt / borrow receipt structs and verify their ability list is empty (no `copy`, `drop`, `store`, `key`)
- Verify the receipt struct contains a `pool_id: ID` field storing the originating pool's object ID
- In the `repay` function, verify `assert!(receipt.pool_id == object::id(pool), EPoolMismatch)` is present
- Search for flash loan `start` or `borrow` functions - check whether calling `start` a second time in the same PTB overwrites an existing receipt snapshot without aborting (nested start vulnerability)
- Verify the repay function checks `returned_amount >= receipt.amount + fee`, not just that the receipt is consumed

## False Positives

- Receipt struct intentionally has `key` ability only (creates an object) with no `drop` or `store`; compiler still enforces consumption via object model
- Protocol uses an alternative enforcement mechanism: shared object state flag checked at transaction end
- Pool ID binding present and validated correctly in repay function

## reference/move/fv-mov-4-shared-objects-concurrency/fv-mov-4-cl3-ptb-price-manipulation.md

# FV-MOV-4-CL3: PTB Atomic Price Manipulation

## TLDR

Sui PTBs allow up to 1024 operations in one atomic transaction. An attacker can compose: (1) borrow via flash loan, (2) manipulate an on-chain price oracle or pool reserve ratio, (3) call the vulnerable function at the manipulated price, (4) repay the flash loan - all within a single transaction. This is the Sui-native equivalent of the flash loan attack vector.

## Detection Heuristics

- Identify all price or valuation sources - are they derived from on-chain pool reserve ratios or spot prices? Those are manipulable within a single PTB
- Check whether any critical operation (collateral valuation, swap execution, liquidation trigger) reads price from the same pool that could be atomically manipulated in the same transaction
- Verify that borrow-then-use-same-pool-price paths exist - if so, TWAP or external oracle is required
- Look for missing `min_amount_out` slippage protection - no user-supplied minimum enables sandwich attacks
- Check for `deadline_ms` parameter on all swap and liquidity operations

## False Positives

- All price feeds use TWAP or external oracle (Pyth, Switchboard) that cannot be manipulated within a single transaction
- Slippage protection from user calldata (`min_amount_out`) present on all swap functions
- Deviation check between oracle price and pool price blocks manipulation

## reference/move/fv-mov-4-shared-objects-concurrency/fv-mov-4-cl4-clock-and-time.md

# FV-MOV-4-CL4: Clock Usage and Time Unit Errors

## TLDR

Sui provides reliable on-chain time via `clock::timestamp_ms(&clock)`, which returns time in milliseconds. Common failures: (1) `Clock` not used at all; (2) time constants mixed between milliseconds and seconds; (3) no deadline parameter on time-sensitive operations. These result in locks that are instantly expired or last years, and operations that execute at stale conditions.

## Detection Heuristics

- Search for hardcoded time comparisons not using `clock::timestamp_ms` - these use no reliable on-chain time source
- Find all time constants (lock durations, staleness thresholds, vesting periods) and verify they end with `_MS` and represent milliseconds
- A constant like `one_day = 86400` (without `_MS`) compared against `clock::timestamp_ms` makes the lock 86 seconds; `one_day = 0` (SuiPad finding) makes it instant
- Check every swap, deposit, and withdrawal function for a `deadline_ms: u64` parameter and `assert!(clock::timestamp_ms(&clock) <= deadline_ms)`
- Verify `Clock` is passed as `&Clock` (shared object at address `0x6`) - not created locally

## False Positives

- Constants documented as milliseconds with explicit `_MS` suffix and correct values
- Deadline parameter present and enforced on all time-sensitive functions
- No time-dependent logic in the contract

## reference/move/fv-mov-4-shared-objects-concurrency/readme.md

# FV-MOV-4: Shared Objects and PTBs

Sui's shared object model and programmable transaction blocks (PTBs) introduce concurrency, composability, and flash-loan attack surfaces not present in owned-object designs. Hot potato pattern enforcement and clock usage are the primary correctness mechanisms.

## Cases

- [fv-mov-4-cl1-shared-object-races.md](fv-mov-4-cl1-shared-object-races.md) - Concurrent mutations without version/sequence check cause lost updates
- [fv-mov-4-cl2-hot-potato-flash-loan.md](fv-mov-4-cl2-hot-potato-flash-loan.md) - Flash loan receipt has incorrect abilities or missing pool binding
- [fv-mov-4-cl3-ptb-price-manipulation.md](fv-mov-4-cl3-ptb-price-manipulation.md) - PTB atomicity enables borrow-manipulate-exploit-repay in one transaction
- [fv-mov-4-cl4-clock-and-time.md](fv-mov-4-cl4-clock-and-time.md) - Clock not used, wrong time unit, or missing deadline parameter

## Key Vectors

V31, V32, V34, V35, V36, V40, V41, V42, V50, V51, V52, V53, V58, V127, V128, V129

## reference/move/fv-mov-5-arithmetic-errors

```

```

## reference/move/fv-mov-5-arithmetic-errors/fv-mov-5-cl1-bitwise-and-custom-math.md

# FV-MOV-5-CL1: Bitwise Overflow and Custom Math Library Errors

## TLDR

Move checks standard arithmetic overflow and aborts, but bitwise left-shift (`<<`) is NOT checked - it silently overflows. This vector directly caused the Cetus $223M exploit: a `checked_shlw` function had an incorrect shift limit (256 instead of 192), allowing the shift to overflow and produce a near-zero price, enabling unlimited drain.

## Detection Heuristics

- Search for `<<` operators in all financial or pricing calculations - any left-shift without an explicit `assert!(shift <= safe_max_shift)` is a finding
- For u64 math: maximum safe left shift is 63; for u128: 127; for u256: 255 - any comparison to a higher or miscalculated value is exploitable
- Audit custom math libraries (fixed-point, sqrt, concentrated liquidity math) for boundary inputs: very small amounts, near-MAX values, zero inputs
- Verify custom library functions are tested with fuzz inputs at `u64::MAX`, `0`, `1`, and values near the maximum safe shift
- Double-check any function named `checked_shl*`, `safe_shift`, or similar - the "checked" name may give false confidence

## False Positives

- Explicit overflow check present before every shift: `assert!(shift < TYPE_BITS)` or equivalent
- Bitwise operations on non-financial data (flags, bitmasks) where overflow is not exploitable
- Well-audited external library used with verified correct bounds

## reference/move/fv-mov-5-arithmetic-errors/fv-mov-5-cl2-division-and-underflow.md

# FV-MOV-5-CL2: Division Before Multiplication, Division by Zero, Underflow

## TLDR

Move has no floating-point types. Early division truncates precision: `(amount / total) * price` can round to zero for small amounts, enabling dust exploits. Division by zero causes an abort, which can permanently DoS critical operations. Subtraction without a bounds check aborts on underflow in debug, wraps in release.

## Detection Heuristics

- Search for division operators (`/`) and check whether they appear before any multiplication in the same expression
- Safe pattern: multiply first - `(amount * price) / total` - use u128 intermediates for u64 inputs to prevent overflow
- For any divisor that can reach zero (total supply, pool balance, total shares), verify an explicit `assert!(divisor > 0)` or early return
- Empty pool scenarios are high-risk: `total_supply == 0` is valid state in many protocols at launch or after full withdrawal
- Search for subtraction expressions involving user balances, collected fees, or pool reserves - trace whether the left side can be less than the right side under any input combination

## False Positives

- Multiply-before-divide pattern used consistently
- Zero divisor guard present: `assert!(total_supply > 0, EZeroSupply)` or equivalent
- Checked subtraction: `assert!(a >= b)` before `a - b`
- Saturating or checked math library handles all edge cases

## reference/move/fv-mov-5-arithmetic-errors/fv-mov-5-cl3-cast-truncation.md

# FV-MOV-5-CL3: Narrowing Cast Truncation

## TLDR

Unlike standard arithmetic, Move's type casts (`value as u64`, `value as u8`) silently truncate high bits without aborting. A u128 value of `2^64 + 100` cast to u64 becomes `100`, bypassing amount checks. This is distinct from arithmetic overflow - Move aborts on arithmetic overflow but not on cast truncation.

## Detection Heuristics

- Search for `as u64`, `as u32`, `as u16`, `as u8` in financial calculations
- For each narrowing cast, trace the maximum value the source expression can hold - if it exceeds the target type's maximum, there is no abort, only silent truncation
- Verify an explicit bounds check precedes every narrowing cast: `assert!(value <= (U64_MAX as u128), EOverflow)`
- Financial calculations often use u128 intermediates for precision, then cast back to u64 for storage - these casts must be checked
- Double-scaling bugs (V139): if a value was already multiplied by a precision factor (1e18), casting or dividing to store may silently corrupt the result

## False Positives

- Bounds check present before the cast: `assert!(value <= MAX_U64)`
- Value is provably bounded by contract invariants (e.g., it was previously stored as u64 and only had u64-range operations applied)
- Cast on non-financial data (indices, flags) where truncation has no security implication

## reference/move/fv-mov-5-arithmetic-errors/fv-mov-5-cl4-rounding-and-constants.md

# FV-MOV-5-CL4: Rounding Direction and Wrong Constants

## TLDR

Vault and share protocols must round against the user to prevent slow pool drain: deposits should issue fewer shares (round down), withdrawals should return fewer tokens (round up). First-depositor vault inflation exploits the absence of virtual shares. Hardcoded constants with wrong digit counts (MAX_U64, SECONDS_PER_DAY, precision) cause silent logic errors - Bluefin lost a Critical finding to a MAX_U64 with a missing digit.

## Detection Heuristics

- Trace deposit calculations: shares minted = `(deposit * total_shares) / total_assets` - verify this rounds DOWN (integer division in Move rounds down by default for unsigned; confirm no adjustments reverse this)
- Trace withdrawal calculations: tokens returned = `(shares * total_assets) / total_shares` - this should also round DOWN (fewer tokens to the user)
- Check the initial deposit (empty vault): if `total_shares == 0`, verify virtual shares or a minimum deposit prevents the first-depositor inflation attack
- For all constants, count digits: u64 max is 18446744073709551615 (20 digits); SECONDS_PER_DAY = 86400; SECONDS_PER_YEAR = 31536000; basis points = 10000
- Verify precision constants (1e6, 1e9, 1e12, 1e18) match the token decimals they represent

## False Positives

- Virtual shares / dead shares pattern correctly prevents first-depositor inflation
- Constants match the intended unit with documentation
- Round-trip test verified: `deposit(X) → withdraw(all) <= X`

## reference/move/fv-mov-5-arithmetic-errors/readme.md

# FV-MOV-5: Arithmetic and Type Safety

Move's integer-only arithmetic, silent bitwise overflow, and unsafe narrowing casts are responsible for multiple multi-million dollar exploits. This category covers every class of numeric bug including the Cetus $223M bitwise shift vector.

## Cases

- [fv-mov-5-cl1-bitwise-and-custom-math.md](fv-mov-5-cl1-bitwise-and-custom-math.md) - Bitwise left-shift overflow and custom math library edge cases
- [fv-mov-5-cl2-division-and-underflow.md](fv-mov-5-cl2-division-and-underflow.md) - Division before multiplication, division by zero, integer underflow
- [fv-mov-5-cl3-cast-truncation.md](fv-mov-5-cl3-cast-truncation.md) - Narrowing casts (u128 → u64) silently truncate without abort
- [fv-mov-5-cl4-rounding-and-constants.md](fv-mov-5-cl4-rounding-and-constants.md) - Rounding direction, vault inflation, wrong constant values

## Key Vectors

V61, V62, V63, V64, V65, V66, V67, V68, V69, V90, V98, V135, V136, V137, V138, V139

## reference/move/fv-mov-6-token-accounting

```

```

## reference/move/fv-mov-6-token-accounting/fv-mov-6-cl1-coin-balance-confusion.md

# FV-MOV-6-CL1: Coin and Balance Accounting Confusion

## TLDR

Sui separates `Coin<T>` (an object with a UID, transferable) from `Balance<T>` (an internal primitive value without an object ID). Mixing these without consistent accounting creates ghost balances - coins exist on-chain with no internal tracking, or internal counters exceed actual coins held.

## Detection Heuristics

- Search for `coin::into_balance` and `coin::from_balance` conversion points - verify internal state is updated at every conversion
- Check whether the protocol ever reads its own `Coin<T>` balance using `coin::value` or `balance::value` on an internally held coin - if an external `coin::join` to the vault's coin changes its balance, reward calculations may be manipulable
- Verify vault accounting uses an internal `Balance<T>` field in a shared object, not the raw on-chain coin balance (which is manipulable by direct transfer)
- Trace `coin::split` calls - verify the sum of the two resulting coins equals the input; internal accounting must track both halves
- For protocols with multiple asset pools, check that `Balance<USDC>` cannot be credited to a `USDT` pool due to missing phantom type checks

## False Positives

- Clear separation enforced: `Balance<T>` for internal state, `Coin<T>` for user-facing I/O only
- `coin::into_balance` / `coin::from_balance` used consistently with no direct coin balance reads for business logic
- Invariant: sum of all internal `Balance<T>` equals actual on-chain token holdings, verified by tests

## reference/move/fv-mov-6-token-accounting/fv-mov-6-cl2-supply-invariant-violations.md

# FV-MOV-6-CL2: Supply Invariant Violations

## TLDR

Token/share supply must remain invariant: every mint has a corresponding deposit, every burn releases the corresponding asset. Violations include minting shares without receiving tokens, burning tokens without releasing collateral, and allowing self-transfers that trigger fee/reward snapshots without economic activity.

## Detection Heuristics

- Trace every call to mint functions - verify each requires a corresponding `Coin<T>` deposit of equal value
- Trace every burn - verify a `Coin<T>` withdrawal of proportional value is released to the user atomically
- Check whether `assert!(shares > 0)` is present after every share calculation - zero-share mints allow side-effect-only deposits
- Search for `transfer::transfer(coin, ctx.sender())` immediately after `balance::withdraw` - verify the withdrawn amount matches the shares redeemed
- Trace total supply update: every `mint` increments total supply; every `burn` decrements it; verify this happens in the same function, not in a separate step

## False Positives

- Mint/burn pair always executed atomically in the same function with amount validation
- Supply invariant assertion at end of deposit/withdrawal: `assert!(total_shares * share_price == total_assets)` (approximately)
- Zero-share check present: `assert!(shares > 0, EZeroShares)`

## reference/move/fv-mov-6-token-accounting/fv-mov-6-cl3-fee-accounting.md

# FV-MOV-6-CL3: Fee Accounting Errors

## TLDR

Fee bypass occurs when an alternate code path (emergency withdraw, admin withdraw, batch operation) skips the fee calculation. Non-atomic fee deduction allows a PTB to skip the fee step. Missing fee withdrawal function locks protocol revenue permanently.

## Detection Heuristics

- Map all exit paths from a vault or pool: normal withdrawal, emergency withdrawal, admin withdrawal, batch withdrawal - verify every path calls the same fee calculation function
- Check whether fee deduction is in a separate function from the main operation; in a PTB, the caller controls execution order and could omit the fee step
- Search for fee collection logic that increments a `fee_balance` or `accumulated_fees` counter - verify a corresponding `withdraw_fees` or `claim_fees` function exists and is accessible to the admin
- Verify fee amount calculation uses consistent pre-fee vs post-fee amounts throughout - mixing gross and net amounts creates discrepancies
- `fee_balance == 0` with no withdrawal function in a protocol that claims to collect fees is a strong signal of a missing function

## False Positives

- Single fee calculation helper called from every exit path
- Fee and principal deducted atomically within the same function with no separable steps
- Fee withdrawal function exists and is admin-gated

## reference/move/fv-mov-6-token-accounting/fv-mov-6-cl4-dust-and-cleanup.md

# FV-MOV-6-CL4: Dust, Orphaned Dynamic Fields, and Destroy-Zero on Non-Zero

## TLDR

Three related cleanup failures: (1) tiny token amounts ("dust") prevent object closure, enabling attacker to permanently lock victim accounts; (2) parent objects deleted without removing dynamic fields, permanently orphaning stored values; (3) `balance::destroy_zero` called on a potentially non-zero balance, either aborting or silently destroying funds.

## Detection Heuristics

- Search for object close/delete functions and check whether they call `balance::destroy_zero` - trace whether the balance could be non-zero at that point due to dust from fee rounding or partial operations
- Identify every place where dynamic fields are added to objects (`dynamic_field::add`, `dynamic_object_field::add`) and verify a cleanup path exists that removes all of them before the parent is modified or deleted
- Check whether the protocol has a force-close or dust-sweep mechanism for accounts with very small token balances
- Look for griefing vectors: can an attacker send a dust amount to any account to prevent it from being closed? If so, the dust threshold or force-close is missing
- Also check `table::destroy_empty` - calling it on a non-empty table aborts; verify the table is empty before destruction

## False Positives

- Dust threshold defined: balances below the threshold are ignored or swept on close
- Dynamic field cleanup function removes all fields before any object modification
- `balance::destroy_zero` only called after verified-zero balance from prior explicit check

## reference/move/fv-mov-6-token-accounting/readme.md

# FV-MOV-6: Token and Coin Accounting

Sui separates `Coin<T>` (object with ID) from `Balance<T>` (internal value without ID). Confusing the two, violating supply invariants, or mishandling fees creates silent accounting errors and permanent fund loss.

## Cases

- [fv-mov-6-cl1-coin-balance-confusion.md](fv-mov-6-cl1-coin-balance-confusion.md) - `Coin<T>` and `Balance<T>` mixed without consistent accounting
- [fv-mov-6-cl2-supply-invariant-violations.md](fv-mov-6-cl2-supply-invariant-violations.md) - Mint without deposit, burn without release, or self-transfer side effects
- [fv-mov-6-cl3-fee-accounting.md](fv-mov-6-cl3-fee-accounting.md) - Fee bypass via alternate paths, non-atomic deduction, or no withdrawal function
- [fv-mov-6-cl4-dust-and-cleanup.md](fv-mov-6-cl4-dust-and-cleanup.md) - Dust locks objects, orphaned dynamic fields, `destroy_zero` on non-zero balance

## Key Vectors

V70, V71, V74, V75, V76, V77, V79, V81, V82, V83, V84, V85, V86, V87, V131, V140

## reference/move/fv-mov-8-advanced-patterns

```

```

## reference/move/fv-mov-8-advanced-patterns/fv-mov-8-cl1-generic-type-confusion.md

# FV-MOV-8-CL1: Generic Type Confusion and Phantom Type Bypass

## TLDR

Unvalidated generic type parameters are the number-one critical vulnerability across real Move audits. An attacker creates a worthless `Coin<FakeUSDC>` and passes it to any function accepting `Coin<T>` without type validation, borrowing real assets against fake collateral. Phantom types on generic capabilities (`RoleCap<T>`) with no concrete type check enable role confusion.

## Detection Heuristics

- Find every function with a generic type parameter `<T>` that handles `Coin<T>`, `Balance<T>`, or any value-bearing type - verify `T` is validated against a stored type identifier or a whitelist
- Safe pattern: pool or vault struct uses `Pool<T>` - the phantom type on the container forces the function to only accept `Coin<T>` for the same `T`; verify this pattern is used consistently
- Search for `type_info::type_of::<T>()` comparisons - verify they compare against a stored expected type, not a hardcoded string that could be bypassed
- For role capabilities `RoleCap<T>` used in access control: verify the function asserts `T` is the expected concrete type, not just any type satisfying the constraint
- Navi Protocol and Econia are named examples - any lending or AMM function accepting generic coins without pool-level phantom type binding is high risk

## False Positives

- Pool/vault struct uses phantom type binding: `Pool<T>` forces all operations to use matching `Coin<T>`
- Explicit type registry: `assert!(type_info::type_of<T>() == stored_type)`
- Function is `public(package)` and only called by trusted internal code with statically verified types

## reference/move/fv-mov-8-advanced-patterns/fv-mov-8-cl2-table-collection-bugs.md

# FV-MOV-8-CL2: Table and Collection Bugs

## TLDR

`table::add` aborts on duplicate keys - missing existence checks cause DoS when a user interacts a second time. Move vectors are limited to ~1000 entries - unbounded vectors DoS when full. Both create permanent denial-of-service.

## Detection Heuristics

- Search for every `table::add` and `dynamic_field::add` call - verify a preceding `table::contains` / `dynamic_field::exists_` check
- Safe pattern: `if (table::contains(&t, key)) { table::remove(&t, key); }; table::add(&mut t, key, value)` for upsert semantics
- For user registration, staking position tracking, or whitelist patterns: check whether the same user can trigger an add twice (e.g., via two deposits before first is settled)
- Identify all `vector<T>` used for unbounded user data (registrations, positions, orders) - these should be `sui::table_vec::TableVec<T>` or `sui::table::Table` instead
- Verify loops over vectors in public functions have a bounded count - loops over growing unbounded vectors eventually exceed gas limits and become permanently uncallable

## False Positives

- `table::contains` check present before every `table::add`
- `TableVec` or `Table` used instead of plain `vector` for unbounded user data
- Vector size explicitly capped with `assert!(vector::length(&v) < MAX_SIZE)` before push

## reference/move/fv-mov-8-advanced-patterns/fv-mov-8-cl3-flash-loan-receipt-binding.md

# FV-MOV-8-CL3: Flash Loan Receipt Pool Binding and Nested Start

## TLDR

Two confirmed critical flash loan findings from real audits: (1) receipt struct has no `pool_id` field - repay function accepts receipts from Pool A to settle loans from Pool B (Cetus, Dexlyn); (2) flash loan `start` callable multiple times in the same PTB - second call resets the balance snapshot so `finish` validates against the wrong baseline, allowing underpayment.

## Detection Heuristics

- Check the flash loan receipt struct definition for a `pool_id: ID` field
- In the `repay` or `return_flash_loan` function, verify `assert!(receipt.pool_id == object::id(pool))`
- For the nested start vulnerability: check the `start` or `borrow` function - does it check for an existing active loan (e.g., `assert!(!pool.loan_active)`) before creating the receipt?
- If the receipt snapshot stores the pre-loan balance, verify a "loan active" mutex prevents the snapshot from being overwritten by a second `start` call
- Trace whether `finish` or `repay` reads the actual current balance and compares it to the receipt's recorded amount - or trusts the receipt's amount directly without re-reading state

## False Positives

- `pool_id: ID` field present in receipt struct and validated in repay function
- `start` function checks for existing active loan and aborts if one exists
- `finish` reads actual balance from the pool object, not from receipt fields

## reference/move/fv-mov-8-advanced-patterns/fv-mov-8-cl4-real-world-exploits.md

# FV-MOV-8-CL4: Transposed Returns, Self-Referential Assertions, and Accumulator Ordering

## TLDR

Three real-world High/Critical patterns: (1) multi-return functions with same-type values returned in wrong order - silently corrupts all callers (KriyaDEX `get_reserves`); (2) `assert!(x == x)` tautology - always passes, validates nothing (Hop Aggregator version check); (3) reward accumulator updated after balance change instead of before - incorrect distribution (Thala Labs, Critical, 2 findings).

## Detection Heuristics

- Find all functions returning two or more values of the same type (e.g., `(u64, u64)`) - compare the function's documented semantics to its implementation to verify return order matches caller expectations
- Search for `assert!(x == x)` or any assertion where both sides of `==` resolve to the same expression - these are always true and validate nothing; the intended check (comparing to a parameter or expected constant) is missing
- For accumulator update ordering: in every stake/unstake/claim function, verify the very first statement updates the reward accumulator before any balance read or write
- Search for copy-paste patterns where version or state checks compare `obj.field == obj.field` instead of `obj.field == expected_value`
- Cross-module calls that mutate state (e.g., accrue interest on any interaction) can cause the caller's pre-read value to become stale - trace whether the caller re-reads after the call

## False Positives

- Multi-return functions returning different types (no transposition risk)
- Assertion correctly compares `obj.field` against an external parameter or constant
- Accumulator update is provably the first operation in all code paths entering the function

## reference/move/fv-mov-8-advanced-patterns/readme.md

# FV-MOV-8: Advanced Patterns and Real-World Exploits

This category captures patterns that emerged from 1141 real findings across 200+ Move audits, including named exploits (Cetus, Baptswap, Bluefin, KriyaDEX, Thala Labs). These are high-signal, high-severity patterns confirmed in production codebases.

## Cases

- [fv-mov-8-cl1-generic-type-confusion.md](fv-mov-8-cl1-generic-type-confusion.md) - Unvalidated generic `T` allows fake token injection (Navi, Econia pattern)
- [fv-mov-8-cl2-table-collection-bugs.md](fv-mov-8-cl2-table-collection-bugs.md) - Table duplicate key DoS, vector size limits, orphaned collections
- [fv-mov-8-cl3-flash-loan-receipt-binding.md](fv-mov-8-cl3-flash-loan-receipt-binding.md) - Receipt without pool_id binding; nested start resets snapshot (Cetus, Dexlyn)
- [fv-mov-8-cl4-real-world-exploits.md](fv-mov-8-cl4-real-world-exploits.md) - Transposed return values, self-referential assertions, accumulator ordering (KriyaDEX, Hop, Thala)

## Key Vectors

V72, V73, V121, V124, V125, V126, V127, V129, V130, V131, V132, V135, V136, V141, V142, V143

## reference/move/protocols

```

```

## reference/move/protocols/amm-dex.md

# AMM and DEX Security Patterns (Sui/Move)

> Applies to: Cetus-style CLMM protocols, KriyaDEX, Turbos, Aftermath AMM, DeepBook, any Move module implementing constant-product or curve invariant swap pools, liquidity managers, position managers, flash swap providers on Sui

## Protocol Context

AMM and DEX protocols on Sui operate with shared pool objects accessible concurrently by multiple transactions in the same checkpoint. Unlike EVM where pool state is modified sequentially, Sui's parallel execution model means two transactions touching different pool objects can proceed simultaneously, but two transactions touching the same pool object are sequenced by the Sui runtime. Flash swaps on Sui are enabled by Sui's hot-potato pattern: a module borrows liquidity as a non-droppable non-storable receipt that must be returned (with repayment) before the transaction block ends. The Cetus exploit (2024) demonstrated that concentrated liquidity math with overflow in tick arithmetic can produce incorrect prices that drain the pool, highlighting the importance of Move integer safety in invariant computation.

## Bug Classes

---

### Missing Slippage Protection (ref: fv-mov-8-cl1)

**Protocol-Specific Preconditions**

- Swap function accepts `min_amount_out: 0` without rejection; caller can set zero slippage intentionally or by omission
- Protocol's Move SDK or TypeScript SDK defaults to zero slippage for simplicity; integrators copy the defaults without adjusting
- Automated keeper paths (liquidation, rebalancing, compound) call swap with hardcoded zero minimum

**Detection Heuristics**

- Find all `swap` and `swap_with_partner` entry functions; check whether `min_amount_out == 0` is rejected by an `assert!`
- Verify that slippage parameters are caller-supplied rather than computed from pool state at execution time
- Check protocol's automated compound or harvest entry points for hardcoded zero minimums
- Verify `assert!(amount_out >= min_amount_out, ERROR_SLIPPAGE_EXCEEDED)` is applied to the actual output after fees, not before

**False Positives**

- Zero slippage explicitly rejected: `assert!(min_amount_out > 0, ERROR_ZERO_SLIPPAGE)`
- Protocol's automated paths use an oracle-derived minimum with a configurable maximum deviation tolerance

---

### Pool Invariant Violation in CLMM Tick Arithmetic (ref: fv-mov-5-cl1)

**Protocol-Specific Preconditions**

- Concentrated liquidity math involves tick-indexed price computations using fixed-point arithmetic; overflow in tick boundary calculations can produce an incorrect `sqrt_price_x64` value
- Swap across tick boundaries accumulates rounding errors; multiple small swaps may produce a different state than one large swap, violating the invariant
- Tick spacing constraints not enforced consistently between position creation and liquidity addition

**Detection Heuristics**

- Review all arithmetic operations on `sqrt_price_x64` and tick values for integer overflow; Cetus exploit involved overflow in tick-to-sqrt-price conversion producing an exploitable price
- Verify that after a swap crossing one or more tick boundaries, the pool's effective price matches `token_b_reserve / token_a_reserve` at the current tick
- Check whether `u128::MAX` overflow is possible in any intermediate step of the invariant computation; Move's default arithmetic aborts on overflow in debug mode but may wrap in release
- Verify the invariant check is applied after multi-tick swaps, not only within a single tick range

**False Positives**

- All fixed-point arithmetic uses checked or saturating operations with explicit error codes on overflow
- Invariant verified after every swap operation with a tolerance for integer rounding

---

### Flash Swap Hot-Potato Escape (ref: fv-mov-3-cl1, fv-mov-8-cl3)

**Protocol-Specific Preconditions**

- Flash swap receipt (hot potato) can be destroyed via a public `destroy` function rather than only through the repayment path
- Repayment amount check uses the pool's current balance rather than the borrowed amount plus fee, allowing a re-donation to satisfy the check
- Flash swap receipt wrapped in another struct or stored in a dynamic field, bypassing Move's hot-potato linearity guarantee

**Detection Heuristics**

- Find the flash swap receipt struct definition; verify it has no `drop` ability; any struct with `drop` ability can be discarded without repayment
- Confirm the only function consuming (destroying) the receipt is the repayment function; no other function takes the receipt by value
- Verify the repayment check compares `pool.balance - initial_balance >= borrow_amount + fee`, not just `pool.balance >= threshold`
- Check whether the receipt can be stored in a `Table` or other persistent storage between PTB steps, enabling a deferred repayment that escapes the PTB boundary

**False Positives**

- Receipt struct has no `drop` or `store` ability; it can only be consumed by the pool's repayment function
- Repayment amount check is based on the borrowed amount stored in the receipt, not derived from pool balance

---

### Shared Object Concurrency in LP Operations

**Protocol-Specific Preconditions**

- Pool's shared object is read by two concurrent transactions: one adding liquidity and one swapping; if the liquidity addition modifies `total_supply` non-atomically, the concurrent swap may observe an intermediate state
- Sui's object-level locking serializes access to a single shared pool object, so true concurrent modification is not possible; however, sequenced transactions in the same checkpoint can still observe state from a prior transaction that has not yet been reflected in their local view

**Detection Heuristics**

- Verify that every modification to pool reserves, total supply, and fee accumulators occurs atomically within a single Move function call with no view reads between mutations
- Check whether LP token minting and pool reserve update occur in separate steps that could be interleaved by another transaction between checkpoints
- For multi-pool operations (cross-pool arbitrage, routing), verify each pool's state is read fresh at the start of each operation on that pool

**False Positives**

- All pool state modifications are atomic within a single transaction; Sui's object-level locking prevents interleaving within a single checkpoint
- Protocol uses owned objects for intermediate computation and only modifies the shared pool object once per transaction

## reference/move/protocols/governance.md

# Governance and Authority Security Patterns (Sui/Move)

> Applies to: Move-based governance modules, AdminCap and UpgradeCap management, DAO voting on Sui, proposal-and-vote modules, protocol parameter updates, multisig-controlled protocols, any Move module managing privileged capabilities, bridge governance, ZK-based governance verification

## Protocol Context

Governance on Sui differs from EVM governance in a fundamental way: privileged operations are gated by capability objects (`AdminCap`, `UpgradeCap`, `GovernanceCap`) rather than by address-based role checks. The security model depends on who holds these capability objects and how they can be transferred. `UpgradeCap` is particularly important: holding it grants the ability to upgrade any module in the package, and if it is stored as a shared object or wrapped in a governance module, the upgrade path must be secured by a timelock and multisig. Flash governance is possible on Sui via PTB: borrow governance tokens, vote, redeem - unless vote weight is anchored to a historical snapshot rather than current balance.

## Bug Classes

---

### UpgradeCap Not Protected (ref: fv-mov-3-cl1)

**Protocol-Specific Preconditions**

- `UpgradeCap` stored as a shared object accessible to any transaction, or held by a single-keypair address rather than a multisig or governance module
- `UpgradeCap` with `policy = COMPATIBLE` allows adding new functions and changing behavior; policy not restricted to `ADDITIVE_ONLY` or `DEP_ONLY` to limit upgrade scope
- No timelock between upgrade proposal and execution; upgrade can be applied in a single transaction from the `UpgradeCap` holder

**Detection Heuristics**

- Find the `UpgradeCap` object at deployment; check its owner: is it a single address, a multisig, or a governance module?
- Check the `UpgradeCap.policy` field: `0` (compatible), `128` (additive-only), `192` (dep-only); less restrictive policies allow more dangerous upgrades
- Verify the upgrade path: if `UpgradeCap` is wrapped in a governance module, check the voting threshold, timelock delay, and cancellation mechanism
- Check whether `package::make_immutable(upgrade_cap)` has been called to permanently lock the package; if so, upgrades are impossible

**False Positives**

- `UpgradeCap` is wrapped in a governance module with a documented threshold, independent signers, and an enforced timelock
- `package::make_immutable` has been called; the `UpgradeCap` is consumed and the package is permanently immutable

---

### AdminCap Transfer Without Two-Step Confirmation (ref: fv-mov-3-cl1)

**Protocol-Specific Preconditions**

- `AdminCap` transfer is a single-step operation: current holder calls `transfer::transfer(admin_cap, new_address)` and the cap immediately moves
- A typo in `new_address` permanently loses the `AdminCap`; no recovery mechanism exists
- No two-step pattern (propose + accept) enforced; the new address cannot reject or confirm the transfer

**Detection Heuristics**

- Find all functions that transfer `AdminCap` or equivalent privileged capability objects; check whether they require the new owner to explicitly accept via a separate transaction
- Verify a two-step pattern: (1) `propose_admin_transfer(cap, new_address)` stores pending address, (2) `accept_admin_transfer(cap)` requires signature of the pending new address
- Check whether there is an emergency revoke function that allows the current holder to cancel a pending transfer before it is claimed
- Verify that the pending new address is validated as non-zero before being stored

**False Positives**

- Two-step transfer pattern implemented: pending address stored, acceptance requires the new address's signature
- Transfer is mediated by a governance module requiring a vote; no single-party transfer is possible

---

### Flash Governance Vote via PTB

**Protocol-Specific Preconditions**

- Vote weight read from the voter's current token balance at voting time rather than a historical snapshot taken before the voting period opened
- PTB allows: borrow governance tokens, vote with inflated weight, sell tokens, repay flash loan - all in one atomic transaction block
- No minimum holding period before a token holder becomes eligible to vote

**Detection Heuristics**

- Check how vote weight is computed in the voting function: is it `coin::value(&voter_coin)` at call time, or a snapshot balance from a past epoch?
- Verify that voting weight comes from a checkpoint object or a snapshot balance stored at proposal creation time, not from live coin balance
- Check whether token transfers are blocked during an active vote period; if not, balance at vote submission time may differ from the balance at any reference point
- Calculate whether a flash-loan-financed vote can exceed the quorum threshold; if so, flash governance is a viable attack

**False Positives**

- Vote weight derived from a snapshot taken before voting opened: balances are checkpointed in a `VoterCheckpoint` object at proposal creation
- Governance tokens have a transfer lock during active vote periods; moving tokens while a vote is open is rejected by the token module

---

### Proposal Execution Without Timelock

**Protocol-Specific Preconditions**

- Governance proposals execute immediately upon reaching the vote threshold
- `proposal.execute_after_ms` is zero or not checked before execution
- Emergency execution path bypasses the normal timelock without requiring a higher approval threshold

**Detection Heuristics**

- Find the proposal execution function; verify `assert!(clock::timestamp_ms(clock) >= proposal.execute_after_ms, ERROR_TIMELOCK_ACTIVE)` before any state mutation
- Check that `execute_after_ms` is set to `passed_at_ms + TIMELOCK_DELAY_MS` when the vote passes, not when the proposal was created
- Verify there is a cancellation function callable during the timelock window that requires a governance vote or a privileged cap to cancel a malicious proposal
- Check whether `TIMELOCK_DELAY_MS` is a mutable parameter; if so, verify reducing it requires its own governance proposal subject to the same timelock

**False Positives**

- Execution requires `clock::timestamp_ms(clock) >= proposal.execute_after_ms` enforced as the first check in the execution function
- Timelock delay is a module constant that cannot be changed without a package upgrade; its value is reviewed at each upgrade

---

### Bridge Message Replay

**Protocol-Specific Preconditions**

- Bridge contract processes inbound messages without storing or checking a message hash or nonce, allowing the same bridge transfer to be submitted multiple times
- Each accepted submission mints tokens on the Sui side without checking whether the originating lock event was already processed
- No `Table<vector<u8>, bool>` or equivalent processed-message registry maintained in the bridge contract's shared state

**Detection Heuristics**

- Find the bridge claim or mint function; check whether `assert!(!table::contains(&processed_messages, message_hash), ERROR_ALREADY_PROCESSED)` is called before any token minting
- Verify that `message_hash` is stored: `table::add(&mut processed_messages, message_hash, true)` after successful processing, not only on error paths
- Check that `message_hash` is derived from immutable fields of the bridge message (source chain ID, source tx hash, amount, recipient) and not from mutable fields the submitter controls
- Verify the processed-message table is a shared object accessible to all validators; using an owned object allows the holder to reset it

**False Positives**

- Every bridge claim checks for and stores the message hash before any state mutation; the table is append-only with no deletion path
- Bridge uses a committee-signed attestation with multi-sig threshold; verify committee key decentralization and threshold adequacy

---

### ZK Proof Nullifier Not Enforced

**Protocol-Specific Preconditions**

- Protocol uses ZK proofs (Groth16, PLONK) for anonymous governance votes or private bridge claims; no on-chain nullifier table maintained
- Same proof can be submitted multiple times; each submission counts as a valid vote or valid claim
- Nullifier derived from the ZK proof is not stored and checked before accepting each proof

**Detection Heuristics**

- Find the ZK proof verification call; check whether the function also reads a nullifier from the proof and verifies it is not already in a `Table<vector<u8>, bool>` or equivalent
- Verify: `assert!(!table::contains(&nullifiers, proof.nullifier), ERROR_PROOF_REPLAYED)` before accepting, and `table::add(&mut nullifiers, proof.nullifier, true)` after
- Check the nullifier derivation: it should be determined by the private input (secret key, deposit note) and not by any public input that the submitter controls
- Verify the nullifier table is a shared object (or stored in a globally accessible config) not an owned object that could be reset by its holder

**False Positives**

- Nullifier table maintained and checked on every proof acceptance; proof replay returns a specific error code
- Protocol uses a commitment-based scheme where each commitment can only be consumed once; nullifier is implicit in the commitment structure

## reference/move/protocols/lending.md

# Lending and Vault Security Patterns (Sui/Move)

> Applies to: Scallop, Navi Protocol, Suilend, Bucket Protocol, any Move module implementing collateralized lending, overcollateralized borrowing, CDP (collateralized debt position), flash loan providers, yield vaults, share-based deposit pools on Sui

## Protocol Context

Lending protocols on Sui use Move's capability pattern extensively: a `LendingPoolCap` or similar object gates privileged operations. Liquidation is synchronous (within a single PTB), unlike async models, which means collateral seizure and debt repayment are atomic. The key risks are oracle-dependent health factor accuracy, share-based vault inflation, and liquidation incentive gaps for dust positions. Flash loans on Sui follow the hot-potato pattern and are structurally sound if the receipt is correctly non-droppable; the main risk is the repayment validation logic.

## Bug Classes

---

### Vault Share Inflation via Donation

**Protocol-Specific Preconditions**

- Vault's `total_assets()` reads the live balance of its treasury or reserve coin rather than a tracked internal counter
- First depositor can manipulate the share price by donating coins directly to the vault's treasury object, increasing `total_assets` without increasing `total_supply`
- Subsequent depositor's share calculation: `shares = deposit * total_supply / total_assets` rounds to 0, causing the deposit to be absorbed by the attacker

**Detection Heuristics**

- Find the vault's `total_assets` computation; check whether it calls `balance::value(&vault.reserves)` on a live object or reads a tracked `deposited_amount` field
- Check the first deposit case: if `total_supply == 0`, verify a minimum number of dead shares are minted to a zero address or a virtual offset is applied
- Verify that the `deposit` function uses `(deposit + VIRTUAL) * (total_supply + VIRTUAL) / (total_assets + VIRTUAL)` or an equivalent inflation-resistant formula
- Test: deposit 1 MIST, donate 1e9 MIST to the vault reserves object directly, attempt a 1e9 MIST deposit; verify the second depositor receives non-zero shares

**False Positives**

- Vault tracks deposited assets in a dedicated `u64` field updated only through the deposit function; coin donations to the reserves object do not affect `total_assets()`
- Virtual shares offset applied at initialization makes the inflation attack require a donation larger than any realistic attack budget

---

### Oracle-Dependent Health Factor Errors

**Protocol-Specific Preconditions**

- Health factor computation reads oracle price without staleness or confidence check; stale prices from a halted oracle feed keep a position appearing healthy when it is actually undercollateralized
- Health factor computed before accruing interest, causing understated debt and preventing correct liquidation
- Decimal normalization error in oracle price consumption: oracle returns price in a different decimal scale than the protocol's internal accounting, causing systematic mis-pricing of collateral

**Detection Heuristics**

- Trace the health factor calculation from collateral value through oracle price to the comparison against liquidation threshold; verify staleness and confidence are checked at the oracle read
- Verify that `accrue_interest()` is called as the first step in any health factor computation, before oracle reads or balance checks
- Check all oracle price normalizations: `price * (10 ^ (protocol_decimals - oracle_decimals))`; verify this computation is correct for every supported asset including those with non-18 decimals
- Verify that the liquidation threshold comparison is: `collateral_value * ltv_ratio > debt_value * 10000`, not an inverted or incorrect inequality

**False Positives**

- Health factor computation uses `get_price_no_older_than` with an appropriate max age and confidence check before every use
- Interest accrual is always the first operation in health factor computation, enforced by a wrapper function called at every entry point

---

### Liquidation Dust Position Griefing

**Protocol-Specific Preconditions**

- Liquidation bonus is a percentage of the collateral value; for small positions, the bonus is less than the on-chain transaction fee, leaving the position permanently unliquidatable
- No minimum position size enforced; positions can be opened at any collateral value
- Partial repayment can reduce a position to below the minimum viable size, creating a permanent dust residue

**Detection Heuristics**

- Calculate the minimum collateral value at which the liquidation bonus exceeds the maximum expected Sui transaction gas fee
- Check whether `open_position` and `repay` enforce a minimum position size: `assert!(collateral_value >= MIN_COLLATERAL_USD, ERROR_TOO_SMALL)`
- Verify that after a partial liquidation, the remaining position either meets the minimum size or is fully liquidated; no partial liquidation should leave a dust residue
- Check for a protocol-sponsored cleanup function that can forcibly close dust positions at no liquidation bonus

**False Positives**

- Minimum position size enforced at creation, repayment, and liquidation; all three entry points have the same minimum check
- Protocol charges a dust fee at position creation that makes opening small positions economically unattractive

---

### Self-Liquidation for Bonus Extraction

**Protocol-Specific Preconditions**

- Liquidator and borrower can be the same address; no check in the liquidation entry function
- Liquidation bonus paid to the liquidator on top of the debt repaid; if self-liquidation is allowed, the borrower can pay their own debt and receive back their collateral plus the bonus
- Liquidation fee charged to the borrower does not fully offset the bonus paid to the liquidator when they are the same party

**Detection Heuristics**

- Find the liquidation entry function; check for `assert!(liquidator_address != borrower_address, ERROR_SELF_LIQUIDATION)`
- Calculate the net profitability of self-liquidation: `bonus_received - liquidation_fee_paid`; if positive, self-liquidation is profitable
- Check whether the protocol explicitly documents self-liquidation as an intended feature; if not, it is an oversight

**False Positives**

- Protocol explicitly prohibits self-liquidation via an assert on liquidator != borrower
- Liquidation fee is equal to or greater than the bonus, making self-liquidation economically neutral or negative

## reference/move/protocols/oracle.md

# Oracle Integration Security Patterns (Sui/Move)

> Applies to: Pyth on Sui, Switchboard on Sui, custom oracle implementations, Move-based oracle consumers, lending collateral pricing, perpetuals mark price feeds, synthetic asset minting, any Move module reading external price data on Sui

## Protocol Context

Oracle integrations on Sui use the Sui object model, where price feed data is accessed through shared objects or owned capability objects. Pyth on Sui exposes price data as a `PriceInfoObject` (shared object) with a hot-potato pattern for pulling and verifying price updates in a single PTB. A key Sui-specific risk is fake oracle injection: since Move's type system enforces struct provenance, an attacker cannot pass a counterfeit `PriceFeed` of the official Pyth type to a module, but they can pass an object of a homonymous type from a malicious module if the consumer validates only struct layout rather than the originating module address. Staleness and confidence validation must be applied explicitly at every consumption site; the Pyth SDK provides helpers but they must be called correctly.

## Bug Classes

---

### Stale Price Acceptance

**Protocol-Specific Preconditions**

- `PriceInfoObject` read via `pyth::price_info::get_price_unsafe` or without calling `pyth::price::get_price_no_older_than` with a clock reference
- `Price.timestamp` not compared against `clock::timestamp_ms(clock) / 1000` with a staleness tolerance
- Switchboard `Aggregator` object read without checking `latest_confirmed_round.round_open_timestamp`

**Detection Heuristics**

- Find all calls to `pyth::price_feeds::get_price` or equivalent; check whether the next statement validates the returned `Price.timestamp`
- Verify that `get_price_no_older_than(price_info, clock, max_age_secs)` is used rather than `get_price_unsafe` at every price consumption site
- For Switchboard, check whether `aggregator.latest_confirmed_round.round_open_timestamp` is compared against `clock::timestamp_ms(clock) / 1000` within an acceptable window
- Verify that the maximum staleness tolerance is stored in a mutable admin-controlled config and is appropriate for the protocol's liquidation time horizon

**False Positives**

- All price consumption sites use `get_price_no_older_than` with an appropriate max age
- Custom staleness check: `assert!(clock::timestamp_ms(clock) / 1000 - price.timestamp <= max_staleness_secs, ERROR_STALE_PRICE)` applied immediately after every price read

---

### Oracle Confidence Interval and Status Ignored

**Protocol-Specific Preconditions**

- `price.conf` not validated against a maximum acceptable ratio before using `price.price`
- Pyth `Price` struct has both `price` (i64) and `conf` (u64) fields; only `price` is read and used in collateral calculations
- No circuit breaker for extreme price deviation; a malfunctioning oracle can report an arbitrarily low or high value without on-chain rejection

**Detection Heuristics**

- Find all sites reading `price.price`; check whether `price.conf` is also read and validated: `assert!(price.conf * 100 / (price.price as u64) <= max_conf_bps, ERROR_LOW_CONFIDENCE)`
- Check whether the Pyth `PriceFeed.status` field is validated as `PRICE_STATUS_TRADING` before use
- Verify the protocol has configurable confidence and deviation bounds stored in a mutable `OracleConfig` object
- Check for minimum and maximum price bounds that reject implausible oracle values regardless of confidence

**False Positives**

- Protocol applies both confidence ratio and status checks before every price use with configurable thresholds
- Price used in a context where confidence validation is documented as intentionally relaxed (e.g., only for monitoring, not for protocol state changes)

---

### Fake Oracle Object Injection

**Protocol-Specific Preconditions**

- Protocol's oracle consumer function accepts a generic `&PriceInfoObject` by type but does not validate the object's ID against a registered expected address stored in a config object
- A malicious actor deploys a module that re-exports the same struct type from the Pyth package (not possible in Move due to module provenance) or passes an object from a forked/unofficial Pyth deployment
- Protocol uses an unofficial Pyth deployment address (testnet vs mainnet, unofficial deployment) without verifying the `PriceInfoObject`'s parent package address

**Detection Heuristics**

- Find every function accepting a Pyth `PriceInfoObject` or Switchboard `Aggregator`; check whether the object's ID is compared against a stored expected ID from a config: `assert!(object::id(price_info) == config.btc_price_feed_id, ERROR_WRONG_ORACLE)`
- Verify that oracle object IDs are registered through an admin-gated function, not hardcoded in the bytecode (hardcoding makes upgrades difficult but is more secure)
- Check whether the module validates the Pyth state object passed to the update call; using an unofficial Pyth deployment means the price authority chain is not the official Pyth network

**False Positives**

- Oracle object ID validated against a governance-controlled `OracleConfig` at every price consumption site
- Protocol uses only the official Pyth deployment addresses documented by the Pyth network and these are verified at deployment

---

### Single Oracle Source Dependency

**Protocol-Specific Preconditions**

- Protocol depends on a single oracle source for all price-sensitive operations with no fallback
- Oracle source goes offline or reports a faulty price; no circuit breaker halts protocol operations until the oracle is restored
- Single oracle source for liquidation pricing means a brief oracle failure window can be exploited to prevent legitimate liquidations (too-stale price) or force illegitimate ones (manipulated price)

**Detection Heuristics**

- Count the number of distinct oracle sources used for any single asset price; one source with no fallback is a medium finding; one source for liquidation pricing is high
- Check whether the protocol has a fallback oracle that is used when the primary oracle is stale
- Verify there is a pause mechanism triggered when all oracle sources are stale, rather than using the last known price indefinitely
- Check whether multiple oracle sources are averaged or the minimum/maximum is taken; document the aggregation method and its security implications

**False Positives**

- Protocol uses two independent oracle sources (e.g., Pyth + Switchboard) and takes the median or the more conservative value
- Protocol has a documented and tested oracle failure mode that halts price-dependent operations when all sources are stale

## reference/move/protocols/staking.md

# Staking and Reward Security Patterns (Sui/Move)

> Applies to: Aftermath liquid staking, Volo liquid staking, SuiFrens staking, Sui validator staking wrappers, Move-based reward distribution modules, veToken staking, any Move module issuing staking receipts or distributing rewards proportional to staked balances on Sui

## Protocol Context

Staking protocols on Sui use Move's object model for staking receipts: a user's staked position is typically a non-fungible owned object (a `StakeReceipt` or `WrappedStakePosition`) rather than a fungible balance. This owned-object model makes most staking operations user-gated (only the receipt owner can unstake), but introduces shared-object dependency for global accumulator state (total staked, reward index). The hot-potato flash loan pattern on Sui enables flash staking: borrow SUI, deposit, receive a receipt, claim rewards, unstake, repay - all within one PTB. A correctly implemented continuous accumulator makes flash staking yield zero reward by construction; protocols with discrete reward distribution events are more vulnerable.

## Bug Classes

---

### Reward Accumulator Ordering

**Protocol-Specific Preconditions**

- Global `reward_per_token` accumulator updated in the same function that modifies a user's staked balance, but after the balance modification
- `pending_rewards = (current_reward_per_token - user_snapshot) * user_balance` computed with `user_balance` already reflecting the new deposit or withdrawal
- Move's object mutation model: the user's receipt object is modified before the global accumulator is updated, causing the snapshot to be recorded against the new balance

**Detection Heuristics**

- Trace every Move function that calls both `update_balance(receipt, delta)` and `update_accumulator(pool)`; verify the accumulator update precedes the balance update
- The safe pattern in Move: (1) update `pool.reward_per_token`, (2) settle `pending = (pool.reward_per_token - receipt.snapshot) * receipt.balance`, (3) add to `receipt.unclaimed`, (4) update `receipt.snapshot = pool.reward_per_token`, (5) update `receipt.balance`
- Check multi-reward-token systems: each reward token requires its own accumulator; verify all accumulators are settled before any balance change
- Test: stake 100 units, wait for reward accumulation, stake 100 more units, immediately claim; verify reward is only for the first 100 units for the pre-second-stake period

**False Positives**

- `settle_rewards(receipt, pool)` is the first call in every balance-modifying function; enforced as a wrapper pattern at every entry point
- Rewards computed from a snapshot taken at the previous balance change, not from the current accumulator; new balance cannot retroactively earn historical rewards

---

### Flash Stake via PTB

**Protocol-Specific Preconditions**

- Deposit and withdraw can be called in the same PTB with no minimum lock enforced on-chain
- Discrete reward distribution event (not continuous accumulator): large deposit just before the distribution captures a proportional share of the reward
- PTB allows: borrow SUI flash loan, deposit, receive receipt, trigger reward distribution, redeem receipt, repay flash loan - all in one atomic transaction block

**Detection Heuristics**

- Check whether the staking module enforces a minimum lock period: `assert!(clock::timestamp_ms(clock) >= receipt.stake_time_ms + MIN_LOCK_MS, ERROR_LOCKED)`
- Identify whether reward distribution is event-driven or continuous; continuous accumulators correctly yield zero reward for a same-block stake and unstake
- For discrete distribution, verify the snapshot of eligible stakers is taken before the distribution transaction is executable; the snapshot cannot include stakers who deposit in the same transaction
- Check whether deposit and unstake can both appear in a single PTB; if so, verify the minimum lock check is applied at unstake time against `receipt.stake_time_ms`

**False Positives**

- Minimum lock period enforced in the unstake function against the receipt's recorded stake timestamp
- Continuous accumulator with correct ordering makes same-block stake and unstake yield zero net reward by design

---

### Liquid Staking Receipt Duplication

**Protocol-Specific Preconditions**

- Liquid staking protocol issues a `LiquidStakeReceipt` object representing the staked amount; a bug in the receipt issuance or transfer path allows the same underlying stake to back multiple receipts
- Receipt is split or merged via a poorly validated operation that creates more total receipt value than the underlying stake
- Redemption function validates receipt by type but not by total supply cap; unlimited redemptions possible if receipts can be freely minted

**Detection Heuristics**

- Find the receipt minting function; verify it is gated by the `LiquidStakingPoolCap` or equivalent privileged capability and is not callable by users directly
- Check all receipt split and merge operations: `split(receipt, amount)` should assert the total value of resulting receipts equals the input receipt value
- Verify the total issued receipt value is tracked and capped: `assert!(pool.total_issued + mint_amount <= pool.total_stake, ERROR_OVERCOLLATERALIZED)`
- Check whether receipts have `store` ability allowing them to be placed in unexpected locations that bypass the normal redemption path

**False Positives**

- Receipt minting is gated by a capability object held only by the staking contract's admin or the staking contract itself
- Total issued receipt value tracked and enforced; no path allows issuing more receipt value than underlying stake

---

### Validator Commission Manipulation in Wrapped Staking

**Protocol-Specific Preconditions**

- Protocol stakes SUI with validators on behalf of users; the validator selection logic uses a commission rate stored in a mutable config
- A validator can increase their commission rate after the protocol has delegated to them, reducing the yield returned to stakers without the protocol automatically switching validators
- No maximum commission rate enforced at delegation time; the protocol does not re-validate commission rates on each epoch boundary

**Detection Heuristics**

- Check whether the protocol validates the validator's commission rate before delegating and on each epoch when claiming rewards
- Verify whether there is a maximum acceptable commission rate: `assert!(validator.commission_rate <= MAX_COMMISSION_BPS, ERROR_HIGH_COMMISSION)`
- Check whether the protocol automatically redelegates from validators that exceed the maximum commission rate
- Verify the validator selection algorithm is not manipulable by an attacker who controls a high-commission validator with favorable other parameters

**False Positives**

- Protocol enforces a maximum commission rate at delegation and re-checks it at every reward claim epoch
- Validator selection is governance-controlled with a whitelist that requires explicit governance approval to update

## reference/solidity

```

```

## reference/solidity/fv-sol-1-reentrancy

```

```

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c1-single-function.md

# FV-SOL-1-C1 Single Function

## TLDR

A single function performs an external call before updating its own state. An attacker's `receive` or `fallback` function re-enters the same function before the state change lands, allowing repeated withdrawal of the same balance within one transaction.

## Detection Heuristics

**CEI Violation in Withdrawal or Payout Functions**
- State variable (e.g., `balances[msg.sender]`) read for the transfer amount but not zeroed or decremented before `.call{value:}()`
- Pattern: `require(balances[x] > 0)` → `.call{value: balances[x]}("")` → `balances[x] = 0` (update after call)
- `.call{value:}("")` targeting `msg.sender` or any caller-controlled address before the corresponding accounting update

**External Call Vectors That Enable Callback**
- `.call{value:}("")` - forwards all remaining gas, allows arbitrary re-entry
- `payable(x).transfer()` or `.send()` - 2300-gas limit, but not a reliable guard post-Cancun
- Any low-level call to a user-supplied or caller-derived address before state is finalized

**Missing or Insufficient Guards**
- No `nonReentrant` modifier on functions that combine a balance read, an external call, and a state write
- Reentrancy guard stored in transient storage (`TSTORE`) without a fallback to regular storage for the 2300-gas path

## False Positives

- All accounting state is updated before the external call (CEI strictly followed)
- `nonReentrant` modifier (backed by regular storage) applied to the function
- `transfer()` or `send()` used and no `TSTORE` write is reachable within 2300 gas from any `receive`/`fallback` in scope
- Function is `view` or `pure` with no state-modifying side effects

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c2-cross-function.md

# FV-SOL-1-C2 Cross Function

## TLDR

Multiple functions within the same contract share a state variable. One function makes an external call before updating that variable, while another function relies on the same variable for access control or accounting. An attacker re-enters the second function during the callback window to exploit the stale shared state.

## Detection Heuristics

**Shared State Variable Across Functions with Mixed Access Patterns**
- Two or more functions read or write the same `mapping` or state variable (e.g., `balances[msg.sender]`)
- One function contains an external call before the shared variable is updated; another function checks or decrements that same variable
- Pattern: `withdraw` does `.call{value: balance}("")` before `balances[x] = 0`, while `play` or `transfer` reads `balances[x]` for authorization or deduction

**Re-entry Path Through a Different Function**
- Attacker's `receive`/`fallback` calls a sibling function (not the same entry point) during the callback window
- The sibling function passes its own `require` because the shared state has not yet been updated by the original caller
- Functions that deduct from `balances` without an external call become weaponizable if a co-function leaves the balance stale during a call

**Missing Guard Coverage**
- `nonReentrant` applied to only one function while the sibling function that shares state is unprotected
- Guard covers `withdraw` but not `play`, `transfer`, `borrow`, or other functions that read the same balance slot

## False Positives

- CEI followed in every function that touches the shared state variable - no function leaves state stale during an external call
- `nonReentrant` applied to all functions that read or write the shared state variable
- Shared variable is written atomically at the start of each function before any interaction (checks-effects pattern, not checks-interactions)

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c3-cross-contract.md

# FV-SOL-1-C3 Cross Contract

## TLDR

An attacker deploys a dedicated malicious contract that acts as `msg.sender` and re-enters the victim contract during an external call callback. The malicious contract's `receive` or `fallback` calls back into the victim before the victim's state update, allowing repeated exploitation across a contract boundary.

## Detection Heuristics

**External Call to Caller-Controlled Address Before State Update**
- `.call{value:}("")" to `msg.sender` before `balances[msg.sender]` is zeroed or decremented
- Any pattern where the recipient of an external call can be a contract address supplied or implied by the caller
- `withdraw`, `redeem`, `claim`, or `payout` functions that send ETH or tokens to `msg.sender` as the last step

**No Verification That Recipient Is an EOA**
- No `extcodesize(msg.sender) == 0` check (note: this check is bypassable during construction, but absence is a signal)
- No `msg.sender == tx.origin` guard (note: this has other trade-offs, but absence is a signal worth investigating)
- No `nonReentrant` modifier restricting reentry from any external address

**State Update Ordering**
- Balance or ownership mapping updated after the external call in functions that transfer ETH or tokens out
- Local variable caches the pre-call balance (`uint256 balance = balances[msg.sender]`) but the mapping is cleared only after the call

## False Positives

- State zeroed or decremented before the `.call{value:}("")` (CEI strictly followed)
- `nonReentrant` modifier present on the withdrawing function
- Function uses `transfer()` or `send()` and no `TSTORE` path is reachable in 2300 gas from any attacker-controlled fallback
- Recipient is a protocol-controlled address (not caller-supplied), verified via access control before the call

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c4-cross-chain.md

# FV-SOL-1-C4 Cross Chain

## TLDR

Cross-chain reentrancy exploits the absence of replay protection and access control on bridge completion functions. An attacker triggers `completeTransfer` (or equivalent) from a manipulated or replayed cross-chain message, crediting balances that were never actually locked on the source chain. The asynchronous nature of cross-chain messaging makes state inconsistencies harder to detect and replay attacks easier to execute.

## Detection Heuristics

**Unguarded Bridge Completion Functions**
- `completeTransfer`, `finalizeDeposit`, `mintWrapped`, or equivalent functions callable by any address without `onlyTrustedRelayer` or equivalent access control
- No check that `msg.sender` is an authorized bridge relayer, oracle, or trusted remote contract
- No verification of a cryptographic proof, Merkle root, or signed attestation from the source chain

**Missing Replay Protection**
- No nonce, `messageId`, or transaction hash tracked in a `processedMessages` mapping
- `completeTransfer` can be called multiple times with the same parameters

**State Increment Without Source-Chain Proof**
- Direct `balances[user] += amount` or `token.mint(user, amount)` triggered solely by calldata values with no on-chain evidence of the source-chain lock
- Emitting `TransferCompleted` before or without atomically marking the message as processed

## False Positives

- Completion function restricted to a trusted relayer or verified bridge contract via `onlyOwner`, role-based access control, or signature verification
- Each bridge message identified by a unique ID and tracked in a `processedMessages` or equivalent mapping that prevents replay
- Amount credited is verified against a Merkle proof or signed attestation anchored to a finalized source-chain block
- Source-chain lock is atomically verified and consumed in the same transaction as the destination-chain credit

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c5-dynamic.md

# FV-SOL-1-C5 Dynamic

## TLDR

Dynamic reentrancy arises when the target of an external call is a user-supplied parameter rather than a hardcoded or whitelist-verified address. Even with correct CEI ordering, the external call hands control to attacker-controlled code, which can re-enter the contract through a different entry point or exploit logic that was not covered by the reentrancy guard.

## Detection Heuristics

**User-Controlled External Call Target**
- Function signature `function f(address target, ...)` where `target` is used in a subsequent `.call{}`, `.delegatecall{}`, or token transfer without whitelist validation
- `target.call{value: amount}("")` where `target` is derived from `msg.sender`, `msg.data`, or any storage slot the caller can influence
- No `require(isApproved[target])` or equivalent allowlist check before the external call

**Reentrancy Through Side-Entry Points**
- The function deducts from a balance before the call but other functions in the same contract read that balance without a guard, enabling the callback to exploit sibling functions
- `nonReentrant` applied only to the function with the explicit guard, while the dynamically-called target can re-enter through an unprotected sibling

**Delegatecall With Dynamic Target**
- `address(target).delegatecall(data)` where `target` is caller-supplied - grants the target full write access to the contract's storage layout
- Proxy or router patterns that forward arbitrary `(target, calldata)` pairs from untrusted input

## False Positives

- State fully updated (all balances decremented, flags set) before the dynamic external call is issued
- `nonReentrant` applied to the function and all sibling functions that share the same state variables
- `target` validated against an immutable allowlist or registry before the call
- `delegatecall` restricted to a single implementation slot controlled by a time-locked admin

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c6-read-only.md

# FV-SOL-1-C6 Read-Only

## TLDR

Read-only reentrancy occurs when a `view` function or an eligibility/price-check is called inside a state-modifying function after an external call. The view function returns stale or inconsistent state because the state update has not yet occurred, allowing a reentrant callback to pass checks it should fail or to read an artificially inflated or deflated value.

## Detection Heuristics

**View Function Called After External Call in the Same Transaction**
- Sequence: external call (e.g., ETH send) → callback re-enters → `view` function consulted before the state update in the original frame completes
- `getPrizeEligibility()`, `isEligible()`, `getPrice()`, `totalAssets()`, or any `view` that reads a state variable is callable from a reentrant callback while the original function's state update is pending
- Protocols that call an external price oracle or vault share-price function during a callback window where the pool's own state is transiently inconsistent

**Eligibility or Access Check Not Committed Before Interaction**
- `require(getPrizeEligibility())` or `require(balances[x] >= threshold)` evaluated, then an external call issued, then the flag or balance updated - the view check can be re-evaluated in a reentrant callback before the update lands
- `prizeClaimed`, `hasClaimed[user]`, or equivalent guard booleans set after the external call rather than before

**DeFi-Specific: Price or Share Manipulation via Read-Only Reentry**
- A DEX or lending protocol's `getPrice()`, `totalSupply()`, or `totalAssets()` read from a callback during a flash loan or liquidity removal, when pool reserves or vault balances are mid-update
- `lpToken.balanceOf(pool)` or `pool.getReserves()` called from a contract that receives the liquidity callback - returns values from an inconsistent intermediate state

## False Positives

- All guard flags and accounting state committed before the external call (`prizeClaimed = true` before `.call{value:}("")`)
- `nonReentrant` applied to both the state-modifying function and any function that the callback could re-enter to read stale state
- View function reads only immutable values or values that are not affected by the pending state update
- Protocol uses a TWAP or delayed oracle that is not susceptible to within-transaction state manipulation

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c7-erc721-erc1155-callback.md

# FV-SOL-1-C7 ERC721 / ERC1155 Callback Reentrancy

## TLDR

`safeTransferFrom`, `safeMint`, and batch ERC1155 transfers invoke receiver callbacks (`onERC721Received`, `onERC1155Received`, `onERC1155BatchReceived`) before the calling contract has finished updating state. This enables reentrancy through the callback.

A related variant affects custom batch mint/transfer loops that update `_balances` per-ID and call `onERC1155Received` per iteration: callbacks execute while balances for later IDs in the loop are still uncredited, allowing reads of stale state.

A second variant causes `totalSupply` inflation: if `totalSupply[id]` is incremented after `_mint` fires the callback, the supply is stale-low during the callback, inflating share calculations.

## Detection Heuristics

**ERC721/ERC1155 Callback Reentrancy**
- `safeTransferFrom` or `safeMint` called before state updates
- Callback hooks (`onERC721Received` / `onERC1155Received`) enable reentry into the protocol
- `ownerOf[tokenId]` or equivalent ownership mapping deleted or updated after `safeTransferFrom` rather than before
- `withdraw`, `redeem`, or `claim` functions that send an NFT to `msg.sender` as their last step

**ERC1155 Batch Partial-State Window**
- Custom batch mint/transfer updates `_balances` and calls `onERC1155Received` per ID in a loop
- Callback reads stale balances for uncredited IDs in later loop iterations
- `for` loop over token IDs where each iteration fires a callback before the next ID's balance is set

**ERC1155 totalSupply Inflation**
- `totalSupply[id]` incremented after `_mint` callback fires
- During `onERC1155Received`, supply is stale-low - inflates share in any supply-dependent formula
- Affected: OZ ERC1155Supply before version 4.3.2 (CVE GHSA-9c22-pwxw-p6hx)

## False Positives

- All state committed before safe transfer (CEI followed)
- `nonReentrant` applied to all entry points that trigger callbacks
- OZ >= 4.3.2 used without custom `_mint` override
- No supply-dependent logic callable from mint callback

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c8-erc777-hook-reentrancy.md

# FV-SOL-1-C8 ERC777 Hook Reentrancy

## TLDR

ERC777 tokens are backward-compatible with ERC20 but fire `tokensToSend` (on sender) and `tokensReceived` (on recipient) hooks via the ERC1820 registry on every transfer, including ERC20-style `transfer()` and `transferFrom()`. A protocol that uses standard ERC20 calls against what it believes is an ERC20 token unknowingly grants the sender or recipient a callback, enabling reentrancy.

## Detection Heuristics

**ERC20-Compatible Call That May Trigger ERC777 Hook**
- `transfer()` or `transferFrom()` called before state updates on a token whose type is not statically restricted
- `IERC20(token).transferFrom(msg.sender, address(this), amount)` followed by `balances[msg.sender] += amount` - hook fires before the state update
- `token.transfer(recipient, amount)` before any accounting update - `tokensReceived` fires on recipient

**Insufficient Token Type Restriction**
- Token whitelist does not explicitly exclude ERC777 (identified by ERC1820 interface registration: `IERC1820Registry.getInterfaceImplementer(token, keccak256("ERC777Token"))`)
- Protocol accepts arbitrary `address token` parameter with no interface check
- Whitelist populated by governance or admin without an ERC777 exclusion rule

**Hook Attack Vectors**
- `tokensToSend` fires on sender - enables reentry from sender's registered hook contract during `transferFrom`
- `tokensReceived` fires on recipient - enables reentry from recipient's registered hook contract during `transfer`
- Both hooks execute before the ERC777 transfer is considered complete, while the calling contract's state may be mid-update

## False Positives

- CEI - all state committed before transfer
- `nonReentrant` on all entry points that accept external tokens
- Token whitelist explicitly excludes ERC777 (no ERC1820 `IERC777Token` implementers accepted)
- Protocol deploys its own token (known not to be ERC777)

## reference/solidity/fv-sol-1-reentrancy/fv-sol-1-c9-transient-storage-reentrancy.md

# FV-SOL-1-C9 Transient Storage Reentrancy (EIP-1153)

## TLDR

Two reentrancy risks introduced by EIP-1153 (Cancun, March 2024): first, the classic `transfer()`/`send()` 2300-gas reentrancy guard is bypassed because `TSTORE` costs only 100 gas, allowing a `receive()` fallback to write transient state and re-enter within the gas limit. Second, a reentrancy lock backed by `TSTORE`/`TLOAD` that is never explicitly cleared persists for the entire transaction, causing permanent DoS for any multicall or flash-loan-callback flow that attempts a second call.

## Detection Heuristics

**2300-Gas Guard Bypass via TSTORE**
- `transfer()` or `send()` used as the sole reentrancy guard while the contract also contains `TSTORE`/`TLOAD` opcodes (inline assembly or via a transient-storage library)
- Contract deployed post-Cancun (block 19426587+) with comments or documentation assuming the 2300-gas limit prevents state modification in callbacks
- `receive()` or `fallback()` in any contract that interacts with the target contains `assembly { tstore(...) }` - executes at ~100 gas, well within 2300

**Transient Mutex Not Cleared**
- `assembly { tstore(LOCK_SLOT, 1) }` at function entry with no corresponding `assembly { tstore(LOCK_SLOT, 0) }` at exit
- Lock cleared only on the success path but not in revert paths - a failed inner call leaves the lock set for subsequent calls in the same transaction
- Multicall or flash-loan pattern where the lock is set in the first inner call and never released, causing all subsequent inner calls to revert

**Transient Storage Used for Security-Critical State**
- Transient variables used to track reentrancy guards, nonces, or access flags without accounting for within-transaction persistence across separate calls
- `tload` used to check a lock that was set in a different call frame earlier in the same transaction

## False Positives

- Reentrancy guard backed by regular storage slot (`SSTORE`/`SLOAD`) - 2300 gas limit remains effective for that guard
- Transient lock explicitly cleared in all exit paths including reverts (via assembly try/catch pattern or `ensure` cleanup blocks)
- CEI followed unconditionally - no external calls before state updates regardless of gas

## reference/solidity/fv-sol-1-reentrancy/readme.md

# FV-SOL-1 Reentrancy

## TLDR

When a contract calls an external contract or function, it temporarily hands control over to that external entity. If the external contract has permission to call back into the original contract before it has updated critical state variables (e.g., balances), this creates an opening for repeated re-entries, allowing an attacker to manipulate funds or states before they are finalized

## Code

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract Vulnerable {
    mapping(address => uint) public balances;

    // Deposit function
    function deposit() public payable {
        balances[msg.sender] += msg.value;
    }

    // Vulnerable withdraw function
    function withdraw(uint amount) public {
        require(balances[msg.sender] >= amount, "Insufficient balance");
        
        // Send funds before updating balance (vulnerability)
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Failed to send Ether");

        // Update balance after sending (allowing reentrancy)
        balances[msg.sender] -= amount;
    }
}
```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-1-reentrancy/<filename>` to read any case file listed below.

#### fv-sol-1-c1-single-function.md

#### fv-sol-1-c2-cross-function.md

#### fv-sol-1-c3-cross-contract.md

#### fv-sol-1-c4-cross-chain.md

#### fv-sol-1-c5-dynamic.md

#### fv-sol-1-c6-read-only.md

#### fv-sol-1-c7-erc721-erc1155-callback.md

Covers: `safeTransferFrom`/`safeMint` callback before state update, ERC1155 batch partial-state window, ERC1155 `totalSupply` inflation via post-mint increment.

#### fv-sol-1-c8-erc777-hook-reentrancy.md

Covers: `tokensToSend`/`tokensReceived` hooks on ERC20-style calls to ERC777 tokens.

#### fv-sol-1-c9-transient-storage-reentrancy.md

Covers: EIP-1153 `TSTORE` bypassing 2300-gas guard; transient mutex not cleared causing DoS in multicall.

## Mitigation Patterns

### FV-SOL-1-M1 Checks-Effects-Interactions

Perform all internal state changes before making any external calls. This ensures that the contract’s state is updated before control is handed over to external contracts, e.g. update the user’s balance before transferring funds to avoid reentrant calls exploiting unfinalized states.

### FV-SOL-1-M2 Reentrancy Guard

Use a reentrancy guard (e.g. OpenZeppelin's `ReentrancyGuard`), typically implemented as a modifier, to prevent reentrant calls by tracking whether a function is already being executed.

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-01-reentrancy-in-buy-function-for-erc777-tokens-allows-buying-funds-with-considerable-discount-code4rena-caviar-caviar-contest-git](https://solodit.cyfrin.io/issues/h-01-reentrancy-in-buy-function-for-erc777-tokens-allows-buying-funds-with-considerable-discount-code4rena-caviar-caviar-contest-git)

## reference/solidity/fv-sol-10-oracle-manipulation

```

```

## reference/solidity/fv-sol-10-oracle-manipulation/fv-sol-10-c1-incorrect-compounding-mechanism.md

# FV-SOL-10-C1 Incorrect Compounding Mechanism

## TLDR

Oracle price is used as a direct multiplier in interest or yield compounding logic without validation. An attacker who can influence the oracle can inflate or deflate the calculated interest in a single call, permanently distorting the cumulative value.

## Detection Heuristics

**Oracle-Dependent Compounding**
- `oracle.getPrice()` return value multiplied directly into an interest or yield calculation with no prior sanity check
- No `require(price > 0)` guard before the price is used in compounding arithmetic
- No `lastPrice` or equivalent state variable stored to compare against the current reading
- No maximum change check between consecutive price reads (e.g., `currentPrice <= lastPrice * 2 && currentPrice >= lastPrice / 2`)

**Missing Time-Weighted or Rate-Limited Input**
- Compounding function callable by anyone with no access control, allowing repeated calls to amplify manipulation
- No TWAP or time-weighted mechanism smoothing price inputs into the compounding calculation
- Interest rate itself derived from or scaled by an oracle value with no independent governance-set cap

## False Positives

- Compounding uses a hardcoded or governance-set interest rate with no oracle input to the rate itself; oracle is used only for display or accounting in a separate path
- Price is validated as non-zero and within a configured deviation band from `lastPrice` before compounding proceeds
- Oracle is a manipulation-resistant source (e.g., Chainlink with full validity suite) and the compounding function enforces per-block or per-period rate limits

## reference/solidity/fv-sol-10-oracle-manipulation/fv-sol-10-c2-price-drift.md

# FV-SOL-10-C2 Price Drift

## TLDR

An oracle price that drifts gradually from reality causes cumulative accounting errors that compound over time. Because no reference price is stored and no deviation band is enforced, each incremental update silently accepts a slightly-wrong value, allowing long-term exploitation through slowly accumulated pricing error.

## Detection Heuristics

**Stateless Price Validation**
- `totalValue` multiplied by oracle price each call with no stored reference price to detect gradual drift
- No `lastValidPrice` or equivalent state variable tracking the previously accepted price
- Price accepted as valid on the sole condition `price > 0` - any non-zero value passes

**Unchecked Incremental Price Updates**
- Percentage-band check (e.g., `price <= lastValidPrice * 105/100 && price >= lastValidPrice * 95/100`) absent between consecutive oracle reads
- Function callable by anyone with no access control, allowing rapid successive calls to compound drift
- No minimum update interval enforced, permitting an attacker to iterate the drift in a single transaction through repeated calls

## False Positives

- `lastValidPrice` stored and compared against the current price with a tight percentage band before each update is accepted
- Price feed is a long-window TWAP that inherently smooths gradual moves and makes single-block drift economically infeasible
- Protocol enforces a minimum update interval (e.g., per-block or per-hour) that prevents rapid successive calls from compounding drift

## reference/solidity/fv-sol-10-oracle-manipulation/fv-sol-10-c3-manipulation-through-external-markets.md

# FV-SOL-10-C3 Manipulation Through External Markets

## TLDR

Oracles that aggregate prices from low-liquidity external markets (DEXes, AMMs) can be temporarily skewed within a single block or flash loan transaction. A protocol using such a price without smoothing or secondary validation executes collateral, liquidation, or swap logic at the manipulated value.

## Detection Heuristics

**Spot Price Oracle from DEX**
- Oracle call resolves to a DEX reserve ratio or AMM pool spot price (e.g., Uniswap `getReserves`, `slot0`) without a TWAP
- No block-level or time-weighted averaging applied before the price is consumed
- `collateral` or equivalent accounting value computed directly from a single `getPrice()` call with no smoothing
- Oracle interface accepts a `token` address argument and returns a single instantaneous value - classic sign of a spot-price aggregator

**No Flash-Loan or Single-Block Resistance**
- Price accepted within the same transaction as a swap or liquidity operation - no delay or snapshot mechanism
- No reentrancy guard or same-block protection on the price-consuming function
- No deviation check against a secondary non-AMM oracle (e.g., Chainlink) to reject manipulated spot readings
- `adjustCollateral` or equivalent function has no cooldown between calls

## False Positives

- Oracle source is Chainlink, Pyth, or another non-AMM feed that is not susceptible to single-block DEX manipulation
- Protocol uses a TWAP with a window of 30 minutes or longer, making single-block or flash-loan manipulation economically infeasible
- Deviation check against a secondary price source rejects outlier readings before they affect accounting

## reference/solidity/fv-sol-10-oracle-manipulation/fv-sol-10-c4-time-lags.md

# FV-SOL-10-C4 Time Lags

## TLDR

Delaying block production or influencing the timing of oracle updates causes the price feed to serve stale data. Protocols that accept arbitrarily old prices expose themselves to exploitation using valuations that no longer reflect market reality - an attacker can front-run the staleness window to lock in favorable rates before a fresh update arrives.

## Detection Heuristics

**Stale Price Acceptance**
- `getLastUpdatedTime()` return value checked only for non-zero (`require(lastUpdated > 0)`), not for freshness relative to `block.timestamp`
- No `MAX_DELAY` constant or equivalent threshold defining the maximum acceptable age for oracle data
- `require(block.timestamp - lastUpdated <= MAX_DELAY)` absent from all price consumption paths
- `updatedAt` field from `latestRoundData()` fetched but the fetched value is unused or only logged

**Missing Heartbeat Enforcement**
- Protocol does not define a staleness tolerance per feed (different assets have different Chainlink heartbeats: 1 h, 24 h, etc.)
- No fallback behavior (pause, revert, or switch to backup oracle) triggered when a freshness check fails
- Price-dependent operations (liquidation, collateral valuation, swap) proceed regardless of how old the last update is

## False Positives

- `require(block.timestamp - lastUpdated <= MAX_DELAY)` enforced before every use of the price, with `MAX_DELAY` set to match or be tighter than the feed's documented heartbeat interval
- Protocol pauses or reverts all price-dependent operations and emits an event when the freshness check fails
- Push-based oracle with on-chain freshness proofs guarantees updates within a bounded window, removing the need for a consumer-side staleness check

## reference/solidity/fv-sol-10-oracle-manipulation/fv-sol-10-c5-chainlink-feed-validity.md

# FV-SOL-10-C5 Chainlink Feed Validity Failures

## TLDR

`latestRoundData()` returns multiple fields that must all be validated. Missing any check leaves the protocol vulnerable to stale prices, deprecated feeds, and decimal scaling errors. Staleness means `updatedAt` is older than the feed's configured heartbeat - the feed stopped updating but continues returning the last known value. Round incompleteness means `answeredInRound < roundId` - the current round has not been answered and the returned price is from a prior round. Deprecated aggregator addresses can be replaced by Chainlink without notice, returning stale or zero values. Hardcoded decimal assumptions (e.g., always 8) fail for feeds that return 18 decimals, causing a 10^10 scaling error.

## Detection Heuristics

**Staleness Check Missing**
- `latestRoundData()` called but `require(updatedAt >= block.timestamp - MAX_STALENESS)` absent
- No per-feed maximum staleness constant defined - a single global timeout used for feeds with different heartbeats
- No fallback oracle or circuit breaker triggered when the staleness check fails

**Round Completeness Missing**
- `answeredInRound >= roundId` check absent from the validity suite
- `roundId` and `answeredInRound` destructured from `latestRoundData()` but neither is compared

**Deprecated Feed or Wrong Decimals**
- Aggregator address is `immutable` or a hardcoded constant with no governance update path
- `feed.decimals()` not called at runtime - value hardcoded as `8` or `18` in the price normalization formula
- Return value of `answer` cast to `uint256` and used directly without scaling to an internal precision (e.g., 1e18)

## False Positives

- All four checks present in every price consumption path: `answer > 0`, staleness against feed-specific heartbeat, `answeredInRound >= roundId`, and `feed.decimals()` called at runtime for normalization
- Aggregator address updatable via a timelock governance mechanism
- Secondary oracle deviation check acts as a circuit breaker, rejecting prices that diverge beyond a configured threshold even if individual validity checks pass

## reference/solidity/fv-sol-10-oracle-manipulation/fv-sol-10-c6-l2-sequencer-uptime.md

# FV-SOL-10-C6 L2 Sequencer Uptime Not Checked

## TLDR

On L2 networks (Arbitrum, Optimism, Base, and others), Chainlink price feeds continue to serve the last known price during sequencer downtime. When the sequencer resumes, there is a brief window where prices may be stale relative to L1 market moves. Protocols that use Chainlink feeds on L2 without querying the L2 Sequencer Uptime Feed may execute liquidations or trades at incorrect prices during or immediately after downtime.

## Detection Heuristics

**Missing Sequencer Uptime Check**
- Contract deployed on Arbitrum, Optimism, Base, or another L2 network that uses a centralized sequencer
- `latestRoundData()` called on a Chainlink price feed with no corresponding query to the L2 Sequencer Uptime Feed
- No `require(sequencerAnswer == 0)` guard (`0` means sequencer is up in Chainlink's uptime feed convention)
- `SEQUENCER_UPTIME_FEED` address absent from contract storage or constructor arguments

**Missing Grace Period After Restart**
- No `require(block.timestamp - startedAt >= GRACE_PERIOD)` enforced after verifying the sequencer is up
- `startedAt` from the uptime feed destructured but unused
- Liquidation, collateral valuation, or swap pricing executes immediately after sequencer restart without a cooldown

## False Positives

- Protocol deployed exclusively on Ethereum mainnet or another L1 with no sequencer
- Sequencer Uptime Feed queried with `answer == 0` check and a grace period enforced after `startedAt` before prices are consumed
- Protocol uses Pyth or Redstone with pull-based price updates that embed freshness proofs, bypassing sequencer-staleness issues

## reference/solidity/fv-sol-10-oracle-manipulation/fv-sol-10-c7-missing-price-bounds.md

# FV-SOL-10-C7 Missing Oracle Price Bounds

## TLDR

An oracle can return a technically valid price - passing all staleness, round, and sign checks - that is wildly wrong for protocol purposes, such as during a flash crash or when a Chainlink circuit breaker activates. Without min/max sanity bounds or a secondary oracle deviation check, the protocol executes liquidations, swaps, or collateral valuations at that incorrect price. A related variant is a short TWAP window: a window under 30 minutes is manipulable by post-Merge validators who can hold a skewed AMM state across consecutive blocks they propose, shifting the TWAP at low cost.

## Detection Heuristics

**Missing Price Bounds**
- Oracle price used in liquidation, collateral valuation, or swap pricing without `require(price >= MIN_PRICE && price <= MAX_PRICE)`
- No deviation check against a secondary oracle source to detect outlier readings
- No heartbeat-rate or price-change-rate limiting (e.g., no maximum allowed per-update delta)
- `MIN_PRICE` and `MAX_PRICE` constants absent from the contract or set to `0` and `type(uint256).max` respectively

**Short TWAP Window**
- TWAP observation window configured to less than 30 minutes
- Post-Merge validator manipulation risk present: a validator controlling consecutive block proposals can hold a skewed AMM state across those blocks, slowly shifting the TWAP at low cost
- TWAP window length is a mutable parameter with no lower-bound governance constraint

## False Positives

- `require(price >= MIN_PRICE && price <= MAX_PRICE)` present in every price consumption path with bounds set to economically meaningful values
- Secondary oracle deviation check present with a reasonable threshold, rejecting any primary reading that diverges too far
- TWAP window is 30 minutes or longer and the window length is immutable or subject to a governance lower-bound
- Chainlink or Pyth used as the primary source rather than an AMM-derived spot or TWAP, eliminating the validator manipulation vector

## reference/solidity/fv-sol-10-oracle-manipulation/readme.md

# FV-SOL-10 Oracle Manipulation

## TLDR

Tampering with the mechanisms that provide asset price data to smart contracts

## Code


```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface Oracle {
    function getCurrentOraclePrice() external view returns (uint256);
}

contract VulnerableCompound {
    Oracle public oracle;
    uint256 public oraclePrice;

    constructor(address _oracle) {
        oracle = Oracle(_oracle);
        oraclePrice = 1e18;
    }

    function getPricingImportant() public {
        // Vulnerable reliance on the oracle
        oraclePrice = oracle.getCurrentOraclePrice(); // Assumes truthfull results
    }
}
```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-10-oracle-manipulation/<filename>` to read any case file listed below.

#### fv-sol-10-c1-incorrect-compounding-mechanism.md

#### fv-sol-10-c2-price-drift.md

#### fv-sol-10-c3-manipulation-through-external-markets.md

#### fv-sol-10-c4-time-lags.md

#### fv-sol-10-c5-chainlink-feed-validity.md

Covers: missing staleness check, incomplete round check, deprecated feed, wrong decimal assumption.

#### fv-sol-10-c6-l2-sequencer-uptime.md

Covers: Chainlink feed on L2 without Sequencer Uptime Feed check; stale prices during/after sequencer downtime.

#### fv-sol-10-c7-missing-price-bounds.md

Covers: no min/max sanity bounds on oracle price; TWAP window under 30 minutes manipulable post-Merge.

## Mitigation Patterns

### Multi-Sourced Oracles (FV-SOL-10-M1)

Use multiple oracle data sources to calculate an aggregated price

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-01-oracle-price-does-not-compound-code4rena-volt-protocol-volt-protocol-contest-git](https://solodit.cyfrin.io/issues/h-01-oracle-price-does-not-compound-code4rena-volt-protocol-volt-protocol-contest-git)

## reference/solidity/fv-sol-2-precision-errors

```

```

## reference/solidity/fv-sol-2-precision-errors/fv-sol-2-c1-token-decimals.md

# FV-SOL-2-C1 Token Decimals

## TLDR

Precision errors occur when contracts hardcode a decimal assumption (commonly 18) rather than reading the token's actual `decimals()` value. When a contract interacts with tokens like USDC (6 decimals) or WBTC (8 decimals) using an 18-decimal assumption, amounts are over- or under-scaled by orders of magnitude, leading to catastrophically incorrect transfers or balance accounting.

## Detection Heuristics

**Hardcoded Decimal Scaling**
- `amount * 10**18` or `amount * 1e18` in a function that accepts an arbitrary ERC20 address
- `amount / 10**18` used to normalize values without consulting `token.decimals()`
- Constant like `uint256 constant PRECISION = 1e18` applied uniformly across tokens with different decimals

**Missing Decimal Query**
- No call to `token.decimals()` anywhere in the contract or its libraries
- Conversion between token amounts and internal units does not factor in per-token decimal count
- Multi-token contracts (e.g., AMMs, lending pools) that normalize all token values with the same fixed exponent

**Cross-Token Comparison Without Normalization**
- Two token balances compared or combined directly without adjusting for differing `decimals()` values
- Price or rate computed as `tokenA.balanceOf(...) / tokenB.balanceOf(...)` where decimals differ
- Oracle price feed combined with a raw token amount without decimal reconciliation

## False Positives

- Contract explicitly documents and enforces that only 18-decimal tokens are accepted, enforced at the token whitelist or constructor level
- `decimals()` is called dynamically per token and the result is used in every scaling operation
- Protocol normalizes all values to a fixed internal precision (e.g., 18 decimals) at ingestion time, with the normalization factor derived from `token.decimals()` on each token

## reference/solidity/fv-sol-2-precision-errors/fv-sol-2-c2-floating-point.md

# FV-SOL-2-C2 Floating Point

## TLDR

Solidity has no native floating-point type. Contracts that perform division before multiplication on integer values silently truncate fractional parts, producing results that are systematically wrong - often rounding small values to zero entirely. The error compounds across repeated calculations and is especially damaging in reward distribution, interest accrual, and price computation where many small fractions must sum correctly.

## Detection Heuristics

**Division Before Multiplication**
- Expression pattern `(a / b) * c` where `a`, `b`, `c` are `uint256` - the division truncates before the multiplication can recover precision
- Intermediate variable stores `a / b` and that variable is later multiplied by another value
- Reward or interest formula written as `rate / DENOMINATOR * principal` rather than `rate * principal / DENOMINATOR`

**Scaling Factor Absent**
- No WAD (`1e18`), RAY (`1e27`), or equivalent scaling constant applied before division in financial formulas
- Percentage or ratio computed as `numerator / denominator` with no preceding multiplication by a precision constant
- Library like PRBMath, FixedPointMathLib, or ABDKMath64x64 not imported despite fractional arithmetic being present

**Small Value Truncation to Zero**
- `(userHoldings / totalHoldings) * reward` where `userHoldings < totalHoldings` - result is zero for minority holders
- Fee calculated as `amount * basisPoints / 10000` where `amount` may be small enough that `amount * basisPoints < 10000`
- Compound interest accumulator updated as `principal * rate / 1e18` where `principal * rate` underflows the denominator

## False Positives

- Multiplication is always performed before division: `a * c / b` pattern is consistent throughout the codebase
- A fixed-point math library (PRBMath, FixedPointMathLib, DSMath) handles all fractional arithmetic
- Values are guaranteed by protocol invariants to be large enough that truncation loss is bounded, documented, and acceptable (e.g., dust below 1 wei per operation)

## reference/solidity/fv-sol-2-precision-errors/fv-sol-2-c3-rounding.md

# FV-SOL-2-C3 Rounding

## TLDR

Solidity performs integer division with truncation (floor rounding toward zero), which silently discards fractional remainders. In contracts that distribute funds, accrue rewards, or compute fees across many users or iterations, these per-operation losses accumulate into meaningful discrepancies - either funds become permanently stuck, or repeated operations allow users to extract slightly more than they contributed.

## Detection Heuristics

**Unscaled Division in Share or Reward Allocation**
- `allocation = (totalFunds * recipientShares) / totalShares` without a prior scaling multiplication
- `reward = (elapsed * rewardRate) / PERIOD` where elapsed and rewardRate are raw values without WAD scaling
- Share price computed as `totalAssets / totalShares` used directly in downstream arithmetic

**Rounding Direction Not Considered**
- Same division formula used for both deposit (should round down) and withdrawal (should round up) paths
- No use of `Math.mulDiv(..., Rounding.Ceil)` or equivalent ceiling division for user-unfavorable paths
- Protocol claims ERC4626 compliance but `previewWithdraw` and `previewRedeem` both round the same direction

**Accumulated Dust**
- `totalFunds` decremented by a rounded `allocation` value across many calls - final state leaves unclaimable residue
- No reconciliation or sweep function for remainder dust in distribution contracts
- Sum of all per-user allocations computed independently and then compared to total - verify they can diverge

**Fee Calculation Precision Loss**
- `fee = amount * feeBps / 10000` where `amount` values can be small enough to round the fee to zero
- Protocol collects fees by subtracting rounded values, allowing fee-free micro-transactions

## False Positives

- A WAD or RAY scaling factor is applied before every division, making truncated remainders sub-wei
- Protocol explicitly tracks and periodically redistributes dust remainder to a designated address
- Rounding direction is intentionally protocol-favorable: deposit rounds down (fewer shares), withdrawal rounds up (more shares burned), consistent with EIP-4626
- Values involved are large enough by protocol invariant (e.g., minimum deposit enforced) that per-operation dust is negligible and bounded

## reference/solidity/fv-sol-2-precision-errors/fv-sol-2-c4-division-by-zero.md

# FV-SOL-2-C4 Division by Zero

## TLDR

Division by zero in Solidity causes an unconditional revert since 0.8.x (via the built-in overflow/underflow checks) or silent undefined behavior in earlier versions. Beyond crashes, an attacker who can set a denominator to zero can selectively brick functions, trigger denial-of-service, or force a contract into an unrecoverable state. The risk is highest when the denominator is user-controlled, derived from an external call, or can reach zero through normal protocol lifecycle (e.g., all shares redeemed, pool fully drained).

## Detection Heuristics

**Denominator From User Input or State**
- `return (userContribution * 100) / totalShares` where `totalShares` is set by any caller via a public setter
- `price = totalAssets / totalSupply()` without a guard - supply can reach zero after all redemptions
- Division by a `mapping` value, an `ERC20.totalSupply()` call, or any balance that legitimately reaches zero

**Missing Zero Guard Before Division**
- No `require(denominator > 0, ...)` or `if (denominator == 0) revert` preceding the division
- Division directly in a `view` function that returns price or rate - callers may not expect it to revert
- Denominator computed via subtraction (e.g., `totalAssets - withdrawnAmount`) that can underflow to zero

**External Call Result as Denominator**
- Result of `oracle.getPrice()` or `pool.getReserves()` used directly as divisor without a zero check
- `reserve0` or `reserve1` from a Uniswap/Curve pool used in a price formula - pools can be drained

**Lifecycle Edge Cases**
- First interaction before any deposits: `totalShares == 0` or `totalAssets == 0` on an uninitialized vault
- All users exit: `totalSupply() == 0` causes price functions to revert, blocking re-entry

## False Positives

- Denominator is a compile-time constant or immutable set in constructor with a `require(> 0)` check
- Guard `require(totalShares > 0)` or equivalent is present immediately before every division by that variable
- Protocol enforces a minimum locked deposit (dead shares) that prevents the denominator from ever reaching zero
- The division is inside an `if` block that is only reached when the denominator is already proven non-zero by the surrounding control flow

## reference/solidity/fv-sol-2-precision-errors/fv-sol-2-c5-time-based.md

# FV-SOL-2-C5 Time-Based

## TLDR

Contracts that use `block.timestamp` for fund release, access control, or randomness are exposed to two distinct risks: miner (or validator) manipulation of the timestamp by up to approximately 12-15 seconds per block, and the imprecision of treating a monotonically-increasing but not strictly-regular clock as a reliable scheduling mechanism. Lock periods enforced by exact timestamp comparisons can be bypassed or have their timing altered by block producers.

## Detection Heuristics

**Exact or Tight Timestamp Comparisons**
- `require(block.timestamp >= unlockTime)` where `unlockTime` was set as `block.timestamp + N` with small N (seconds to minutes)
- `if (block.timestamp == deadline)` - exact equality comparison against a stored timestamp
- Lock duration under 15 minutes where miner drift represents a non-trivial fraction of the intended window

**Timestamp as Unique Identifier or Seed**
- `block.timestamp` used as a seed for pseudo-randomness: `keccak256(abi.encode(block.timestamp, ...))`
- `block.timestamp` used as a unique nonce or ID in a mapping - two transactions in the same block share the same timestamp
- `tokenId = block.timestamp` or similar ID assignment

**Timestamp-Dependent Access Control**
- Function gated by `block.timestamp < startTime` where `startTime` is set by an admin in the same transaction as a critical action
- Vesting schedule or auction timing entirely controlled by stored timestamps without any block number cross-check
- `unlockTime` shared across all depositors (single state variable overwritten per deposit) allowing last-depositor to reset the lock

**Validator / Miner Manipulation Surface**
- Critical thresholds set within 30 seconds of block time granularity
- No buffer or grace period added to time-sensitive deadlines
- Protocol relies on timestamp for MEV-sensitive ordering (e.g., Dutch auction pricing)

## False Positives

- Time windows are measured in hours or days, making 15-second miner drift a negligible fraction of the intended duration
- Contract uses `block.number` instead of `block.timestamp` for sequencing logic
- Timestamp is used only for informational or logging purposes with no state-changing consequence
- Protocol explicitly documents and accepts the bounded imprecision of block timestamps for the given use case

## reference/solidity/fv-sol-2-precision-errors/fv-sol-2-c6-erc4626-rounding.md

# FV-SOL-2-C6 ERC4626 Rounding Direction Violations

## TLDR

EIP-4626 mandates specific rounding directions on every conversion function to prevent share-price manipulation and round-trip profit extraction. The invariant is: the vault must favor itself over the user at every step.

- `previewDeposit` / `convertToShares` (deposit path): round down - issue fewer shares
- `previewMint` (mint path): round up - charge more assets
- `previewWithdraw` (withdraw path): round up - burn more shares
- `previewRedeem` / `convertToAssets` (redeem path): round down - return fewer assets

Violations allow attackers to cycle deposit→redeem repeatedly for net profit, or to extract more assets than deposited.

## Detection Heuristics

**Preview/Mint Asymmetry**
- `previewDeposit` returns more shares than `deposit` actually mints
- `previewMint` charges fewer assets than `mint` actually takes
- Single `_convertToShares` helper with same `Rounding` arg on both paths

**Deposit/Withdraw Share Asymmetry**
- `_convertToShares` uses `Rounding.Floor` for withdraw path
- `withdraw(a)` burns fewer shares than `deposit(a)` minted - cycling manufactures free shares
- `convertToShares` and `previewWithdraw` return identical values without rounding distinction

**Mint/Redeem Asset Asymmetry**
- `_convertToAssets` uses `Rounding.Ceil` in `redeem` and `Rounding.Floor` in `mint`
- `redeem(s)` returns more assets than `mint(s)` costs - cycling yields net profit
- `previewRedeem` and `previewMint` both round in the user's favor

**Share Inflation via Rounding**
- `shares = assets / pricePerShare` rounds down for deposit, up for redeem
- First-depositor donation attack amplifies rounding error
- No `_decimalsOffset()` or dead-share initialization pattern

## False Positives

- OpenZeppelin ERC4626 base used without overriding `_convertToShares`/`_convertToAssets`
- Custom implementation explicitly uses: deposit with `Rounding.Floor`, withdraw with `Rounding.Ceil`, mint with `Rounding.Ceil`, redeem with `Rounding.Floor`
- `_decimalsOffset()` returns non-zero virtual shares offsetting first-depositor attack
- Protocol documentation explicitly accepts bounded dust loss by design with verified bounds

## reference/solidity/fv-sol-2-precision-errors/fv-sol-2-c7-special-token-accounting.md

# FV-SOL-2-C7 Special Token Accounting

## TLDR

Three related token behaviors break standard balance accounting assumptions: fee-on-transfer tokens deduct a fee during `transferFrom` so the contract records more than it received; rebasing tokens (stETH, AMPL, aTokens) change `balanceOf` over time without any transfer so cached balances go stale; and any contract that reads `balanceOf` once and stores it is vulnerable to drift from either mechanism or from direct token transfers. In all three cases the accounting variable diverges from actual holdings, enabling over-withdrawal, price manipulation, or stuck funds.

## Detection Heuristics

**Fee-on-Transfer**
- `balances[user] += amount` after `transferFrom(..., amount)` without a before/after balance check
- Protocol claims to support "any ERC20" or lists PAXG, STA, or other known deflationary tokens
- Share issuance formula `shares = amount * totalShares / totalAssets` - inflated numerator if `amount` exceeds actual receipt

**Rebasing Token**
- State variable (e.g., `totalAssets`, `_reserves`) accumulates deposit amounts for tokens like stETH, AMPL, or aTokens
- `totalAssets` or equivalent is updated only in protocol functions, not reflecting external rebase events
- Price or LTV calculation derived from stale accumulated value that diverges from live `balanceOf`

**Stale Cached Balance**
- `totalDeposited` or similar state variable never reconciled against live `token.balanceOf(address(this))`
- Protocol accepts direct `token.transfer(contract, x)` as a valid operation path, bypassing accounting
- Share price manipulable by donation: `balanceOf(this)` is higher than the internal tracking variable allows for

## False Positives

- Before/after balance delta used for all accounting: `received = balanceOf(after) - balanceOf(before)`
- Live `balanceOf(address(this))` is read in every view and price function rather than a cached state variable
- Wrapper tokens used: wstETH instead of stETH, eliminating rebasing exposure
- Token whitelist explicitly excludes fee-on-transfer and rebasing tokens with documented rationale
- Rebase is handled by a reconciliation function called atomically before any state-changing operation

## reference/solidity/fv-sol-2-precision-errors/readme.md

# FV-SOL-2 Precision Errors

## TLDR

Precision errors arise when contracts mishandle decimal scaling or rounding in calculations, leading to inaccurate results

## Code

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VulnerableToken {
    string public name = "VulnerableToken";
    string public symbol = "VUL";
    uint8 public decimals = 18;
    uint256 public totalSupply;
    mapping(address => uint256) public balanceOf;

    event Transfer(address indexed from, address indexed to, uint256 value);

    constructor(uint256 initialSupply) {
        // Initialize total supply without accounting for decimals
        totalSupply = initialSupply;
        balanceOf[msg.sender] = totalSupply;
        emit Transfer(address(0), msg.sender, totalSupply);
    }

    // Mint function vulnerable to incorrect decimal handling
    function mint(uint256 amount) public {
        // Fails to scale by decimals, causing inflated supply
        totalSupply += amount;
        balanceOf[msg.sender] += amount;
        emit Transfer(address(0), msg.sender, amount);
    }
}

```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-2-precision-errors/<filename>` to read any case file listed below.

#### fv-sol-2-c1-token-decimals.md

#### fv-sol-2-c2-floating-point.md

#### fv-sol-2-c3-rounding.md

#### fv-sol-2-c4-division-by-zero.md

#### fv-sol-2-c5-time-based.md

#### fv-sol-2-c6-erc4626-rounding.md

EIP-4626 rounding direction violations: preview/mint asymmetry, deposit/withdraw share asymmetry, mint/redeem asset asymmetry, share inflation via first-depositor attack.

#### fv-sol-2-c7-special-token-accounting.md

Fee-on-transfer tokens receive less than recorded; rebasing tokens (stETH, AMPL) change balance without transfer; stale cached balance exploitable via direct donation.

### FV-SOL-2-M1 Unit Testing on Edge Cases

Write tests for edge cases, such as small or very large values, fractions close to rounding boundaries, zero values, and more.

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-4-victims-fund-can-be-stolen-due-to-rounding-error-and-exchange-rate-manipulation-sherlock-napier-git](https://solodit.cyfrin.io/issues/h-4-victims-fund-can-be-stolen-due-to-rounding-error-and-exchange-rate-manipulation-sherlock-napier-git)
* [https://solodit.cyfrin.io/issues/h-05-vault-treats-all-tokens-exactly-the-same-that-creates-huge-arbitrage-opportunities-code4rena-yaxis-yaxis-contest-git](https://solodit.cyfrin.io/issues/h-05-vault-treats-all-tokens-exactly-the-same-that-creates-huge-arbitrage-opportunities-code4rena-yaxis-yaxis-contest-git)

## reference/solidity/fv-sol-3-arithmetic-errors

```

```

## reference/solidity/fv-sol-3-arithmetic-errors/fv-sol-3-c1-overflow-and-underflow.md

# FV-SOL-3-C1 Overflow and Underflow

## TLDR

Integer overflow and underflow occur when arithmetic results exceed or drop below the bounds of the integer type. In Solidity versions before 0.8.0, these conditions wrap silently without reverting. In 0.8.0 and later, the compiler inserts checked arithmetic by default, but `unchecked {}` blocks and inline assembly restore the wrapping behavior.

## Detection Heuristics

**Pre-0.8.0 Contracts Without SafeMath**
- `pragma solidity ^0.7.x` or earlier with `+`, `-`, `*` applied to user-controlled values
- No `using SafeMath for uintN` declaration on contracts performing balance or supply arithmetic
- Arithmetic on `mapping` values (balances, allowances, shares) without overflow guards

**Unchecked Blocks in 0.8.0+ Contracts**
- `unchecked { x += amount; }` where `amount` is caller-supplied or unbounded
- `unchecked { balance -= withdrawal; }` without a prior `require(balance >= withdrawal)`
- Loop accumulators inside `unchecked {}` with no iteration bound enforced

**Multiplication Before Division Patterns**
- `a * b / c` where `a * b` can overflow before the division reduces the result
- Intermediate product assigned to same-width variable: `uint256 product = a * b` before `/ PRECISION`
- No `mulDiv` or equivalent full-precision multiplication used for fixed-point math

**Balance and Supply Accounting**
- Token mint/burn functions that add to or subtract from `totalSupply` without bounds
- Reward accumulation: `rewards[user] += rate * elapsed` where `rate * elapsed` is unbounded
- Share calculations: `shares * pricePerShare` with large values and no overflow check

## False Positives

- Solidity 0.8.0 or later compiler version used with no `unchecked {}` wrapping the arithmetic
- `unchecked {}` block where both operands are proven bounded (e.g., loop index `< 256` for a `uint8`)
- SafeMath library (`SafeMath.add`, `SafeMath.sub`, `SafeMath.mul`) applied to all operations on the affected variables
- Operands constrained by earlier `require` statements that cap values below overflow threshold
- Fixed-point multiplication using a verified `mulDiv` implementation that handles intermediate overflow

## reference/solidity/fv-sol-3-arithmetic-errors/fv-sol-3-c2-sign-extension.md

# FV-SOL-3-C2 Sign Extension

## TLDR

Sign extension issues occur when a smaller signed integer type is implicitly or explicitly cast to a larger signed integer type. The sign bit propagates to fill the additional high-order bits, which can turn a small negative value into a very large negative number or corrupt a value that was intended to be treated as unsigned.

## Detection Heuristics

**Signed-to-Wider-Signed Cast**
- `int8`, `int16`, or `int32` variable cast to `int256` or any wider signed type
- Cast occurs on a value that can be negative at runtime (no prior `require(x >= 0)`)
- Result used in multiplication, comparison, or storage without range validation

**Signed Value Used as Unsigned Index or Offset**
- `int` type cast to `uint` for use as array index, storage slot, or memory offset
- Pattern: `uint256(int8(userInput))` where `userInput` can be negative, producing a large `uint256`
- Negative value passed through ABI boundary and cast to unsigned type in receiving contract

**Bitwise Masking Absent After Cast**
- `int256(smallSignedVar)` used directly in bitwise operations without `& 0xFF` or equivalent mask
- Mixed signed/unsigned arithmetic where sign extension inflates a term: `uint256(int8(x)) * factor`
- Packed-encoding functions that cast signed fields to bytes without masking

**Cross-Contract ABI Mismatch**
- Callee function parameter is `int8`/`int16` but caller passes `int256` and truncates on return path
- ABI-encoded struct with signed fields decoded into wider types in a second contract

## False Positives

- Cast is from unsigned type (`uint8` to `uint256`): no sign bit exists, no extension
- Value is proven non-negative by a preceding `require(x >= 0)` or by its type constraints
- Explicit bitwise mask applied immediately after cast: `int256(x) & 0xFF`
- Operands explicitly upcast to `uint` before arithmetic: `uint256(uint8(x))` strips the sign
- Library (e.g., OpenZeppelin `SignedMath`) handles the conversion safely

## reference/solidity/fv-sol-3-arithmetic-errors/fv-sol-3-c3-truncation-in-type-casting.md

# FV-SOL-3-C3 Truncation in Type Casting

## TLDR

Downcasting from a wider integer type to a narrower one silently drops the high-order bits. Any value larger than the target type's maximum is truncated to its low-order bits, producing a different value with no revert or warning. This affects both explicit casts and implicit narrowing in storage assignments.

## Detection Heuristics

**Explicit Downcast Without Bounds Check**
- `uint8(x)`, `uint16(x)`, `uint32(x)`, `uint128(x)` where `x` is `uint256` or wider
- No preceding `require(x <= type(uintN).max)` before the cast
- Downcast result stored directly to state variable or emitted in event

**Return Value Downcast**
- External call return value cast to smaller type: `uint16(token.balanceOf(user))`
- Solidity ABI decoder target variable narrower than the ABI-declared return type
- `bytes32` to `address` cast where upper bytes may be non-zero

**Packed Struct / Storage Slot Truncation**
- Struct fields of mixed widths where a wider computed value is assigned to a narrower field
- Bit-shifting followed by narrowing cast: `uint8(x >> 8)` applied to potentially large `x`
- Storage packing via explicit cast in setter function without validation

**Intermediate Accumulator Overflow**
- Loop accumulating values into `uint32` or `uint64` variable where sum may exceed type max
- Fee or reward calculation result assigned to a `uint128` state variable without a cap check
- Price or rate derived from division stored in `uint96` without checking divisor constraint

## False Positives

- OpenZeppelin `SafeCast` library used: `SafeCast.toUint16(x)` reverts on truncation
- `require(x <= type(uintN).max)` or equivalent bound check immediately precedes the cast
- Value is produced by a modulo operation that guarantees it fits: `x % 256` cast to `uint8`
- Constant or literal value that provably fits the target type at compile time
- Compiler-level type constraint (e.g., function parameter already declared `uint16`) prevents wider input

## reference/solidity/fv-sol-3-arithmetic-errors/fv-sol-3-c4-misuse-of-environment-variables.md

# FV-SOL-3-C4 Misuse of Environment Variables

## TLDR

Environment variables such as `block.timestamp`, `block.number`, and `block.basefee` carry miner- or validator-influenced values that should not be used as precise inputs to arithmetic or access control. Small manipulations of `block.timestamp` (up to ~15 seconds on Ethereum mainnet) are within validator discretion, and `block.number` advances at variable real-world time between networks. Arithmetic that assumes exact or predictable values from these variables is exploitable or unreliable.

## Detection Heuristics

**Timestamp Arithmetic for Time-Sensitive Logic**
- `block.timestamp +/- N` used to set deadlines, unlock times, or cooldown windows shorter than ~15 minutes
- `require(block.timestamp >= start + duration)` where `duration` is seconds-to-minutes scale
- `block.timestamp % period` used for slot selection, randomness, or round scheduling

**Timestamp as Randomness Source**
- `block.timestamp` hashed alone or combined only with on-chain predictable values to seed randomness
- `uint256(keccak256(abi.encodePacked(block.timestamp, msg.sender)))` used as a random number
- Lottery, NFT reveal, or game outcome determined solely from block-level variables

**Block Number as Wall-Clock Proxy**
- `block.number * SECONDS_PER_BLOCK` used for time calculations where `SECONDS_PER_BLOCK` is hardcoded
- Hardcoded assumption (e.g., 6500 blocks per day) applied across multiple chain deployments without network-specific override
- Vesting or interest calculation based on block number difference using a fixed rate not validated per network

**Arithmetic Overflow With Environment Variables**
- `block.timestamp + userSuppliedOffset` where offset is unbounded (can overflow `uint256`)
- `block.basefee * gasEstimate` without overflow guard in fee accounting
- `block.number - deployBlock` underflow if deployment block stored incorrectly

## False Positives

- Time windows measured in hours or days where sub-minute manipulation is economically irrelevant to the outcome
- `block.timestamp` used only for logging in events, not for access control or arithmetic
- `block.number` used with a network-specific, governance-updatable rate parameter rather than a hardcode
- Chainlink VRF or equivalent verifiable randomness used alongside block variables without relying on them for entropy
- Timestamp comparison with wide tolerance band: `require(block.timestamp >= deadline - TOLERANCE)` where `TOLERANCE` absorbs manipulation

## reference/solidity/fv-sol-3-arithmetic-errors/fv-sol-3-c5-assembly-arithmetic.md

# FV-SOL-3-C5 Assembly Arithmetic Silent Overflow and Division-by-Zero

## TLDR

Arithmetic inside `assembly {}` (Yul) does not benefit from Solidity 0.8's checked math. Overflow and underflow wrap silently (same as `unchecked {}`) and division by zero returns 0 instead of reverting. Developers accustomed to the Solidity 0.8 safety guarantees frequently introduce these bugs when writing inline assembly for gas optimization. Narrow-type arithmetic before upcast is a related Solidity-level issue: `uint8 a * uint8 b` overflows in the narrow type even though the result is assigned to a `uint256`.

## Detection Heuristics

**Assembly Division by Zero**
- `div(x, y)` or `sdiv(x, y)` inside `assembly {}` where denominator is user-supplied or not guaranteed non-zero
- No `if iszero(y) { revert(0, 0) }` guard before the division opcode
- Called in price, share, or ratio calculations where a zero denominator is a reachable state

**Assembly Overflow**
- `mul`, `add`, `sub` inside `assembly {}` without subsequent overflow check
- `mulmod` or `addmod` unused when wrapping-safe arithmetic is needed
- No `if gt(result, MAX)` guard after multiplication involving user-supplied values
- `add` used for pointer arithmetic without checking against `calldatasize()` or allocated memory bound

**Narrow-Type Overflow Before Upcast**
- Arithmetic on `uint8`, `uint16`, `uint32` operands before assignment to wider type
- Inside `unchecked {}` where Solidity 0.8 checked math is disabled
- Variables explicitly cast to narrow type as optimization: `uint8(x) * uint8(y)` before assigning to `uint256`

## False Positives

- Manual overflow checks in assembly present after each arithmetic op: `if gt(result, x) { revert(0, 0) }`
- Denominator checked with `require(denom > 0)` before entering the assembly block
- Assembly block is read-only (`mload`, `sload`, `calldataload` only, no arithmetic opcodes)
- Narrow-type operands explicitly upcast before operation: `uint256(a) * uint256(b)`
- `SafeCast` library used for all type conversions surrounding the block
- Mathematical proof of bounded operand range (e.g., both values `<= type(uint8).max / 2`)

## reference/solidity/fv-sol-3-arithmetic-errors/fv-sol-3-c6-assembly-memory-calldata-pitfalls.md

# FV-SOL-3-C6 Assembly Memory and Calldata Pitfalls

## TLDR

Inline assembly bypasses Solidity's memory safety guarantees. Six distinct pitfalls arise from incorrect memory layout assumptions, dirty bits, scratch space reuse, and calldata boundary handling. Each manifests as silent data corruption rather than a revert.

- `mstore8` dirty bytes: writing a single byte leaves 31 dirty bytes in the surrounding word
- Scratch space corruption: Solidity overwrites `0x00-0x3f` between assembly blocks
- Dirty higher-order bits: loading sub-256-bit values without masking
- `returndatasize` as zero substitute: nonzero after any preceding external call
- `calldataload` out-of-bounds: reads zero-padded bytes silently past `calldatasize()`
- Free memory pointer corruption: writing above `mload(0x40)` without updating it

## Detection Heuristics

**mstore8 Partial Write**
- `mstore8` in a loop building a byte array, followed by `keccak256` or `return` on the full word region
- Slot not zeroed with `mstore(ptr, 0)` before byte-level writes
- `mload` used to read a word containing `mstore8`-written bytes with uninitialized neighbors

**Scratch Space Corruption**
- `mstore(0x00, ...)` or `mstore(0x20, ...)` in one assembly block, with Solidity statements in between, then `mload` in a later assembly block
- Intervening `keccak256(a, b)`, `abi.encode`, or any memory allocation can clobber `0x00-0x3f`

**Dirty Higher-Order Bits**
- `calldataload`, `sload`, or `mload` into a variable used as `address`, `uint128`, `uint8`, or `bool` without bitmask
- Comparison `if eq(addr, target)` where `addr` is not masked to 20 bytes
- `mapping[addr]` lookup where `addr` has dirty upper bits, producing the wrong storage slot

**returndatasize as Zero**
- `let ptr := returndatasize()` or `mstore(returndatasize(), x)` appearing after any `call` or `staticcall`
- Intended optimization of using `returndatasize()` as a cheaper `0` is only valid before any external call in the same execution context

**calldataload Out-of-Bounds**
- `calldataload(offset)` where offset is user-controlled or derived from user input without a bound check
- No `require(calldatasize() >= minSize)` before the assembly block
- Index multiplication: `add(base, mul(index, 32))` without `require(index < maxCount)`

**Free Memory Pointer Corruption**
- `mstore` at `mload(0x40)` without a subsequent `mstore(0x40, newPtr)` updating the free pointer
- Assembly block writes to arbitrary offsets with no pointer update
- Data overwritten by the next Solidity-level memory allocation after the block

## False Positives

- Only scratch space (`0x00-0x3f`) used, and all reads occur within the same contiguous assembly block
- `mload(0x40)` read, data written above it, and pointer updated: `mstore(0x40, add(ptr, size))`
- Block annotated `/// @solidity memory-safe-assembly` and verifiably compliant with the Solidity memory model
- Dirty bit concern does not apply: value produced by a prior Solidity expression that already cleaned the high-order bits
- `returndatasize()` used before any external call in the same execution context
- Calldataload offset is static and fixed-size with a compiler-generated ABI decoder handling bounds

## reference/solidity/fv-sol-3-arithmetic-errors/readme.md

# FV-SOL-3 Arithmetic Errors

## TLDR

Arithmetic-related security vulnerabilities primarily stem from issues with numeric operations, particularly when they handle unexpected values or edge cases

## Code

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;

contract OverflowExample {
    uint256 public count = 2**256 - 1;

    function increment() public {
        count += 1; // This will overflow and wrap to 0
    }
}
```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-3-arithmetic-errors/<filename>` to read any case file listed below.

#### fv-sol-3-c1-overflow-and-underflow.md

#### fv-sol-3-c2-sign-extension.md

#### fv-sol-3-c3-truncation-in-type-casting.md

#### fv-sol-3-c4-misuse-of-environment-variables.md

#### fv-sol-3-c5-assembly-arithmetic.md

Assembly `div`/`mul`/`sub` silent overflow and division-by-zero; narrow-type arithmetic overflow before upcast in `unchecked` blocks.

#### fv-sol-3-c6-assembly-memory-calldata-pitfalls.md

mstore8 dirty bytes, scratch space corruption between assembly blocks, dirty higher-order bits, returndatasize-as-zero misuse, calldataload out-of-bounds, free memory pointer corruption.

### Update Solidity Version (FV-SOL-3-M1)

Solidity 0.8+ offers built-In Overflow and Underflow protection

### Using Established Math Libraries (FV-SOL-3-M2)

Complex calculations should be using hard work premade in trusted math libraries available

### Unit Testing on Edge Cases (FV-SOL-3-M3)

Write tests for edge cases, such as small or very large values, fractions close to rounding boundaries, zero values, and more.

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-06-incorrect-solidity-version-in-fullmathsol-can-cause-permanent-freezing-of-assets-for-arithmetic-underflow-induced-revert-code4rena-good-entry-good-entry-git](https://solodit.cyfrin.io/issues/h-06-incorrect-solidity-version-in-fullmathsol-can-cause-permanent-freezing-of-assets-for-arithmetic-underflow-induced-revert-code4rena-good-entry-good-entry-git)

## reference/solidity/fv-sol-4-bad-access-control

```

```

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c1-using-tx.origin-for-authorization.md

# FV-SOL-4-C1 Using tx.origin for Authorization

## TLDR

`tx.origin` always resolves to the original EOA that initiated the transaction, regardless of how many contracts the call passed through. A malicious contract invoked by a privileged user can exploit this to impersonate that user in any contract that relies on `tx.origin` for access control.

## Detection Heuristics

**tx.origin Used as Access Guard**
- `require(tx.origin == admin)` or `require(tx.origin == owner)` as the sole authorization check
- `if (tx.origin != trustedAddress) revert` pattern in state-changing functions
- `tx.origin` compared against any stored address to gate privileged operations

**Combination Patterns That Still Fail**
- `tx.origin` used as a fallback when `msg.sender` check fails - still exploitable via phishing
- `tx.origin` used to set an owner or beneficiary address during initialization, then later compared for authorization

## False Positives

- `msg.sender` is used instead of `tx.origin` for the authorization check
- `tx.origin` appears only in event emissions or logging, not in require/revert guards
- `tx.origin == msg.sender` used to assert caller is an EOA with no downstream privileged action gated solely on that check

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c10-commit-reveal-merkle-binding.md

# FV-SOL-4-C10 Commit-Reveal and Merkle Proof Binding

## TLDR

Cryptographic access control schemes fail when the protected value is not bound to `msg.sender`. A commitment hash that omits the sender can be front-run or replayed from a different address. A Merkle leaf that omits the sender is claimable by anyone who observes the proof on-chain. Single-hashed leaves are additionally vulnerable to second-preimage attacks where a 64-byte intermediate node is passed as a leaf.

## Detection Heuristics

**Commit-Reveal Not Bound to Sender**
- `keccak256(abi.encodePacked(value, salt))` without `msg.sender` included in the hash
- Commitment stored in a public mapping - visible on-chain once committed, allowing front-running
- Reveal function does not validate `msg.sender` against the address that originally committed

**Merkle Second Preimage**
- `keccak256(abi.encodePacked(input))` where `input` is user-supplied bytes with no length constraint
- A 64-byte user input can masquerade as two sibling hashes and pass as a valid intermediate node
- Leaf constructed as a single hash: `bytes32 leaf = keccak256(abi.encodePacked(addr, amount))`
- OZ `MerkleProof` version below v4.9.2 used without manual double-hashing

**Merkle Proof Reuse and Front-Running**
- Leaf does not include `msg.sender`: `keccak256(abi.encodePacked(amount))` or `keccak256(abi.encodePacked(tokenId))`
- Proof not recorded as consumed after first use - replayable across multiple transactions
- Public whitelist where proof is visible on-chain before the intended user claims

## False Positives

- Commitment includes sender: `keccak256(abi.encodePacked(msg.sender, value, salt))`
- Reveal function validates that the stored committer address equals `msg.sender`
- Merkle leaf double-hashed: `keccak256(bytes.concat(keccak256(abi.encode(...))))`
- OZ `MerkleProof` v4.9.2 or later used with sorted-pair leaf validation
- Proof recorded as spent (`hasClaimed[leaf] = true`) before payout is executed

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c11-hash-collision-and-encoding.md

# FV-SOL-4-C11 Hash Collision via Encoding and Calldata Malleability

## TLDR

Two encoding bugs allow attackers to produce colliding hashes or bypass deduplication. When `abi.encodePacked` is used with two or more dynamic-type arguments, inputs with different field boundaries but identical concatenated bytes produce the same hash. When protocols deduplicate by hashing raw `msg.data`, attackers can produce semantically identical but bytewise-different calldata by manipulating ABI offset pointers, bypassing replay protection.

## Detection Heuristics

**abi.encodePacked Collision**
- `keccak256(abi.encodePacked(a, b, ...))` where two or more arguments are `string`, `bytes`, or dynamic arrays
- Result used as access control key, nullifier, permit hash, or uniqueness check
- Solidity compiler warning about tight packing with dynamic types present and unresolved
- Two distinct input combinations produce the same packed bytes (e.g., `("a","bc")` and `("ab","c")`)

**Calldata Malleability**
- `keccak256(msg.data)` used for replay protection or deduplication of relayed transactions
- Function accepts dynamic types in calldata (strings, bytes, or arrays) with ABI offset pointers
- Non-canonical ABI encoding: malformed offset pointers decode to the same Solidity values but differ in raw bytes
- `calldataload(offset)` at hardcoded positions assuming standard canonical ABI layout

## False Positives

- `abi.encode()` used instead of `abi.encodePacked` - includes length prefixes, no boundary collision possible
- Only one dynamic type argument present (no collision possible with a single dynamic argument)
- All arguments are fixed-size types (`address`, `uint256`, `bytes32`) - calldata is non-malleable
- Deduplication hashes decoded parameters rather than raw calldata: `keccak256(abi.encode(decodedA, decodedB))`
- Nonce-based replay protection makes calldata-level uniqueness irrelevant

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c2-unrestricted-role-assignment.md

# FV-SOL-4-C2 Unrestricted Role Assignment

## TLDR

When a function that assigns ownership, admin rights, or privileged roles is public and lacks any access control guard, any caller can elevate their own privileges or assign them to an attacker-controlled address, taking full control of the contract.

## Detection Heuristics

**Unguarded Role-Assignment Function**
- `public` or `external` function that writes to `owner`, `admin`, or a role-tracking mapping without a `require(msg.sender == admin)` or role-based modifier
- Missing `onlyOwner`, `onlyRole`, or equivalent guard on any function that grants or revokes roles
- `setOwner(address)`, `grantRole(bytes32, address)`, `addAdmin(address)` callable by any address

**Initialization-Time Exposure**
- `initialize()` function with no access control that sets owner - callable by anyone after deployment if not called atomically
- Proxy pattern where `initialize` is not gated by `initializer` modifier, allowing re-initialization

**Indirect Privilege Escalation**
- A public function that writes to a mapping used later as an authorization check without validating who the caller is
- `privilegedUsers[user] = true` reachable without a caller identity check

## False Positives

- Role-assignment function is `internal` or `private`
- `onlyOwner` or `onlyRole` modifier present and correctly enforced
- Assignment occurs exclusively in the `constructor` where `msg.sender` is implicitly trusted
- OpenZeppelin `AccessControl` or `Ownable` used without overrides that bypass guards

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c3-lack-of-multi-signature-for-crucial-operations.md

# FV-SOL-4-C3 Lack of Multi-Signature for Crucial Operations

## TLDR

When a single address controls a critical operation - such as draining contract funds, upgrading implementation logic, or changing protocol parameters - that address is a single point of failure. Compromise, loss, or coercion of that one key results in irreversible protocol damage with no recourse.

## Detection Heuristics

**Single-Owner Control Over High-Impact Operations**
- `require(msg.sender == owner)` as the sole guard on functions that transfer all funds, pause the protocol, or change fee parameters
- `withdrawAllFunds`, `emergencyDrain`, `transferOwnership`, or `upgradeTo` callable by a single EOA without a timelock or co-signer requirement
- Owner address is an EOA (not a multisig) verified via etherscan or deployment scripts

**Missing Approval Threshold Pattern**
- No multi-step approval mapping (e.g., `approvals[tx]++` + `require(approvals[tx] >= threshold)`)
- No timelock delay before execution of high-impact changes
- No governance vote or quorum check before execution

**Irreversibility Without Safeguards**
- Fund withdrawal sends full balance in a single call with no partial-withdrawal limit
- Upgrade or ownership transfer takes effect immediately with no cancellation window

## False Positives

- Owner address is a deployed Gnosis Safe or other multisig contract
- Operation is protected by a timelock contract requiring a mandatory delay before execution
- Governance module requires on-chain vote with quorum before the privileged call can execute
- Operation is bounded by a small daily limit making catastrophic single-transaction drain impossible

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c4-signature-security-flaws.md

# FV-SOL-4-C4 Signature Security Flaws

## TLDR

Signature-based authentication is vulnerable to three related issues: malleability (the same signing key produces two valid signatures for the same message), zero-address recovery (`ecrecover` returns `address(0)` for malformed signatures), and replay attacks (a valid signature used once can be reused). Raw use of `ecrecover` without input validation exposes all three.

## Detection Heuristics

**Signature Malleability**
- Raw `ecrecover` without `require(uint256(s) <= 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0)`
- Both `(v, r, s)` and `(v', r, s')` recover the same address - bypasses signature-based deduplication
- Deduplication based on `(r, s)` bytes rather than a message hash nullifier

**ecrecover Returns address(0)**
- Raw `ecrecover` without `require(recovered != address(0))`
- If `authorizedSigner` is uninitialized or `permissions[address(0)]` is non-zero, any garbage signature gains privileges

**Signature Replay / Missing Nonce**
- Signed message has no per-user nonce, or nonce is present but not stored or incremented after use
- Same signature resubmittable indefinitely across transactions or chains
- No `chainId` or contract address bound into the signed digest

## False Positives

- OZ `ECDSA.recover()` used (validates `s` range and reverts on `address(0)`)
- Message hash used as deduplication key (not raw signature bytes) preventing malleability bypass
- Monotonic per-signer nonce included in signed payload, checked and incremented atomically
- `usedSignatures[hash]` mapping marks signatures as consumed after first use

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c5-callback-authorization-bypass.md

# FV-SOL-4-C5 Callback Authorization Bypass

## TLDR

External callback functions (`onFlashLoan`, `onERC721Received`, `onERC1155Received`) are invoked by third-party contracts. If the callback does not verify `msg.sender` is the expected caller, anyone can invoke it directly with fabricated parameters, bypassing intended access control. ERC4626 `withdraw`/`redeem` has a related variant: when `msg.sender != owner`, allowance must be checked and decremented or any address can burn an arbitrary owner's shares.

## Detection Heuristics

**Flash Loan Callback**
- `onFlashLoan` does not verify `msg.sender == address(lendingPool)`
- Initiator, token, or amount parameters unchecked - callable directly without a real flash loan
- State changes or fund transfers triggered solely by caller-supplied parameters

**ERC721 onERC721Received Spoofing**
- `onERC721Received` uses `from` or `tokenId` to update state without checking `msg.sender == address(expectedNFT)`
- Any caller can invoke directly with fabricated parameters to trigger unintended state changes

**ERC1155 Burn Without Authorization**
- Public `burn(address from, ...)` callable by anyone without `msg.sender == from` or operator approval check
- Any caller can burn another user's tokens

**ERC4626 Missing Allowance Check**
- `withdraw(assets, receiver, owner)` or `redeem(shares, receiver, owner)` where `msg.sender != owner` but no `_spendAllowance` call present

**ERC1155 setApprovalForAll Over-Permission**
- Protocol requires `setApprovalForAll(protocol, true)` for deposits - operator can transfer any token ID at full balance, not just the deposited amount

## False Positives

- `require(msg.sender == address(lendingPool))` and `initiator == address(this)` both validated in flash loan callback
- `require(msg.sender == address(nft))` present before state update in `onERC721Received`
- `require(from == msg.sender || isApprovedForAll(from, msg.sender))` in custom burn
- OZ `ERC4626` used without custom overrides (allowance check is built in)
- Protocol uses direct `safeTransferFrom` with user as `msg.sender` (no `setApprovalForAll` needed)

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c6-arbitrary-external-call.md

# FV-SOL-4-C6 Arbitrary External Call

## TLDR

When a contract executes `target.call{value: v}(data)` where `target` or `data` are caller-supplied, an attacker can craft parameters to invoke unintended functions on any target contract. Common impact includes draining ERC20 allowances the contract holds, invoking `transferFrom` on behalf of the contract, or calling governance or upgrade functions on contracts that trust the calling contract as an authorized party.

## Detection Heuristics

**Caller-Controlled Target or Calldata**
- `target.call{value: v}(data)` where `target` or `data` (or both) arrive as function parameters from `msg.sender`
- No whitelist check on `target` before the call is executed
- Selector filtering absent, bypassable, or only applied to `data[0:4]` without validating the full calldata layout

**Token Allowance Drain**
- Contract holds ERC20 `approve` allowances or NFT custody - attacker crafts calldata to call `transferFrom` or `safeTransferFrom` on the token contract with the vulnerable contract as `from`
- Contract previously called `token.approve(address(this), type(uint256).max)` and exposes a generic call executor

**Privilege Escalation via Trusted Caller**
- Target contract grants special permissions to the calling contract's address - arbitrary call lets attacker invoke those privileged functions through the trusted intermediary

## False Positives

- Target restricted to a hardcoded address or a governance-approved whitelist
- Function selector restricted to a known-safe enumerated set before execution
- Contract holds no token approvals and no asset custody, removing economic impact
- Only `delegatecall` variant present - covered separately in fv-sol-7, not this class

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c7-erc1271-signature-delegation.md

# FV-SOL-4-C7 ERC-1271 Signature Validation Delegation

## TLDR

ERC-1271 allows smart contract accounts to validate signatures by implementing `isValidSignature(bytes32 hash, bytes calldata signature) returns (bytes4)`. When a protocol relies on this for authorization and the implementation delegates to an externally-supplied or insufficiently-guarded module, a malicious module can always return the magic value `0x1626ba7e`, bypassing all signature checks unconditionally.

## Detection Heuristics

**Unguarded Module Delegation**
- `isValidSignature` delegates to an address stored in state that is settable by any caller without access control
- `setSignatureModule(address)` or equivalent has no `onlyOwner` or guardian check
- Any address can deploy a contract returning `0x1626ba7e` unconditionally and register it as the active module

**Module Registry Without Approval Gate**
- Module address stored in a mutable state variable with no timelock or multisig approval requirement before activation
- No whitelist of audited modules - arbitrary user-deployed contracts accepted

**Return Value Not Validated**
- Caller of `isValidSignature` treats any non-reverting response as valid without checking the exact `bytes4` return value equals `0x1626ba7e`
- Protocol accepts `true` or non-zero return instead of the exact magic bytes

## False Positives

- Module delegation restricted to an owner-controlled whitelist of audited contracts
- Module registry requires timelock or multisig guardian approval before a new module becomes active
- `isValidSignature` implementation is self-contained with no external delegation
- Module address is immutable or set only once in the constructor

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c8-arbitrary-storage-write.md

# FV-SOL-4-C8 Arbitrary Storage Write

## TLDR

Two distinct patterns allow writing to arbitrary storage slots: inline assembly `sstore(slot, value)` where the slot is derived from user input without bounds checking, and in Solidity < 0.6, direct assignment to `array.length` combined with a crafted large index causes slot arithmetic to wrap, writing to any storage location. Both patterns allow an attacker to overwrite critical state variables including access control roles and ownership addresses.

## Detection Heuristics

**User-Controlled Assembly sstore**
- `sstore(slot, value)` in inline assembly where `slot` is derived from `msg.sender`, calldata, or any function parameter
- No allowlist or bounds check on the slot value before the assembly write
- Public or external function exposing direct assembly storage writes

**Pre-0.6 Array Length Manipulation**
- Solidity version `< 0.6` with `array.length =` assignment present anywhere in scope
- A `setLength(uint256)` or equivalent function sets an array length to an arbitrarily large value
- Subsequent indexed write `array[index] = value` with a large index wraps slot arithmetic to reach any storage slot

**Slot Collision via Proxy Patterns**
- Unstructured storage proxy where implementation slot selection depends on runtime input
- Storage slot for admin or implementation address reachable by writing to a colliding array or mapping slot

## False Positives

- Assembly is read-only (`sload` only, no `sstore` present)
- Slot is a compile-time constant (e.g., EIP-1967 literal `0x360894...`) with no user influence over its value
- Solidity >= 0.6 used throughout (compiler disallows `array.length` write assignment)
- `sstore` target slot derived exclusively from hardcoded internal constants, not from any external input

## reference/solidity/fv-sol-4-bad-access-control/fv-sol-4-c9-constructor-bypass-and-create2-squatting.md

# FV-SOL-4-C9 Constructor and Counterfactual Address Bypass

## TLDR

Two related patterns allow attackers to bypass access controls that assume `msg.sender` is an EOA or a not-yet-deployed address. During construction, `extcodesize` is zero even though the caller is a contract, allowing constructor-context calls to pass EOA-only guards. With CREATE2, if the salt is not bound to `msg.sender`, an attacker can precompute the deterministic address and deploy first, squatting the victim's expected counterfactual address and taking ownership.

## Detection Heuristics

**extcodesize Bypass**
- `require(msg.sender.code.length == 0)` or `require(extcodesize(caller()) == 0)` used as the primary security guard
- Pattern used in NFT minting limits, whitelist claims, or anti-bot checks where economic value is gated
- No secondary check preventing calls from constructor context (e.g., prior-block deposit requirement, signed permit, or merkle proof)

**CREATE2 Address Squatting**
- `salt` is user-supplied without incorporating `msg.sender` into the salt derivation
- Factory function calls `Create2.deploy(0, salt, bytecode)` where salt is a raw user-provided `bytes32`
- Account abstraction wallets where the counterfactual address is used for fund custody before deployment
- `initialize(owner)` called as a separate transaction after `Create2.deploy` - owner address squattable by a front-runner who deploys first

## False Positives

- `require(msg.sender.code.length == 0)` is non-security-critical (informational soft anti-bot only, no economic gating)
- Access protected by alternative mechanism: signed permit, merkle proof, or prior-block deposit that a constructor-context call cannot satisfy
- Salt binds to deployer: `keccak256(abi.encodePacked(msg.sender, userSalt))`
- Factory restricts deployment to whitelisted callers only
- Owner set via constructor argument embedded in `creationCode` - different owner produces a different deterministic address

## reference/solidity/fv-sol-4-bad-access-control/readme.md

# FV-SOL-4 Bad Access Control

## TLDR

Improper access control can let unauthorized users access or modify restricted functionality

## Code

```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract BadAccessControl {
    address public owner;

    constructor() {
        owner = msg.sender;
    }

    function deposit() public payable {}

    function withdraw() public {
        // No access control here, anyone can call this
        payable(msg.sender).transfer(address(this).balance);
    }
}
```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-4-bad-access-control/<filename>` to read any case file listed below.

#### fv-sol-4-c1-using-tx.origin-for-authorization.md

#### fv-sol-4-c2-unrestricted-role-assignment.md

#### fv-sol-4-c3-lack-of-multi-signature-for-crucial-operations.md

#### fv-sol-4-c4-signature-security-flaws.md

Covers: signature malleability, `ecrecover` returning `address(0)`, signature replay via missing nonce.

#### fv-sol-4-c5-callback-authorization-bypass.md

Covers: flash loan callback spoofing, `onERC721Received` caller spoofing, ERC1155 unauthorized burn, ERC4626 missing allowance check, `setApprovalForAll` over-permission.

#### fv-sol-4-c6-arbitrary-external-call.md

Covers: user-supplied `target` + `calldata` enabling asset theft via crafted `transferFrom`.

#### fv-sol-4-c7-erc1271-signature-delegation.md

Covers: `isValidSignature` delegated to untrusted or user-set module.

#### fv-sol-4-c8-arbitrary-storage-write.md

#### fv-sol-4-c9-constructor-bypass-and-create2-squatting.md

extcodesize returns zero during constructor execution, bypassing EOA checks; CREATE2 salt not bound to msg.sender allows address squatting.

#### fv-sol-4-c10-commit-reveal-merkle-binding.md

Commit-reveal not bound to msg.sender enables front-running; merkle second preimage attack; merkle proof not bound to caller allows replay.

#### fv-sol-4-c11-hash-collision-and-encoding.md

abi.encodePacked collision with multiple dynamic types; calldata malleability bypasses raw msg.data deduplication.

Covers: assembly `sstore` with user-controlled slot, Solidity <0.6 array length assignment.

## Mitigation Patterns

### Ownership Pattern (FV-SOL-4-M1)

The ownership pattern restricts critical functions to the contract owner, usually set during contract deployment. This is commonly achieved with an `onlyOwner` modifier

### Proper RBAC (FV-SOL-4-M2)

Role-Based Access Control allows defining multiple roles, each with specific permissions. For example, roles like `Admin`, `Minter`, or `Pauser` can be created, allowing more granular control

### Multi-Signature Approval (FV-SOL-4-M3)

Multi-sig patterns require multiple accounts to approve a critical action before it can be executed. This reduces the risk of unauthorized actions due to a compromised account

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-02-eth-gets-locked-in-the-groupcoinfactory-contract-pashov-audit-group-none-groupcoin-markdown](https://solodit.cyfrin.io/issues/h-02-eth-gets-locked-in-the-groupcoinfactory-contract-pashov-audit-group-none-groupcoin-markdown)

## reference/solidity/fv-sol-5-logic-errors

```

```

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c1-boundary-misalignment.md

# FV-SOL-5-C1 Boundary Misalignment

## TLDR

Occurs when code fails to correctly define exclusive versus inclusive boundaries at interval thresholds, causing values at the cutoff to fall into the wrong range, be double-counted, or be skipped entirely.

## Detection Heuristics

**Mixed Inclusive/Exclusive Operators in Chained Conditions**
- Adjacent if-else ranges mix `<` and `<=` without accounting for overlap at shared boundary values
- A value satisfies two consecutive conditions simultaneously (e.g., `score == 100` matches both `score <= 100` and `score < 150` if the chain is written incorrectly)
- Boundary constant appears on both sides of adjacent range checks without mutual exclusion

**Off-by-One in Loops and Epoch Windows**
- Loop bound written as `i <= arr.length` instead of `i < arr.length`
- Time window start or end expressed as `block.timestamp >= windowEnd` when `>` is required to exclude the boundary
- Epoch or slot number checked with `>=` at both lower and upper bound of adjacent tiers

**Tiered Threshold Logic**
- Token amount or score thresholds use inconsistent operators across tiers
- A tier boundary value is reachable by two different branches due to operator mismatch
- Hardcoded boundary constants differ between the condition and the documented specification

## False Positives

- Each range uses explicit `>= lower && < upper` with no overlap between adjacent conditions
- Boundary constants defined once and reused consistently across all comparison sites
- Unit tests cover exact boundary values and confirm each lands in exactly one branch

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c10-data-structure-state-integrity.md

# FV-SOL-5-C10 Data Structure State Integrity

## TLDR

Three related patterns where data structure operations leave inconsistent state:

- **Array delete gap**: `delete array[index]` zeroes the element but does not shift or shrink - iteration over the array sees phantom zero-value entries.
- **Duplicate items in user-supplied array**: no deduplication check allows a user to pass the same ID multiple times in one call, repeatedly applying an action intended to occur once.
- **Nested mapping not cleared on struct delete**: `delete myMapping[key]` zeroes primitive fields but cannot clear nested `mapping` or dynamic array fields - reused keys expose stale values.

## Detection Heuristics

**Array Delete Gap**
- `delete array[index]` followed by iteration over `array` (not using swap-and-pop)
- Distribution loop: `for (i; i < arr.length; i++) transfer(arr[i], ...)` after element deletion
- `arr.length` unchanged after delete - loop visits zero-address entries

**Duplicate Array Items**
- Function accepts `uint256[]` or `address[]` parameter (tokenIds, positions, claimIds)
- No `require(!seen[id])` guard or sorted-unique check
- State zeroed inside loop body - second iteration sends 0 (if not reverted) or double-charges

**Nested Mapping Not Cleared**
- `delete myMapping[key]` on a struct type containing `mapping` fields
- Key reused after deletion - stale nested values accessible
- Approvals, allowances, or configuration sub-maps not explicitly cleared before reuse

## False Positives

- Swap-and-pop used for all array element removal
- Sorted-unique input enforced: `require(ids[i] > ids[i-1])`
- Deduplication via `mapping(id => bool) seen` reset per call
- State change (zero-out) happens before any transfer in loop - second duplicate reverts naturally
- Nested mapping cleared manually before struct delete or key reuse explicitly prevented

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c11-weak-onchain-randomness.md

# FV-SOL-5-C11 Weak On-Chain Randomness

## TLDR

Randomness derived from on-chain values is manipulable by validators/miners or predictable by any caller, making it unsuitable for games, lotteries, NFT trait generation, or any outcome where a participant can gain an advantage by knowing the result in advance.

- `block.prevrandao` (formerly `block.difficulty`): validator-influenceable on PoS - validators can choose to reveal or withhold their block proposal to get a favorable value
- `blockhash(block.number - 1)`: visible to the tx sender before inclusion; miners could reorder
- `block.timestamp`, `block.coinbase`: influenceable by block proposer

A commit-reveal scheme provides genuine randomness only when the reveal is bound to a future block hash and there is slashing/penalty for non-reveal.

## Detection Heuristics

- `block.prevrandao`, `block.difficulty`, `block.timestamp`, `block.coinbase`, or `blockhash` used as primary randomness source
- Any combination of the above: `keccak256(abi.encodePacked(block.timestamp, msg.sender))` is still manipulable
- Commit-reveal without future-block reveal: reveal uses current block hash
- Commit-reveal with no penalty for non-reveal: validator reveals only favorable outcomes
- `uint256(keccak256(...)) % N` for lottery, rare NFT, or game outcome

## False Positives

- Chainlink VRF v2+ with minimum 3-block confirmation delay
- Commit-reveal with verifiably future block hash and economic penalty (slashing) for non-reveal
- Outcome has no economic value - randomness manipulation unprofitable
- Off-chain randomness with on-chain verification (e.g., DRAND, VDF proof)

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c2-incorrect-conditionals.md

# FV-SOL-5-C2 Incorrect Conditionals

## TLDR

Results from conditions in if-else chains where more specific cases are tested after more general ones, making specific branches unreachable, or from wrong comparison operators that cause values to be handled by the wrong branch or skipped entirely.

## Detection Heuristics

**Unreachable Branches Due to Condition Order**
- A less restrictive condition (`balance > 100`) appears before a more restrictive one (`balance > 500`) in an if-else chain, making the latter dead code
- Any input satisfying the later condition also satisfies an earlier condition, so execution never reaches the later branch
- Multi-tier reward or fee logic where higher-value tiers are checked after lower-value tiers

**Wrong Comparison Operator**
- `>=` used where `>` is required, or vice versa, causing a reward or penalty to trigger one block too early or too late
- `block.number >= lastRewardBlock` instead of `block.number > lastRewardBlock` duplicates or skips a reward distribution
- `<` versus `<=` confusion at the boundary of a cooldown, vesting cliff, or lock period

**Boolean Logic Errors**
- `||` used where `&&` is required in a guard: `!isActive || isBlocked` passes when only one condition is true
- Negation applied to compound expression with wrong precedence: `!a && b` when `!(a && b)` was intended
- Double negation or tautological condition that always evaluates to true or false

## False Positives

- Most restrictive conditions checked first in descending threshold order
- Single comparison with no chained else-if - only one branch possible
- Coverage tests confirm all branches reachable with distinct input classes

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c3-improper-state-transitions.md

# FV-SOL-5-C3 Improper State Transitions

## TLDR

A contract with a defined lifecycle (e.g., NotStarted, Active, Paused, Completed) allows state-modifying functions to execute without validating the current state, permitting out-of-order or repeated transitions that violate invariants and can be exploited.

## Detection Heuristics

**Missing Predecessor State Guard**
- A transition function modifies `state` without a `require(state == ExpectedPredecessor)` guard
- Terminal state (e.g., `Completed`, `Cancelled`) reachable directly from any state, not only from the valid predecessor
- Function that advances lifecycle phase contains no state check at all

**Multiple Entry Points Without Mutual Exclusion**
- Two or more functions can set the contract to the same state without checking for conflicts
- Re-entrancy or repeated calls to an initializer move state backward or cycle it
- `pause()` and `resume()` functions do not verify opposing states before toggling

**Missing State Validation on Operational Functions**
- Functions that should only execute in a specific phase (e.g., `claimReward` only during `Active`) lack a phase guard
- Withdrawal, reward distribution, or settlement callable before the contract reaches the required state
- State variable used as a flag is set but never checked by dependent functions

## False Positives

- Every transition function has an explicit `require(state == PreviousState)` check
- Only one valid predecessor state is permitted for each target state
- State machine transitions are documented and tested with invalid-sequence inputs that confirm reversion

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c4-misordered-calculations.md

# FV-SOL-5-C4 Misordered Calculations

## TLDR

Arithmetic operations applied in the wrong sequence produce incorrect results. Common cases include applying a bonus before a tax so the bonus is also taxed, computing interest before updating the principal, or applying a percentage to a post-adjusted value when the specification requires it on the pre-adjusted amount.

## Detection Heuristics

**Bonus or Premium Applied Before Percentage Deduction**
- Bonus, incentive, or premium added to a base value before a tax or fee percentage is applied to the combined sum
- Specification states tax applies only to the principal, but code computes tax on `principal + bonus`
- Protocol fee deducted from `amount + reward` rather than from `amount` alone

**Incorrect Sequencing of Running Totals**
- Cumulative counter or running balance updated before a per-item calculation that should use the pre-update value
- Price impact or slippage applied before the fee deduction step rather than after
- Interest accrual computed on a balance that already includes the current period's deposit

**Compound Percentage Operations Applied Sequentially When Composition Is Required**
- Two successive percentage reductions applied as independent multiplications rather than as `(1 - r1) * (1 - r2)`
- Multiplication overflow possible because intermediate result exceeds type bounds before division
- Division performed before multiplication in a single expression, losing precision

## False Positives

- Order of operations explicitly matches the documented formula with a referenced specification
- Tax applied to base amount only; bonus added to the already-taxed result
- Unit tests verify boundary and midpoint values against expected formula output

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c5-event-misreporting.md

# FV-SOL-5-C5 Event Misreporting

## TLDR

Events emit incorrect values - such as a cumulative balance instead of the current operation amount - causing off-chain indexers, monitoring systems, and oracles to record wrong data while on-chain state may be correct.

## Detection Heuristics

**Post-Update State Emitted Instead of Operation Delta**
- Event parameter passes `balances[msg.sender]` (cumulative post-update value) when the deposit delta `msg.value` should be reported
- State variable updated before the `emit`, and the emitted field reads the updated state rather than a pre-captured local variable
- Event field named `amount` or `value` that actually emits a running total or accumulated balance

**Missing Emit on Critical State Change**
- Role grant, ownership transfer, or privileged parameter update executed without emitting an event
- Multiple code paths modify the same state variable but only some paths emit the corresponding event
- Conditional logic causes the emit to be skipped on one branch (e.g., emit inside an `if` without a matching emit in the `else`)

**Wrong Event or Wrong Parameters**
- Event emitted with arguments in wrong order (e.g., `emit Transfer(to, from, amount)` instead of `emit Transfer(from, to, amount)`)
- Stale local variable captured before state update used in emit, reporting the pre-state when post-state is expected
- Event emitted for every loop iteration using a per-item value when a single summary event was intended

## False Positives

- Emitted value is explicitly the operation delta (`msg.value` or the `amount` parameter) not the post-state balance
- Local variable captures the value before state update and is passed to emit: `uint256 depositAmount = msg.value; balances[msg.sender] += depositAmount; emit Deposit(msg.sender, depositAmount);`
- Event documented as intentionally emitting cumulative balance with explicit naming (e.g., `newBalance`)

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c6-same-block-snapshot-abuse.md

# FV-SOL-5-C6 Same-Block Snapshot and Flash Loan Governance Abuse

## TLDR

Protocols that calculate yield, rewards, voting power, or insurance coverage based on a balance snapshot at a single point in time are vulnerable to flash loan amplification. An attacker borrows tokens, deposits before the snapshot (or in the same block), claims the benefit, then repays - all in one transaction. No minimum holding period means the capital requirement is zero.

## Detection Heuristics

- Governance voting uses `balanceOf` or current `balances[msg.sender]` rather than `getPastVotes(block.number - 1)`
- Reward/yield distribution uses current balance snapshot with no lock period
- Insurance or coverage calculated from `balanceOf` at claim time
- No minimum deposit age enforced before claiming rewards, votes, or benefits
- Deposit and withdraw in same block allowed - no cooldown between them
- Flash loan callbacks exist in the token contract and protocol is token-agnostic

## False Positives

- `getPastVotes(user, block.number - 1)` or equivalent past-block snapshot used
- Minimum holding period: `require(block.number > depositBlock[msg.sender] + N)`
- Reward accrual requires multiple blocks of staking - single-block stake earns nothing
- Protocol explicitly non-compatible with flash loanable tokens (whitelist enforced)

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c7-msg-value-reuse-multicall.md

# FV-SOL-5-C7 msg.value Reuse in Loop and Multicall

## TLDR

`msg.value` is a global that persists for the entire transaction. Reading it inside a loop or inside `delegatecall`-based multicall credits the full original ETH value on every iteration - a single payment appears as N payments.

This also applies to `delegatecall` multicall: each sub-call executes in the same context and sees the same `msg.value`, so calling a payable function N times via multicall charges ETH once but credits N times.

## Detection Heuristics

- `msg.value` read inside a `for` loop without a local accumulator variable
- `msg.value` compared against per-iteration cost without decrement
- `delegatecall`-based multicall where any sub-function is `payable`
- Uniswap V3 / OZ `Multicall` inherited with added `payable` functions
- Pattern: `address(this).delegatecall(data[i])` in a payable function

## False Positives

- `msg.value` captured to local variable before loop: `uint256 remaining = msg.value`
- `remaining -= cost` enforced per iteration
- Multicall uses `call` (not `delegatecall`) - separate context, `msg.value` is 0
- Function is `nonpayable` - `msg.value` always 0
- Single-item loop (length enforced as 1)

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c8-force-eth-injection.md

# FV-SOL-5-C8 Force ETH Injection

## TLDR

Three mechanisms send ETH to a contract without triggering `receive()` or `fallback()`:

1. **selfdestruct**: forced ETH transfer to any address, no code execution
2. **Coinbase / block reward**: mining/validating awards ETH directly to `block.coinbase`
3. **CREATE2 pre-funding**: sending ETH to a deterministic address before the contract is deployed

Contracts that use `address(this).balance` for invariant checks, exact-match accounting, or as a trigger condition can have those invariants violated by any of these mechanisms.

## Detection Heuristics

- `require(address(this).balance == X)` or `require(address(this).balance >= X)` as invariant guard
- `require(address(this).balance == 0)` as initialization guard
- ETH accounting that adds only through `receive()`/`fallback()` without reconciliation against `address(this).balance`
- Token price derived from `address(this).balance` without an internal accounting variable

## False Positives

- Internal accounting only: `totalDeposits` state variable updated in all ETH-receiving paths
- Contract explicitly designed to accept arbitrary ETH (e.g., ETH wrapper, donation contract)
- `address(this).balance` read only for informational/view purposes with no state side-effect
- selfdestruct target protection not required for non-critical ETH flows (documented)

## reference/solidity/fv-sol-5-logic-errors/fv-sol-5-c9-deployment-config-pitfalls.md

# FV-SOL-5-C9 Deployment and Configuration Pitfalls

## TLDR

Errors in deployment scripts and contract configuration cause permanent misconfiguration or front-runnable initialization windows. These are not runtime logic bugs but deployment-time failures that are often irrecoverable.

Key patterns:
- **Cross-chain replay**: deployment tx replayed on other chains (same nonce → same CREATE address, different owner/state)
- **Nonce gap from reverted txs**: pre-computed CREATE addresses wrong if intermediate tx reverts
- **Missing chain ID validation**: scripts that broadcast without asserting `block.chainid`
- **Non-atomic deployment**: separate deploy + initialize transactions leave a front-runnable window
- **Immutable misconfiguration**: constructor args silently swapped (multiple same-type addresses)
- **Hardcoded addresses**: literal `address(0x...)` for external dependencies, wrong on other chains
- **Block number as timestamp**: `block.number * 13` assumes fixed block times across chains

## Detection Heuristics

**Cross-Chain Replay / Wrong Network**
- `block.chainid` not asserted at start of deployment script or in constructor
- No `--chain-id` flag in Foundry script; no EIP-155 enforcement
- Same deployer EOA used across chains without nonce tracking

**Nonce Gap / CREATE Address Mismatch**
- Deployment script uses `CREATE` with pre-computed addresses from deployer nonce
- Multiple `vm.broadcast()` blocks with intermediate revertable calls
- Addresses stored in config before deployment receipt confirmed

**Non-Atomic Deploy + Init**
- `new Proxy(impl, admin, "")` with empty data, `initialize()` called in separate tx
- Uninitialized proxy in public mempool between two transactions

**Immutable Misconfiguration**
- Multiple `address` parameters in constructor without named deployment config
- Post-deploy assertions absent from deployment script

**Block Number as Timestamp**
- `(block.number - startBlock) * 13` for vesting/interest/reward calculation
- Hardcoded block time constant used on multi-chain deployment

**Hardcoded Addresses**
- Literal `address(0x...)` for routers, oracles, tokens in production code
- No per-chain config file keyed by `block.chainid`

## False Positives

- `require(block.chainid == expectedChainId)` at script start
- `block.timestamp` used for all time calculations
- Atomic deploy: init calldata passed in proxy constructor `new Proxy(impl, admin, initData)`
- `_disableInitializers()` in implementation constructor
- Per-chain config file with addresses looked up by `block.chainid`
- Deployment script reads back and asserts every configured immutable value
- `CREATE2` used - nonce-independent, pre-computed addresses correct regardless of intermediate reverts

## reference/solidity/fv-sol-5-logic-errors/readme.md

# FV-SOL-5 Logic Errors

## TLDR

Logic errors arise from mistakes in the program’s control flow or conditional statements.

These errors usually occur when the code’s behavior deviates from its intended purpose, not because of a flaw in the underlying arithmetic but due to a conceptual mistake in implementing rules or boundaries.

## Code


```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VulnerableMarket {
    uint256 constant BLOCK_EPOCH = 100000;
    mapping(uint256 => uint256) public cantoPerBlock; // Reward rates by epoch
    struct MarketInfo {
        uint256 lastRewardBlock;
        uint256 accCantoPerShare;
    }
    mapping(address => MarketInfo) public marketInfo;

    constructor() {
        cantoPerBlock[0] = 100;       // Reward for epoch 0-99999
        cantoPerBlock[BLOCK_EPOCH] = 0; // No reward for epoch 100000+
    }

    function update_market(address _market) public {
        MarketInfo storage market = marketInfo[_market];
        if (block.number > market.lastRewardBlock) {
            uint256 i = market.lastRewardBlock;
            while (i < block.number) {
                uint256 epoch = (i / BLOCK_EPOCH) * BLOCK_EPOCH;
                // Incorrect: should be `epoch + BLOCK_EPOCH`
                // Adding BLOCK_EPOCH to i only creates a fixed offset from i. It moves i forward by 100,000 blocks from whatever its current position is. However, this new position could land anywhere within an epoch and will not necessarily align with the start of the next epoch boundary
                uint256 nextEpoch = i + BLOCK_EPOCH; 
                uint256 blockDelta = min(nextEpoch, block.number) - i;

                // Incorrect reward calculation across epochs
                market.accCantoPerShare += blockDelta * cantoPerBlock[epoch];
                i += blockDelta;
            }
            market.lastRewardBlock = block.number;
        }
    }

    function min(uint256 a, uint256 b) internal pure returns (uint256) {
        return a < b ? a : b;
    }
}
```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-5-logic-errors/<filename>` to read any case file listed below.

#### fv-sol-5-c1-boundary-misalignment.md

#### fv-sol-5-c2-incorrect-conditionals.md

#### fv-sol-5-c3-improper-state-transitions.md

#### fv-sol-5-c4-misordered-calculations.md

#### fv-sol-5-c5-event-misreporting.md

#### fv-sol-5-c6-same-block-snapshot-abuse.md

Flash loan + same-block deposit captures governance votes, yield, or insurance coverage; no minimum holding period enforced.

#### fv-sol-5-c7-msg-value-reuse-multicall.md

msg.value read inside loop credits full ETH on every iteration; delegatecall-based multicall allows msg.value reuse across sub-calls.

#### fv-sol-5-c8-force-eth-injection.md

selfdestruct, coinbase rewards, and CREATE2 pre-funding send ETH without triggering receive(); breaks balance-based invariants.

#### fv-sol-5-c9-deployment-config-pitfalls.md

Cross-chain replay, nonce gaps, non-atomic init front-running, immutable misconfiguration, hardcoded addresses, block-number-as-timestamp.

#### fv-sol-5-c10-data-structure-state-integrity.md

Array delete leaves zero gap; duplicate IDs in user-supplied arrays enable double-claims; nested mappings not cleared on struct delete.

#### fv-sol-5-c11-weak-onchain-randomness.md

prevrandao, blockhash, block.timestamp, and coinbase all manipulable or predictable; commit-reveal without future-block binding insufficient.

## Mitigation Patterns

### State Machine Design (FV-SOL-5-M1)

The State Machine Design mitigation pattern is a robust approach for managing complex workflows or processes with defined states

### Fail-Safe Defaults (FV-SOL-5-M2)

Use safe defaults in case of unexpected conditions or edge cases

### Unit Testing on Edge Cases (FV-SOL-5-M3)

Implement exhaustive tests for each function, focusing on boundary values, extreme inputs, and edge cases

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-02-eth-gets-locked-in-the-groupcoinfactory-contract-pashov-audit-group-none-groupcoin-markdown](https://solodit.cyfrin.io/issues/h-02-update_market-nextepoch-calculation-incorrect-code4rena-canto-canto-git)

## reference/solidity/fv-sol-6-unchecked-returns

```

```

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c1-unchecked-call-return.md

# FV-SOL-6-C1 Unchecked Call Return

## TLDR

Low-level calls (`call`, `delegatecall`, `staticcall`, `send`) return a boolean success flag instead of reverting on failure. When the return value is discarded the caller continues execution under the false assumption that the operation succeeded, enabling lost funds, skipped logic, and corrupted state.

## Detection Heuristics

**Discarded Return Value**
- `target.call(data)` as a standalone statement with no `(bool success, ...)` capture
- `target.delegatecall(data)` or `target.staticcall(data)` return value not stored or checked
- `target.send(amount)` without storing and checking the returned bool

**Captured but Unchecked**
- `(bool success, bytes memory ret) = target.call(data)` followed by no `require(success)` or conditional revert
- `bool ok = addr.call(...)` where `ok` is never read after assignment

**Indirect Patterns**
- Helper function wraps a low-level call and returns void, discarding the inner bool
- Assembly `call` opcode with the success value popped from stack rather than stored

## False Positives

- `require(success, "...")` or `if (!success) revert ...` immediately follows the captured bool
- Intentional fire-and-forget call where failure is an accepted outcome and is explicitly documented in a NatSpec comment
- Wrapped in an internal helper that itself reverts on failure and is used consistently throughout the codebase

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c10-nonstandard-erc20-behavior.md

# FV-SOL-6-C10 Non-Standard ERC20 Behavior

## TLDR

Several widely-used tokens deviate from the ERC20 spec in ways that break standard protocol integration:

- **Missing return value** (USDT, BNB): `transfer()`/`transferFrom()` return nothing instead of `bool`. Calling `require(token.transfer(...))` reverts.
- **Non-zero to non-zero approve revert** (USDT): `approve(spender, amount)` reverts if current allowance is non-zero. Requires `approve(0)` first.
- **Max-approval revert** (some tokens): `approve(type(uint256).max)` reverts.
- **Missing/incorrect events**: custom `transfer()`/`transferFrom()` not emitting `Transfer`, or `approve()` not emitting `Approval`. Off-chain indexers and integrations break silently.

All of these are silent integration failures - no revert, wrong state, or broken tooling.

## Detection Heuristics

**Missing Return Value**
- `require(token.transfer(...))` or `require(token.transferFrom(...))` without SafeERC20
- `bool success = token.transfer(...)` without checking that call didn't revert
- Protocol claims USDT/BNB/WBTC support but uses raw `.transfer()`

**Non-Standard Approve**
- `token.approve(spender, newAmount)` without first calling `approve(0)` or using `forceApprove`
- Re-approval in loops: `token.approve(router, amounts[i])` per-iteration
- `token.approve(spender, type(uint256).max)` without token compatibility check

**Missing Events**
- Custom ERC20 override of `transfer`/`transferFrom` that skips `emit Transfer`
- `_mint`/`_burn` override that skips `emit Transfer(address(0), to, amount)`
- Custom `approve` that skips `emit Approval`

## False Positives

- OZ `SafeERC20.safeTransfer()`/`safeTransferFrom()` used for all token operations
- OZ `SafeERC20.forceApprove()` or `safeIncreaseAllowance()` used for approvals
- Token whitelist restricted to fully ERC20-compliant tokens (verified in tests)
- OZ ERC20 base used without overriding transfer/approve/event logic

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c11-create-deployment-silent-failure.md

# FV-SOL-6-C11 CREATE / CREATE2 Deployment Silent Failure

## TLDR

Assembly `create(v, offset, size)` and `create2(v, offset, size, salt)` return `address(0)` on failure - insufficient ETH balance, address collision, or init code revert. Unlike the high-level `new Contract()` syntax, these opcodes do not revert automatically on failure.

If the code does not check for the zero return value, `address(0)` is stored or used in subsequent logic. Calls to `address(0)` succeed as no-ops (no deployed code) or interact with precompiles, silently corrupting state.

## Detection Heuristics

- `create(...)` or `create2(...)` in assembly without `if iszero(addr) { revert(0,0) }` immediately after
- Returned address stored in mapping/array or used in an interface call without zero check
- Factory pattern: result passed directly to `IContract(addr).initialize(...)` 
- `create2` with user-supplied salt where collision is possible (salt not bound to `msg.sender`)
- No Solidity-level `require(addr != address(0))` after the assembly block

## False Positives

- `if iszero(addr) { revert(0, 0) }` immediately after create/create2 in assembly
- High-level `new Contract{salt: s}(args)` syntax (reverts automatically on failure)
- Address validated with `require(addr != address(0))` after the assembly block before any use
- Salt collision impossible by construction (salt = `keccak256(abi.encodePacked(msg.sender, nonce))`)

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c2-unchecked-transfer-return.md

# FV-SOL-6-C2 Unchecked Transfer Return

## TLDR

Failing to check the return value of `transfer` or `transferFrom` on ERC20 tokens allows silent transfer failures to go undetected. Certain tokens return `false` instead of reverting on failure; ignoring the return value lets execution continue as if the transfer succeeded, leading to incorrect balance accounting or drained protocol value.

## Detection Heuristics

**Discarded Return Value**
- `token.transfer(recipient, amount)` as a bare statement with return value ignored
- `token.transferFrom(from, to, amount)` with no bool capture or require check

**Captured but Unchecked**
- `bool success = token.transfer(...)` where `success` is never evaluated before the function returns
- Return value stored in a local variable that is shadowed or unused

**Missing SafeERC20 Wrapper**
- Raw interface call to `IToken.transfer` or `IERC20.transferFrom` without `SafeERC20` in import list
- Protocol documents USDT or BNB support while using raw `transfer` calls that require a bool return

## False Positives

- `require(token.transfer(...), "failed")` explicitly enforces revert on false return
- `SafeERC20.safeTransfer` or `SafeERC20.safeTransferFrom` used for all token operations
- Token is a known fully-compliant ERC20 that always reverts on failure and the whitelist is enforced in tests

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c3-silent-fail.md

# FV-SOL-6-C3 Silent Fail

## TLDR

A function call fails without detection and execution continues as if it succeeded, producing an invalid or inconsistent contract state. This pattern arises whenever a callee signals failure through a return value rather than a revert and the caller does not inspect that value.

## Detection Heuristics

**Bool-Returning External Calls Without Checks**
- External function typed `returns (bool)` called as a statement with the return discarded
- `externalContract.performAction()` where `performAction` returns `bool` but the caller does not capture it

**State Updated After Unchecked Call**
- Contract state (mappings, balances, counters) updated immediately after a call whose success was not verified
- Event emitted signaling completion before the success condition is confirmed

**Interface Mismatch**
- Interface declares `returns (bool)` but the implementing contract may return `false` on failure rather than reverting
- Protocol mixes reverting and non-reverting callee contracts under the same interface without differentiating handling

## False Positives

- Return value is captured and `require(success, "...")` is present before any state mutation
- Callee contract is verified to always revert on failure and never returns `false` (documented and tested)
- Function is a view/pure call with no state impact where the result is intentionally unused

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c4-false-positive-success-assumption.md

# FV-SOL-6-C4 False Positive Success Assumption

## TLDR

The contract captures a failure signal from an external call but treats the failure path as a no-op, allowing execution to continue as if the call succeeded. This produces state inconsistencies and incorrect balance or permission assumptions when the external call actually failed.

## Detection Heuristics

**Empty Failure Branch**
- `if (!success) { }` or `if (!success) { /* ignored */ }` with no revert, emit, or corrective action
- `bool success = ext.doSomething(); if (!success) {}` pattern where the else path continues normally

**Suppressed Error with Continued Execution**
- Failure condition acknowledged in a comment but not acted upon: `// ignore failure`
- `try/catch` block with an empty `catch` body followed by state-altering code

**Incorrect Fallback Logic**
- Failure branch emits an event but does not revert, allowing the transaction to commit with partial state
- Failure branch logs an error off-chain (event) while on-chain state reflects success

## False Positives

- `require(success, "...")` enforces an immediate revert on the failure path
- Failure branch explicitly undoes prior state changes and reverts: `balance -= amount; revert(...)`
- Intentional degraded-mode logic where failure of the external call is a safe and documented operational outcome with correct state handling

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c5-partial-execution-with-no-rollback.md

# FV-SOL-6-C5 Partial Execution with No Rollback

## TLDR

When an external call fails mid-function, prior state mutations already applied in the same transaction are not automatically rolled back unless the function reverts. Manual compensation logic is error-prone and may leave state partially modified, producing inconsistencies that compound over subsequent transactions.

## Detection Heuristics

**State Mutated Before External Call**
- `balance += amount` or mapping write followed by `externalContract.doAction()` where the call result may indicate failure
- Multiple sequential state changes before an external call, with only the last change manually reversed on failure

**Incomplete Manual Rollback**
- `if (!success) { balance -= amount; }` that reverses one change but ignores others made earlier in the function
- Manual compensation missing from one or more modified state variables
- Reentrancy risk: partial state visible to reentrant calls between the first mutation and the rollback

**Checks-Effects-Interactions Violation**
- External call placed before all state updates are finalized
- `require(success)` placed after multiple state changes rather than before them

## False Positives

- `require(success, "...")` causes the EVM to atomically revert all state changes in the transaction
- External call is made before any state mutation (checks-effects-interactions pattern fully followed)
- All state changes occur after the external call returns and are conditional on its success

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c6-false-contract-existence-assumption.md

# FV-SOL-6-C6 False Contract Existence Assumption

## TLDR

Calling a function on an address that contains no contract code does not revert - the EVM treats it as a successful call returning empty data. When a protocol stores or accepts an external address without verifying it is a deployed contract, calls to that address silently succeed as no-ops, producing incorrect state updates or bypassed logic.

## Detection Heuristics

**Unvalidated Address at Construction or Initialization**
- Constructor assigns `externalContract = _addr` without `require(_addr.code.length > 0)`
- `initialize(address token)` stores `token` without verifying it is a contract
- Admin setter `setTarget(address t)` with no `extcodesize` or `code.length` check

**Interface Cast Without Existence Check**
- `IExternalContract(addr).performAction()` where `addr` is user-supplied or comes from an unvalidated storage variable
- Multicall or batch executor iterates over user-provided addresses without per-entry validation

**Post-Creation Use Without Zero-Address Check**
- `factory.deploy()` result used to call methods without checking the returned address is non-zero and is a contract
- Address loaded from a mapping or array that was never validated at write time

## False Positives

- Address validated at storage time with `require(addr.code.length > 0)` before assignment
- Address is a compile-time constant referencing a known deployed contract
- Address constrained to a whitelist where all entries were verified to be contracts at onboarding time
- EIP-1167 minimal proxy: zero-code check is not applicable because the proxy is deployed atomically in the same call

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c7-zero-amount-transfer-revert.md

# FV-SOL-6-C7 Zero-Amount Transfer Revert

## TLDR

Some non-standard ERC20 tokens (LEND, early BNB, and others) revert on `transfer(to, 0)` or `transferFrom(from, to, 0)`. Protocols that perform distribution loops or yield claims without guarding against zero amounts will be DoS'd when the distributed amount rounds to zero - permanently bricking claims for affected users or entire distribution rounds.

## Detection Heuristics

- `token.transfer(to, amount)` or `token.transferFrom(from, to, amount)` where `amount` can be zero
- Distribution loop: `share = total * weight[i] / totalWeight` - per-recipient share rounds to zero when `total` is small or `totalWeight` is large
- Unclaimed yield/fee accumulated over short periods with integer truncation
- No `if (amount > 0)` guard before transfer in claim/distribute functions
- Protocol documents support for tokens without specifying zero-transfer behavior

## False Positives

- `if (amount > 0)` guard before every transfer call in the hot path
- Minimum claim amount enforced: `require(claimable >= MIN_CLAIM)`
- Token whitelist explicitly verified to accept zero-amount transfers
- Pull-pattern where users claim non-zero amounts only (zero-balance claims reverted upstream)

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c8-return-bomb.md

# FV-SOL-6-C8 Return Bomb (Returndata Copy DoS)

## TLDR

When a contract makes an external call using `(bool success, bytes memory data) = target.call(payload)`, the EVM copies the full returndata into memory. A malicious or compromised `target` can return enormous amounts of data, causing the caller to spend enormous gas copying it - potentially exceeding the block gas limit and reverting the entire transaction.

This is particularly dangerous when `target` is user-supplied (e.g., in batch executors, meta-transaction relayers, or arbitrary call dispatchers).

## Detection Heuristics

- `(bool success, bytes memory returndata) = target.call(payload)` where `target` is user-controlled
- Batch executor or multicall copying returndata from arbitrary addresses
- `revert(add(returndata, 32), mload(returndata))` pattern propagating returndata from untrusted call
- Gas-limited calls where the gas budget doesn't account for returndata copy cost
- No `returndatasize()` check or cap before `returndatacopy`

## False Positives

- Returndata not copied: `(bool success,) = target.call(data)` (empty bytes pattern)
- Assembly call with explicit `outsize = 0`: `call(gas(), target, value, inOffset, inSize, 0, 0)` - no copy occurs
- Callee is hardcoded trusted contract (no user control over `target`)
- Gas-limited call with budget accounting for worst-case returndata size
- `returndatasize()` capped before copy: `if gt(returndatasize(), MAX_RETURN) { revert(0,0) }`

## reference/solidity/fv-sol-6-unchecked-returns/fv-sol-6-c9-erc721-unsafe-transfer.md

# FV-SOL-6-C9 ERC721 Unsafe Transfer to Non-Receiver Contract

## TLDR

`ERC721._transfer()` and the low-level `transferFrom()` do not check whether the recipient contract implements `IERC721Receiver`. Sending an NFT to a contract that lacks the receiver interface permanently locks the token - it can never be recovered.

`safeTransferFrom()` and `_safeMint()` trigger `onERC721Received()` on the recipient and revert if the return value is not the expected selector. Using the unsafe variants on user-supplied or unknown recipient addresses silently locks tokens.

## Detection Heuristics

- `_mint(to, tokenId)` or `_transfer(from, to, tokenId)` called directly where `to` is user-supplied
- `nft.transferFrom(from, to, id)` in marketplace/escrow/settlement logic without `to.code.length` check
- Custom token contract overrides `_transfer` and calls base `_transfer` without safe receiver check
- `nft.transferFrom` used because `safeTransferFrom` was "too expensive" (common comment in code)

## False Positives

- All mint/transfer paths use `_safeMint`/`safeTransferFrom` exclusively
- Recipient is always an EOA (enforced: `require(to.code.length == 0)`)
- Function is `nonReentrant` AND a prior check confirms recipient implements the interface
- Protocol explicitly limits recipients to whitelisted contracts verified to implement `IERC721Receiver`

## reference/solidity/fv-sol-6-unchecked-returns/readme.md

# FV-SOL-6 Unchecked Returns

### TLDR

Failure to check returns is a surprising pitfall to many smart contracts. Not checking returns properly could cause unexpected behavior leading to security issues as a result.

## Code


```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract VulnerableUncheckedCall {
    mapping(address => uint256) public balances;

    // Allows users to deposit Ether into the contract
    function deposit() external payable {
        balances[msg.sender] += msg.value;
    }

    // Vulnerable withdraw function with an unchecked external call
    function withdraw(uint256 amount) external {
        require(balances[msg.sender] >= amount, "Insufficient balance");

        balances[msg.sender] -= amount;

        // Unchecked call to send Ether to the sender
        // Vulnerability: If this call fails, the contract does not handle it,
        // resulting in potential issues for the user
        (bool success, ) = msg.sender.call{value: amount}("");
        
        // The return value of the call is unchecked, so the contract assumes the transfer succeeded
    }
}

```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-6-unchecked-returns/<filename>` to read any case file listed below.

#### [.](./ "mention")

#### fv-sol-6-c2-unchecked-transfer-return.md

#### fv-sol-6-c3-silent-fail.md

#### fv-sol-6-c4-false-positive-success-assumption.md

#### fv-sol-6-c5-partial-execution-with-no-rollback.md

#### fv-sol-6-c6-false-contract-existence-assumption.md

#### fv-sol-6-c7-zero-amount-transfer-revert.md

Tokens (LEND, early BNB) that revert on zero-amount transfers; distribution loops and rounded-fee claims DoS'd.

#### fv-sol-6-c8-return-bomb.md

Malicious callee returns enormous returndata; copying cost exhausts caller gas. Affects arbitrary-call dispatchers and batch executors.

#### fv-sol-6-c9-erc721-unsafe-transfer.md

`_mint`/`transferFrom` used instead of `_safeMint`/`safeTransferFrom`; NFTs permanently locked in non-receiver contracts.

#### fv-sol-6-c10-nonstandard-erc20-behavior.md

Missing return values (USDT/BNB), non-zero-to-non-zero approve revert, max-approval revert, missing Transfer/Approval events.

#### fv-sol-6-c11-create-deployment-silent-failure.md

Assembly `create`/`create2` returns `address(0)` on failure without reverting; zero address stored or called silently.

## Mitigation Patterns

### Checked Returns FV-SOL-6-M1)

It is generally a good strategy to ensure that all returns in your contract has at least minimal checks for validity, success and expected return values

### Checks-Effects-Interactions(FV-SOL-6-M2)

This pattern ensures that all internal changes are made (checks and effects) before any external calls are made, reducing reentrancy risks and ensuring contract state integrity before interactions

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/m-11-unchecked-return-value-of-low-level-calldelegatecall-code4rena-nextgen-nextgen-git](https://solodit.cyfrin.io/issues/m-11-unchecked-return-value-of-low-level-calldelegatecall-code4rena-nextgen-nextgen-git)
* [https://solodit.cyfrin.io/issues/lack-of-contract-existence-check-on-delegatecall-may-lead-to-unexpected-behavior-trailofbits-yield-v2-pdf](https://solodit.cyfrin.io/issues/lack-of-contract-existence-check-on-delegatecall-may-lead-to-unexpected-behavior-trailofbits-yield-v2-pdf)
* [https://solodit.cyfrin.io/issues/h-03-result-of-transfer-transferfrom-not-checked-code4rena-spartan-protocol-spartan-protocol-contest-git](https://solodit.cyfrin.io/issues/h-03-result-of-transfer-transferfrom-not-checked-code4rena-spartan-protocol-spartan-protocol-contest-git)

## reference/solidity/fv-sol-7-proxy-insecurities

```

```

## reference/solidity/fv-sol-7-proxy-insecurities/fv-sol-7-c1-delegatecall-storage-collision.md

# FV-SOL-7-C1 delegatecall Storage Collision

## TLDR

`delegatecall` executes code from another contract in the calling contract's storage context, preserving `msg.sender` and `msg.value`. When the proxy and implementation contracts declare state variables at overlapping storage slots, writes from the implementation silently corrupt proxy-level state such as the admin address or the stored implementation pointer.

## Detection Heuristics

**Proxy and Implementation Share Sequential Slot Layout**
- Proxy declares one or more state variables (e.g., `address public implementation`) starting at slot 0
- Implementation also declares state variables starting at slot 0
- No EIP-1967 or EIP-7201 namespaced slot used for proxy-reserved storage

**Implementation Pointer Stored in Sequential Slot**
- `implementation` address stored as a regular top-level state variable instead of via `sstore` to a `keccak256`-derived slot
- First storage slot of proxy holds the implementation address, making it overwritable by any implementation function that writes to slot 0

**User-Controlled delegatecall Target**
- `delegatecall` called with a target address supplied by the caller or stored in unconstrained proxy state
- No validation that the target address is an approved or expected implementation contract

**Missing Zero-Address Guard Before delegatecall**
- Fallback or forwarding function calls `delegatecall` without checking `implementation != address(0)`
- Uninitialized proxy delegates to the zero address, which succeeds silently on some chains

## False Positives

- Implementation pointer stored via `sstore` at a `keccak256`-derived slot (EIP-1967: `keccak256("eip1967.proxy.implementation") - 1`)
- All proxy-reserved storage uses EIP-7201 namespaced positions with no overlap with sequential implementation slots
- Implementation contract has no state variables at slot 0 (all storage in a diamond-style namespaced struct)
- Read-only proxies that never write storage through delegatecall

## reference/solidity/fv-sol-7-proxy-insecurities/fv-sol-7-c2-function-selector-collision.md

# FV-SOL-7-C2 Function Selector Collision

## TLDR

Function selectors are the first four bytes of the keccak256 hash of a function signature. When a proxy contract exposes public functions whose selectors match functions in the implementation, the proxy intercepts and handles those calls itself rather than delegating them, causing silent misbehavior or unauthorized access to proxy-level operations.

## Detection Heuristics

**Public Functions on Proxy Contract**
- Proxy contract defines `public` or `external` functions beyond the fallback and constructor
- Any proxy function selector can be brute-forced or accidentally matched by an implementation function

**Upgrade or Admin Functions Exposed as Public**
- `setImplementation`, `upgradeTo`, or admin transfer functions are `public` instead of `internal` or protected behind a dedicated admin-only path
- Callers targeting the implementation can accidentally trigger proxy-level state changes

**No Selector Isolation Between Proxy and Implementation**
- Proxy and implementation compiled without a tool (e.g., OZ upgrades plugin) that checks for selector collisions at build time
- Implementation ABI not compared against proxy ABI for four-byte collisions before deployment

**Transparent Proxy Pattern Not Applied**
- Proxy does not distinguish between admin callers (routed to proxy functions) and non-admin callers (routed to implementation)
- All callers share the same routing logic, making selector collisions exploitable by any address

## False Positives

- Transparent proxy pattern where admin calls are routed to proxy functions and all other callers are unconditionally forwarded via fallback
- UUPS pattern where upgrade logic lives in the implementation (no public proxy functions that can collide)
- Selector collision checks enforced in CI via the OpenZeppelin upgrades plugin or equivalent static analysis
- Proxy exposes only `fallback` and `receive`, with all admin operations gated through a separate `ProxyAdmin` contract

## reference/solidity/fv-sol-7-proxy-insecurities/fv-sol-7-c3-centralized-update-control.md

# FV-SOL-7-C3 Centralized Update Control

## TLDR

When upgrade authority is held by a single EOA or unconstrained admin address, a compromised or malicious key holder can replace the implementation with arbitrary code, draining funds or bricking the contract. This represents a critical trust assumption that undermines the security guarantees of the protocol for all users.

## Detection Heuristics

**Single EOA Holds Upgrade Authority**
- `require(msg.sender == admin, ...)` in upgrade function where `admin` is set to `msg.sender` in the constructor
- No multisig, timelock, or governance contract in the upgrade call chain
- Admin address is a regular EOA rather than a contract address

**No Timelock on Upgrades**
- `updateImplementation` or `upgradeTo` takes effect immediately without a queuing delay
- No `TimelockController` or equivalent in the upgrade path
- Users have no window to exit before a new implementation is active

**Admin Role Non-Transferable or Irrevocable**
- No mechanism to transfer admin to a more secure address after deployment
- No two-step admin transfer (propose + accept) to prevent accidental lockout

**Lack of Upgrade Event or Transparency**
- Implementation change emits no event or logs no verifiable on-chain record
- No mechanism for users or watchers to detect that an upgrade has occurred

## False Positives

- Admin is a multisig wallet (e.g., Gnosis Safe) with a threshold requiring multiple independent signers
- Upgrade function is gated behind a governance contract with on-chain voting and a timelock
- Upgrades require a two-step process: proposal followed by a time-delayed execution
- Protocol is in a guarded launch phase with planned migration to decentralized governance, documented and time-bounded

## reference/solidity/fv-sol-7-proxy-insecurities/fv-sol-7-c4-uninitialized-proxy.md

# FV-SOL-7-C4 Uninitialized Proxy

## TLDR

Proxy contracts that use `initialize()` instead of constructors for setup can be left in an uninitialized state if the initializer is never called, or can be re-initialized if the initializer lacks a one-time-use guard. Either condition allows an attacker to set critical ownership or configuration variables to their own address.

## Detection Heuristics

**No Zero-Address Check on Implementation Before delegatecall**
- `fallback` forwards calls via `delegatecall` without verifying `implementation != address(0)`
- Proxy deployed with implementation address not yet set, calls silently succeed or misbehave

**initialize() Not Protected Against Replay**
- `initialize` function uses no `initializer` modifier or equivalent initialized flag
- `initialized` flag is stored at a slot that can be overwritten by delegatecall storage collision
- Implementation contract's `initialize` is callable directly (not only through the proxy)

**Implementation Contract Missing disableInitializers in Constructor**
- Implementation constructor does not call `_disableInitializers()`
- Direct calls to the implementation's `initialize` can set an attacker-controlled owner on the implementation itself, enabling delegatecall-based exploits (e.g., selfdestruct via implementation takeover)

**Non-Atomic Proxy Deployment and Initialization**
- Proxy deployed in one transaction, `initialize()` called in a separate transaction
- Gap between deployment and initialization exploitable by front-running

**Re-initialization Possible in Upgrade**
- V2 implementation uses `initializer` modifier instead of `reinitializer(2)`, resetting already-initialized state on upgrade

## False Positives

- `initialize` guarded by OpenZeppelin `Initializable.initializer` modifier and called atomically in the proxy constructor via `data` parameter
- `_disableInitializers()` called in the implementation's constructor preventing direct initialization
- Proxy deployment and initialization are a single atomic transaction (init calldata passed to proxy constructor)
- `reinitializer(N)` used with a correctly incrementing version number for each upgrade

## reference/solidity/fv-sol-7-proxy-insecurities/fv-sol-7-c5-proxy-implementation-attacks.md

# FV-SOL-7-C5 Proxy Implementation Attack Vectors

## TLDR

Implementation contracts are not just storage targets - they are execution contexts with their own attack surface. Vulnerabilities include immutable variable context mismatch across proxies, arbitrary delegatecall exposed in the implementation, incomplete assembly fallback propagation, minimal proxy (EIP-1167) destruction when implementation is killed, and metamorphic contract substitution via CREATE2 and selfdestruct.

## Detection Heuristics

**Immutable Context Mismatch**
- `immutable` variables in implementation for addresses, chain IDs, or other per-deployment config
- Multiple proxies pointing to same implementation with different expected configurations
- `immutable` set in implementation constructor (not `initialize`) - same value forced everywhere

**Arbitrary Delegatecall**
- `target.delegatecall(data)` where `target` is caller-supplied or role-controlled but unbounded
- Implementation inherited from upgradeable library exposes generic execute function
- No whitelist or address validation on delegatecall target

**Assembly Proxy Propagation**
- Custom fallback with `delegatecall` but no `returndatacopy`
- No `switch result case 0 { revert(...) }` - swallowed failures
- `calldatacopy` absent - implementation receives empty calldata

**Minimal Proxy Destruction**
- `Clones.clone(impl)` where implementation has `selfdestruct` or unprotected `initialize`
- Implementation not protected by `_disableInitializers()` in constructor
- EIP-1167 clone factory without checking implementation is live

**Metamorphic via CREATE2**
- `CREATE2` deployment from address that can `selfdestruct` and redeploy
- Governance votes on bytecode hash but execution occurs after timelock expiry
- Pre-Dencun: `selfdestruct` + redeploy at same address with different code possible
- Post-Dencun (EIP-6780): only mitigated for non-same-tx create-destroy

## False Positives

- Per-proxy config in `initialize()` via storage variables, no `immutable` for deployment-specific values
- Delegatecall targets hardcoded as `immutable` verified library addresses
- OZ `Proxy.sol` used - complete calldata/returndata propagation correct by default
- `_disableInitializers()` in implementation constructor prevents direct initialization
- Post-Dencun deployment: `selfdestruct` no longer destroys code mid-tx

## reference/solidity/fv-sol-7-proxy-insecurities/fv-sol-7-c6-proxy-upgrade-lifecycle.md

# FV-SOL-7-C6 Proxy Upgrade Lifecycle Security

## TLDR

The upgrade lifecycle - initialization, authorization, and execution ordering - has several critical failure modes including re-initialization via wrong modifier, permanent loss of upgrade capability when UUPS logic is dropped, race conditions between upgrade and configuration, missing access control on `_authorizeUpgrade`, front-runnable non-atomic initialization, and admin routing confusion in transparent proxies.

## Detection Heuristics

**Re-initialization**
- V2+ contract uses `initializer` modifier instead of `reinitializer(N)`
- Upgrade resets initialized counter or storage-collides the `_initialized` flag
- No version bump in OZ `Initializable` usage

**UUPS Upgrade Logic Removed**
- New implementation doesn't inherit `UUPSUpgradeable`
- `upgradeTo`/`upgradeToAndCall` not present in V2 ABI
- `_authorizeUpgrade` not overridden in new implementation

**Upgrade Race Condition**
- `upgradeTo(V2)` and `V2.initialize()` or config calls in separate transactions
- No `upgradeToAndCall()` usage

**Missing Upgrade Authorization**
- `_authorizeUpgrade(address) internal override {}` with empty body
- No `onlyOwner`, role check, or governance gate

**Non-Atomic Initialization**
- `new TransparentUpgradeableProxy(impl, admin, "")` with empty `data` param
- `initialize()` broadcasted in separate transaction after proxy deployment

**Admin Routing Confusion**
- `ProxyAdmin` not a dedicated contract - admin is same EOA used for protocol operations
- Admin calls protocol functions directly instead of through `ProxyAdmin`

## False Positives

- `reinitializer(version)` with correctly incrementing versions for V2+
- `_authorizeUpgrade` has `onlyOwner` or equivalent governance gate
- `upgradeToAndCall()` bundles upgrade + init atomically
- Init calldata passed in proxy constructor - atomic initialization
- Dedicated `ProxyAdmin` contract used exclusively for admin operations
- OZ upgrades plugin validates storage layout and upgrade compatibility in CI

## reference/solidity/fv-sol-7-proxy-insecurities/fv-sol-7-c7-diamond-proxy-pitfalls.md

# FV-SOL-7-C7 Diamond Proxy Pitfalls

## TLDR

EIP-2535 Diamond proxies introduce unique storage and selector collision risks beyond standard proxy patterns. Facets that declare top-level state variables all start at slot 0, overwriting each other's data. Adding a facet with a selector that already exists in another facet hijacks all calls to that function. Shared `DiamondStorage` structs accessed at non-namespaced slots collide with facet storage.

## Detection Heuristics

**Cross-Facet Storage Collision**
- Facet contracts declare top-level `uint256`, `address`, or other state variables (not inside a struct)
- `assembly { ds.slot := 0 }` or low-numbered slot for `DiamondStorage` struct
- No EIP-7201 `@custom:storage-location` annotation on storage structs

**Selector Collision on diamondCut**
- `diamondCut` implementation doesn't check for existing selectors before registering
- `DiamondLoupeFacet.facetFunctionSelectors()` not called to verify post-cut state
- No governance review of selector collision before upgrade

**Shared DiamondStorage Not Namespaced**
- Multiple facets import and mutate the same `DiamondStorage` struct
- Storage position derived from sequential slot or small constant
- Storage position not verified against EIP-7201 formula

## False Positives

- All facets use EIP-7201 namespaced storage: `keccak256(abi.encode(uint256(keccak256("namespace")) - 1)) & ~bytes32(uint256(0xff))`
- No top-level state variables in any facet - only function definitions and struct definitions
- `diamondCut` validates no selector collisions before registering
- `DiamondLoupeFacet` enumerates all selectors post-cut for off-chain verification
- Multisig + timelock on `diamondCut` with mandatory selector review step

## reference/solidity/fv-sol-7-proxy-insecurities/readme.md

# FV-SOL-7 Proxy Insecurities

### TLDR

Upgradeability is essential for maintaining and improving deployed contracts and fixes over time

Due to their nature, they are often misunderstood or implemented insecurely

## Code


```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

// Delegate contract contains logic but no storage
contract Delegate {
    uint public storedData;  // This variable will be ignored when using delegatecall

    // Function to be called via delegatecall
    function setValue(uint _value) public {
        storedData = _value;  // This will set the caller's storage, not Delegate's
    }
}

// Caller contract with storage that will be updated
contract Caller {
    uint public storedData;  // The storage slot used in Delegate

    // Function to execute delegatecall to Delegate contract
    function setDelegateValue(address _delegateAddress, uint _value) public {
        // Prepare data for delegatecall (function selector + argument)
        (bool success, ) = _delegateAddress.delegatecall(
            abi.encodeWithSignature("setValue(uint256)", _value)
        );
        require(success, "Delegatecall failed");
    }
}
```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-7-proxy-insecurities/<filename>` to read any case file listed below.

#### fv-sol-7-c1-delegatecall-storage-collision.md

#### fv-sol-7-c2-function-selector-collision.md

#### fv-sol-7-c3-centralized-update-control.md

#### fv-sol-7-c4-uninitialized-proxy.md

#### fv-sol-7-c5-proxy-implementation-attacks.md

Immutable context mismatch across proxies; arbitrary delegatecall in implementation; assembly proxy missing returndata propagation; minimal proxy implementation destruction; metamorphic CREATE2 code swap.

#### fv-sol-7-c6-proxy-upgrade-lifecycle.md

Re-initialization with wrong version; UUPS upgrade logic removed in V2; upgrade race condition; missing _authorizeUpgrade access control; non-atomic initialization front-running.

#### fv-sol-7-c7-diamond-proxy-pitfalls.md

Cross-facet storage collision from top-level variables; selector collision on diamondCut; DiamondStorage not at EIP-7201 namespaced position.

## Mitigation Patterns

### Validate Addresses Being Called (FV-SOL-7-M1)

Ensure that the address used with `delegatecall` is fixed or restricted to trusted sources

### Limit State Changes (FV-SOL-7-M2)

Be cautious of contracts that use `delegatecall` to avoid unintended storage changes

### \_\_gap Array (FV-SOL-7-M3)

The `__gap` variable is a common technique used in Solidity's upgradeable contract design to prevent storage layout issues during contract upgrades. It is essentially a reserved area in the contract's storage layout that provides "padding" for future storage variables

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-03-attacker-can-gain-control-of-counterfactual-wallet-code4rena-biconomy-biconomy-smart-contract-wallet-contest-git](https://solodit.cyfrin.io/issues/h-03-attacker-can-gain-control-of-counterfactual-wallet-code4rena-biconomy-biconomy-smart-contract-wallet-contest-git)
* [https://solodit.cyfrin.io/issues/h01-corruptible-storage-upgradeability-pattern-openzeppelin-ribbon-finance-audit-markdown](https://solodit.cyfrin.io/issues/h01-corruptible-storage-upgradeability-pattern-openzeppelin-ribbon-finance-audit-markdown)
* [https://solodit.cyfrin.io/issues/diamond-proxy-initialize-functions-can-be-called-multiple-times-halborn-polemos-lending-pdf](https://solodit.cyfrin.io/issues/diamond-proxy-initialize-functions-can-be-called-multiple-times-halborn-polemos-lending-pdf)

## reference/solidity/fv-sol-8-slippage

```

```

## reference/solidity/fv-sol-8-slippage/fv-sol-8-c1-price-manipulation.md

# FV-SOL-8-C1 Price Manipulation

## TLDR

Relying solely on a DEX's own spot price to validate or derive swap parameters allows an attacker to manipulate the pool state (via flash loan or large trade) immediately before the victim transaction, causing extreme slippage. Without cross-referencing an external or time-weighted price, the contract has no means to detect that the in-block price is artificially distorted.

## Detection Heuristics

**Single On-Chain Price Source**
- `dex.getPrice(tokenIn, tokenOut)` used as sole price reference with no secondary validation
- Spot price read and swap executed in the same transaction without a deviation check
- No TWAP oracle reference; no Chainlink or Pyth feed comparison before swap

**Missing Output Validation**
- `amountOut` not checked against a caller-supplied or oracle-derived minimum
- `require(amountOut > 0)` is the only post-swap guard - accepts any non-zero output
- `amountOutMinimum` absent from swap call parameters

**No Deviation Bound**
- No `maxSlippagePercent` or equivalent parameter accepted from caller
- No `require` that spot price is within N% of reference price before proceeding with swap

## False Positives

- Price is validated against a TWAP of at least 10 minutes before the swap executes
- Deviation check is enforced between DEX spot price and a Chainlink or Pyth reference feed
- `amountOutMinimum` is a caller-supplied parameter validated on-chain by the router

## reference/solidity/fv-sol-8-slippage/fv-sol-8-c2-front-running.md

# FV-SOL-8-C2 Front-Running

## TLDR

Swap transactions published to the public mempool expose their parameters - token pair, input amount, and minimum output - before inclusion. MEV bots observe these parameters and sandwich the victim: a buy is inserted before the transaction to move the price up, and a sell is inserted after, extracting value from the slippage tolerance the victim granted.

## Detection Heuristics

**Transparent Swap Parameters**
- Swap function accepts `tokenIn`, `amountIn`, and `minAmountOut` directly as calldata with no obfuscation
- No commit-reveal pattern: trade details are visible in the pending transaction before it mines
- No private relay integration documented or enforced at the contract level

**Permissive or Hardcoded Slippage**
- `minAmountOut` is zero or derived from a hardcoded constant rather than a caller-supplied tight bound
- Slippage tolerance set to a percentage wide enough to make sandwiching profitable (e.g. >1% on liquid pairs)
- `minAmountOut` computed from a stale off-chain price without deadline enforcement

**No Commitment Verification**
- No `mapping(address => bytes32) tradeCommitments` or equivalent on-chain hash commitment
- Reveal step does not `require(hash(params) == commitment[msg.sender])`
- Stale commitments not invalidated - no block number or timestamp bound on the commit

## False Positives

- Commit-reveal scheme used: trade hash committed on-chain and verified at reveal time
- Transactions submitted via private relay (Flashbots Protect, MEV Blocker) - not visible in public mempool
- `minAmountOut` is a tightly calibrated caller-supplied parameter combined with a short deadline
- Protocol operates on a sequencer with a private mempool where ordering is not publicly observable

## reference/solidity/fv-sol-8-slippage/fv-sol-8-c3-insufficient-liquidity.md

# FV-SOL-8-C3 Insufficient Liquidity

## TLDR

When a DEX pool has insufficient liquidity relative to the swap size, price impact grows non-linearly and the resulting output can be drastically below fair value. Without a pre-swap liquidity check or a tightly enforced `amountOutMinimum`, the contract accepts any output the pool returns, including near-zero amounts caused by thin liquidity.

## Detection Heuristics

**No Pre-Swap Liquidity Validation**
- `dex.swap(tokenIn, tokenOut, amountIn)` called without checking available pool reserves
- No call to `getAvailableLiquidity()`, `getReserves()`, or equivalent before the swap
- Swap size not compared against pool depth as a percentage - no maximum trade-size-to-liquidity ratio enforced

**Insufficient Post-Swap Output Check**
- `require(amountOut > 0)` is the only output validation - accepts any non-zero dust amount
- `amountOutMinimum` absent or set to zero in the swap call
- No caller-supplied minimum output parameter; contract does not propagate slippage bound to the DEX router

**No Liquidity Threshold Parameter**
- Function signature lacks a `minLiquidity` or `minAmountOut` parameter
- Liquidity floor, if any, is hardcoded to zero or not present

## False Positives

- `amountOutMinimum` is a caller-supplied parameter validated on-chain by the router before execution
- Protocol enforces a minimum pool TVL threshold and reverts if liquidity falls below it before swapping
- Swap is routed across multiple pools with aggregate liquidity validation ensuring total output meets the user's minimum
- Concentrated liquidity pool (e.g. Uniswap v3) with tight price range guarantees sufficient depth at current tick

## reference/solidity/fv-sol-8-slippage/fv-sol-8-c4-unexpected-gas-increase.md

# FV-SOL-8-C4 Unexpected Gas Increase

## TLDR

When a swap wrapper forwards execution to an external DEX without a gas cap, an attacker or a malicious/upgraded DEX implementation can consume unbounded gas. This drives up transaction costs, can cause out-of-gas reverts for the user, or - in protocols that deduct gas costs from the output amount - constitutes a form of slippage that bypasses the stated minimum output check.

## Detection Heuristics

**Unbounded External Call**
- `dex.swap(tokenIn, tokenOut, amountIn)` called without a `{gas: N}` cap
- Low-level `address(dex).call(...)` without gas limit parameter
- No `gasleft()` check before or after the external call

**Missing Output Validation**
- Return value of `dex.swap(...)` not stored or not validated with `require(amountOut > 0)`
- `success` bool from low-level call not checked before decoding return data
- No minimum output enforced after the external call returns

**No Gas Cost Accounting**
- Protocol deducts fees or calculates net output after the swap without accounting for gas consumed by external call
- No refund mechanism when excess gas is consumed by an external DEX callback (e.g. `uniswapV3SwapCallback`)
- Flash loan callbacks or hook callbacks inside the DEX not considered in gas budget

## False Positives

- Gas costs are paid by the protocol treasury and are not deducted from the user's output amount
- Contract is a thin pass-through to a trusted, immutable router (e.g. Uniswap UniversalRouter) with known gas bounds
- Protocol uses a fixed-fee model where output calculation is independent of actual gas consumed
- MEV-protected relay handles gas optimization externally and the contract itself does not factor gas into output

## reference/solidity/fv-sol-8-slippage/fv-sol-8-c5-missing-deadline.md

# FV-SOL-8-C5 Missing or Ineffective Deadline on Swaps

## TLDR

Without a meaningful deadline, a swap transaction can be held in the mempool indefinitely by validators and executed at an arbitrary future time when conditions may be unfavorable. `deadline = block.timestamp` is always valid (the transaction executes in the same block it appears to be submitted), and `deadline = type(uint256).max` provides no protection at all.

A related issue is enforcing slippage only at intermediate hops in a multi-hop route. If only the first hop has `minAmountOut`, the final output to the user has no bound - subsequent hops can be freely sandwiched.

## Detection Heuristics

**Deadline Issues**
- `deadline: block.timestamp` passed to router - trivially always passes
- `deadline: type(uint256).max` - no expiry protection
- No `deadline` parameter in swap wrapper function - hardcoded internally
- Deadline not forwarded from user calldata: derived from `block.timestamp + N` internally

**Multi-Hop Slippage Gap**
- `minAmountOut` enforced on first or intermediate hop but final output amount unchecked
- `_swapBtoC(mid, 0)` - zero minimum on second leg
- `amountOutMinimum` checked against mid-route output, not user's final received balance
- Delta check (`post - pre`) not performed on user's final token balance after multi-hop

## False Positives

- Deadline is calldata parameter validated with `require(deadline >= block.timestamp)` on-chain
- `minAmountOut` validated against final user balance delta: `require(token.balanceOf(user) - before >= minOut)`
- Single-hop swap where there are no intermediate steps
- Protocol is an aggregator - each hop has independent user-specified minimums

## reference/solidity/fv-sol-8-slippage/fv-sol-8-c6-oracle-price-update-frontrunning.md

# FV-SOL-8-C6 Oracle Price Update Front-Running

## TLDR

Push-model oracle integrations (Chainlink push, custom off-chain updater submitting to public mempool) expose the price update transaction before it lands. An attacker who sees a favorable update in the mempool can front-run it: open a position at the stale price, let the update land, then profit from the new price.

This is distinct from AMM price manipulation (FV-SOL-8-C1) - no flash loan is required. The attacker simply reads the public mempool and submits a transaction with a higher gas price.

## Detection Heuristics

**Push Oracle in Public Mempool**
- Protocol uses push-model oracle and `updatePrice()` submitted via public mempool
- Price is read in same block as update opportunity - no TWAP buffer
- No cooldown or circuit breaker between price update and position opening
- Pyth/Chainlink used in push mode without requiring price attestation in action tx
- Oracle updater uses `eth_sendRawTransaction` without a private relay

**Position Opening Against Stale Price**
- `oracle.latestAnswer()` or `oracle.getPrice()` called without verifying update recency
- No `require(updatedAt >= block.timestamp - maxStaleness)` freshness check
- Position sizing computed directly from the oracle value with no TWAP smoothing

## False Positives

- Pull-oracle: price attestation must be submitted atomically with the user action in the same tx (Pyth `updatePriceFeeds` pattern)
- TWAP of at least 30 minutes - single-block mempool visibility does not enable profitable front-run
- Private relay used for oracle update submissions (Flashbots Protect / MEV Blocker)
- Sequencer with private mempool (no public tx visibility before inclusion)
- Position size bounded - profit opportunity too small to cover gas cost of front-run

## reference/solidity/fv-sol-8-slippage/readme.md

# FV-SOL-8 Slippage

### TLDR

Slippage vulnerabilities in Solidity typically refer to situations where unexpected price changes or inadequate checks on the value transferred in transactions cause a user to receive less than expected. This is especially relevant in decentralized exchanges (DEXs) and automated market makers (AMMs)

## Code


```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

interface IERC20 {
    function transferFrom(address sender, address recipient, uint256 amount) external returns (bool);
    function transfer(address recipient, uint256 amount) external returns (bool);
    function balanceOf(address account) external view returns (uint256);
}

contract VulnerableSwap {
    IERC20 public tokenA;
    IERC20 public tokenB;
    uint256 public rate; // Rate of tokenA to tokenB

    constructor(address _tokenA, address _tokenB, uint256 _rate) {
        tokenA = IERC20(_tokenA);
        tokenB = IERC20(_tokenB);
        rate = _rate; // Number of tokenB units per 1 tokenA unit
    }

    function swap(uint256 amountIn) external {
        uint256 amountOut = amountIn * rate;

        // No slippage check! The user will receive whatever `amountOut` is, even if rate changes.
        require(tokenA.transferFrom(msg.sender, address(this), amountIn), "Transfer of tokenA failed");
        require(tokenB.transfer(msg.sender, amountOut), "Transfer of tokenB failed");
    }
}
```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-8-slippage/<filename>` to read any case file listed below.

#### fv-sol-8-c1-price-manipulation.md

#### fv-sol-8-c2-front-running.md

#### fv-sol-8-c3-insufficient-liquidity.md

#### fv-sol-8-c4-unexpected-gas-increase.md

#### fv-sol-8-c5-missing-deadline.md

`deadline: block.timestamp` or `type(uint256).max` provides no protection; multi-hop slippage enforced at intermediate step only.

#### fv-sol-8-c6-oracle-price-update-frontrunning.md

Push-model oracle update visible in public mempool; attacker front-runs with position at stale price before update lands.

## Mitigation Patterns

### Minimum Amount Checks (FV-SOL-8-M1)

Accept a `minAmountOut` parameter in functions that perform token swaps or trades. Before finalizing the transaction, check that the amount received meets or exceeds `minAmountOut`

### Time-Weighted Average Price (FV-SOL-8-M2)

Use a time-weighted average price (TWAP) instead of the immediate spot price to reduce the impact of temporary price manipulation

### Decentralized Oracles (FV-SOL-8-M3)

Use a decentralized oracle network (e.g., Chainlink) to provide reliable and tamper-resistant price data for slippage calculations

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-07-missing-slippage-checks-code4rena-spartan-protocol-spartan-protocol-contest-git](https://solodit.cyfrin.io/issues/h-07-missing-slippage-checks-code4rena-spartan-protocol-spartan-protocol-contest-git)

## reference/solidity/fv-sol-9-unbounded-loops

```

```

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c1-dynamic-array.md

# FV-SOL-9-C1 Dynamic Array

## TLDR

Loops that iterate over a dynamic array whose length grows with user-controlled input have unbounded gas cost. As the array grows, any function performing a full iteration will eventually exceed the block gas limit and revert permanently.

## Detection Heuristics

**Iteration Over User-Growable Array**
- `for (uint256 i = 0; i < arr.length; i++)` where `arr` is a storage array with no length cap
- Array is appended to by an externally callable function with no `require(arr.length < MAX)` guard
- No pagination or chunked access pattern for the iteration

**Missing Invariant on Array Bounds**
- No maximum length constant or state variable enforced at push time
- Array length depends on cumulative user calls rather than a protocol-controlled parameter

## False Positives

- Array length is bounded by a hard cap enforced on every push (`require(arr.length < MAX)`)
- Iteration occurs off-chain via a view function used only in scripts or subgraphs, never in a state-changing call chain
- Precomputed aggregate stored alongside the array so the full loop is never executed on-chain

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c2-unrestricted-mapping.md

# FV-SOL-9-C2 Unrestricted Mapping

## TLDR

Solidity mappings cannot be iterated natively, so developers often maintain a parallel array of keys. If this auxiliary array grows without restriction, any function that loops over it to aggregate or process mapping values will eventually exceed the block gas limit.

## Detection Heuristics

**Unbounded Key-Tracking Array**
- A storage `address[]` or `uint256[]` array is appended to inside a public or externally callable function with no length cap
- New keys are pushed when a mapping entry is first set, with no `require(arr.length < MAX)` guard
- The array is used as the iteration source for an on-chain aggregation or processing function

**Full-Array Iteration Without Pagination**
- `for (uint256 i = 0; i < users.length; i++)` iterates the key array inside a function called from a state-changing context
- No start/end range parameters allow callers to paginate the iteration
- Running total or aggregate is recomputed on every call rather than maintained incrementally

## False Positives

- Maximum key array length enforced unconditionally at insertion time
- Aggregation uses a precomputed running total updated at insertion rather than iterating at read time
- Iteration function accepts `start` and `end` parameters and callers are expected to chunk reads off-chain
- Array is populated only by a privileged role with a known, protocol-bounded upper size

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c3-recursive-calls.md

# FV-SOL-9-C3 Recursive Calls

## TLDR

Solidity imposes a call-stack depth limit of 1024 frames. Recursive self-calls or chains of delegating function calls that scale with user-supplied input will exhaust the call stack or gas budget before completing. Even when disguised as iterative withdrawal logic, each self-call consumes additional stack frames and per-call gas overhead that grows linearly with the input value.

## Detection Heuristics

**Direct Self-Recursive Call**
- A `public` or `external` function calls itself with a decremented counter or shrinking parameter
- No base case enforces an early return before call-stack exhaustion
- Each recursive frame performs an external interaction (transfer, call) multiplying gas consumption

**Indirect Recursion via External Call Cycle**
- Function A calls function B which calls back into function A within the same transaction
- No reentrancy guard prevents the cycle
- Depth scales with a user-controlled amount or count parameter

**Linear Work Per Unit of User-Supplied Input**
- Gas cost is O(n) where n is a user-supplied numeric argument (e.g., processing 1 unit per recursive frame)
- No upper bound enforced on the argument at function entry
- A loop with the same logic would be equally unbounded

## False Positives

- Recursion depth is strictly bounded by a protocol-controlled constant, not user input
- `nonReentrant` modifier is present and prevents callback cycles
- Operation is restructured to a single bulk transfer rather than one-unit-at-a-time calls

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c4-reentrancy-loops.md

# FV-SOL-9-C4 Reentrancy Loops

## TLDR

Loops that perform external calls on each iteration compound two risks: unbounded gas cost from the iteration itself, and reentrancy from any external call within the loop body. A malicious recipient can re-enter the looping function mid-iteration, causing state corruption, double-spending, or out-of-gas reversions that permanently lock funds.

## Detection Heuristics

**External Call Inside Loop With State Updated After Call**
- `payable(addr).transfer(amount)` or `addr.call{value:...}("")` inside a `for` loop where the balance decrement follows the transfer
- `IERC20(token).transfer(recipient, amount)` inside a loop over untrusted recipient addresses
- State invariant (total balance, processed flag) is not fully committed before the first external call in the loop

**No Reentrancy Protection on Looping Function**
- Function performing the loop lacks a `nonReentrant` modifier
- Checks-effects-interactions pattern violated: effects (state writes) interleaved with or after interactions (external calls)
- No per-recipient state isolation preventing a re-entering call from replaying a prior iteration

**Caller-Controlled Recipient List**
- `recipients` array is passed as a calldata or memory argument by an untrusted caller
- Caller can include a contract address they control as a recipient
- No address validation or whitelist applied to the recipient list

## False Positives

- All state updates occur before any external calls in the loop (strict checks-effects-interactions order)
- `nonReentrant` modifier applied to the function
- Pull-over-push pattern: recipients claim individually rather than being iterated over in a single transaction
- Recipients are a protocol-controlled, pre-validated set containing no external contracts

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c5-nested-loops.md

# FV-SOL-9-C5 Nested Loops

## TLDR

Nested loops whose bounds are both determined by user-controlled data produce O(n*m) gas costs. Even modest growth in either dimension can push the combined iteration count past the block gas limit, permanently bricking any function that relies on full traversal in a single transaction.

## Detection Heuristics

**Double-Dimension User-Controlled Iteration**
- Outer loop iterates over a user-supplied or user-growable array of addresses or IDs
- Inner loop iterates over a per-user storage array (e.g., `mapping(address => uint256[])`) with no bounded length
- Neither loop dimension has a hard cap enforced at insertion time

**Quadratic or Superlinear Gas Growth**
- Gas cost scales as O(n * m) where both n and m grow with user input
- No precomputed aggregate eliminates the inner loop at read time
- Function is called in a state-changing context, not exclusively off-chain

**Multiple Levels of Nesting**
- More than two nested loops present in a single function
- Each level iterates over a different user-contributed collection
- Aggregate or total recomputed on every invocation rather than updated incrementally

## False Positives

- Inner loop iterates over a fixed-size array bounded by a protocol constant
- Precomputed per-user totals stored in a mapping eliminate the inner loop at read time
- Function is a `view` used exclusively off-chain and never in the call chain of a state-changing transaction
- Both array dimensions are bounded by hard caps enforced unconditionally at insertion

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c6-blacklistable-token-payment.md

# FV-SOL-9-C6 Blacklistable Token in Payment Path

## TLDR

Push-model payment loops that transfer tokens to recipient addresses will revert entirely if any recipient is blacklisted by the token contract (USDC, USDT, and other compliant stablecoins support blacklisting). A single blacklisted address in a liquidation path, fee distribution loop, or withdrawal route can permanently brick that operation.

## Detection Heuristics

**Push Transfer to Untrusted Address With Blacklistable Token**
- `IERC20(token).transfer(recipient, amount)` or `safeTransfer` inside a loop where the token is USDC, USDT, or any contract exposing a `blacklist`, `blocklist`, or `isBlacklisted` function
- Token address is a constructor or governance parameter, not a hardcoded non-blacklistable token
- No `try/catch` or skip-on-failure logic around the individual transfer call

**Blocking Liquidation or Settlement Path**
- Liquidation function iterates over collateral recipients or debt holders and pushes token payments in the loop body
- A single revert from one recipient causes the entire liquidation to fail and revert
- Protocol provides no alternative to complete the operation without the blocked recipient

**Missing Pull-Pattern or Fallback**
- No `pendingClaims` or equivalent mapping allowing recipients to withdraw independently
- No mechanism to remove or skip a recipient that has caused prior reverts
- Fee distribution, reward claiming, or airdrop uses a single-transaction push loop with no recovery path

## False Positives

- Pull-over-push pattern: recipients withdraw own funds independently
- `try/catch` wraps individual transfers and continues on failure
- Token whitelist explicitly excludes blacklistable tokens

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c7-gas-griefing.md

# FV-SOL-9-C7 Gas Griefing and 63/64 Rule

## TLDR

Two related gas-based DoS patterns: block stuffing fills blocks with high-gas-price transactions to prevent time-sensitive protocol operations from executing within their window; the 63/64 rule allows a relayer to forward insufficient gas so the inner call silently fails while the outer call marks the request as processed.

## Detection Heuristics

**Block Stuffing**
- Time-sensitive function with a short execution window (seconds to minutes)
- No economic incentive protection against block stuffing
- Protocol on PoS Ethereum where validators control slot timing

**63/64 Gas Forwarding**
- `target.call(data)` with no explicit gas parameter in a relayer or meta-transaction pattern
- Request or operation marked as completed regardless of subcall return value
- No `require(gasleft() >= minGas)` before the forwarded call
- Return value and returndata not validated; outer call does not revert on subcall failure

## False Positives

- Time window long enough that block stuffing is economically infeasible given gas costs
- `require(gasleft() >= minGas)` present before subcall
- Return value and returndata both validated; failure reverts the outer call
- EIP-2771 trusted forwarder with verified gas parameter in signed payload

## reference/solidity/fv-sol-9-unbounded-loops/fv-sol-9-c8-dust-griefing.md

# FV-SOL-9-C8 Dust and Threshold Griefing

## TLDR

Two griefing patterns exploiting minimal-cost interactions: a dust deposit with no minimum amount can reset a per-user timelock indefinitely, and a dust token transfer to a contract can permanently block a zero-balance gate that guards a state transition.

## Detection Heuristics

**Dust Deposit / Timelock Reset**
- `lastActionTime[user] = block.timestamp` inside a deposit or action function with no `require(amount >= MIN)` guard
- Timelock or cooldown resets on any deposit regardless of amount
- No per-user isolation: an attacker targeting another user's lock by calling the function on their behalf

**Zero Balance Check Griefing**
- `require(token.balanceOf(address(this)) == 0)` gates a state transition
- Direct token transfers to the contract are not rejected (no `receive()` guard, token is ERC20 pushable)
- State transition is access-restricted but the balance check remains exploitable via a direct token send

## False Positives

- Minimum deposit enforced unconditionally (`require(amount >= MIN_DEPOSIT)`)
- Cooldown assessed only at withdrawal time using deposit amount, not reset on small deposits
- Threshold check (`<= DUST_THRESHOLD`) instead of exact `== 0`
- Function is access-controlled such that only trusted addresses can call it

## reference/solidity/fv-sol-9-unbounded-loops/readme.md

# FV-SOL-9 Unbounded Loops

### TLDR

Overly verbose iterations can result in failed transactions, denial of service, and reduced contract usability

## Code


```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract UnboundedLoopExample {
    address public owner;
    address[] public recipients;
    uint256 public rewardAmount = 1 ether;

    constructor() {
        owner = msg.sender;
    }

    // Adds a recipient to the list (for testing)
    function addRecipient(address _recipient) external {
        recipients.push(_recipient);
    }

    // Distributes rewards to all recipients in the array
    function distributeRewards() external {
        require(msg.sender == owner, "Only owner can distribute rewards");

        // Unbounded loop over dynamic array "recipients"
        for (uint256 i = 0; i < recipients.length; i++) {
            // For demonstration, we assume "transfer" sends the reward.
            // In practice, we might call an ERC20 transfer or similar function.
            (bool success, ) = recipients[i].call{value: rewardAmount}("");
            require(success, "Transfer failed");
        }
    }

    // Receive Ether to fund the contract
    receive() external payable {}
}

```

## Classifications

Run `cat $SKILL_DIR/reference/solidity/fv-sol-9-unbounded-loops/<filename>` to read any case file listed below.

#### fv-sol-9-c1-dynamic-array.md

#### fv-sol-9-c2-unrestricted-mapping.md

#### fv-sol-9-c3-recursive-calls.md

#### fv-sol-9-c4-reentrancy-loops.md

#### fv-sol-9-c5-nested-loops.md

#### fv-sol-9-c6-blacklistable-token-payment.md

Covers: push-model transfer with USDC/USDT in critical payment path; single blacklisted address blocks entire operation.

#### fv-sol-9-c7-gas-griefing.md

Covers: block stuffing of time-sensitive windows; 63/64 rule insufficient gas forwarding in relayer patterns.

#### fv-sol-9-c8-dust-griefing.md

Covers: dust deposit resetting timelocks/cooldowns; zero-balance gate bricked by dust transfer.

## Mitigation Patterns

### Batch Processing (FV-SOL-9-M1)

Break down large loops into smaller batches, allowing users to process data over multiple transactions rather than a single on

### Gas Hard Limit (FV-SOL-9-M2)

Set a gas threshold or limit for loop processing and exit the loop once it approaches that threshold

### Avoid Dynamic Data in Loops (FV-SOL-9-M3)

Limit loop iterations to fixed-sized arrays or arrays with capped sizes. Avoid using user-input data or dynamic arrays in loop conditions

### Events Instead of Iteration (FV-SOL-9-M4)

In cases where a function needs to notify many users or accounts, consider emitting events instead of looping through recipients, allowing users to handle their own state separately

## Actual Occurrences

* [https://solodit.cyfrin.io/issues/h-04-unbounded-loop-in-\_removenft-could-lead-to-a-griefingdos-attack-code4rena-visor-visor-contest-git](https://solodit.cyfrin.io/issues/h-04-unbounded-loop-in-_removenft-could-lead-to-a-griefingdos-attack-code4rena-visor-visor-contest-git)

## reference/solidity/protocols

```

```

## reference/solidity/protocols/algo-stables.md

# Algorithmic Stablecoin Security Patterns

> Applies to: algorithmic stablecoins, rebase tokens, seigniorage stablecoins, uncollateralized stablecoins, Terra/LUNA-style, Ampleforth-style, peg maintained purely through mint/burn mechanics

## Protocol Context

Algorithmic stablecoins maintain their peg through programmatic supply adjustment tied to on-chain price signals rather than explicit overcollateralization. Rebalancing logic, epoch tracking, and minting authorization are the three principal failure surfaces: rebalancing must read manipulation-resistant prices; epoch counters must advance unconditionally regardless of user activity; and mint/burn operations must be strictly gated to authorized actors. Because peg defense depends on real-time market price rather than idle collateral, any attacker who can move the reference price before a rebalance can redirect protocol capital in a single transaction.

Seigniorage and rebase designs introduce epoch-synchronized reward accounting where multiple state variables must advance together. Stale epoch state, misaligned accumulator updates, and governance vote manipulation affecting emission parameters all become exploitable when the peg invariant is fragile enough that a single large attack can break the feedback loop entirely. The structural reliance on AMM pool prices rather than deep external feeds makes flash-loan-driven oracle manipulation a near-universal precondition for the highest-severity bugs in this category.

## Bug Classes

---

### Missing Access Control on Mint/Burn (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Critical supply-management functions (mintRebalancer, burnRebalancer, liquidateFrom, setOracleAddress) are callable by any address. The function was intended to be restricted to a specific rebalancer, admin, or position owner role but the modifier is absent. The unrestricted function can alter total supply, collateral ratios, or user positions directly.

**Detection Heuristics**
Search for `public` or `external` functions that modify mint, burn, liquidate, or oracle configuration state without access control modifiers. Compare function naming conventions and inline comments against the actual modifier list. Look for functions accepting an arbitrary `from` address for operations that should be self-initiated. Check whether modifier declarations exist elsewhere in the contract but were not applied to recently added or refactored functions.

**False Positives**
Functions that are intentionally permissionless by design (e.g., permissionless liquidation with correct incentive alignment). Access control enforced at the calling contract layer. View/pure functions with no state modification.

**Notable Historical Findings**
USSD's mintRebalancer and burnRebalancer were missing the onlyRebalancer modifier, allowing any caller to inflate or deflate the token supply at will. MCDEX Mai Protocol allowed any address to call liquidateFrom on behalf of any account, enabling force-liquidation of healthy positions with proceeds redirected to an arbitrary address. Fei Protocol's EthCompoundPCVDeposit lacked a recovery function for stranded ETH, a softer access control gap that locked protocol-owned funds.

**Remediation Notes**
Apply role modifiers (onlyRebalancer, onlyOwner, onlyGovernance) to all supply-changing and configuration functions. For permissionless liquidation flows, ensure the liquidator receives a reward but cannot redirect collateral proceeds; verify the position's collateral ratio before permitting the call.

---

### AMM/DEX Price Manipulation via Flash Loans (ref: fv-sol-10)

**Protocol-Specific Preconditions**
Protocol reads on-chain price from an AMM pool using instantaneous spot price (Uniswap V3 `slot0`, Uniswap V2 reserve ratios) rather than a time-weighted average. Flash loans of the governance or collateral token are available. The manipulated price triggers rebalancing, minting, or collateral valuation in the same block.

**Detection Heuristics**
Search for `slot0()` calls used in price calculations that feed into on-chain decisions. Look for `getReserves()` used directly as a price oracle without TWAP protection. Check if the price source feeds into rebalancing, minting, liquidation, or collateral valuation logic within the same transaction. Verify whether a TWAP oracle (`observe()`) is used instead of spot price.

**False Positives**
Price used only for off-chain monitoring or UI display. AMM pool with extremely deep liquidity where manipulation is economically infeasible. Protocol uses a TWAP with a sufficiently long window (30+ minutes). Additional circuit breakers exist (e.g., max price deviation per block).

**Notable Historical Findings**
USSD's rebalancer used Uniswap V3 `slot0` to determine whether to buy or sell collateral, allowing an attacker to flash-loan tokens, skew the pool price, trigger an erroneous rebalance, and profit from the resulting trade. A separate USSD finding showed that the reserve ratio of the Uniswap pair was used directly as price, trivially manipulable via a flash swap. Malt Protocol's livePrice variable could be manipulated across two consecutive blocks to trigger the defaultIncentive payout.

**Remediation Notes**
Replace `slot0` with Uniswap V3 `observe()` using a 30-minute TWAP window. Cross-check spot price against the TWAP and revert if deviation exceeds a protocol-defined threshold. For collateral valuation, rely exclusively on time-averaged prices and add circuit breakers for extreme deviations.

---

### Epoch and Share Accounting Gaps (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol uses epoch-based reward or state tracking where epoch advancement is triggered lazily (only on user interaction). State variables like `epoch[asset]`, `totalShares`, or `accRewardsPerShare` are advanced conditionally. When no user interaction occurs during one or more epochs, the epoch counter stalls, causing newly created bonds to record a stale `mintEpoch`.

**Detection Heuristics**
Search for `epoch[` assignments that are conditional on `totalShares > 0` or similar guards. Look for `createLock` or `createBond` functions that use `epoch[asset]` as a start-time marker. Check whether `accRewardsPerShare` arrays have gap-filling logic when epochs are skipped. Verify that `claim()` functions handle intermediate epochs with uninitialized accumulator values.

**False Positives**
A reliable keeper bot calls `distribute()` every epoch regardless of user activity. The protocol guarantees `totalShares > 0` at all times via protocol-owned permanent deposits. A dedicated `fillInEpochGaps()` function is always called before any reads.

**Notable Historical Findings**
Tigris Trade's BondNFT distribute() skipped epoch advancement when totalShares was zero, causing bonds created afterward to record a stale mintEpoch and expire far earlier than intended. Malt Protocol's RewardThrottle had multiple related issues: changing the timekeeper contract caused epoch discontinuities, and populateFromPreviousThrottle was exposed to front-run attacks that could corrupt cumulative APR calculations. An epoch without profit could also fail to carry its reward checkpoint into the next epoch.

**Remediation Notes**
Separate epoch advancement from the distribution guard: always advance the epoch counter regardless of totalShares, then conditionally distribute. Provide a `fillEpochGaps()` helper that initializes unset accumulator slots from the prior epoch before any claim calculation.

---

### Governance Vote Manipulation (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol has on-chain governance with token-weighted voting and delegation. Voting power snapshots are taken at or after proposal creation, and delegation or undelegation can occur while proposals are in a locked or pending state. Flash loans of governance tokens are available, or NFT-based voting power is derived from a `totalPower` variable that is not refreshed before snapshot creation.

**Detection Heuristics**
Check whether delegation and undelegation can occur within the same transaction or while a proposal is active. Verify that voting power snapshots are taken at a block strictly prior to proposal creation. Look for flash loan mitigations that check direct voting but not delegated voting. Check if NFT-based `totalPower` is recalculated before snapshots. Verify restricted users cannot bypass voting restrictions through delegation chains.

**False Positives**
Governance tokens are non-transferable or unavailable on lending markets. A timelock between delegation and voting prevents same-block attacks. Commit-reveal voting schemes prevent flash loan attacks. Quorum thresholds are based on total supply rather than active voting power.

**Notable Historical Findings**
Dexe suffered multiple governance manipulation vectors simultaneously: an attacker could combine a flash loan with delegated voting to reach quorum on a proposal, then undelegate and withdraw before the proposal finalized; separately, `totalPower` for NFT-based voting was never recalculated before the snapshot, allowing an attacker to artificially deflate the quorum denominator to near zero. A delegation chain bypass allowed restricted addresses to vote on proposals they were explicitly excluded from, and treasury voting power delegated to users could be turned against the protocol itself.

**Remediation Notes**
Prevent undelegation while a delegatee has active votes on unfinalized proposals. Force `recalculateAllNftPower()` before every snapshot. Apply a delegation cooldown (minimum one block) before tokens can be withdrawn. Validate delegation targets against restricted-user lists at delegation time, not only at vote time.

---

### Missing Slippage and Deadline Protection (ref: fv-sol-8)

**Protocol-Specific Preconditions**
Protocol performs token swaps through a DEX where `amountOutMinimum` is set to zero or `deadline` is set to `block.timestamp`. Minting functions accept no `minAmountOut` parameter, allowing sandwich attacks that profit from the mint price impact. Transactions can be delayed by validators or held in the mempool.

**Detection Heuristics**
Search for `amountOutMinimum: 0` or `amountOutMinimum = 0` in swap router calls. Look for `deadline: block.timestamp` which provides no protection since block producers set the timestamp. Check `exactInput`, `exactOutput`, and `swapExactTokensForTokens` calls for hardcoded zero slippage. Search for minting or rebalancing functions that lack a `minAmountOut` parameter in their external interface.

**False Positives**
Protocol uses a private mempool (e.g., Flashbots Protect) that prevents sandwich attacks. Swap is performed atomically within a larger transaction that has its own slippage check at a higher level. Token pair has liquidity depth making manipulation economically infeasible. Swap amount is trivially small dust.

**Notable Historical Findings**
USSD's UniV3SwapInput used `amountOutMinimum: 0` and `deadline: block.timestamp` on all rebalancer swaps, making every rebalance sandwichable at zero cost to the attacker. A separate USSD finding showed that mintForToken accepted no slippage parameter, allowing a front-runner to manipulate oracle-derived collateral values and force an unfavorable mint. Tigris Trade's limit order execution did not revalidate price bounds after position opening, allowing traders to lock in profits beyond the maximum PnL cap.

**Remediation Notes**
Expose `minAmountOut` and `deadline` as caller-supplied parameters on all swap and mint entrypoints. Validate deadline strictly against `block.timestamp` at the start of the function. For protocol-initiated rebalance swaps, derive `minAmountOut` from an on-chain TWAP with a defined maximum deviation.

---

### Oracle Stale Price Validation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
Protocol integrates Chainlink or another external price oracle for asset valuation in minting, trading, collateral assessment, or rebalancing. Oracle return values lack staleness checks, round completeness checks, or min/max circuit breaker bounds. Protocol prices WBTC using the BTC/USD feed without accounting for a potential WBTC/BTC depeg.

**Detection Heuristics**
Search for calls to `latestAnswer()`, which is deprecated and lacks staleness metadata. Search for `latestRoundData()` calls that discard `updatedAt`, `answeredInRound`, or `roundId` return values. Check for a maximum staleness threshold comparing `block.timestamp - updatedAt` against a heartbeat constant. Look for missing `minAnswer`/`maxAnswer` circuit breaker checks. Verify that the base and quote token ordering in oracle calculations matches the actual feed definition.

**False Positives**
Protocol uses a fallback oracle with its own freshness guarantees. Oracle data is used only for off-chain display. A separate circuit breaker pauses operations on stale data. A TWAP oracle is used alongside Chainlink as a cross-check.

**Notable Historical Findings**
Multiple USSD oracle integrations were found critically broken in a single audit: StableOracleDAI returned a price with incorrect decimal precision, the base/rate token pair was inverted producing a reciprocal price, and none of the oracle wrappers validated staleness. Additionally, pricing WBTC via the BTC/USD Chainlink feed would leave the protocol exposed to a WBTC depeg event. Tigris Trade and Fei Protocol both had Chainlink integrations that used `latestRoundData()` without checking `updatedAt` or `answeredInRound`.

**Remediation Notes**
Use `latestRoundData()` and validate all five return values: positive price, non-zero `updatedAt`, freshness within the feed heartbeat, `answeredInRound >= roundId`, and price within `minAnswer`/`maxAnswer` bounds. For wrapped assets (WBTC, stETH), use a dedicated depeg-aware oracle or add a secondary price deviation check. Normalize all oracle outputs to a consistent 18-decimal basis before use.

---

### Reentrancy via ERC721 Mint Callback (ref: fv-sol-1)

**Protocol-Specific Preconditions**
Contract uses `_safeMint()` to mint ERC721 position or bond tokens and updates state mappings after the mint call rather than before. No `nonReentrant` modifier is applied. The `onERC721Received` callback on the recipient contract can re-enter the minting function and observe inconsistent state (e.g., duplicate position IDs, uninitialized trade records).

**Detection Heuristics**
Search for `_safeMint` calls followed by state-modifying operations (the callback enables reentrancy). Look for `transfer`, `transferFrom`, `call{value:}`, or `send` followed by state updates. Check if functions performing external calls have `nonReentrant` or equivalent guard. Search for cross-contract calls that occur before local state changes.

**False Positives**
External call target is a trusted immutable contract with no callback capability. `nonReentrant` is applied at a higher-level entry point that calls the vulnerable internal function. Token being transferred does not support callbacks (standard ERC20 without ERC777 hooks).

**Notable Historical Findings**
Tigris Trade's Position contract called `_safeMint` before updating `_openPositions`, `initId`, and `_trades`, allowing an attacker's contract to re-enter during `onERC721Received` and mint duplicate tokens with colliding position IDs, resulting in theft of funds. Dexe governance NFTs used `_mint` instead of `_safeMint`, which avoids the callback reentrancy but permanently locks tokens if the recipient is a contract without ERC721 receiver support. MCDEX Mai Protocol had reentrancy possibilities in deposit, withdraw, and insurance fund functions.

**Remediation Notes**
Apply the checks-effects-interactions pattern: update all state mappings before any external call including `_safeMint`. Apply `nonReentrant` on every public minting and withdrawal function. Use `_mint` only when recipients are guaranteed to be EOAs; otherwise use `_safeMint` with all effects completed first.

---

### Reward Distribution Accounting Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol has a staking, locking, or bonding mechanism distributing rewards via an accumulated-rewards-per-share pattern. `totalShares` is decremented only on explicit user release rather than automatically on expiry, causing expired shares to dilute active participants. `rewardDebt` is computed using the total `virtualAmount` rather than the delta during partial withdrawals.

**Detection Heuristics**
Check if `totalShares` is decremented only upon user-triggered release rather than on expiry. Look for `rewardDebt` calculations during partial withdrawals and verify the delta (not the total) is used. Search for `distribute()` or reward accumulation functions that skip epoch updates when `totalShares == 0`. Verify that reward coefficient changes do not retroactively affect already-active reward periods.

**False Positives**
A keeper bot reliably releases expired positions within the same epoch they expire. `totalShares == 0` is impossible due to protocol-owned permanent stakes. Reward debt calculation uses a different but mathematically equivalent formulation. Distribution is event-driven rather than epoch-driven.

**Notable Historical Findings**
Tigris Trade's BondNFT had at least 34 findings related to reward distribution across two audit rounds: expired bonds remained in `totalShares`, diluting rewards for active stakers; a malicious user could exploit this to steal all assets in the BondNFT contract. Fei Tribechief used `user.virtualAmount` instead of the withdrawal delta `virtualAmountDelta` when computing `rewardDebt` during partial withdrawals, causing systematic over- or under-accounting. Malt Protocol's LinearDistributor set `previouslyVested` to `currentlyVested` even when the actual distributed amount was capped by available balance, permanently losing the unclaimed remainder.

**Remediation Notes**
Decrement `totalShares` at bond expiry, not only at user-initiated release. For partial withdrawal `rewardDebt` updates, use the proportional delta. Always advance epoch tracking before distributing rewards, even when `totalShares` is zero. Test reward accounting invariants (sum of pending rewards equals total undistributed balance) as part of the test suite.

---

### Stale Protocol State Usage (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol caches aggregated data (collateral ratios, collateral deficits, NFT voting power totals, epoch counters) in state variables that are updated lazily. Critical functions read these cached values without triggering a refresh. Multiple contracts share state that must be synchronized before derived values are trusted.

**Detection Heuristics**
Search for functions that read aggregated state (deficit, ratio, totalPower, epoch) without calling an update or sync function first. Look for multi-contract architectures where one contract caches data from another without refreshing before use. Check whether `stabilize()`, `rebalance()`, or similar critical functions call `sync()` or `update()` on their data sources. Look for NFT power calculations using snapshot values without forcing a recalculation.

**False Positives**
A keeper bot reliably updates state every block or every epoch. The staleness window is bounded and its impact is negligible relative to decision thresholds. The function has explicit freshness checks (e.g., `require(lastUpdated == block.number)`). State is immutable or changes only through governance.

**Notable Historical Findings**
Malt Protocol's `stabilize()` read `swingTraderCollateralDeficit` and `swingTraderCollateralRatio` from a global implied collateral service that was not synchronized at the start of the call, causing stabilization decisions to be based on outdated deficits and potentially over-buying Malt. A companion finding showed `_distributeProfit` had the same staleness issue. Dexe's proposal creation read `erc721Power.totalPower()` for quorum calculation without first calling `recalculateTotalPower()`, allowing the denominator to reflect a stale and potentially manipulated value.

**Remediation Notes**
Call `sync()` or equivalent on all upstream state aggregators at the start of any function that derives critical values from them. Separate the epoch advancement logic into an internal `_advanceEpoch()` function that is called unconditionally before any distribution or accounting read.

---

### Token Decimal Mismatch (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Protocol interacts with multiple ERC20 tokens having different decimal precisions (USDC: 6, DAI: 18, WBTC: 8). Arithmetic operations assume a fixed 18-decimal basis without normalizing inputs. Oracle price feeds return values in a different decimal base than the token being priced. Cross-token calculations (collateral ratios, exchange rates, price conversions) are performed without explicit normalization.

**Detection Heuristics**
Search for hardcoded `10**18` or `10**(18 - decimals)` patterns that do not handle tokens with more than 18 decimals. Look for `from18()`/`to18()` conversion functions applied to amounts already in native token decimals. Check oracle integration code for mismatches between the oracle's return decimals and the expected scale. Identify inverted base/quote token pairs in price calculations (e.g., using ETH/DAI as DAI/ETH).

**False Positives**
Protocol only supports tokens with a known fixed decimal enforced by governance. Decimal conversion is handled by a well-tested library that covers all edge cases. Mismatch is in a view function used only for off-chain display.

**Notable Historical Findings**
USSD had at least seven decimal-related findings in a single audit: StableOracleDAI returned a price with an incorrect number of decimals; the base/rate token pair was inverted producing a reciprocal price; getOwnValuation contained arithmetic errors in the price calculation; SellUSSDBuyCollateral's DAI check was wrong; and amountToSellUnit was computed with an off-by-one decimal factor. Dexe's TokenSaleProposal implicitly assumed the buy token had 18 decimals, producing a total loss for buyers using USDC or USDT. Tigris Trade's deposit handler would revert on tokens with more than 18 decimals due to an underflow in the decimal scaling expression.

**Remediation Notes**
Implement explicit bidirectional normalization helpers (`normalizeAmount`, `denormalizeAmount`) that handle decimals both above and below 18. Validate all Chainlink oracle decimal assumptions at integration points. Add fuzz tests that exercise all supported collateral assets with their actual decimal configurations.

---

### Unsafe ERC20 Token Operations (ref: fv-sol-6)

**Protocol-Specific Preconditions**
Protocol interacts with arbitrary or semi-arbitrary ERC20 tokens (USDT, fee-on-transfer tokens, deflationary tokens) as collateral or trading assets. Token interactions assume standard ERC20 return values, no transfer fees, and standard `approve` semantics. Protocol uses `approve()` without first resetting to zero for USDT-style tokens, or records the requested `amount` parameter rather than measuring the actual post-transfer balance.

**Detection Heuristics**
Search for `approve(` calls that do not first set allowance to zero. Look for `transferFrom` or `transfer` calls where the return value is unchecked and `safeTransfer` is not used. Check if the protocol records the `amount` parameter rather than measuring the actual balance change. Identify hardcoded assumptions that stablecoins maintain a 1:1 peg without oracle verification.

**False Positives**
Protocol explicitly documents and enforces that only standard ERC20 tokens are supported. A governance-maintained token whitelist excludes all non-standard tokens. Fee-on-transfer tokens are explicitly blacklisted. Protocol uses WETH exclusively and never interacts with raw ETH or non-standard tokens.

**Notable Historical Findings**
Tigris Trade used raw `IERC20.approve()` without first resetting to zero, causing permanent failure when USDT was used as collateral because USDT's approve reverts if the current allowance is non-zero. A separate Tigris finding showed the protocol assumed stablecoins always equaled exactly $1, ignoring real depeg events. Dexe accepted fee-on-transfer tokens in distribution proposals, recording the requested amount rather than the actual received amount, making such proposals permanently under-funded. A Dexe DAO Pool finding showed tokens could be allocated in a tier sale without the DAO actually transferring them, due to an unchecked return value.

**Remediation Notes**
Use `safeApprove(spender, 0)` followed by `safeApprove(spender, amount)` for USDT compatibility, or prefer `safeIncreaseAllowance`. Measure actual received amounts via balance deltas for all `transferFrom` calls. Verify stablecoin peg via oracle with configurable deviation bounds before accepting as collateral at face value.

---

### Unsafe NFT Minting and Transfer Operations (ref: fv-sol-1)

**Protocol-Specific Preconditions**
Protocol mints ERC721 position or bond tokens using `_mint()` instead of `_safeMint()`, or uses `_safeMint()` but performs state changes after the mint. Batch transfer functions claim to be "safe" but internally call `transferFrom` or `_transfer` rather than `safeTransferFrom`. NFT recipients may be smart contracts without `IERC721Receiver` support.

**Detection Heuristics**
Search for `_mint(` calls in ERC721 contracts that should use `_safeMint(`. Look for `_safeMint` calls where state changes occur after the mint, violating checks-effects-interactions. Search for functions named `safeTransfer*` that internally use `transferFrom` or `_transfer`. Verify that `_safeMint` callers have `nonReentrant` applied.

**False Positives**
Recipient is always a verified EOA by design. Recipient address is validated against a whitelist of known-safe contracts. Reentrancy from `_safeMint` cannot cause meaningful state manipulation. Minting function has `nonReentrant` already applied at an appropriate level.

**Notable Historical Findings**
Tigris Trade's Position contract updated `_openPositions` and `_trades` after `_safeMint`, allowing a malicious `onERC721Received` callback to re-enter and produce duplicate position IDs, which was confirmed as a fund-theft vulnerability in two separate audit rounds. In the same codebase, `safeTransferMany()` had a misleading name: it used `_transfer()` internally instead of `safeTransferFrom`, silently skipping the receiver check for batch transfers. Dexe's governance NFT used `_mint()` instead of `_safeMint()`, which avoided the callback but caused permanent token loss if the recipient was a contract.

**Remediation Notes**
Apply `nonReentrant` to all minting functions. Update all state (position arrays, ID mappings, trade records) before calling `_safeMint`. Audit all batch transfer helpers for naming accuracy and ensure they call `safeTransferFrom` when the name implies it. Prefer `_safeMint` over `_mint` for all ERC721 tokens that may be received by contracts.

## reference/solidity/protocols/bridges.md

# Bridge and Cross-Chain Security Patterns

> Applies to: asset bridges, token bridges, cross-chain message passing, LayerZero integrations, Wormhole integrations, Axelar integrations, lock-and-mint bridges, burn-and-mint bridges, optimistic bridges

## Protocol Context

Bridges are architecturally unique because correctness depends on two independent execution environments that cannot atomically read each other's state: a lock or burn on the source chain must be faithfully reflected as a mint or release on the destination chain with no shared transaction context to enforce atomicity. The trust model extends beyond on-chain code to off-chain relayers, oracle sets, or validator committees whose compromise can authorize fraudulent minting without corresponding locking. Every cross-chain message carries a distinct attack surface - replay across chains, payload tampering during transmission, and gas griefing on the destination side - that has no equivalent in single-chain protocols.

## Bug Classes

### Access Control Misconfiguration (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Critical bridge functions (router recipient setter, fee recipient, mirror connector address) lack access control modifiers or use one-time-set patterns vulnerable to front-running
- Privileged roles such as admin or operator can unilaterally change bridge parameters (acceptance delay, stable swap address, flow rate) or drain router liquidity
- Ownership renouncement or transfer to the zero address removes the only actor capable of performing emergency actions, permanently bricking bridge operations
- Role assignment for multisig participants lacks a removal path, leaving compromised signers permanently privileged

**Detection Heuristics**
1. Enumerate every `external` and `public` function that writes state; verify each has an appropriate access control modifier
2. Check one-time-set patterns for front-run exposure: any setter that checks `if (value == address(0))` rather than `if (msg.sender == deployer)` is vulnerable
3. Confirm every privileged role can be revoked and that revocation does not orphan protocol functionality
4. Verify ownership transfer uses the two-step nominate-then-accept pattern
5. Audit diamond facets: `diamondCut` allowing re-execution of already-applied cuts is a distinct access control failure

**False Positives**
- Intentionally permissionless functions (public liquidations, permissionless relayer calls)
- Admin functions protected by a multi-sig with a timelock
- Setters that affect only the calling account's own state

**Notable Historical Findings**
In Connext audits, multiple findings documented that the `WatcherManager`, router recipient, and `acceptanceDelay` could be configured by unauthorized actors or configured only once with no removal path, leaving the bridge in an irreparable state after a misconfiguration. The Decent bridge allowed anyone to overwrite the router address in the `DcntEth` contract, enabling immediate fund theft at zero cost. Axelar's multisig implementation allowed the same proposal to be executed repeatedly due to missing deduplication, and a deployer wallet retained the ability to spoof validated senders after an ownership transfer completed.

**Remediation Notes**
One-time-set bridge parameters must be protected by the deployer address stored at construction time, not by checking whether the parameter is already populated. All bridge admin roles must support removal, and any removal path must be tested for cascading impact on live routes. Ownership transfers must use the nominate-and-accept pattern so that a mis-typed address does not permanently lock administration.

---

### Reentrancy (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Token transfer callbacks (ERC-777 `tokensReceived`, ERC-721 `safeTransfer`, native ETH `receive`) fire before bridge accounting is finalized
- Bridge executor or router contracts perform external calls to user-specified targets that can re-enter the same function
- Read-only reentrancy: a bridge pricing function reads pool balances (Balancer, Uniswap) during a vault callback, yielding a stale or manipulated price
- Gnosis Safe module hooks (`checkTransaction`, `checkAfterExecution`) can be re-entered before the module's own state is consistent

**Detection Heuristics**
1. Identify all functions that make external calls; confirm state updates precede the call (Checks-Effects-Interactions)
2. Flag any function lacking `nonReentrant` that transfers tokens or calls user-supplied targets
3. Check for ERC-777 token support and whether `tokensReceived` hooks can re-enter deposit or withdraw paths
4. Identify view functions that query on-chain AMM state; determine whether that state can be manipulated inside a callback from the same transaction

**False Positives**
- External calls to immutable, trusted contracts with no callbacks
- Functions where all state updates provably precede external calls and no re-entrant path exists back to the function

**Notable Historical Findings**
Axelar ITS allowed `expressReceiveToken` to be re-entered via ERC-777 token hooks, enabling double-minting of bridged tokens without a corresponding lock on the source side. The Connext Executor's forwarding of user-supplied calldata could re-enter bridge logic before the delivered tokens were marked as claimed. Balancer read-only reentrancy was demonstrated in the Cron Finance audit, where a pricing function could be called during a Balancer vault callback, returning pool balances that were mid-modification.

**Remediation Notes**
Bridge executor and router contracts that forward arbitrary calldata must apply `nonReentrant` regardless of apparent CEI compliance, because the payload target is untrusted. Contracts pricing assets via AMM pool balances should call the Balancer vault reentrancy guard or use a TWAP source that does not read live pool state.

---

### Arithmetic and Precision Errors (ref: fv-sol-2, fv-sol-3)

**Protocol-Specific Preconditions**
- Bridges move tokens between chains with differing decimal precision (e.g., USDC uses 6 decimals on Ethereum, 18 on some L2 deployments); arithmetic that assumes a fixed decimal count silently misprices amounts
- Fee and exchange rate calculations apply division before multiplication, creating compounding precision loss at scale
- `unchecked` arithmetic in packed storage or reward accumulators can silently overflow between claims, causing permanent fund loss
- Collateral valuations compare amounts denominated in different decimal bases without normalization
- Rounding direction is inconsistent with the protocol safety invariant; rounding in the user's favor on withdrawals drains the vault over time

**Detection Heuristics**
1. Check every division that precedes a multiplication in fee, reward, or exchange rate calculations
2. Audit all `unchecked` blocks for overflow potential when values originate from user input or cross-chain messages
3. Verify that token arithmetic normalizes to a common decimal base before comparison or aggregation
4. Confirm oracle price scaling (typically 1e8 for Chainlink) is applied consistently relative to token decimals
5. Confirm rounding direction: shares-to-assets conversions should round against the redeemer; assets-to-shares should round against the depositor

**False Positives**
- `unchecked` blocks used for counter increments where overflow is geometrically impossible given supply constraints
- Precision loss documented as accepted and economically negligible at the protocol's minimum transfer size
- Intentional rounding direction documented in the specification

**Notable Historical Findings**
Connext audits found that `_slippageTol` was evaluated on incomparable scales because it was not adjusted for decimal differences between paired tokens. Axelar ITS had completely broken balance tracking for tokens with different decimal counts on different chains, leading to systematic under-crediting on destination chains. In the Blueberry audit, `IchiLpOracle` returned inflated prices due to a decimal precision error in the price calculation path, causing affected collateral to be valued far above market rate.

**Remediation Notes**
Bridge code that interacts with tokens on multiple chains must never assume a fixed decimal count. All cross-chain accounting should normalize amounts to an internal representation (18-decimal WAD) immediately upon receipt and denormalize only when transferring to the destination token contract. Use `Math.mulDiv` with an explicit rounding direction constant rather than bare division.

---

### Unchecked Return Values (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Bridge contracts use low-level `.call()` to forward execution or send ETH without asserting the returned success flag
- ERC20 `transfer` and `transferFrom` are called directly without `SafeERC20`, silently succeeding on tokens that return `false`
- A `require(success)` check appears after a `return` statement, making it dead code
- External protocol calls (staking, yield vault withdrawals) return a boolean that is discarded

**Detection Heuristics**
1. Grep for `.call{value:` patterns; confirm every returned `bool` is asserted in a `require` or conditional
2. Find all `IERC20(token).transfer(` and `IERC20(token).transferFrom(` usages; flag any not wrapped in `SafeERC20`
3. Look for `return` statements followed by `require` statements in the same function scope
4. Audit protocol-specific external calls (staking, vault deposit/withdraw) for discarded boolean returns

**False Positives**
- Contracts using `SafeERC20` throughout, which internalizes return value handling
- Fire-and-forget refund attempts where failure is intentionally non-blocking and documented

**Notable Historical Findings**
LI.FI had a finding where the return value of a low-level `.call()` was never checked in the receiver contract, allowing a failed bridge execution to silently pass without delivering funds. In the Sturdy audit, the success check for an ETH withdrawal was placed after a `return` statement and was therefore unreachable, meaning a failed transfer would be treated as successful. Notional finance audits found that `auraBooster.deposit` and `auraRewardPool.withdrawAndUnwrap` returned booleans that were never inspected, leaving failed staking operations undetected and bridge accounting incorrect.

**Remediation Notes**
Every `.call()` that sends ETH in a bridge context must check the success flag; failed delivery should emit an event and queue a retry rather than silently proceeding. Use `SafeERC20` without exception for all ERC20 interactions in bridge contracts, which must handle arbitrary tokens including those that return `false` rather than reverting.

---

### Slippage and Price Manipulation (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- Destination-chain swap legs in bridge transactions do not accept a user-specified `minAmountOut`, forcing users to accept any resulting price
- Cross-chain swap calls use `block.timestamp` as the deadline, which is always satisfied and provides no MEV protection
- The same `slippageTol` parameter is applied to two distinct swaps with different token denominations, making the check incorrect for at least one
- Spot prices from AMM reserves (`getReserves()`) are used for fee or collateral valuation without TWAP protection
- Bridge liquidity operations omit minimum amount parameters

**Detection Heuristics**
1. Find all DEX router calls and check whether `amountOutMin` is 0 or hardcoded to a constant
2. Identify `block.timestamp` used as the `deadline` parameter in any swap call
3. Check bridge functions that execute swaps on the destination side for user-configurable slippage
4. Look for `getReserves()` or `balanceOf`-derived pricing in fee or collateral valuation logic
5. Verify every swap parameter that can be sandwiched is either user-specified or derived from a manipulation-resistant oracle

**False Positives**
- Atomic arbitrage within a single transaction where price is guaranteed by construction
- Admin-controlled rebalancing routed via private mempool with off-chain slippage enforcement
- Functions where a separate oracle-derived check independently enforces minimum output

**Notable Historical Findings**
Connext audits documented that users were forced to accept any slippage on the destination chain because `xcall` offered no mechanism for the initiating user to specify a destination-side minimum output, and separately that `SponsorVault` used an AMM spot price for fee calculation, making it directly exploitable via sandwich attack. WooFi's cross-chain router was found not to correctly enforce slippage in `crossSwap`, allowing large cross-chain swaps to receive heavily discounted outputs. The Juicebox protocol audit found that a delegate architecture forced callers to set zero slippage with no override mechanism.

**Remediation Notes**
Bridge interfaces must accept a user-specified `minAmountOut` and `deadline` for any swap executed on the destination side, even when the swap is performed by a relayer on the user's behalf. Any pricing derived from on-chain AMM state must be validated against a Chainlink or TWAP oracle with an acceptable deviation bound before being used in bridge fee or collateral calculations.

---

### Denial of Service and Gas Griefing (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- Withdrawal queues are activated globally when any single transfer exceeds the flow rate limit, allowing an attacker to delay all bridge withdrawals at minimal cost
- Cross-chain message handlers (`lzReceive`, Connext `execute`) can be fed malicious calldata that causes an unrecoverable revert, permanently blocking the message channel
- Unbounded loops over pending withdrawals or inbound message roots exceed the block gas limit as arrays grow
- Gnosis Safe threshold updates can be triggered to exceed the count of valid signers, bricking the multisig guard

**Detection Heuristics**
1. Identify all flow rate or withdrawal queue mechanisms; check whether activation is global or per-token and per-user
2. Examine cross-chain message handler callbacks for unbounded gas cost or revert paths with no recovery mechanism
3. Find loops over dynamic arrays that grow with protocol usage; confirm they are paginated or bounded by a constant
4. Calculate the cost for an attacker to activate the rate limit versus the damage inflicted on legitimate users

**False Positives**
- Global rate limiting serving as an intentional circuit breaker with a documented governance override path
- Loops bounded by a small configuration constant that cannot be inflated by user action
- Message handlers that use try/catch to isolate per-message failures without blocking the channel

**Notable Historical Findings**
Immutable's bridge had a flow rate check that activated a global withdrawal queue, meaning a single attacker transaction slightly above the threshold would delay every pending withdrawal across all users and tokens. Axelar ITS had two separate high-severity DoS findings: one where the bridge could be blocked by initializing an ITSHub balance for a wrong chain, and another where bridging to a chain with no deployed interchain token caused a permanent DoS on that route. Holograph found a critical issue where an operator could set a destination gas limit above the destination chain's block gas limit, permanently preventing message execution.

**Remediation Notes**
Flow rate limits must be tracked and enforced per-token; activation of a queue for one token must not affect withdrawals of other tokens. Cross-chain message handlers must use try/catch with a stored-payload retry mechanism so that a failed individual message does not block the entire channel. Withdrawal loops must be paginated with an explicit batch size parameter enforced at the call site.

---

### Oracle and Price Feed Issues (ref: fv-sol-10, fv-sol-10-c5, fv-sol-10-c6, fv-sol-10-c7)

**Protocol-Specific Preconditions**
- Chainlink `latestRoundData()` is called without checking `updatedAt` staleness, `answeredInRound >= roundId`, or `price > 0`
- Unhandled Chainlink reverts (e.g., access-controlled feeds on some L2s) cause a total DoS of all price-dependent operations
- TWAP oracles register token pairs in the wrong order, returning the inverse price
- Balancer read-only reentrancy allows a bridge pricing function to read pool balances during a mid-transaction vault callback

**Detection Heuristics**
1. Find every `latestRoundData()` call; verify staleness, round completeness, and positivity checks are all present
2. Wrap Chainlink calls in `try/catch` and confirm a fallback oracle or cached price is used on revert
3. For TWAP implementations, verify token0/token1 order matches the actual pool ordering
4. Identify any view function reading Balancer pool balances; check whether it is callable during a Balancer vault callback from the same transaction

**False Positives**
- Oracle used only for non-critical off-chain display output
- Protocol with a correctly implemented and tested secondary oracle fallback

**Notable Historical Findings**
Juicebox audits found that Chainlink oracle data could be outdated and used without staleness validation, and separately that an unhandled Chainlink revert would lock all price oracle access. Vader Protocol had two findings where the TWAP oracle registered tokens in the wrong order and where the TWAP average itself was computed incorrectly, both producing systematically wrong prices throughout the protocol. WooFi's oracle failed silently when the Chainlink price fell outside acceptable bounds, with no fallback mechanism to prevent the bridge from operating on stale prices.

**Remediation Notes**
All Chainlink calls in bridge contracts must use the full validation pattern: positive price, non-zero `updatedAt`, `answeredInRound >= roundId`, and a configurable `MAX_STALENESS` constant. The call must be wrapped in `try/catch` with a documented fallback. Staleness thresholds must be set conservatively relative to the feed's published heartbeat and tightened for feeds on chains with unreliable sequencers.

---

### Cross-Chain Message Verification and Replay (ref: fv-sol-4-c10, fv-sol-4-c11)

**Protocol-Specific Preconditions**
- Cross-chain message handlers do not verify that `msg.sender` is the trusted bridge endpoint, or do not verify the original sender address on the source chain
- Signed payloads omit `block.chainid` or the contract address, making them valid on every chain where the contract is deployed
- Processed message IDs or nonces are not tracked, allowing the same proof or signature to be submitted multiple times
- Gas limits for cross-chain execution are hardcoded or underestimated; messages that exceed the limit fail permanently with no retry path
- Diamond proxy facet upgrades do not track which cuts have been applied, allowing replay of already-executed upgrades

**Detection Heuristics**
1. Verify every cross-chain message handler checks `msg.sender == trustedBridgeEndpoint` and validates the original source chain sender
2. Confirm that processed message IDs are written to storage before execution to prevent replay within the same transaction
3. Check that signed payloads include `block.chainid`, `address(this)`, and a nonce or unique message ID
4. Verify that cross-chain gas limits are configurable and that a minimum floor is enforced at call time
5. Check `diamondCut` implementations for per-cut deduplication tracking

**False Positives**
- Bridge transport layers (LayerZero, Wormhole) that natively enforce sender verification and message deduplication at the protocol level, provided the application layer correctly validates the transport-layer guarantees
- Idempotent operations where duplicate delivery has no additional state impact

**Notable Historical Findings**
Connext audits found that router signatures could be replayed on the destination domain because the signed hash omitted the destination chain ID, and that `diamondCut` allowed already-applied facet updates to be re-executed, potentially reverting security fixes. Biconomy had a cross-chain signature replay vulnerability where a valid signature issued on one chain could be submitted on any other chain where the same contract was deployed. In the Era (zkSync) audit, priority operations could be re-executed when migrating from Gateway to L1 because neither system had recorded the operation as already processed.

**Remediation Notes**
Every cross-chain message handler must follow a strict sequence: (1) verify `msg.sender` is the bridge endpoint, (2) verify the source chain identifier and sender address, (3) mark the message as processed in storage, (4) execute. Steps one through three must be atomic and must precede any state changes or external calls. Gas limits must be parameterized per message with a protocol-enforced minimum covering the worst-case destination execution cost, and a retry or refund mechanism must exist for messages that fail due to insufficient gas.

---

### External Call Injection (ref: fv-sol-4-c6)

**Protocol-Specific Preconditions**
- Bridge executor or router contracts forward arbitrary calldata supplied by users to arbitrary target addresses with no whitelist restriction
- Token approvals are granted to user-supplied addresses before the external call executes
- `delegatecall` is used with a target derived from user input, running untrusted code in the contract's own storage context
- Executor contracts hold residual token balances between transactions, making them profitable to drain via crafted calldata

**Detection Heuristics**
1. Find all `.call()` and `.delegatecall()` invocations; check whether the target address originates from user input or a trusted whitelist
2. Verify function selector validation: the first 4 bytes of calldata should be compared against an allowed-selector mapping before forwarding
3. Audit token approvals granted before external calls; confirm the approval target is an immutable or whitelisted address
4. Check whether executor or router contracts accumulate token balances; if so, confirm no external call path can redirect those balances

**False Positives**
- Calls to hardcoded, immutable contract addresses where the target cannot be influenced by callers
- Functions restricted to admin or trusted operator roles with no user-controlled parameters

**Notable Historical Findings**
LI.FI's `GenericBridgeFacet` accepted arbitrary bridge addresses and calldata, allowing an attacker to pass a malicious target that drained approved tokens in a single transaction. Connext's `Executor` held unclaimed tokens between bridge steps and was exploitable via crafted calldata that redirected those tokens to an attacker-controlled address before the intended recipient claimed them. Biconomy's paymaster contract allowed theft by constructing a specific relayed transaction that triggered an arbitrary external call using the contract's own existing token approvals.

**Remediation Notes**
Bridge executor contracts must maintain an explicit allowlist of callable target addresses and permissible function selectors. Token approvals must be granted only to whitelisted addresses, consumed atomically in the same transaction, and revoked immediately after use. Executor contracts must not hold persistent token balances; residual tokens after each execution should be swept to a designated recovery address.

---

### Flow Rate and Rate Limiting (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- Bridge flow rate limits apply globally across all users when any single user exceeds the per-token threshold, enabling griefing at minimal cost
- Alternative entry points (e.g., deploying a second `TokenManager` instance) bypass the primary rate limit check
- Per-transfer size caps are absent, allowing a single large transfer to exhaust the bridge's available liquidity within one transaction
- Rate limit thresholds are set in nominal token amounts and are not adjusted as token prices change, making fixed thresholds economically meaningless over time

**Detection Heuristics**
1. Check whether rate limit activation triggers a global withdrawal queue or a per-token queue
2. Identify all code paths that result in a token transfer; confirm each path is subject to the same rate limit check
3. Calculate the cost for an attacker to activate the rate limit relative to the damage inflicted on legitimate users
4. Check whether large individual transfers can bypass per-period rate limits through a single transaction

**False Positives**
- Intentional global circuit breakers activated only by governance with a documented and timelocked override path
- Rate limits where the activation threshold requires economic exposure exceeding the attacker's potential benefit

**Notable Historical Findings**
Immutable's bridge had a flow rate check that activated a global withdrawal queue on the first token that exceeded its threshold, meaning a single attacker transaction just above the limit would delay every pending withdrawal for all users across all tokens. Axelar ITS had a finding where the `TokenBalance` limit could be bypassed entirely by deploying a new `TokenManager` instance, as the limit was enforced at the manager level rather than at the bridge level. A separate Axelar finding demonstrated that ERC-777 token support in the `TokenManager` broke the flow limit logic because the re-entrant hook could trigger multiple limit evaluations within a single transfer.

**Remediation Notes**
Rate limit state must be tracked and enforced per-token; activation of a restricted mode for one token must not affect withdrawals of other tokens. Every code path that moves tokens out of the bridge must pass through the same rate limit check. Token manager deployments must be authenticated to prevent bypass via new instances. Consider expressing rate limits in USD value using a price oracle rather than in nominal token amounts to maintain consistent security properties over time.

---

### State Update Inconsistency (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- Burn or cancel operations do not clear all associated mappings (e.g., `orderOwner` persists after NFT burn), enabling the next mint of the same token ID to inherit stale ownership
- Signer count or threshold counters are decremented unconditionally rather than only when the removed entity was actually active, causing the threshold to exceed the valid signer count
- Domain separator or name hash caches are not invalidated when the underlying value changes
- Array swap-and-pop removals update the array but not the associated index mapping, corrupting future lookups on the swapped element
- Cross-chain accounting fails to synchronize source-chain locked amounts with destination-chain minted amounts when intermediate steps fail or are retried

**Detection Heuristics**
1. For every remove, burn, or cancel operation, enumerate all state variables referencing the affected entity and verify each is cleared
2. Check counter variables (signer counts, total supply, cumulative balances) for correctness on both increment and decrement paths
3. Verify that cached computed values (domain separators, price accumulators) are invalidated when any of their inputs change
4. For swap-and-pop array patterns, confirm the index mapping for the moved element is updated before the pop executes

**False Positives**
- Deliberately lazy state updates reconciled by a keeper in a subsequent transaction, with the interim inconsistency documented and bounded
- State variables used only for historical reference or off-chain indexing with no on-chain security impact

**Notable Historical Findings**
CLOBER audits found that `orderOwner` was not zeroed after an NFT burn, allowing the next mint of the same token ID to inherit stale ownership and enabling order theft. Connext audits identified that the domain separator was not rebuilt after a `name` change, causing EIP-712 signatures to silently fail for users whose clients had cached the new name. Hats Protocol had a finding where `_removeSigner` decremented `signerCount` even when the removed signer was already invalid, causing the threshold to be set higher than the actual number of valid signers and bricking the Safe module.

**Remediation Notes**
Any operation that removes an entity from the bridge (token manager deregistration, signer removal, route deletion) must include an explicit cleanup pass over all mappings that reference that entity. Threshold and counter arithmetic must guard against double-decrement by checking whether the entity is active before modifying the counter. Domain separators must be rebuilt atomically within any setter that modifies the values they encode.

---

### ERC4626 Vault Integration Issues (ref: fv-sol-2-c6)

**Protocol-Specific Preconditions**
- Bridge-connected vaults are vulnerable to first-depositor share price inflation when no virtual shares offset is present
- The `mint` function uses the `shares` parameter in the `transferFrom` call where it should use the computed `assets` value, under-collecting tokens
- `maxDeposit`, `maxWithdraw`, and `maxRedeem` do not return 0 when the vault is paused or at capacity, causing integrators to attempt operations that will revert
- Lossy yield strategies cause the vault exchange rate to fall below 1:1, under-collateralizing the bridge's outstanding liabilities
- Preview functions disagree with actual execution amounts due to fees or limits not reflected in the preview

**Detection Heuristics**
1. Check for virtual shares or an equivalent mechanism protecting against share price inflation on the first deposit
2. Compare `previewDeposit`/`previewRedeem` return values against actual `deposit`/`redeem` return values; any discrepancy indicates a specification violation
3. Verify that all `max*` functions return 0 when the contract is paused or capped
4. Check that `mint()` and `deposit()` use the correct parameter (`assets` vs. `shares`) in the token `transferFrom` call
5. Verify round-trip consistency: depositing then immediately redeeming must not lose funds beyond a 1-wei rounding tolerance

**False Positives**
- Vaults using OpenZeppelin's ERC4626 virtual shares offset, which is an accepted mitigation for inflation attacks
- Vaults that document and disclose non-compliance with specific EIP-4626 clauses

**Notable Historical Findings**
Tribe's `xERC4626` used the wrong `amount` parameter in the `mint` function, causing callers to receive shares without transferring the correct asset quantity. PoolTogether v5 audits produced numerous ERC4626 compliance findings, including a case where the vault's internal exchange rate could only decrease and never recover from a lossy strategy, permanently under-collateralizing outstanding shares over time. GoGoPool's `TokenggAVAX` returned incorrect values from `maxWithdraw` and `maxRedeem` when the contract was paused, causing external integrators to attempt operations that would immediately revert.

**Remediation Notes**
Bridges that custody assets in ERC4626 vaults must validate full specification compliance before integration, including paused-state behavior of all `max*` functions. First-deposit inflation attacks are mitigated by OpenZeppelin's virtual shares pattern; any custom vault implementation must replicate this protection. Exchange rate decreases due to lossy strategies must be handled explicitly, either by pausing withdrawals or by maintaining a separate solvency reserve proportional to outstanding bridge liabilities.

---

### Non-Standard Token Handling (ref: fv-sol-2-c7, fv-sol-6-c10)

**Protocol-Specific Preconditions**
- Bridge accepts fee-on-transfer tokens but records the nominal transfer amount rather than the actual balance delta, overstating the locked amount and permitting over-release on the destination chain
- USDT-style tokens require the approval amount to be set to 0 before setting a new non-zero value; omitting this causes a revert that blocks bridge operations
- ERC-777 tokens trigger `tokensReceived` callbacks that re-enter bridge logic before accounting is finalized
- Tokens with non-standard or mutable `decimals()` values are not normalized before cross-chain amount encoding
- Rebasing tokens (e.g., stETH, aTokens) change their balance between the lock event and the corresponding release, causing systematic accounting drift

**Detection Heuristics**
1. Confirm all `transferFrom` calls use a balance-before/balance-after delta to record the actual received amount rather than the input parameter
2. Search for `IERC20(token).approve(` calls that do not reset allowance to 0 before setting a new value
3. Identify tokens explicitly supported by the bridge; flag any with non-standard behavior (fee, rebase, ERC-777) and verify each has explicit handling
4. Check `decimals()` usage in all cross-chain amount scaling paths; verify it is called dynamically rather than hardcoded
5. Verify `safeTransfer` and `safeTransferFrom` are used throughout rather than bare `transfer` and `transferFrom`

**False Positives**
- Bridges that explicitly document and enforce a whitelist of non-fee-on-transfer, non-rebasing tokens
- Approval calls where the contract is known to consume the full allowance in the same transaction and the token is not USDT-like

**Notable Historical Findings**
Axelar ITS had balance tracking completely broken for rebasing tokens because the bridge locked a snapshot amount but the rebased balance changed before and after the cross-chain operation, enabling attackers to exploit the gap. A separate Axelar finding showed that ERC-777 reentrancy allowed `expressReceiveToken` to be re-entered before the express delivery was marked as settled, enabling double delivery. LI.FI received multiple findings for not resetting token allowances after swaps, leaving residual approvals that could be exploited by subsequent callers interacting with the same bridge contract.

**Remediation Notes**
Bridge contracts must use the balance-delta pattern for every inbound token transfer unconditionally, regardless of whether the token is expected to be fee-on-transfer. USDT compatibility requires the two-step approve pattern (set to 0, then set to amount). ERC-777 support requires re-entrancy guards on all token receipt paths. Rebasing tokens should either be explicitly unsupported and blocked, or converted to a non-rebasing wrapper before bridging.

---

### Native ETH Handling (ref: fv-sol-5-c8)

**Protocol-Specific Preconditions**
- `msg.value` sent to a bridge function is not forwarded to the downstream messaging layer, leaving ETH permanently stranded in the bridge contract
- Excess ETH above the required fee is not refunded to the caller
- Inconsistent ETH/WETH handling causes some code paths to wrap ETH while others pass it as native, resulting in mismatched accounting on the destination side
- Arbitrum retryable ticket creation uses an incorrect function variant, causing aliasing issues that prevent fund recovery
- Wormhole bridge facets omit the `{value: msg.value}` syntax on the bridge call, sending the message without the required attached value

**Detection Heuristics**
1. For every `payable` function, trace `msg.value` through all downstream calls and verify none is left unaccounted
2. Check whether excess ETH (`msg.value - requiredFee`) is explicitly refunded to `msg.sender` using a success-checked low-level call
3. Audit ETH-to-WETH wrapping paths for asymmetry: every `weth.deposit` should have a corresponding `weth.withdraw` on paths that need native ETH output
4. Verify L2-specific bridge calls (Optimism, Arbitrum, zkSync) use the correct function signatures for fee forwarding and refund aliasing

**False Positives**
- Contracts that intentionally collect excess ETH as a fee, with this behavior documented
- Atomic wrap/unwrap within a single transaction where no value can be stranded

**Notable Historical Findings**
LI.FI's Wormhole facet was found not to include the native token in the bridge call, and the Arbitrum facet used the wrong function to create retryable tickets, causing submitted fees to be unrecoverable. Connext's executor and asset logic handled native tokens inconsistently across code paths, causing `execute()` to revert when the bridge had forwarded native ETH instead of WETH. Decent's bridge sent any ETH refunded by the destination router to the `DecentBridgeAdapter` contract address rather than back to the original caller, permanently locking refunded value.

**Remediation Notes**
Every `payable` bridge function must forward `msg.value` in full to the underlying messaging layer using explicit `{value: msg.value}` syntax. Excess ETH must be returned to `msg.sender` via a low-level call with a checked success flag. ETH and WETH must be handled through a single canonical adapter function to eliminate mixed-handling inconsistencies. L2-specific fee mechanics (Arbitrum, Optimism, zkSync) must be tested with the exact function variants documented by the respective bridge infrastructure.

### lzCompose Sender Impersonation (ref: pashov-7)

**Protocol-Specific Preconditions**
- The bridge contract implements `lzCompose` but does not validate that `msg.sender` is the trusted LayerZero endpoint
- The `_from` parameter is accepted without checking it matches the expected OFT or OApp peer address
- Nested compose messages degrade the sender context to `address(this)`, allowing a malicious contract to impersonate the OFT when triggering a composed call
- The contract grants privileged actions (mints, unlocks, parameter changes) inside `lzCompose` based solely on the unvalidated `_from` argument

**Detection Heuristics**
1. Locate every `lzCompose` implementation; verify `require(msg.sender == address(endpoint))` is the first check
2. Verify `_from` is validated against a stored peer or trusted OFT address before executing any state-changing logic
3. Check whether the contract supports nested compose messages; if so, confirm the sender context is explicitly re-validated at each composition level
4. Search for any privilege escalation inside `lzCompose` (mint, unlock, transfer) that executes before both sender checks are satisfied

**False Positives**
- `lzCompose` implementations that use the standard `OAppReceiver` modifier, which already enforces both `msg.sender == endpoint` and peer validation
- Contracts where `lzCompose` performs only read-only or idempotent operations with no economic impact

**Notable Historical Findings**
Tapioca USDO/TOFT exploit: a HIGH severity finding where the `lzCompose` implementation omitted both the endpoint sender check and the `_from` peer check, allowing an attacker to call `lzCompose` directly and trigger unauthorized token operations by fabricating the `_from` parameter.

**Remediation Notes**
Every `lzCompose` implementation must begin with `require(msg.sender == address(endpoint))` followed by `require(_from == trustedPeer[srcEid])`. The standard `OAppReceiver` modifier enforces this pattern and should be used without modification. Protocols that support nested compose chains must treat each composition level as a fresh, unvalidated call and re-apply sender verification at every level.

---

### Delegate Privilege Escalation (ref: pashov-38)

**Protocol-Specific Preconditions**
- The OApp calls `endpoint.setDelegate(delegateAddress)` but the delegate address is an EOA, a hot wallet, or a contract with weaker access controls than the OApp owner
- `setDelegate` is protected by a lesser access control than `setPeer`, allowing an actor who cannot register peers to nonetheless reconfigure DVNs, executors, and message libraries
- The delegate has the ability to call `skipPayload` or `clearPayload`, effectively censoring or selectively dropping cross-chain messages
- No governance timelock separates the `setDelegate` transaction from the configuration change taking effect

**Detection Heuristics**
1. Find all calls to `endpoint.setDelegate`; verify the supplied address is identical to the OApp owner or is governed by at least the same multisig and timelock
2. Confirm `setDelegate` is guarded by the same access modifier as `setPeer` and other critical OApp configuration functions
3. Check whether the current delegate can unilaterally invoke `skipPayload`, `clearPayload`, or library version overrides without additional authorization
4. Audit deployment and initialization scripts to confirm no EOA retains the delegate role after the protocol goes live

**False Positives**
- Delegate set to the same multisig address as the OApp owner, making the privilege equivalent
- Protocols where the delegate is a governance timelock contract, providing a delay window for community response

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
The delegate role must be treated as equivalent in power to the OApp owner. The safest pattern is `setDelegate(address(this))` or `setDelegate(owner())`, ensuring no external party can reconfigure the security stack. Where a distinct delegate is operationally necessary, it must be a multisig with a timelock, and its authority should be narrowly scoped to non-security-critical operations if the LayerZero SDK allows such restriction.

---

### Cross-Chain Supply Accounting Invariant Violation (ref: pashov-39)

**Protocol-Specific Preconditions**
- The bridge's fundamental invariant `total_locked_source >= total_minted_destination` is not enforced by on-chain code or continuously monitored off-chain
- Decimal conversion between chains is implemented incorrectly, causing the destination to mint more tokens than were locked on the source
- `_credit` is callable through a path that does not require a corresponding `_debit` to have been executed, allowing minting without locking
- Race conditions exist in multi-chain deployments where two destinations can both process the same source event due to a missing uniqueness check
- Any off-path function (emergency recovery, admin mint, airdrop) can increase the destination supply without modifying the source lock accounting

**Detection Heuristics**
1. Map every code path that calls `_credit` or any equivalent minting function; verify each path is exclusively reachable via `lzReceive` from a verified peer
2. Confirm decimal conversion is tested for all token/chain combinations; verify that `sharedDecimals` normalization correctly handles non-18-decimal tokens
3. Check for emergency or admin functions that can mint on destination or unlock on source without updating the complementary accounting on the other chain
4. Verify replay protection ensures each source-chain event triggers at most one destination credit
5. Review multi-chain topologies (hub-and-spoke vs. mesh) for scenarios where a message can be delivered to two destinations from a single debit

**False Positives**
- Protocols that implement conservative rate limits capping maximum per-window minting, limiting exposure even if the invariant is temporarily violated
- Bridges where `_credit` is callable only via a verified `lzReceive` path and the LayerZero endpoint enforces message uniqueness

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
The invariant `total_locked_source >= total_minted_destination` must be maintained across every code path. `_credit` must be callable exclusively through `lzReceive` from a verified peer address; no admin or emergency shortcut should bypass this path. Decimal conversion must be unit-tested for every supported token and chain pair. Rate limits on cross-chain transfers provide a defense-in-depth layer that caps maximum exposure if the invariant is momentarily violated by an undiscovered bug.

---

### Ordered Message Channel Blocking (ref: pashov-42)

**Protocol-Specific Preconditions**
- The OApp uses ordered nonce execution, meaning messages from a given source must be processed in strict sequence on the destination
- At least one message type can permanently revert on the destination due to invalid state, a reverted recipient contract call, or an out-of-bounds operation
- No administrative mechanism (skipPayload, clearPayload, admin override) is available or access-controlled to an entity that can respond quickly to a channel freeze
- An attacker can craft a message whose payload is guaranteed to revert on the destination, requiring only the one-time cross-chain messaging fee to freeze the entire channel indefinitely

**Detection Heuristics**
1. Determine whether the OApp uses ordered or unordered nonce mode; ordered mode is the risk-bearing configuration
2. Identify every revert condition inside `_lzReceive`; for each one, assess whether an attacker can deliberately trigger it via crafted message content
3. Verify whether `_lzReceive` is wrapped in a try/catch that records and skips permanently-failing messages rather than propagating the revert
4. Confirm that `skipPayload` or `clearPayload` exists and is callable by an actor who can respond within the expected channel-freeze impact window
5. Check whether the `NonblockingLzApp` pattern (V1) or its V2 equivalent is used to decouple message failures from channel progression

**False Positives**
- OApps using LayerZero V2 unordered nonce mode, where a single failed message does not block subsequent messages from the same source
- `_lzReceive` implementations so simple (single mapping write) that a revert is geometrically impossible given valid message encoding

**Notable Historical Findings**
Code4rena Maia DAO finding #883: an ordered nonce OApp was found to be permanently blockable because a single crafted message could permanently revert on the destination, freezing all subsequent messages from that source chain indefinitely with no recovery path.

**Remediation Notes**
Bridge and OFT contracts should default to LayerZero V2 unordered nonce mode unless message ordering is a strict protocol requirement. Where ordering is required, `_lzReceive` must be wrapped in a try/catch that stores failing messages for later manual retry rather than propagating the revert. An accessible `skipPayload` or `clearPayload` function guarded by a sufficiently responsive multisig must be available as a last-resort recovery mechanism.

---

### State-Time Lag Exploitation via lzRead (ref: pashov-44)

**Protocol-Specific Preconditions**
- The protocol uses `lzRead` to query state on a remote chain and then acts on the result delivered by `lzReceive`, with a non-trivial and non-deterministic latency between query submission and result delivery
- Decisions made from the read result are irreversible (token mints, collateral unlocks, position closures) and carry economic value proportional to the queried state
- The queried state (token ownership, position health, balance) can change between the moment of query and the moment the result is acted upon
- No on-chain re-validation of the read result occurs before the irreversible action is executed

**Detection Heuristics**
1. Identify every `lzRead` invocation; for each one, determine what state is queried and whether that state can change between query and delivery
2. Assess whether the action triggered by the `lzReceive` callback is reversible or irreversible; irreversible actions on stale read results are the primary risk
3. Check whether the destination contract re-validates the read result against current on-chain state before executing the privileged action
4. Evaluate the latency window: longer windows increase the probability and severity of state changes between query and execution

**False Positives**
- `lzRead` used exclusively to query immutable or append-only data (contract deployment bytecode, historical block hashes) that cannot change between query and delivery
- Protocols that treat the read result as a hint and perform a fresh on-chain state check before executing any irreversible action

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Irreversible cross-chain actions must not be based solely on `lzRead` results. The safe pattern requires the destination contract to re-validate the critical condition (e.g., re-checking `ownerOf(tokenId)` or balance on the local chain, or requiring a fresh signed attestation) at the time of execution rather than trusting the cross-chain read result. `lzRead` is appropriate for slowly-changing or immutable data; time-sensitive authorization decisions require fresh on-chain state.

---

### OFT Shared Decimals Truncation (ref: pashov-47)

**Protocol-Specific Preconditions**
- The OFT token uses a non-standard `sharedDecimals` configuration where `sharedDecimals >= localDecimals`, eliminating the intended precision reduction and making `_toSD()` a no-op conversion that still casts to `uint64`
- Transfer amounts can exceed `type(uint64).max` (~18.4e18) in absolute units, causing silent truncation in the `uint64` cast inside `_toSD()` with no revert
- A custom fee mechanism is applied before `_removeDust()` is called, causing the fee to be calculated on a pre-dust-removal amount that differs from the actual transferred amount
- The OFT is deployed with `localDecimals == 18` and `sharedDecimals == 18`, which is a non-standard configuration that bypasses the decimal normalization designed to prevent `uint64` overflow

**Detection Heuristics**
1. Read the OFT constructor to determine `localDecimals` and `sharedDecimals`; flag any configuration where `sharedDecimals >= localDecimals`
2. Identify the maximum transferable amount in absolute token units; verify it does not exceed `type(uint64).max` after division by `10 ** (localDecimals - sharedDecimals)`
3. Locate custom fee or deduction logic; verify it is applied after `_removeDust()` is called, not before
4. Check whether transfer amounts are validated against `uint64.max` before the `_toSD()` conversion

**False Positives**
- Standard OFT deployments using the default `sharedDecimals = 6` with `localDecimals = 18`, where the `_toSD()` conversion reduces amounts by `10^12` before the `uint64` cast, making overflow practically impossible for realistic token supplies
- Protocols where fee logic is explicitly applied after dust removal and tested with amounts near the `uint64` boundary

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
OFT contracts must use `sharedDecimals = 6` (the LayerZero default) with `localDecimals = 18`. Custom fee logic must be applied after `_removeDust()` to ensure fees are calculated on the same amount that will be transferred. Transfer amounts should be validated with `require(amountLD <= type(uint64).max * decimalConversionRate)` before invoking `_toSD()` to surface overflow conditions as explicit reverts rather than silent truncation.

---

### Cross-Chain Address Ownership Variance (ref: pashov-59)

**Protocol-Specific Preconditions**
- The bridge or OApp uses `lzRead` to check `ownerOf(tokenId)` or `balanceOf(address)` on a remote chain and grants rights to the same address on the local chain, assuming address identity implies ownership identity across chains
- One or more supported chains use `CREATE`-based deployment where the same address can be controlled by entirely different parties depending on nonce history on each chain
- An EOA key used on one chain has never been imported or used on another chain, meaning the address exists in a different security context
- Authorization is granted based on address equality across chains rather than through a verified cross-chain message from an authorized peer

**Detection Heuristics**
1. Search for any cross-chain read (`lzRead`, oracle query, off-chain attestation) that resolves to an address, then grants rights to that same address on the local chain
2. Identify whether the cross-chain authorization path uses address equality (`localAddress == remoteAddress`) rather than an explicit (chainId, address) pair mapping
3. Check peer mappings: verify they bind (srcChainId, srcAddress) as a composite key, not srcAddress alone
4. Audit `CREATE`-deployed contracts that appear at the same address on multiple chains; verify the deployer and constructor arguments are identical, confirming the same entity controls both

**False Positives**
- `CREATE2`-deployed contracts where the factory address, salt, and init code hash are all identical across chains, which cryptographically guarantees the same controlling entity
- Protocols that use cross-chain messaging (not address equality) to prove ownership: e.g., a message signed by the remote owner and verified by a registered peer

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Cross-chain authorization must never rely on address equality as a proxy for ownership identity. The safe pattern binds authorization to an explicit `(chainId, address)` pair stored in a peer registry. Ownership proofs for cross-chain operations must flow through verified cross-chain messages rather than address inference. `CREATE2` deployments with a deterministic factory provide a safe exception when the factory address, salt, and bytecode are verifiably identical across all target chains.

---

### Missing enforcedOptions - Insufficient Gas for lzReceive (ref: pashov-71)

**Protocol-Specific Preconditions**
- The OApp never calls `setEnforcedOptions()` to establish a minimum gas floor for destination execution, leaving gas entirely at the discretion of the message sender
- `lzReceive` on the destination performs non-trivial computation (multiple storage writes, external calls, token mints) that requires more gas than a user might supply with a minimal options configuration
- When `lzReceive` reverts on the destination due to out-of-gas, the source-chain debit has already been committed, leaving funds stranded in the LayerZero channel
- Recovery requires an admin to invoke `skipPayload` or the LayerZero executor to retry with adequate gas, both of which introduce delay and operational complexity

**Detection Heuristics**
1. Check whether `setEnforcedOptions` is called during deployment or initialization for each message type the OApp sends; a missing call is a finding
2. Measure the gas consumption of `_lzReceive` under worst-case conditions (maximum payload size, maximum number of storage writes); compare against the enforced minimum
3. Identify whether users can supply a custom `_options` bytes parameter that overrides gas limits; if so, verify `enforcedOptions` provides an absolute floor that cannot be undercut
4. Review the recovery path for stuck messages: confirm `skipPayload` or an equivalent mechanism is available and access-controlled appropriately

**False Positives**
- OApps where `_lzReceive` performs a single mapping write and the LayerZero executor's default gas grant is demonstrably sufficient under all conditions
- Protocols using an executor configuration that guarantees a minimum gas delivery regardless of user-supplied options

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Every OApp that sends cross-chain messages must call `setEnforcedOptions()` during initialization with gas limits derived from benchmarked worst-case `_lzReceive` execution costs, including a safety margin of at least 20%. Enforced options must be applied per message type. The LayerZero SDK's `Options.newOptions().addExecutorLzReceiveOption(gasLimit, value)` builder should be used to construct enforced options, and changes to enforced options after deployment must be governed by the same access controls as peer configuration.

---

### Insufficient Block Confirmations / Reorg Double-Spend (ref: pashov-114)

**Protocol-Specific Preconditions**
- The DVN relays cross-chain messages after a confirmation count that is below the chain's practical reorg depth, accepting messages as final before they are irreversibly settled
- The source chain has a history of reorgs at the configured confirmation depth (e.g., Polygon frequently reorgs at depths below 128 blocks, some L2 sequencers are centralized with known failure modes)
- An attacker can profitably execute a deposit on the source chain, receive bridged assets on the destination, and then cause or exploit a reorg to reverse the source deposit while retaining the destination assets
- The DVN does not differentiate between chains with probabilistic finality and chains with deterministic finality, applying the same low confirmation threshold universally

**Detection Heuristics**
1. Read the DVN configuration for each supported source chain; compare the `requiredConfirmations` value against publicly documented reorg depths and finality guarantees for that chain
2. For Polygon PoS, verify confirmations are at least 128; for Ethereum pre-merge, verify at least 12; for chains with probabilistic finality, require confirmation depths aligned with the economic value secured
3. Check whether the DVN configuration distinguishes between chains with fast cryptographic finality (e.g., post-merge Ethereum, most L1s with BFT consensus) and chains without it
4. Assess the economic profitability of a reorg attack given the bridge's liquidity depth and the cost of the required confirmations

**False Positives**
- Source chains with deterministic, near-instant finality (e.g., most modern BFT chains, Ethereum after finalization checkpoints) where reorgs beyond one or two blocks are cryptographically impossible
- DVN configurations that wait for finalized block tags rather than counting confirmations, providing stronger guarantees than confirmation counting

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Confirmation counts must be set chain-specifically based on each chain's finality guarantees, not as a uniform default. DVNs should wait for finalized block tags where the chain's RPC supports them (e.g., Ethereum's `finalized` tag). For chains with probabilistic finality, the confirmation depth should be calibrated against both the historical maximum reorg depth and the maximum economic value exposed per message. Rate limits on bridge transfers provide a complementary control by capping the value at risk in any single reorg window.

---

### Cross-Chain Message Spoofing (ref: pashov-117)

**Protocol-Specific Preconditions**
- The receiver contract's `lzReceive` or equivalent entry point does not verify that `msg.sender` is the trusted LayerZero endpoint address
- The `_origin.sender` field is not validated against a registered peer address for the originating chain ID, allowing fabricated origin data to pass unchecked
- The contract grants high-value actions (token mints, asset unlocks, privileged state changes) based solely on message content without validating the message delivery path
- A direct external call to the receive function with attacker-controlled parameters is indistinguishable from a legitimate endpoint delivery in the absence of sender verification

**Detection Heuristics**
1. Locate the `lzReceive` function or equivalent; verify the first two checks are `require(msg.sender == address(endpoint))` and `require(_origin.sender == peers[_origin.srcEid])`
2. Search for any function that processes cross-chain message content (minting, unlocking, state changes) without being exclusively reachable via the verified endpoint path
3. Verify the `onlyPeer` modifier or `_acceptNonce` function from the standard `OAppReceiver` is used and not overridden in a way that weakens either check
4. Test whether the receive function can be called directly from an external address without triggering an access control revert

**False Positives**
- Contracts that use the standard `OAppReceiver` base without modification, which already enforces both endpoint and peer validation
- Receive functions that are `internal` and exclusively called by a validated dispatcher that performs the endpoint and peer checks

**Notable Historical Findings**
CrossCurve bridge exploit (January 2026): an attacker called `expressExecute` directly with spoofed message data, bypassing the endpoint sender check entirely. The missing `msg.sender == endpoint` validation allowed the attacker to fabricate a cross-chain message and trigger unauthorized token minting, resulting in approximately $3M in losses.

**Remediation Notes**
Every cross-chain receive function must enforce both `msg.sender == address(endpoint)` and `_origin.sender == registeredPeer[_origin.srcEid]` as non-bypassable preconditions. The standard `OAppReceiver._lzReceive` wrapper provides these checks; custom receivers must replicate both. The endpoint address must be set at construction time as an immutable and must not be configurable post-deployment without governance controls.

---

### Unauthorized Peer Initialization (ref: pashov-119)

**Protocol-Specific Preconditions**
- `setPeer()` or `setTrustedRemote()` is callable by an account that does not require multisig authorization or a governance timelock
- The owner key used to call `setPeer` is an EOA without hardware wallet or multisig protection, making it susceptible to compromise
- The OApp's `allowInitializePath()` implementation accepts peers that have not been explicitly registered, falling back to permissive behavior
- No peer registry or deployment verification system is used to cross-check peer addresses against a canonical deployment manifest before registration

**Detection Heuristics**
1. Identify the access control on `setPeer` or `setTrustedRemote`; verify it requires a multisig with a meaningful threshold, not a single EOA
2. Check whether a timelock separates the `setPeer` transaction from the peer taking effect, providing a window to detect and respond to unauthorized changes
3. Review `allowInitializePath()`: verify it returns false for any (srcEid, sender) pair not explicitly registered via `setPeer`
4. Audit the deployment process to confirm peer addresses are verified against a canonical registry before being registered on-chain

**False Positives**
- Protocols where `setPeer` is governed by a multisig with timelock and peer addresses are verified against a published deployment registry before registration
- OApps using a factory pattern where peer addresses are deterministically computed and verified at deployment time

**Notable Historical Findings**
GAIN token exploit (September 2025): an attacker registered a fraudulent peer contract on the source chain by exploiting inadequate access control on the peer registration function. The fake peer was then used to trigger unauthorized minting of 5 billion tokens on the destination chain, with approximately $3M extracted before the protocol could respond.

**Remediation Notes**
`setPeer` must be protected by a multisig requiring at least two independent signers, combined with a timelock of at least 24 hours. `allowInitializePath()` must explicitly check `peers[_origin.srcEid] != bytes32(0)` and return false for unregistered origins. Peer addresses should be registered only after cross-referencing with a published deployment manifest. Post-registration, peer addresses should be treated as immutable unless a governance process with timelock and public notice is used to update them.

---

### Missing chainId / Message Uniqueness in Bridge (ref: pashov-140)

**Protocol-Specific Preconditions**
- The bridge does not maintain a `processedMessages` mapping or equivalent deduplication structure, allowing the same message to be processed more than once
- The message hash used for deduplication does not include the destination chain ID, enabling the same message to be replayed on a different chain that shares the same contract address
- The source chain ID is absent from the message hash, allowing messages originating from different chains to collide and be treated as equivalent
- No per-sender nonce is enforced, meaning message ordering and uniqueness depend solely on the content of the message rather than a monotonically incrementing counter

**Detection Heuristics**
1. Locate the message processing function; verify a `processedMessages[messageHash] = true` check and set operation surrounds the state-changing logic
2. Examine the message hash construction; confirm it includes `sourceChainId`, `destinationChainId`, a per-sender nonce, and the full payload
3. Check whether `require(block.chainid == destinationChainId)` is validated inside the receive function to prevent delivery to the wrong chain
4. Verify the contract address is included in the hash or that the hash is validated against a domain separator that encodes the contract address

**False Positives**
- Bridges that delegate replay protection to the underlying messaging layer (e.g., LayerZero endpoint enforces per-channel nonce uniqueness) and document this reliance explicitly
- Message types where replaying is economically harmless by design (e.g., price update messages that are idempotent)

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Every bridge must maintain a `processedMessages[keccak256(abi.encode(sourceChainId, destChainId, nonce, sender, payload))]` mapping and revert on any duplicate. The domain separator must encode both chain IDs and the contract address. A per-sender monotonic nonce must be included in the hash and incremented atomically on each processed message. Where the underlying messaging layer provides these guarantees, the bridge must document its reliance and verify the guarantees hold for every supported chain.

---

### DVN Collusion or Insufficient DVN Diversity (ref: pashov-142)

**Protocol-Specific Preconditions**
- The OApp is configured with a `1/1/1` security stack using a single required DVN and no optional DVNs, meaning one entity's compromise is sufficient to approve fraudulent messages
- Multiple DVNs are configured but are operationally or legally controlled by the same entity, or use the same underlying verification method (e.g., multiple DVNs all relying on the same oracle feed), eliminating meaningful independence
- The DVN configuration was set at deployment with no governance path for updating it as the DVN landscape evolves or as individual DVNs become compromised
- The OApp relies exclusively on the LayerZero default DVN configuration without explicitly overriding it, accepting whatever default the endpoint administrator has configured

**Detection Heuristics**
1. Read the OApp's `setConfig` call or configuration storage; identify `requiredDVNCount` and the list of required and optional DVN addresses
2. Research each configured DVN to determine its controlling entity and verification methodology; flag any two DVNs with shared control or shared underlying data sources
3. Verify the OApp does not fall back to the endpoint's default DVN configuration; an explicit override should be present
4. Check whether DVN configuration can be updated via governance with a timelock, or whether it is immutable post-deployment

**False Positives**
- OApps using a diverse DVN set with at least two independent entities applying different verification methods (light client, oracle-based, ZKP-based) and a threshold requiring at least two of them to agree
- Protocols that run their own required DVN in addition to third-party DVNs, reducing the ability of any single external party to approve fraudulent messages

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
OApps securing material value must configure at least a `2/3` DVN threshold using DVNs from independent entities that employ different verification methodologies. Using Google Cloud DVN, a ZKP-based DVN, and the protocol's own DVN provides diversity across both organizational and technical dimensions. The OApp should explicitly set its DVN configuration via `setConfig` rather than relying on endpoint defaults, and DVN configuration changes should require a governance timelock to prevent rapid reconfiguration by a compromised admin key.

---

### Missing Cross-Chain Rate Limits / Circuit Breakers (ref: pashov-143)

**Protocol-Specific Preconditions**
- The bridge or OFT contract enforces no per-transaction maximum transfer size, allowing a single transaction to transfer the entire locked asset pool if a vulnerability is exploited
- No time-window transfer cap (e.g., maximum N tokens per hour) limits the rate at which assets can flow through the bridge
- The contract has no `pause` function or the `pause` function is only callable by a single EOA key without a responsive on-call guardian arrangement
- Anomaly detection and automated pause triggers are absent, meaning an ongoing exploit continues unimpeded until manually detected

**Detection Heuristics**
1. Search for per-transaction `require(amount <= maxTransferSize)` checks on both send and receive paths; flag their absence
2. Check for time-window rate limiting: a moving-window accumulator pattern that reverts when the window's total exceeds a configured cap
3. Locate the `pause` function; verify it is callable by a multisig or guardian address that can respond within minutes, not only by a slow governance process
4. Assess the total value locked relative to the absence of rate limits; the risk severity scales with the amount that could be drained in a single transaction

**False Positives**
- Bridges where the total locked value is small enough that the gas cost of an exploit transaction constitutes a meaningful deterrent relative to the potential gain
- OFTs with per-chain supply caps enforced at the token contract level that effectively limit per-transaction exposure

**Notable Historical Findings**
Ronin bridge hack: the exploit drained approximately $625M over multiple transactions across six days before being detected. Per-window rate limits would have capped the loss to a fraction of this amount by triggering an automatic pause after the first anomalous window, providing time for human intervention.

**Remediation Notes**
All bridges securing material value must implement both per-transaction maximums and time-window rate limits on send and receive paths. The `whenNotPaused` modifier must be applied to all token movement functions. A guardian address - a multisig with on-call key holders - must have the ability to pause the bridge immediately without requiring a full governance vote. Automated monitoring that triggers a pause when transfer volume exceeds a statistical threshold should be deployed as an off-chain companion to the on-chain circuit breaker.

---

### Cross-Chain Reentrancy via Safe Transfer Callbacks (ref: pashov-156)

**Protocol-Specific Preconditions**
- The cross-chain receive function (`lzReceive`, `_credit`, or equivalent) calls `_safeMint` or `safeTransferFrom` before updating supply counters, ownership mappings, or bridge accounting state
- The recipient address is a contract that implements `onERC721Received` or `onERC1155Received` and uses the callback to initiate a new outbound cross-chain send on the same bridge
- The re-entrant outbound send executes before the original receive has updated the balance or supply state, creating a window where the asset appears to exist simultaneously on both chains
- The bridge lacks a reentrancy guard on the receive path, allowing the callback-triggered outbound send to fully execute before the original receive completes

**Detection Heuristics**
1. Locate every `_safeMint` and `safeTransferFrom` call inside cross-chain receive functions; verify all state updates (balances, supply counters, ownership records) precede the call
2. Check whether `nonReentrant` is applied to the receive path; its absence combined with a `_safeMint` call is a direct finding
3. Trace whether a re-entrant call into `send` or `_debit` from within `onERC721Received` / `onERC1155Received` would see consistent or stale bridge state
4. Consider replacing `_safeMint` with `_mint` on receive paths where the recipient is a user-specified address that could be a malicious contract

**False Positives**
- Receive functions that use `_mint` instead of `_safeMint`, which does not trigger any callback and therefore has no reentrancy surface
- Protocols where `nonReentrant` is applied to both the receive path and the send path, preventing the re-entrant outbound send from executing mid-receive

**Notable Historical Findings**
Ackee Blockchain cross-chain reentrancy proof-of-concept: a demonstration where `lzReceive` calling `_safeMint` before updating supply counters allowed a malicious `onERC721Received` callback to initiate a second bridge send, resulting in token duplication across chains.

**Remediation Notes**
The receive path must follow the Checks-Effects-Interactions pattern: all state updates (supply increments, ownership assignments, balance credits) must be committed before any safe transfer or mint callback. `nonReentrant` must be applied to the receive function. Where the callback is not required for contract recipient validation, `_mint` should be used instead of `_safeMint` to eliminate the callback surface entirely.

---

### Missing _debit Authorization in OFT (ref: pashov-159)

**Protocol-Specific Preconditions**
- The OFT contract overrides `_debit` or `_debitFrom` without including authorization logic verifying that the caller is permitted to burn or transfer tokens on behalf of `_from`
- The custom `_debit` implementation calls `_burn(_from, amount)` or `transferFrom(_from, address(this), amount)` without checking `msg.sender == _from` or verifying a sufficient allowance from `_from` to `msg.sender`
- The `send()` function does not enforce that `msg.sender == _from` before delegating to `_debit`, or it accepts `_from` as a caller-supplied parameter
- Any address can call `send()` specifying an arbitrary victim as `_from`, triggering an unauthorized burn or lock of that victim's tokens

**Detection Heuristics**
1. Locate any override of `_debit` or `_debitFrom`; verify it includes `require(msg.sender == _from || allowance[_from][msg.sender] >= amount)` before modifying token balances
2. Check `send()` to confirm `_from` is set to `msg.sender` internally and is not a caller-supplied parameter
3. If `send()` accepts `_from` as a parameter, verify the function contains `require(_from == msg.sender || isApprovedForAll(_from, msg.sender))` before invoking `_debit`
4. Compare the custom `_debit` implementation against the standard LayerZero OFT reference implementation; any divergence in authorization logic is a finding

**False Positives**
- Contracts using the standard LayerZero OFT implementation without any override of `_debit` or `_debitFrom`, which correctly derives `_from` from `msg.sender` inside `send()`
- Custom `_debit` implementations that include full ERC20 allowance validation and are covered by tests demonstrating rejection of unauthorized calls

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Custom `_debit` implementations must replicate the authorization logic of the standard ERC20 `transferFrom` pattern: either `msg.sender == _from` or `allowance[_from][msg.sender] >= amount`, with the allowance decremented atomically on use. The safest approach is to avoid overriding `_debit` at all and instead extend the standard OFT's hook points. If an override is necessary, it must be reviewed against the standard implementation line-by-line to ensure no authorization step is omitted.

---

### Default Message Library Hijack (ref: pashov-160)

**Protocol-Specific Preconditions**
- The OApp does not explicitly pin its send library via `setSendLibrary()` or receive library via `setReceiveLibrary()` on the LayerZero endpoint, relying on the endpoint's mutable default library
- The LayerZero endpoint administrator updates the default message library to a new version, and the OApp silently adopts it without any notification, governance vote, or opportunity for the OApp to review the new library's security properties
- The new default library uses a different DVN or oracle validation mechanism than the one the OApp's security model was designed around, effectively changing the trust assumptions without the OApp's consent
- In a malicious or compromised scenario, a default library update could introduce a library that accepts fraudulent messages with reduced verification requirements

**Detection Heuristics**
1. Check whether the OApp calls `endpoint.setSendLibrary(oapp, eid, lib)` and `endpoint.setReceiveLibrary(oapp, eid, lib, 0)` during initialization; flag any missing explicit library pin
2. Verify the pinned library addresses are stored in a verifiable configuration and that changes require governance with a timelock
3. Review the LayerZero endpoint's default library history for the chains the OApp operates on; assess whether any past default update would have changed the OApp's security properties
4. Confirm the OApp has a governance-controlled mechanism to update its pinned library, as security updates to the library layer may be necessary over time

**False Positives**
- OApps that explicitly pin their library versions in the constructor or initialization function and have a governance-controlled update path with a timelock
- Protocols that have reviewed the LayerZero V2 EndpointV2's non-upgradeability guarantees and explicitly accept the mutable default library risk as documented

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
OApps must explicitly pin their send and receive library versions via `setSendLibrary` and `setReceiveLibrary` during deployment or initialization. Library pins should be treated as security-critical configuration and governed by the same multisig and timelock as peer addresses. A governance-controlled update path must exist so the OApp can adopt verified security patches to the library layer without emergency key ceremonies. The LayerZero V2 EndpointV2 is non-upgradeable, but library defaults remain mutable; explicit pinning is the only reliable mitigation.

---

## reference/solidity/protocols/decentralized-stablecoin.md

# Decentralized Stablecoin Security Patterns

> Applies to: overcollateralized stablecoins, CDP-issued stablecoins, decentralized stablecoins, MakerDAO-style, DAI-style, LUSD-style, collateral-backed peg protocols

## Protocol Context

Overcollateralized CDP stablecoins issue debt tokens against locked collateral at a ratio enforced by liquidation. The entire system invariant - that outstanding stablecoin supply is always backed by more collateral value than debt - depends on accurate, manipulation-resistant collateral pricing, correct liquidation mechanics that can close undercollateralized positions without being griefed, and accounting correctness in vault state across borrow, repay, and stability fee accrual. Any path that allows a user to open a vault, draw stablecoin, or delay liquidation based on an inflated collateral price directly threatens the backing ratio and, at scale, the peg.

Governance parameter management introduces a second systemic risk surface: debt ceilings, collateral factors, liquidation ratios, and stability fees are all tunable by token vote or administrative multisig. Incorrect parameter application - whether through unsafe casting, a missing bounds check, or a race condition between a parameter update and an in-flight transaction - can silently misconfigure the protocol's core risk model. Emergency shutdown mechanisms that must drain all vaults and allow stablecoin holders to redeem pro-rata introduce their own edge cases around vault ordering, partial redemption accounting, and interaction with ongoing liquidation auctions.

## Bug Classes

---

### Oracle Price Manipulation for Collateral Valuation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
Protocol reads collateral price from a Chainlink feed or an on-chain AMM without staleness validation. The collateral price feeds directly into the vault health check that determines whether a user can borrow more stablecoin or avoid liquidation. A Chainlink feed with a wide heartbeat and no `updatedAt` check allows a stale high price to persist during a market crash, preventing timely liquidations. An AMM-sourced price without a TWAP window allows a single large flash swap to temporarily inflate collateral value within a transaction, enabling a user to open an undercollateralized vault and withdraw stablecoin before the price reverts.

**Detection Heuristics**
Search for `latestAnswer()` calls, which are deprecated and carry no staleness metadata. Search for `latestRoundData()` where `updatedAt`, `answeredInRound`, or `roundId` are discarded. Check for a staleness threshold: `require(block.timestamp - updatedAt < heartbeatInterval)`. Look for AMM `getReserves()` or `slot0()` used directly as a price source without a TWAP window. Identify whether the collateral price is read once at vault creation or continuously at every health check. Verify that wrapped-asset feeds (stETH/ETH, WBTC/BTC) account for depeg scenarios through a secondary deviation check.

**False Positives**
Protocol uses a dual-oracle design where both Chainlink and a TWAP must agree within a deviation threshold before a price is accepted. A circuit breaker pauses vault operations when oracle freshness degrades below a configurable threshold. Collateral type is an on-chain stablecoin whose price is enforced by a separate stability mechanism and is treated as 1:1 by explicit governance decision.

**Notable Historical Findings**
Multiple MakerDAO collateral integrations over the years have required oracle security module (OSM) delays to prevent same-block price manipulation from bypassing liquidation. Angle Protocol's oracle integration was found to lack staleness checks, allowing a Chainlink feed that had not updated within several hours to produce a price accepted as live. Liquity's direct Chainlink integration includes multiple fallback and staleness checks precisely because the protocol has no governance to react to oracle failures in real time; any fork that removes those checks loses the entire safety mechanism.

**Remediation Notes**
Use `latestRoundData()` and validate all five return values: positive answer, non-zero `updatedAt`, freshness within the declared heartbeat, `answeredInRound >= roundId`, and answer within `minAnswer`/`maxAnswer` circuit breaker bounds. For AMM-sourced prices, use a TWAP of at least 30 minutes and cross-check against a Chainlink feed with a maximum deviation bound. For wrapped assets, add a secondary depeg check using a dedicated ETH/stETH or BTC/WBTC feed before accepting the unwrapped price.

---

### Liquidation Mechanism Flaws (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Liquidation functions share a global pause flag with borrowing or repayment, so pausing one operation disables the ability to close undercollateralized positions. Health factor checks read collateral value without first refreshing oracle state or accruing pending interest, causing a vault that has just crossed the liquidation threshold to appear healthy. Dutch auction liquidation mechanisms contain incorrect bid validation that allows winning bids below the reserve price, or permit re-entrancy during the callback that lets a bidder manipulate vault state before the auction finalizes. Liquidation penalty and bonus calculations use integer division that silently truncates to zero for small vaults, making liquidation economically irrational for sub-dust positions.

**Detection Heuristics**
Check whether liquidation functions share the same `require(!paused)` guard as user-facing deposit or repay functions. Verify that `_accrueInterest()` or equivalent is called before any health factor read in the liquidation path. In Dutch auction contracts, trace the bid validation path: does it enforce `bid >= reservePrice`? Check for reentrancy via `onERC721Received` or similar callbacks triggered during collateral transfer. Compute the liquidation bonus for positions at the dust threshold - does it round to zero? Verify that partial liquidation correctly updates vault state so the remaining position is either healthy or fully closeable.

**False Positives**
Protocol has a dedicated liquidation pause flag independent of all other pause switches. Health factor reads are always preceded by an interest accrual modifier at the function entry point. Minimum vault size enforced at creation prevents sub-dust positions from existing.

**Notable Historical Findings**
MakerDAO's multi-collateral DAI system has an extensive liquidation 2.0 architecture precisely because the original liquidation 1.0 was subject to auction griefing and zero-bid attacks during the March 2020 market crash, when a single bidder acquired collateral for near-zero DAI by being the only participant during network congestion. Liquity's liquidation system allows permissionless callers but requires the Stability Pool to have sufficient LUSD; when the pool is empty, liquidations fall through to a redistribution mechanism, and the interaction between these two modes has been the subject of multiple edge-case analyses. Angle Protocol had a finding where the liquidation path could be blocked by a third party calling a related function with carefully crafted state, turning the permissionless liquidation into a griefable one.

**Remediation Notes**
Assign liquidation functions a dedicated pause flag that is never set by general protocol pause logic. Always accrue interest before reading vault health in the liquidation path. Dutch auction bids must be validated strictly at or above the reserve price before executing any callback. Enforce a minimum collateral size at vault creation that guarantees the liquidation bonus is always above gas cost.

---

### Governance Parameter Manipulation (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Governance controls debt ceilings, collateral ratios, liquidation thresholds, and stability fees via on-chain proposals. Parameter update functions lack input validation, allowing governance to set a collateral factor above 100%, a debt ceiling to zero (bricking borrowing), or a stability fee to a value that overflows the accrual accumulator. A proposal that sets `liquidationRatio` below the current outstanding collateral ratio of all active vaults instantly makes every vault liquidatable simultaneously. Flash loan governance attacks are possible when voting power is not snapshotted before proposal creation.

**Detection Heuristics**
Enumerate all setter functions for protocol risk parameters and check for upper and lower bound validation on each. Verify that `liquidationRatio` setters cannot be set below `collateralizationRatio` without a migration path for existing vaults. Check that stability fee and interest rate setters validate against an overflow boundary for the accumulator data type. Look for governance vote paths where voting power is snapshotted at or after proposal creation rather than strictly before. Identify any parameter setter that is callable by an EOA admin without a timelock.

**False Positives**
All parameter setters are behind a multi-step timelock that provides sufficient public observation time. Parameter bounds are enforced in a configuration contract reviewed separately from the core protocol. A formal verification proof exists that the parameter space is globally safe.

**Notable Historical Findings**
MakerDAO's governance system includes an executive vote spell mechanism with a governance security module delay precisely to allow the community to react to malicious spell proposals before they execute. Beanstalk, while not a CDP protocol, demonstrated the flash loan governance attack vector in a single transaction that borrowed governance tokens, passed a malicious proposal, and drained the treasury. Compound's governance has had multiple incidents where incorrect parameter values were submitted in proposals, including a distribution formula error that accidentally allocated far more COMP than intended.

**Remediation Notes**
Add explicit `require` bounds on every risk parameter setter: `liquidationRatio` must be above `collateralizationRatio`, stability fees must be within an economically reasonable range, and debt ceilings must be positive. Snapshot voting power at the block immediately preceding proposal submission, not at the block of the vote. Enforce a governance delay sufficient for the community to observe and cancel malicious proposals.

---

### Vault Accounting and Dust Limit Bypass (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol enforces a minimum vault size (dust limit) to prevent uneconomical positions. The dust check is applied at vault creation but not at partial repayment, allowing a user to repay all but a sub-dust amount and leave a position that can never be profitably liquidated. Vault accounting stores debt as a normalized amount using a rate accumulator; casting this normalized value to a smaller integer type silently truncates the precision, causing debt to be recorded as less than it actually is. A vault's collateral and debt are stored as separate mappings that can diverge if the collateral withdrawal path updates one but reverts before updating the other.

**Detection Heuristics**
Check the dust limit validation path: is it applied only at `openVault` and `borrow` time, or also at `repay` time to prevent leaving sub-dust residues? Search for unsafe casts from `uint256` normalized debt to `uint128` or `uint96` in vault storage structs. Verify that collateral withdrawal and debt repayment are atomic: if either reverts, both must revert. Check whether `normalizedDebt * rateAccumulator` can exceed the storage type before casting. Trace the vault state after a sequence of partial borrows and partial repayments to confirm the final debt matches the sum of all borrow amounts minus repayments.

**False Positives**
Protocol enforces the dust limit on both entry and exit by requiring that after any operation, the vault is either above dust or has zero debt. Vault storage uses `uint256` throughout and no downcasting occurs. Collateral and debt updates are in a single storage write to an atomic struct.

**Notable Historical Findings**
MakerDAO's `dust` parameter applies on both open and close sides; the MIP that introduced it was specifically motivated by cases where small vault remnants could not be liquidated profitably. Liquity enforces a minimum net debt of 2,000 LUSD at all times when a trove is open, preventing sub-threshold remnants after partial repayment. A generalized finding across multiple CDP protocols is that `uint128` debt storage without overflow checks on accumulation fails silently at high interest rates over long time periods.

**Remediation Notes**
Apply the dust check at every operation that can reduce vault debt, not only at creation. Use `uint256` for all intermediate debt calculations and only downcast to storage types after verifying the value fits. Make collateral and debt updates atomic within a single function: revert both if either fails.

---

### Stability Fee and Interest Accrual Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol accrues stability fees using a rate accumulator that multiplies the per-second rate by elapsed time. The accumulator is stored as a fixed-point value; if the per-second rate and the time delta are both large, the multiplication overflows before truncation. Accrual is lazy: the accumulator is only updated when a vault is touched, meaning long-dormant vaults accrue no interest until a user interacts. A stale accumulator read can understate outstanding debt and allow a user to redeem collateral without paying accrued fees. Fee revenue credited to the protocol surplus buffer uses a different calculation path than the user-facing debt display, creating a discrepancy that can be exploited to drain the surplus.

**Detection Heuristics**
Search for `rmul` or equivalent fixed-point multiplication in the accumulator update path and check for overflow protection. Verify that fee accrual is forced before any vault state read that informs a user-initiated operation. Check whether the surplus buffer crediting logic uses the same accumulator snapshot as the user-facing debt calculation. Look for `block.timestamp` in the accrual calculation and verify it cannot be manipulated by a miner to skip accrual. Verify that the initial rate accumulator value is exactly `1 RAY` (1e27) and that division in `normalizeDebt` rounds correctly.

**False Positives**
Protocol uses a formal fixed-point library (e.g., DSMath) that has been audited for overflow. Accrual is forced by a modifier on every state-reading function, making lazy accrual impossible. Accumulator precision is high enough that truncation errors are sub-wei per vault per year.

**Notable Historical Findings**
MakerDAO's `jug.drip()` must be called before any stability fee-sensitive operation; the protocol enforces this through keeper incentives but the contract itself does not force accrual before reads. A theoretical attack on Liquity's interest-free model would involve borrowing LUSD, waiting for the base rate to decay toward zero, and redeeming at the lower fee - the base rate decay formula uses block timestamps and would be manipulable by a miner in a proof-of-authority context. Several CDP forks have been found with accumulator overflows at high interest rates due to using `uint128` for the rate ray instead of `uint256`.

**Remediation Notes**
Force accumulator refresh before every vault read that influences a financial outcome. Store rate accumulators as `uint256` and use overflow-safe fixed-point multiplication. Ensure that the debt calculation path used to debit the user and the revenue path used to credit the surplus buffer share the exact same accumulator snapshot within a single transaction.

---

### Collateral Ratio Check Bypass (ref: fv-sol-5)

**Protocol-Specific Preconditions**
The collateral ratio check is the core safety invariant: outstanding debt divided by collateral value must remain below the protocol's liquidation threshold. This check can be bypassed if: the collateral price is read before a flash loan manipulation reverts; the check is only applied on `borrow` calls but not on collateral withdrawal; a reentrancy vector allows state to be modified between the ratio check and the state update; or a batch operation processes multiple vaults and applies the ratio check only at the end, allowing intermediate states to violate the invariant.

**Detection Heuristics**
Enumerate every public function that modifies vault state: `borrow`, `withdrawCollateral`, `repay`, `liquidate`, and any batch variant. For each, verify that the collateral ratio check is applied after all state changes and not before. Check for reentrancy via ERC-777 `tokensToSend` hooks, ERC-4626 callbacks, or ETH sends that could allow a user to re-enter a function between the state change and the ratio validation. In batch operation contracts, verify that the invariant check is applied per-vault, not only on the aggregate.

**False Positives**
All collateral is ERC-20 tokens with no transfer hooks and the protocol does not accept ERC-777 or native ETH as collateral. Reentrancy guard is applied at the entry point of every vault-modifying function. The oracle is a manipulation-resistant TWAP, making same-block flash loan attacks economically infeasible.

**Notable Historical Findings**
Reflexer Finance (RAI) identified a theoretical batch-operation reentrancy vector in safe engine operations. MakerDAO's collateral adapter pattern carefully separates the join/exit of collateral from the vault manipulation, with the ratio check applied by `vat.frob` after all collateral movements, to prevent reentrancy from the collateral token itself. Multiple DeFi protocols have been exploited through ERC-777 hooks that allowed reentrance into a borrow function before the debt balance was updated, resulting in double-borrow at the same collateral.

**Remediation Notes**
Apply collateral ratio validation as the last step in every function that modifies vault state, after all balance updates are written. Add `nonReentrant` to all vault-modifying functions. Explicitly reject ERC-777 tokens and other tokens with transfer hooks as collateral types, or handle them in dedicated adapters that disable reentrancy before any collateral movement.

---

### ERC4626 Vault Edge Cases (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
Protocol wraps a stablecoin or yield-bearing token using an ERC4626 vault with share-based accounting. A minimum share threshold exists to prevent dust positions. `totalAssets()` is derived from the vault's token balance, which is manipulable via direct transfers. Cooldown or withdrawal delay mechanisms interact with global configuration changes (toggling cooldown on/off). Deposit and withdrawal of native tokens are asymmetric.

**Detection Heuristics**
Check if `totalAssets()` uses raw `balanceOf` on the vault address, which is manipulable via direct token transfers. Look for minimum share thresholds and calculate the cost to inflate the vault to deny new depositors. Verify that `_decimalsOffset()` provides sufficient protection against first-depositor inflation attacks. Trace cooldown state transitions when global settings change and confirm that existing user-specific timers respect the new configuration. Check for asymmetric native token handling and the presence of a `receive()` function if any withdrawal path sends native ETH.

**False Positives**
Vault is initialized atomically with a meaningful first deposit in the deployment transaction, preventing inflation. Protocol always uses a virtual shares/assets offset that makes inflation attacks uneconomical. Cooldown settings are documented as immutable after deployment and the toggle scenario is operationally impractical.

**Notable Historical Findings**
Ethena Labs' StakedUSDe was found to have three related ERC4626 issues in the same audit: an attacker could front-run the first deposit by sending tokens directly to inflate `totalAssets`, causing subsequent depositors to receive zero shares until they deposited more than the inflated threshold; users who had started a cooldown period were unable to withdraw even after governance disabled the global cooldown duration, because the per-user `cooldownEnd` timestamp was not cleared on toggle; and the vault supported ETH withdrawal but lacked a `receive()` fallback, making direct native token deposits impossible.

**Remediation Notes**
Use a virtual offset (e.g., `1e6`) in share conversion math to raise the cost of inflation attacks above economic viability. When the global `cooldownDuration` is set to zero, clear or bypass any existing per-user cooldown timestamps in the `unstake` path. Add `receive() external payable {}` if any protocol path can push native ETH to the vault. Deploy the vault with an initial protocol-owned deposit in the same transaction to prevent the zero-supply first-depositor window.

---

### Role Restriction Bypass (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Protocol enforces role-based access control with blacklist and whitelist roles that restrict token operations (mint, burn, transfer, deposit, withdraw). Roles are not mutually exclusive: an address can hold both blacklisted and whitelisted roles simultaneously. Restriction checks inspect only `msg.sender` or the caller but not the token owner or the actual beneficiary. The ERC4626 `withdraw`/`redeem` three-address signature (`caller`, `receiver`, `owner`) is not fully checked against all restriction roles.

**Detection Heuristics**
Enumerate all role-based restriction checks and verify they cover all relevant addresses: `msg.sender`, `from`, `to`, and `owner`/`controller`. Check whether blacklist and whitelist roles are mutually exclusive or if overlapping roles are possible. Trace the ERC4626 `withdraw`/`redeem` paths with the three-address signature and verify restrictions on all three. Look for Sybil bypass opportunities: can a restricted address transfer tokens to a second address they control? Check that `_beforeTokenTransfer` hooks enforce restrictions consistently across all transfer states.

**False Positives**
Protocol intentionally allows restricted addresses to perform certain operations as documented (e.g., soft-restricted users can trade on secondary markets). Bypass requires cooperation from a trusted party. Economic impact of the bypass is net-positive for the protocol (e.g., burning reduces supply and strengthens the peg).

**Notable Historical Findings**
Ethena Labs' UStb and StakedUSDe contracts were found to have five role restriction bypass variants in two audit rounds. A blacklisted user who also held the WHITELISTED_ROLE could burn tokens during the WHITELIST_ENABLED transfer state because the burn path checked whitelist membership but not blacklist membership. FULL_RESTRICTED stakers could withdraw by approving an unrestricted third-party address to call `withdraw` on their behalf, since the restriction check only covered `caller` and `receiver` but not `_owner`. SOFT_RESTRICTED stakers could bypass their restriction through a similar approval-based mechanism. In a separate finding, a soft-restricted user could redeem stUSDe for USDe via a collateral redemption path that lacked the restriction check.

**Remediation Notes**
Enforce mutual exclusivity of blacklist and whitelist roles: granting one should automatically revoke the other. In the `_beforeTokenTransfer` hook, check both whitelist and blacklist status for the `from` address in all code paths. In ERC4626 `_withdraw`, check the restriction role for all three of `caller`, `receiver`, and `_owner`. Audit all code paths that result in a token balance change (burn, withdraw, redeem, transfer) for consistent role enforcement.

---

### Peg Defense Arbitrage Attack Surface (ref: fv-sol-8)

**Protocol-Specific Preconditions**
Protocol exposes a redemption path that allows stablecoin holders to redeem 1 USD worth of collateral per stablecoin. This path is intended as a peg defense mechanism but is callable permissionlessly, allowing an adversary to use it to extract specific collateral types at a discount if the redemption ordering selects undercollateralized vaults first. Redemption fees are computed on stale base rate state, allowing a user to front-run a large redemption with their own smaller redemption to manipulate the base rate used for fee calculation. The redemption path does not enforce slippage protection on the collateral output, making it sandwichable.

**Detection Heuristics**
Identify the redemption function and trace how it selects which vaults to redeem from. Check if vault selection is deterministic (e.g., lowest ICR first) and whether it can be manipulated by an attacker who opens a targeted vault just before a redemption. Verify that the base rate used for fee calculation is updated atomically with the redemption. Look for any redemption path that executes a collateral swap with no `minAmountOut` parameter. Check whether the redemption path can be used to grief a specific vault owner by repeatedly partially redeeming from their vault to reduce their collateral.

**False Positives**
Redemption fees are high enough to make arbitrage consistently unprofitable. Redemption path is rate-limited per block or per epoch to prevent large-scale attacks. Vault ordering for redemption is randomized or uses a time-weighted metric that cannot be gamed in a single transaction.

**Notable Historical Findings**
Liquity's redemption mechanism selects troves with the lowest ICR first; this is the intended behavior and creates an incentive for vault owners to maintain high ICR, but it has been analyzed extensively as a potential griefing vector where a targeted vault can be partially redeemed from repeatedly. Fee front-running on the base rate has been discussed in Liquity's documentation as a known property of the system that is mitigated by the base rate decay function but not fully eliminated. Angle Protocol's redemption path was identified as subject to MEV extraction due to lack of output slippage bounds.

**Remediation Notes**
Ensure the redemption base rate is updated before fee calculation in the same transaction, not lazily from a prior block. Add a minimum collateral output parameter to the redemption interface that the caller can set to prevent sandwich attacks. Document vault selection ordering in the specification and verify it in invariant tests that simulate adversarial vault creation immediately before redemption.

---

### Emergency Shutdown Edge Cases (ref: fv-sol-9)

**Protocol-Specific Preconditions**
Emergency shutdown is designed to freeze all protocol operations and allow stablecoin holders to redeem pro-rata against the collateral pool. Edge cases arise when: shutdown is triggered while an auction is in progress, leaving collateral locked in the auction contract; vault owners who have already been liquidated partially receive incorrect redemption claims; the settlement price used for collateral valuation at shutdown is taken from a single oracle snapshot that can be manipulated immediately before the shutdown transaction; or the shutdown itself can be triggered by a governance vote that is subject to flash loan attack.

**Detection Heuristics**
Trace the shutdown trigger: is it callable by a single key, a multisig, a governance vote, or automatically by an oracle failure? For governance-triggered shutdown, check if voting power can be flash-borrowed. Verify that in-progress auction state is handled at shutdown: are auctions cancelled, or can they complete after the freeze? Check whether the settlement price is taken from a single oracle read or from a time-delayed price feed. Verify that post-shutdown vault claim calculation correctly handles partially liquidated vaults. Check for any function that is callable during shutdown when it should be frozen, or frozen when it should remain callable (e.g., collateral withdrawal after setting vault debt to zero).

**False Positives**
Emergency shutdown is triggered only by a community-controlled multisig with a long delay, making flash loan governance attacks impractical. The oracle used for the shutdown settlement price has a time-delay module that prevents same-block manipulation. In-progress auctions at shutdown time have been formally specified to complete before the collateral distribution begins.

**Notable Historical Findings**
MakerDAO's emergency shutdown module (ESM) requires a specific amount of MKR to be burned to trigger, making it resistant to flash loan attacks; the protocol went through extensive specification of how in-progress auctions and undercollateralized vaults are handled at shutdown. Liquity has no emergency shutdown in the traditional sense; instead, it has a recovery mode triggered by system-level collateral ratio thresholds, and the interaction between recovery mode and the Stability Pool has been a significant area of formal verification effort. A generalized CDP fork finding is that the settlement price oracle at shutdown is not the same as the operational oracle, creating a price discrepancy window.

**Remediation Notes**
Use a time-delayed or volume-weighted oracle snapshot for the shutdown settlement price rather than a spot read at the moment of shutdown. Specify and test the exact behavior of in-progress auctions at shutdown: either cancel them and return collateral to vaults, or allow them to complete before the shutdown distribution begins. Ensure the shutdown trigger cannot be activated by flash-borrowed governance tokens by requiring a minimum holding period before votes count.

---

### Token Decimal Mismatch in Collateral Accounting (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Protocol accepts multiple collateral types with different decimal precisions (WBTC: 8, USDC: 6, standard ERC-20: 18). Collateral amounts are stored in a normalized 18-decimal format, but the normalization conversion is missing or incorrectly applied for non-18-decimal tokens. Oracle prices for these tokens are returned by Chainlink in 8-decimal format; combining an 8-decimal price with an 18-decimal amount without explicit scaling produces a value off by 10^10. A single collateral adapter contract handles multiple tokens and applies a hardcoded scaling factor that is correct for one token but wrong for another.

**Detection Heuristics**
Identify all collateral types the protocol accepts and their native decimal counts. Trace the code path from collateral deposit to vault state storage: is there a `decimals()` call or a hardcoded scaling factor? Verify that oracle price decimals are explicitly accounted for at the integration point, not assumed to be 18. Look for `10**18` literals in collateral valuation formulas and check whether they should be `10**token.decimals()`. Test a USDC (6 decimal) deposit and verify the vault records the correct 18-decimal normalized amount.

**False Positives**
Protocol only supports WETH and DAI-like 18-decimal tokens, enforced by a governance-controlled whitelist that explicitly rejects non-18-decimal tokens. Decimal normalization is handled in a shared adapter library that has been independently audited. All oracle feeds are normalized to 18 decimals before being consumed by the core protocol.

**Notable Historical Findings**
USSD had at least seven decimal-related findings in a single audit including inverted base/rate pairs and incorrect decimal precision in oracle wrappers, all of which affected collateral valuation. Multiple CDP protocol forks of MakerDAO incorrectly adapted the `ilk.spot` calculation when adding non-18-decimal collateral, resulting in collateral ratios that were either 10^10 too high or too low. A common finding in Chainlink integrations is that `latestRoundData()` returns prices in 8 decimals while the protocol assumes 18, with the mismatch only manifesting at extreme price values.

**Remediation Notes**
Read `token.decimals()` dynamically in all collateral adapter contracts and compute the scaling factor at initialization or per-transaction rather than hardcoding. Normalize Chainlink oracle output to 18 decimals by reading `feed.decimals()` and scaling accordingly. Add integration tests for each supported collateral type that verify vault accounting is correct at multiple price points.

---

### Reentrancy via Collateral Token Callbacks (ref: fv-sol-1)

**Protocol-Specific Preconditions**
Protocol accepts ERC-777 tokens or tokens with transfer hooks as collateral. When a user repays debt or closes a vault, the protocol transfers collateral back to the user before updating the vault's debt balance. An ERC-777 `tokensReceived` hook on the user's address re-enters the vault's borrow function while the debt state still shows the pre-close balance, allowing the user to borrow against collateral that is simultaneously being returned. Alternatively, a user receiving ETH from an ETH-collateral vault through a `call{value:}` transfer can re-enter a borrowing function during the ETH receipt.

**Detection Heuristics**
Identify all code paths that transfer collateral to a user-controlled address: `withdrawCollateral`, `repay`, `liquidate`, and `emergencyShutdownClaim`. Check whether the transfer occurs before or after the vault state update that removes the corresponding debt or collateral balance. Search for `nonReentrant` modifiers on all vault-modifying functions. Identify whether ERC-777 tokens are explicitly excluded from the collateral whitelist. Check ETH transfer paths for `call{value:}` followed by vault state updates.

**False Positives**
All collateral is restricted to a whitelist that contains only standard ERC-20 tokens without callbacks, enforced at the adapter level. `nonReentrant` is applied to all vault entry points and re-entrant calls revert. Checks-effects-interactions is applied correctly: vault state is fully updated before any external transfer in all code paths.

**Notable Historical Findings**
MCDEX Mai Protocol had reentrancy possibilities in its deposit, withdraw, and insurance fund functions where collateral transfers were not guarded by a reentrancy lock. A finding in a Fei Protocol integration showed that ERC-777 tokens used as collateral could allow a borrow during the deposit callback, effectively allowing collateral to be double-counted. Dexe governance contract interactions showed reentrancy via ERC721 `onERC721Received` callbacks that re-entered state-modifying functions before they completed their updates.

**Remediation Notes**
Apply `nonReentrant` to all vault-modifying functions. Follow checks-effects-interactions strictly: update all vault state (debt, collateral balance, vault status) before making any external transfer. Restrict collateral types to a protocol-controlled whitelist that explicitly excludes ERC-777 tokens and tokens with `beforeTokenTransfer` or `afterTokenTransfer` hooks that can invoke arbitrary logic.

---

## reference/solidity/protocols/derivatives.md

# Derivatives Protocol Security Patterns

> Applies to: perpetuals, futures, options, leveraged trading, GMX-style, dYdX-style, Perp Protocol-style, funding rate mechanisms, margin accounts, position management

## Protocol Context

Derivatives protocols - perpetuals, options, and leveraged trading venues - operate through continuous mark-to-market accounting, funding rate mechanisms, and real-time margin calculations that depend on accurate and fresh price data. Their state is unusually complex: open interest, funding rates, and fee accumulators are updated on every trade, and any arithmetic imprecision in these paths accumulates into systemic undercollateralization. The combination of high leverage and oracle dependency makes price manipulation and stale-price exploitation categorically more severe than in spot protocols, since a small price deviation can immediately render large positions undercollateralized or force incorrect liquidations.

The settlement and liquidation paths introduce additional surface area: vault share accounting for collateral deposits, withdrawal queue mechanics that can be griefed to lock user funds, and cross-margin position tracking that can desynchronize from the underlying collateral state if balance updates are applied out of order. Funding rate calculations that depend on a time-weighted skew between long and short open interest are particularly sensitive to manipulation, since they affect all open positions continuously rather than at discrete settlement events.

## Bug Classes

---

### Reentrancy via External Calls (ref: fv-sol-1)

**Protocol-Specific Preconditions**
Contract performs ETH transfers, ERC-721 `safeMint`, or token callbacks before updating position state, margin balances, or insurance reserves. The `nonReentrant` modifier is absent from the vulnerable function or is missing from sibling functions that share state, enabling cross-function reentrancy. Perpetuals and leveraged vaults are particularly exposed because position open/close flows often interleave token transfers with state writes.

**Detection Heuristics**
- Identify functions that transfer ETH via `.call` or tokens via `safeTransfer` before decrementing reserves or closing positions.
- Check for `_safeMint` calls in position NFT contracts where token ID counters or position arrays are updated after the mint callback fires.
- Verify that both `deposit` and `withdraw` functions carry `nonReentrant` when they share balance state.
- Look for initializer functions using a simple boolean guard rather than a modifier that includes reentrancy protection.
- Check pool upkeep or funding settlement functions that transfer fees to external addresses before updating `lastUpkeepTime` or `executionPrice`.

**False Positives**
- External call targets are immutable, audited contracts that cannot execute arbitrary logic.
- Checks-effects-interactions is followed: all state is finalized before any external call.
- Reentrancy guard covers all possible reentry paths including cross-function paths.
- The token in question is a standard ERC-20 without transfer hooks.

**Notable Historical Findings**
A reentrancy vulnerability in Tigris Trade's position NFT contract allowed an attacker to reenter `mint()` via the `onERC721Received` callback before the position array and token ID counter were updated, enabling duplicate position creation. In Hubble Exchange, `processWithdrawals` sent ETH before decrementing the reserve, allowing a malicious recipient to reenter and repeatedly extend the queue. Tracer's pool upkeep was vulnerable when fee tokens with callbacks were used, permitting reentry before `lastUpkeepTime` was written. OpenLeverage used `payable.transfer` for ETH sends in `doTransferOut`, which fails for contract recipients with non-trivial receive logic and can brick withdrawals.

**Remediation Notes**
Apply `nonReentrant` to all entry points sharing position or balance state, not just the most obvious one. Follow checks-effects-interactions strictly: decrement reserves and close positions before any external call. For position NFT contracts, increment token IDs and write position arrays before calling `_safeMint`. Store failed ETH withdrawals in a claimable mapping rather than assuming delivery.

---

### Rounding Direction and Precision Loss (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Protocol performs share/asset conversions, interest rate accruals, or liquidation repayment calculations using integer division. Rounding direction in deposit/redeem paths favors the user rather than the protocol, or division occurs before multiplication causing intermediate truncation. Virtual shares that accrue interest create compounding precision errors in lending vaults embedded in derivative protocols.

**Detection Heuristics**
- Identify all division operations in share-to-asset and asset-to-share conversions; deposits should round shares down, withdrawals should round assets up.
- Search for `(a / b) * c` patterns that should be `(a * c) / b`.
- Check `mulDivUp` vs `mulDivDown` consistency across paired functions (deposit/withdraw, mint/redeem, liquidate seize/repay).
- Look for `uint256(int256Value)` casts on values that can be negative.
- Verify that virtual/dead shares do not accrue interest or inflate share prices over time.
- Check boundary conditions where 1 wei produces 0 shares in subsequent depositor paths.

**False Positives**
- Rounding error is bounded to 1 wei per operation and accumulation is economically negligible.
- The protocol explicitly documents accepting a specific rounding direction with stated rationale.
- Virtual shares are designed to absorb rounding dust and do not compound.
- `mulDiv` with correct rounding direction is already used consistently across all paired functions.

**Notable Historical Findings**
Morpho had multiple rounding direction issues: supply cap calculations rounded down when they should round up, liquidation `repaidShares` rounded down leaving borrowers less healthy after liquidation rather than more, and redemptions in the Strata vault leaked value because `previewWithdraw` rounded in the wrong direction per ERC-4626 specification. Asymmetry Finance suffered precision loss in minimum output calculations because division occurred before multiplication, amplifying slippage in `calculateMinOut`. Float Capital had an unsafe `int256` to `uint256` cast in rebalance logic that produced a near-`2^255` value when the input was negative, corrupting subsequent arithmetic.

**Remediation Notes**
For liquidation paths, round repaid shares up so borrower health strictly improves after a liquidation event. For deposit paths into ERC-4626 vaults, use `previewWithdraw` rounding up and `previewDeposit` rounding down as the spec requires. Always multiply before dividing in minimum output calculations. Handle negative int256 values with explicit sign checks before casting to uint256.

---

### Funding Rate Manipulation (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol implements a funding rate mechanism based on market price versus oracle price or long/short open interest imbalance. A single trade's price is recorded as the hourly reference, enabling wash trading to skew the time-weighted average. Cumulative funding index accumulation uses the wrong reference index (current rather than previous), causing the rate to never accumulate. Insurance funding rates lack caps and can grow unboundedly as leveraged notional increases relative to pool holdings.

**Detection Heuristics**
- Check if market price fed into funding rate is derived from a single trade or a small sample rather than a volume-weighted accumulation.
- Verify cumulative funding rate logic uses `cumulativeRate[index] + instantRate` written to `index + 1`, not overwriting `index`.
- Look for unbounded growth in pool or insurance funding rate calculations without a `MAX_FUNDING_RATE` cap.
- Check if funding settlement is atomic across all markets in a single loop - this blocks with gas limit growth.
- Verify that validators or keepers cannot selectively delay order matching to profit from funding rate timing.

**False Positives**
- Market prices are sourced from external oracles rather than internal trades, making wash trading irrelevant.
- Funding rate caps are enforced at a higher protocol governance level.
- Markets settle independently and no atomic cross-market loop exists.
- The protocol has a small fixed number of markets that will never approach block gas limits.

**Notable Historical Findings**
Tracer had two distinct funding rate manipulation vulnerabilities where a single trade's price was used as the hourly reference, allowing an attacker to self-trade at extreme prices with zero net position risk to skew the funding rate. A separate Tracer finding showed the cumulative funding index was updated using the current index rather than the previous one, meaning the rate reset to zero on every update rather than accumulating. Hubble Exchange's insurance funding rate increased without bound as leveraged notional grew relative to pool holdings, eventually making funding costs economically absurd. Hubble also had a cross-market funding settlement function that would exceed block gas limits as market count grew.

**Remediation Notes**
Use volume-weighted price accumulation over the measurement interval rather than point-in-time trade prices. Write cumulative funding to `currentIndex + 1` derived from `currentIndex` to ensure proper accumulation. Cap the insurance or pool funding rate at a protocol-defined maximum. Settle funding per market independently to avoid gas limit denial of service as market count scales.

---

### Incorrect Fee and Reward Accounting (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol calculates trading fees for multiple order types (market open/close, limit, TP, SL, liquidation) using a branching fee schedule. Fee percentage is applied to the wrong base (pre-fee vs post-fee position size), or the wrong order type branch applies a flat fee. Reward and reserve token balances are conflated when the reward token is also a valid market asset. Keeper rewards are calculated in 18-decimal arithmetic but paid in 6-decimal settlement tokens.

**Detection Heuristics**
- Identify fee calculation branches that apply the same flat percentage across market close, TP, SL, and liquidation order types; each typically has a distinct fee tier.
- Check if overlapping fee components are each computed on the full position size rather than as a single combined percentage, causing double-counting.
- Look for sign errors in fee application: both long and short sides should be charged fees (subtracted), not one side credited.
- Verify that governance NFT reward distributions, referral payouts, and protocol treasury fees are each routed to the correct recipient.
- Check keeper reward calculations for decimal mismatch between 18-decimal gas cost computation and the settlement token's actual decimals.

**False Positives**
- A flat fee is intentionally applied to specific order types as a documented design choice.
- Fee overlap is intentional and the resulting margin calculation is correct by design.
- Protocol supports only one token with a known fixed decimal count.
- Reward and reserve tokens are structurally guaranteed to be different contracts.

**Notable Historical Findings**
Gainsnetwork applied a 5% flat fee to all non-market-close order types, overcharging TP and SL closures that should receive pair-specific fee rates. Tigris Trade's `_handleOpenFees` computed each fee component independently on the full position size and summed them, causing the margin calculation to treat fee amounts as if they did not overlap. Tracer had a sign error where the fee was added to the long side rather than subtracted, allowing one side to collect fees instead of paying them. Morpho's `claimToTreasury` sent the full underlying balance to the treasury including COMP rewards belonging to users when the underlying token was also the reward token.

**Remediation Notes**
Define distinct fee percentages per order type and apply them from a unified fee schedule rather than branching with hardcoded constants. Compute total fee as a single combined percentage of position size to avoid double-counting. Subtract fees symmetrically from both sides. Track reward token balances in a separate accounting variable from protocol reserves when the reward token coincides with a market asset. Scale keeper rewards to the settlement token's actual decimal precision before transfer.

---

### Position and Open Interest Accounting Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol tracks aggregate open interest per trading pair to enforce exposure limits and calculate price impact. OI is recorded using pre-fee position sizes rather than post-fee actual sizes, inflating the tracked exposure. Position increase validations pass the full new position size to exposure limit checks, double-counting existing OI. PnL settlement during leverage updates routes closing fees to the trader rather than the protocol vault.

**Detection Heuristics**
- Check if OI is added using the pre-fee or post-fee position size; OI should reflect the actual position size after fee deduction.
- Look for position increase validations that pass total new position size to `isWithinExposureLimits` instead of only the delta.
- Verify OI removal uses the same price denomination as OI addition (consistent collateral pricing).
- In leverage update PnL flows, verify closing fees are routed to the protocol, not included in the trader's net payout.
- Check for operator precedence bugs in leverage recalculation expressions involving position size, collateral, and a scaling constant.
- Verify `addOI` and `removeOI` are called symmetrically on all position open and close paths including limit order execution.

**False Positives**
- Protocol intentionally tracks gross OI including fees for risk management and has a separate limit check that uses deltas.
- Double-counting is mitigated at a higher layer that correctly applies delta-only checks.
- OI tracking is approximated and used only for informational display, not limit enforcement.
- Collateral is a stablecoin whose USD value does not meaningfully diverge.

**Notable Historical Findings**
Gainsnetwork had a critical vulnerability where decreasing position size via leverage update sent the closing fees to the trader rather than the diamond contract, effectively draining the protocol fee pool on every leveraged position modification. Tigris Trade recorded OI using the full pre-fee position size in `executeLimitOrder`, permanently inflating tracked exposure by the fee amount times leverage. A separate Gainsnetwork finding showed position increase validation passed the total new size to exposure checks rather than the incremental delta, incorrectly rejecting valid position increases near the limit. An operator precedence bug in `requestIncreasePositionSize` caused the new leverage to be calculated incorrectly due to missing parentheses around an addition before a scale multiplication.

**Remediation Notes**
Compute fees before recording OI and use the post-fee actual position size for all OI tracking. Pass only the size delta to exposure limit validation functions. Separate closing fee disbursement from trader PnL in settlement logic and explicitly route each to the correct recipient. Add parentheses around additive subexpressions before scale multiplications in leverage calculations.

---

### Missing Slippage Protection (ref: fv-sol-8)

**Protocol-Specific Preconditions**
Protocol performs token swaps via Uniswap, Curve, or Balancer on behalf of users using a hardcoded `0` minimum output. Slippage protection is present at a public interface but discarded by an internal swap wrapper. The public rebalance or swap function accepts both a user-specified `amountOutMinimum` and an `account` parameter, enabling any caller to trigger swaps on another user's behalf with zero slippage. Missing `deadline` in Uniswap V2/V3 router calls allows miners to delay execution until a favorable block.

**Detection Heuristics**
- Search for `exchange`, `exchange_underlying`, `swapExactTokensForTokens`, `exactInputSingle` calls and verify the minimum output argument is not `0`.
- Check if user-facing functions propagate a `minOut` parameter through to internal swap wrappers.
- Look for missing `deadline` fields in Uniswap router parameter structs.
- Identify public functions that accept both `amountOutMinimum` and `account` - this pattern exposes third-party accounts to frontrunning.
- Verify `calculateMinOut` does not compute division before multiplication which truncates to zero for small amounts.

**False Positives**
- Swaps occur within an atomic flash loan where the caller controls all steps and reverts on unfavorable output.
- Protocol uses a private mempool or commit-reveal scheme that prevents frontrunning.
- Swap size is trivially small relative to pool liquidity, making sandwich attacks unprofitable.
- Slippage protection is enforced by a parent contract that always wraps the internal function.

**Notable Historical Findings**
Asymmetry Finance's `VotiumStrategy` called Curve's `exchange_underlying` with hardcoded zero minimum output, leaving every CVX purchase vulnerable to sandwich attacks. UXD Protocol had a public `rebalanceLite` function that accepted a user-supplied `amountOutMinimum` alongside an `account` address, meaning any caller could trigger a swap against another account with zero slippage protection. A separate Asymmetry finding showed `calculateMinOut` computed division before multiplication, causing the minimum output to truncate to zero for typical deposit sizes, providing no effective protection. Tracer's insurance slippage reimbursement logic contained an error that allowed an attacker to exploit the mechanism to drain the insurance fund rather than compensate for slippage.

**Remediation Notes**
Never hardcode zero as a minimum output in production swap calls. Propagate caller-supplied `minOut` parameters through all internal wrappers without discarding them. Restrict rebalance functions to owner or keeper roles so they cannot be called against arbitrary account addresses. Include `block.timestamp` as the deadline in Uniswap router calls as a baseline; allow callers to provide shorter deadlines for time-sensitive operations.

---

### Withdrawal Queue Denial of Service (ref: fv-sol-9)

**Protocol-Specific Preconditions**
Protocol implements a FIFO withdrawal queue where a failed ETH transfer (e.g., to a USDC-blacklisted address, or a contract without a `receive` function) permanently skips the entry rather than storing it for later claim. Minimum withdrawal amounts are too low to prevent queue spam. Multi-vault or multi-derivative unstake functions revert the entire operation when any single vault or derivative fails rather than isolating failures.

**Detection Heuristics**
- Identify sequential withdrawal queue implementations where a failed transfer causes the entry to be permanently lost rather than stored for a retry claim.
- Check minimum withdrawal amounts against the gas cost of queue processing to gauge spam feasibility.
- Look for multi-vault withdrawal loops where a single paused vault reverts the entire withdrawal.
- Verify that blacklistable tokens (USDC, USDT) used in queue-based push-withdrawal systems cannot block processing.
- Check for `break` statements in withdrawal loops that halt all processing on the first reserve shortfall.
- Verify that the queue length is bounded or that bounded batch processing exists.

**False Positives**
- An admin-callable skip or drain function can bypass stuck entries.
- Withdrawals use a pull pattern where users claim individually.
- Minimum withdrawal amount is high enough that spam is economically infeasible.
- Failed withdrawals automatically produce credit entries that users can claim separately.

**Notable Historical Findings**
Hubble Exchange had a `processWithdrawals` function that, on a failed ETH transfer, incremented the index and moved on, permanently losing the failed withdrawal with no recourse for the affected user. A separate finding showed the same queue was still subject to denial of service via spam even after a partial fix because the 5 VUSD minimum withdrawal was far too low to prevent cheap queue flooding. Strata's multi-vault `redeemRequiredBaseAssets` used `previewRedeem` without checking `maxWithdraw`, causing the entire withdrawal to fail if the targeted vault was paused even when other vaults had sufficient assets. Asymmetry Finance's unstake flow called each derivative's `withdraw` in a loop without try-catch isolation, meaning a single failing derivative bricked the entire unstake for all positions.

**Remediation Notes**
Store failed withdrawal amounts in a per-user claimable mapping rather than silently discarding them. Set minimum withdrawal amounts at a level that makes queue flooding economically infeasible relative to the attacker's capital cost. In multi-vault withdrawal flows, use `maxWithdraw` to check availability before attempting withdrawal and aggregate across vaults rather than requiring any single vault to satisfy the full amount. Wrap per-derivative calls in try-catch to isolate failures and route failed amounts to a pending mapping.

---

### Stale Chainlink Oracle Validation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
Protocol integrates Chainlink price feeds for margin calculations, liquidation thresholds, and funding rate computation without validating staleness, round completeness, or circuit breaker bounds. Using the deprecated `latestAnswer()` omits the round metadata needed for any validation. On L2 chains, the protocol does not check the sequencer uptime feed, allowing stale prices during sequencer downtime when `block.number` does not increment reliably.

**Detection Heuristics**
- Search for `latestAnswer()` calls - this is deprecated and provides no staleness or round completeness data.
- Search for `latestRoundData()` calls and verify `updatedAt` is compared against `block.timestamp` with a per-feed heartbeat threshold.
- Verify `answeredInRound >= roundId` to ensure the round is complete before using the price.
- Check for `minAnswer`/`maxAnswer` aggregator circuit breaker validation to detect price floor/ceiling clamps.
- Verify `startedAt > 0` to confirm the round has actually been initiated.
- On Arbitrum/Optimism deployments, check for sequencer uptime feed validation with a grace period after sequencer restart.

**False Positives**
- Protocol uses a TWAP that inherently smooths over brief stale intervals.
- Oracle is used only for non-critical display purposes and never influences on-chain state.
- Protocol wraps Chainlink calls in try-catch with a secondary fallback oracle.
- Heartbeat interval for the specific feed is extremely short and staleness is practically impossible.

**Notable Historical Findings**
Tigris Trade used `latestRoundData()` without any staleness check, round completeness check, or circuit breaker bounds, making liquidation prices manipulable during periods of oracle inactivity. Hubble Exchange had both a staleness bug and a separate missing `minAnswer`/`maxAnswer` circuit breaker check, meaning the protocol could use artificially floored prices during a market crash when the aggregator clamps to its minimum answer. Float Capital's market could become completely non-functional during Chainlink update gaps because the funding settlement function depended on fresh oracle data to proceed. Asymmetry Finance's AfEth deposits used oracle responses without validating the round, allowing deposits to proceed with stale or invalid price data that could misvalue collateral.

**Remediation Notes**
Validate `answeredInRound >= roundId`, `updatedAt + heartbeat >= block.timestamp`, `startedAt > 0`, and `answer > 0` on every `latestRoundData` call. Configure per-feed heartbeat thresholds appropriate to each feed's update frequency. Add `minAnswer`/`maxAnswer` bounds checks specific to the expected price range for each asset. On L2 deployments, gate all oracle reads behind a sequencer uptime check that also enforces a grace period after the sequencer resumes.

---

### ERC-4626 Vault Integration Issues (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol integrates ERC-4626 vaults for yield generation or collateral management and uses `previewRedeem`/`previewDeposit` for on-chain accounting decisions. Per the ERC-4626 specification, preview functions must not account for redemption limits such as vault pauses or caps, making them unreliable for actual withdrawal routing. Multi-vault architectures attempt to satisfy full withdrawal amounts from a single vault rather than aggregating partial amounts across available vaults. Slippage parameters in ERC-4626 wrapper functions are applied to a capped intermediate value rather than the user's original requested amount.

**Detection Heuristics**
- Search for `previewRedeem` or `previewDeposit` used to determine actual withdrawal or deposit amounts; verify `maxWithdraw`, `maxDeposit`, `maxMint`, or `maxRedeem` is checked first.
- Look for multi-vault loops that break on the first vault that can satisfy the full amount, ignoring the possibility of aggregating partial amounts.
- Check ERC-4626 wrapper mint functions for slippage parameter application order: slippage should apply to the original user-requested shares, not a capped intermediate.
- Verify that vault access controls such as whitelists cannot be bypassed via wrapper or bundler contracts.
- Check rounding direction: `previewWithdraw` should round up, `previewDeposit` should round up on shares side.

**False Positives**
- Protocol integrates a single vault guaranteed never to be paused or capped.
- Preview functions are used only for off-chain estimation, never for on-chain accounting.
- Wrapper contracts are intentionally designed to provide broader access as a documented protocol design choice.
- Rounding differences are bounded to 1 wei per operation and are economically insignificant.

**Notable Historical Findings**
Strata's `MetaVault` used `previewRedeem` to decide which vault to withdraw from, but `previewRedeem` ignores pause states per the spec, causing withdrawals to revert when a vault was paused even though other vaults had sufficient assets. A separate Strata finding showed `previewWithdraw` rounded in the protocol-unfavorable direction during yield phase redemptions, allowing value to leak from pUSDe holders to redeemers over many transactions. Morpho's ERC-4626 wrapper had a broken slippage check because `shares` was capped to `maxMint` before the `maxAssets` check, making the slippage protection apply to fewer shares than the user intended. Another Morpho finding showed that non-whitelisted users could deposit into permissioned vaults via the bundler by using the `erc20WrapperDepositFor` path, which did not check the original depositor's whitelist status.

**Remediation Notes**
Use `maxWithdraw` and `maxDeposit` to check vault availability before calling `withdraw` or `deposit`. In multi-vault withdrawal loops, aggregate partial amounts across all available vaults rather than requiring any single vault to satisfy the full request. Apply slippage checks against the original user-requested amount before any capping. Enforce whitelist checks on the original depositor identity, not the bundler or wrapper contract address.

---

### Fee-on-Transfer Token Incompatibility (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
Protocol accepts ERC-20 tokens for position collateral, pool deposits, or trade settlement and assumes the `amount` specified in `transferFrom` equals the amount received by the contract. Fee-on-transfer tokens such as PAXG, STA, or tokens with dormant fee switches (USDT, USDC) deliver less than the nominal transfer amount. Internal accounting credits the full `amount`, creating phantom balance that is not backed by actual holdings.

**Detection Heuristics**
- Search for `transferFrom()` calls where the `amount` parameter is directly credited to user state without measuring the actual balance change.
- Identify `deposit`, `supply`, `commit`, or `stake` functions that add `amount` to user balances after a `transferFrom`.
- Check if protocol documentation or token whitelists include tokens with known or potential fee-on-transfer mechanics.
- Look for DEX swap return values used for debt accounting without verifying the contract's actual post-swap balance.
- Check if `uncommit` or `withdraw` returns the full originally deposited amount rather than the actually-received amount.

**False Positives**
- Protocol explicitly restricts to a fixed token set known to have no transfer fees.
- Protocol documentation states fee-on-transfer tokens are not supported and the token whitelist enforces this at registration.
- Balance-before/balance-after patterns are already used consistently throughout all deposit paths.
- The fee mechanism on a specific token is dormant and governance has committed to not enabling it.

**Notable Historical Findings**
OpenLeverage's `closeTrade` with a V3 DEX path used the DEX's return value for debt repayment accounting rather than measuring actual received tokens, causing repayment to be overstated when the bought token had a transfer fee. A separate OpenLeverage finding in `uniClassSell` had the same root cause in the V2 sell path. Tracer's pool commitment functions credited the full committed amount to pending commit records without measuring what was actually received, meaning uncommit would attempt to return more than the contract held. Morpho's position manager accepted fee-on-transfer tokens without balance-difference measurement, causing position collateral to be overstated.

**Remediation Notes**
Measure the actual received amount by computing `balanceAfter - balanceBefore` around every `transferFrom` call and credit only that measured amount to user state. Store the actual received amount in any pending or committed records rather than the nominal parameter. For swap output accounting, measure the contract's output token balance delta rather than relying on the DEX return value.

---

### First Depositor Share Inflation Attack (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol implements a share-based vault or pool where share price is determined by `totalAssets / totalSupply`. The vault has zero deposits at initialization and does not enforce a minimum initial deposit or mint dead shares. An attacker can directly transfer tokens to the vault contract, inflating `totalAssets` without receiving shares, making subsequent depositors receive zero or very few shares. Virtual shares in lending protocols that compound interest create permanent bad debt or value leakage.

**Detection Heuristics**
- Check if the vault mints dead shares to `address(0)` or a burn address during initialization to anchor share price.
- Look for `totalSupply == 0` branches in deposit functions that lack minimum share requirements.
- Verify direct token transfers to the vault address cannot inflate `totalAssets()` without minting shares.
- Check if virtual/dead shares in embedded lending vaults accrue interest or earn yield.
- Look for ERC-4626 vaults missing `_decimalsOffset()` or equivalent virtual share offset protection.
- Verify share price cannot reach values where a typical deposit rounds down to zero shares.

**False Positives**
- Vault is initialized by the protocol deployer with a protected first deposit that anchors share price.
- Minimum deposit amount is large enough to make the donation attack economically infeasible.
- Vault uses OpenZeppelin ERC-4626 with `_decimalsOffset()` providing virtual share protection.
- Share pricing uses a separate oracle rather than `totalAssets / totalSupply`.

**Notable Historical Findings**
Asymmetry Finance's `AfEth` vault allowed an attacker to manipulate `preDepositPrice` by depositing 1 wei to receive 1 share, then donating a large amount directly to the contract, inflating the price-per-share so that subsequent depositors received zero shares and the attacker could redeem at a profit. Hubble's insurance fund suffered a similar attack where the first depositor could be priced out entirely. Morpho's virtual supply shares accrued interest from the total supply including the dead shares, meaning the unowned virtual shares claimed a growing percentage of total interest, effectively stealing from real suppliers over time. The complementary virtual borrow shares finding showed these unowned borrow shares compound interest as bad debt that can never be repaid, shrinking the withdrawable pool over time.

**Remediation Notes**
Mint a minimum quantity of dead shares to a burn address on the first deposit to anchor the share price and make inflation attacks prohibitively expensive. Alternatively, apply the OpenZeppelin ERC-4626 `_decimalsOffset()` pattern to create a virtual offset of `1e6` shares. Exclude virtual shares from interest accrual by tracking real shares separately and distributing interest only to the real share supply.

---

### Cross-Chain Messaging Failures (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
Protocol uses LayerZero, Wormhole, or a similar messaging layer to synchronize position state, bridge collateral, or relay funding operations across chains. The protocol assumes address symmetry (same address on both chains) without accounting for account abstraction wallets or multisigs. LayerZero's default blocking delivery model means a single malformed or oversized message permanently blocks all subsequent messages on that pathway. Amount parameters do not account for dust removal applied by the OFT layer before minimum amount checks.

**Detection Heuristics**
- Check if LayerZero `_send` calls validate the `_toAddress` length - an oversized payload causes the destination to run out of gas inside the try-catch, triggering the blocking failure mode.
- Look for cross-chain NFT bridges where burn-on-source and mint-on-destination are not atomically guaranteed and no retry or recovery mechanism exists.
- Check if cross-chain operations hard-code `msg.sender` as the destination address without allowing the user to specify a different destination for non-EVM or AA wallet use cases.
- Verify amount parameters account for OFT dust removal before applying minimum amount checks.
- Check if access control restrictions enforced on the direct path are also enforced on the cross-chain composer path.

**False Positives**
- Protocol operates on a single chain and cross-chain messaging is not used.
- Address symmetry is guaranteed because the protocol only supports EOAs on EVM-compatible chains.
- Dust amounts are economically negligible and failed operations can be trivially retried.
- The messaging layer provides non-blocking delivery guarantees.

**Notable Historical Findings**
UXD Protocol had a high-severity finding where an attacker could pass an excessively large `_toAddress` in `OFTCore.sendFrom`, causing the destination transaction to run out of gas and permanently block all subsequent LayerZero messages on that channel due to the default blocking behavior. Tigris Trade's cross-chain NFT bridge could mint duplicate NFTs with the same token ID on different chains if message delivery failed after the source burn but before the destination mint. Brix Money had three related cross-chain issues: enforced address symmetry breaking account abstraction wallets, minimum amount checks failing after OFT dust removal, and a cross-chain deposit path through the composer that bypassed staking restrictions enforced on the direct path.

**Remediation Notes**
Validate `_toAddress` length with a strict maximum before passing to the LayerZero send function. Allow users to explicitly specify a destination chain address rather than hard-coding `msg.sender` to support AA wallets and multisigs. Apply dust removal to the minimum amount threshold before comparing against the post-dust-removal send amount. Enforce all access control restrictions on cross-chain entry points using the same checks applied to direct entry points.

## reference/solidity/protocols/dexes.md

# DEX and AMM Security Patterns

> Applies to: AMM, DEX, swap protocols, Uniswap-style, Curve-style, Balancer-style, order books, concentrated liquidity, token swap, liquidity managers, position managers, Arrakis-style, Gamma-style, concentrated liquidity position management, Uniswap v3 position wrappers

## Protocol Context

DEX and AMM protocols are architecturally defined by constant-product or curve-based invariant math, real-time liquidity pool mechanics, and dense external call flows involving token callbacks, oracle reads, and router integrations. Their pricing state is determined entirely by on-chain reserves or slot data, making it trivially manipulable within a single transaction by anyone holding sufficient capital or flash loan access. MEV exposure is structural: every state-changing operation that involves a price-sensitive output visible in the public mempool is a candidate for sandwich attack, front-running, or oracle manipulation.

## Bug Classes

---

### Front-Running and MEV (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- Swap or trade functions compute `amountOutMinimum` on-chain from current pool reserves or a quoter call, which the attacker has already moved
- Deadline is set to `block.timestamp`, providing no execution-time protection
- Automated keeper or harvest flows call swaps without user-supplied slippage bounds
- Permit signatures are submitted in the same transaction as the main call, enabling front-run DoS by replaying the permit first
- Rebalance operations in concentrated liquidity managers compute `sqrtPriceLimitX96` from `slot0().sqrtPriceX96`, which is movable by a flash loan executed in the same block as the rebalance transaction
- Position manager `rebalance()` and `init()` functions pass `amount0Min: 0` and `amount1Min: 0` to `NonfungiblePositionManager.mint()` and `increaseLiquidity()` without accepting caller-supplied bounds
- Deposit routing across multiple Uniswap V3 fee tiers does not enforce a minimum pool liquidity check, allowing a front-runner to thin the target pool before the deposit is routed

**Detection Heuristics**
- Search for swap calls where `amountOutMinimum`, `amountOutMin`, or `minAmountsOut` is set to `0` or computed in the same transaction from `quoter.quoteExactInput()` or `getReserves()`
- Check for `deadline: block.timestamp` in `ExactInputSingleParams` or equivalent swap structs
- Identify automated compound/harvest functions that route reward tokens through a DEX with no user-controlled minimum
- Check for `IERC20Permit.permit()` calls not wrapped in `try/catch`
- In concentrated liquidity managers, check all `NonfungiblePositionManager.mint()`, `increaseLiquidity()`, and `decreaseLiquidity()` call sites for `amount0Min: 0` and `amount1Min: 0`
- Verify that `rebalance()` and `init()` functions in position manager contracts accept explicit `slippage` and `deadline` parameters rather than deriving them from on-chain pool state

**False Positives**
- The function is only callable by a trusted keeper using a private mempool relay (Flashbots, MEV Blocker)
- Slippage bounds are enforced at a higher layer, such as an aggregator router that validates output before forwarding
- The swap amount is dust-level in a pool with deep liquidity, making sandwich attacks economically irrational

**Notable Historical Findings**
Multiple DEX integrations at protocols including Derby and Blueberry contained swap calls in vault harvest and reward-compounding functions where `amountOutMinimum` was hardcoded to zero, exposing the full swap value to sandwich attacks. Redacted Cartel's AutoPxGmx compound function allowed anyone to trigger a swap with caller-controlled slippage parameters, enabling a third party to set zero-minimum swaps and profit from the resulting sandwich. Cron Finance identified overpayment of one LP pair side during `joinPool` due to no slippage guard, directly extractable via MEV. Notional's settlement slippage was either bypassable or implemented incorrectly across multiple findings, making vault settlement vulnerable to predatory execution ordering.

**Remediation Notes**
Accept `minAmountOut` and `deadline` as explicit caller parameters in all functions that execute swaps, including automated flows. For keeper-executed paths, derive the minimum from an oracle price with a configurable `MAX_SLIPPAGE_BPS` tolerance rather than from on-chain pool state. Wrap all `permit()` calls in `try/catch` so a replayed permit does not brick the main transaction.

---

### Liquidation Logic Flaws (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Liquidation functions share a pause state with deposit or repay operations, so pausing one disables the other
- Position health factor reads `totalAssets()` or share price without first accruing interest, understating or overstating actual collateral value
- An origination fee is applied at loan creation time but the health check runs before the fee is deducted, producing a position that is immediately liquidatable
- The liquidation path iterates over dynamic collateral type arrays that can grow to exceed the block gas limit

**Detection Heuristics**
- Check if liquidation functions contain a generic `require(!paused)` that also blocks them when unrelated modules are paused
- Verify that `_accrueInterest()` or equivalent is called before any health factor read in the liquidation path
- Look for `origination_fee` applied after the collateral check that would push a new position below the liquidation threshold
- Check if Balancer BPT or other LP token valuations used for collateral rely on `getReserves()` or `slot0()` rather than TWAP

**False Positives**
- The protocol intentionally disables liquidations during an oracle failure mode with an explicit `oracleDown` flag distinct from the general pause
- Health factor checks always use the more conservative of cached versus live price

**Notable Historical Findings**
Blueberry had a finding where repayments being disabled via one flag would cause borrowers to lose collateral without the ability to repay, while liquidations remained enabled. Morpho exhibited state desynchronization where liquidating a user's position through Aave would leave Morpho's internal accounting diverged from the actual Aave position, creating exploitable inconsistencies. Astaria contained numerous liquidation path flaws including incorrect auction end validation, improper handling of winning versus non-winning bids on Seaport, and lien stack updates that did not propagate correctly on partial payments. Sentiment found that the origination fee could make a freshly opened position immediately liquidatable at loan inception.

**Remediation Notes**
Liquidation must have its own pause flag, independent of deposit or repay pauses. Always trigger interest accrual before any health factor or collateral valuation read. Validate that no combination of fee application and collateral check ordering can produce a liquidatable position immediately after opening.

---

### Oracle Price Manipulation (ref: fv-sol-10, fv-sol-10-c5, fv-sol-10-c6, fv-sol-10-c7)

**Protocol-Specific Preconditions**
- Protocol reads `IUniswapV3Pool.slot0()` for pricing; slot0 reflects the last executed trade and is trivially movable by a flash loan in the same transaction
- Chainlink `latestRoundData()` is called without checking `updatedAt` staleness, `answeredInRound >= roundId` completeness, or minAnswer/maxAnswer circuit breaker bounds
- The protocol uses an LP token oracle (e.g., IchiVault) that derives price from internal token balances, which are directly influenced by single-sided deposits
- No fallback oracle exists when the primary feed returns zero or reverts
- Concentrated liquidity managers read `slot0().sqrtPriceX96` to compute `sqrtPriceLimitX96` passed to the pool during a rebalance swap, making the swap limit directly manipulable in the same block
- Rebalance trigger logic reads `slot0().tick` to determine whether the current price has moved outside the managed tick range; an attacker can temporarily move the tick to force or prevent a rebalance

**Detection Heuristics**
- Search for `slot0()` calls used directly in price or valuation calculations without a corresponding `observe()` TWAP call
- Check every `latestRoundData()` call: confirm `updatedAt > 0`, `block.timestamp - updatedAt <= MAX_STALENESS`, `answeredInRound >= roundId`, `answer > 0`
- Verify that the returned `answer` is checked against `minAnswer` and `maxAnswer` from the underlying aggregator (circuit breaker scenario)
- Search for `getReserves()` in Uniswap V2 or `IUniswapV2Pair.getReserves()` used for pricing without TWAP
- Check any path that passes `slot0().sqrtPriceX96` or `slot0().tick` directly into `NonfungiblePositionManager` or pool swap parameters in a manager contract
- Verify that rebalance trigger conditions use `IUniswapV3Pool.observe()` TWAP ticks rather than `slot0().tick` for determining whether a position is out of range

**False Positives**
- TWAP window is sufficiently long (30+ minutes) and the pool has liquidity deep enough to make manipulation cost-prohibitive
- A secondary oracle provides a sanity-check bound that catches manipulation before it reaches critical state
- Staleness threshold is deliberately tuned to the specific Chainlink feed's heartbeat interval (some feeds update every 24 hours)

**Notable Historical Findings**
Blueberry contained three separate oracle findings: ChainlinkAdapterOracle returned stale prices due to missing freshness checks, the WBTC feed used BTC/USD without accounting for potential WBTC depeg, and the IchiLpOracle derived prices from IchiVault internal balances that were easily manipulated via single-sided deposits. Float Capital's entire market misbehaved when a Chainlink feed had an update gap, because no staleness validation existed. Notional relied on a Balancer oracle that updated infrequently, making its collateral valuations exploitably stale during low-activity windows. ParaSpace incorrectly valued UniswapV3 LP positions by mishandling tokens of different decimal scales in the price formula, leading to wrongly triggered liquidations.

**Remediation Notes**
Never use `slot0()` for pricing in critical protocol paths; use `observe()` with a TWAP interval of at least 30 minutes. For Chainlink, validate all five return values from `latestRoundData()` and check circuit breaker bounds. For LP token oracles, derive price from external reference prices rather than internal balance ratios.

---

### Access Control (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- State-changing functions that modify fee parameters, reward token lists, pool configuration, or upgrade paths are exposed as `external` without ownership or role guards
- Approval and token transfer functions in LP/order-book implementations omit `msg.sender` authorization checks against the `from` address
- Diamond proxy facets have globally accessible state variables rather than namespaced storage, allowing one facet to corrupt another's state
- Admin roles have unrestricted power over fee collection, token transfers, and allowance changes with no timelock
- `rebalance()`, `reinvest()`, and `compound()` functions in concentrated liquidity manager contracts are externally callable without a keeper or governance guard, allowing any caller to trigger rebalance at a strategically unfavorable time
- Fee collection calls that forward collected tokens to a caller-supplied `recipient` parameter do not validate the recipient against a stored `feeRecipient`, allowing an unauthorized caller to redirect fees

**Detection Heuristics**
- Scan all `external` and `public` state-mutating functions for the absence of `onlyOwner`, `onlyRole`, or equivalent modifiers
- Check `transferFrom` implementations for the three-way authorization: `msg.sender == from || isApprovedForAll(from, msg.sender) || getApproved(id) == msg.sender`
- In diamond proxy contracts, look for global variable declarations that should use `getStorage()` or a namespace pattern
- Identify functions that can be called through a proxy fallback that bypasses the checks on the implementation
- Check if `rebalance()`, `reinvest()`, or `compound()` in position manager contracts have `onlyManager`, `onlyOwner`, or `onlyStrategist` guards
- For functions that call `NonfungiblePositionManager.collect()` with a caller-supplied recipient, verify the recipient is constrained to a pre-registered `feeRecipient` address

**False Positives**
- The function is intentionally permissionless because it is a view or performs a beneficial public action such as liquidation or fee distribution
- Access control is enforced upstream in the router or wrapper that is the sole entry point

**Notable Historical Findings**
CLOBER had a missing ownership check in its token transfer path, allowing any caller to invoke `transferFrom` on behalf of any holder. Astaria contained a case where anyone could take a loan on behalf of any collateral holder without authorization, using valid commitment data from a self-registered vault. Connext exposed `acceptanceDelay` mutation to arbitrary callers, allowing unauthorized modification of a security-critical timing parameter. LI.FI's GenericBridgeFacet allowed arbitrary external calls with approved token balances because call targets were not whitelisted, effectively granting any caller the ability to route approved tokens to an attacker address.

**Remediation Notes**
Apply `onlyOwner` or role-based guards to all functions modifying protocol configuration. In diamond proxies, enforce namespaced storage via EIP-2535 best practices to prevent cross-facet state pollution. Wrap sensitive admin operations in timelocks and multisig requirements.

---

### Stale State After Actions (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Transfer or buyout of a lien, position, or LP token does not update the associated payee, slope, or intercept mappings
- Interest accrual is not triggered before adding a new loan or reading total debt, causing overborrowing against understated debt
- Order NFT burns leave ownership or order data mappings non-zeroed, enabling token ID recycling attacks
- Diamond storage gaps (`__gap`) are not correctly sized, risking storage slot collision on upgrade

**Detection Heuristics**
- After any ownership transfer path, verify that payee, approval, and claim mappings are all updated atomically
- Search for loan or position creation functions that add to `totalDebt` without first calling `_accrueInterest()`
- Look for `_burn()` calls not followed by explicit `delete ownerOf[tokenId]` and associated data cleanup
- Check if `stateHash` is updated in all code paths that modify lien state, not just the primary path

**False Positives**
- The protocol uses a lazy accrual pattern where any subsequent interaction with the position forces a state catch-up, and this is consistently enforced across all entry points
- The stale value is overwritten atomically in the same transaction before it can be read by any dependent calculation

**Notable Historical Findings**
Astaria contained more than ten findings related to stale state, including `makePayment` not properly updating the lien stack, `setPayee` not updating the y-intercept or slope (allowing vault owners to redirect funds), `stateHash` not being updated on `buyoutLien`, and clearing house state not reflecting auction outcomes when a Seaport bid was non-winning. CLOBER had order ownership not zeroed after burning, which combined with predictable token ID recycling allowed theft of future order NFTs. Morpho Aave position liquidation left internal Morpho state desynced from the actual Aave state after a cross-protocol liquidation call.

**Remediation Notes**
Treat state updates as atomic sets: any operation that changes ownership, debt, or accounting must update every derived or associated mapping in the same call. Enforce interest accrual as the first action in any path that reads total debt or share price.

---

### Reentrancy (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Pool reserve or `totalAssets` state is updated after token transfers, creating a window where an ERC-777 or hook-bearing token callback can read stale reserves
- Read-only reentrancy: `getReserves()` or `slot0()` is consumed by an external pump or oracle contract mid-transfer, before the pool finalizes its state update
- ERC-721 `safeTransfer` or `safeTransferFrom` triggers `onERC721Received` on a malicious receiver that reenters the DEX
- The `nonReentrant` guard is applied to one entry point but not to all functions sharing the same state
- `NonfungiblePositionManager.collect()` is invoked to realize accrued fees before the manager's internal fee accounting is updated, allowing a re-entrant call during the token transfer callback to observe stale unclaimed fee balances
- Position NFT transfers trigger `onERC721Received` on the recipient before the prior owner's position record is cleared, enabling the recipient to call back into the manager against a half-updated ownership state

**Detection Heuristics**
- Check if `_setReserves()` or equivalent pool state finalization happens before or after token transfers to recipients
- Look for Balancer read-only reentrancy: any protocol reading Balancer pool reserves via `getPoolTokens()` without confirming the pool is not mid-execution
- Identify ERC-777 tokens in scope; any `safeTransfer` to a user-controlled address with state not yet committed is a reentrancy vector
- Verify that every function sharing mutable state with a `nonReentrant` function is also guarded
- Check whether `NonfungiblePositionManager.collect()` calls in manager contracts precede any state variable updates that track unclaimed fees or position balances
- Look for `safeTransferFrom` of position NFTs where `onERC721Received` fires before the sender's position mapping is zeroed

**False Positives**
- The external call targets WETH or another immutable contract with no callback path
- All state is committed (effects applied) before any interaction, with strict CEI compliance verified end-to-end
- The contract only accepts tokens from a hardcoded whitelist that excludes ERC-777 and hook-bearing tokens

**Notable Historical Findings**
Beanstalk Wells had a read-only reentrancy finding where pumps (oracle-like components) were updated using pool reserves that had not yet been finalized after a liquidity removal, allowing external callers to read stale state via callbacks during the transfer phase. CLOBER's `collectFees` function drained tokens due to reentrancy because fee state was updated after the token transfer. Caviar's buy function allowed a discount-priced purchase using ERC-777 tokens by reentering before the price state was updated. Notional Finance's `redeemNative()` reentrancy enabled permanent fund freeze and systemic misaccounting by allowing reentrant calls to execute against uncommitted liquidation state.

**Remediation Notes**
For AMMs, update reserves and burn LP tokens before transferring tokens to users. Apply `nonReentrant` to all entry points sharing pool state, not just the swap path. For read-only reentrancy, downstream oracle consumers of Balancer or other multi-token pool reserves must check that the pool is not currently in an execution context before reading.

---

### Integer Overflow and Underflow (ref: fv-sol-3)

**Protocol-Specific Preconditions**
- `unchecked` blocks are used in LP points tracking or reward accumulation where subtraction can underflow if a position is modified concurrently or out of order
- UniswapV3 swap return values (`int256 amount0`, `int256 amount1`) are cast to `uint256` without negating the sign convention, causing the caller to treat a debit as a credit
- Type-narrowing casts (`uint256` to `uint128`, `int256` to `int128`) in pool accounting occur without a bounds check
- Solidity < 0.8.0 is used in any component, or `unchecked` is applied to multiplication of user-controlled values

**Detection Heuristics**
- Search for `unchecked { ... }` containing subtraction, particularly in reward point or LP accounting
- Search for `uint256(-amount1)` or the absence of negation when consuming `IUniswapV3Pool.swap()` return values
- Look for `int256(uint256Value)` casts without a preceding `require(value <= uint256(type(int256).max))`
- Check for `uint128(uint256Value)` or `uint64(uint256Value)` without explicit bounds checks in tick or fee accumulator math

**False Positives**
- The `unchecked` block is in a context where prior guards mathematically guarantee no overflow or underflow
- The narrowing cast is immediately preceded by an explicit `require(value <= type(uintN).max)` check

**Notable Historical Findings**
Neo Tokyo's LP withdrawal function contained an `unchecked` subtraction on `lpPosition.points` that could underflow, granting the caller near-infinite points and enabling unlimited reward claims. Maia DAO's `RootBridgeAgent` was vulnerable to DoS because UniswapV3 `swap()` return values were not negated before being cast to unsigned types, causing the agent to misinterpret token debts as credits. Cron Finance's long-term swap implementation lost proceeds in pools with decimal or price imbalances because accumulator types were too narrow for the values produced. Astaria's `claim()` reverted for any token without 18 decimals due to an unchecked underflow in the amount calculation.

---

### Missing Slippage Protection (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- The swap's `amountOutMinimum` is zero or computed on-chain from pool state (quoter), which is already manipulable by the time the transaction executes
- Balancer `joinPool` or `exitPool` calls have `minAmountsOut` arrays set to all zeros
- Deadline is absent or set to `block.timestamp` (always passes regardless of block inclusion delay)
- Automated flows (compound, harvest, rebalance) execute swaps triggered by any caller with no minimum output parameter
- Concentrated liquidity manager `init()` and `rebalanceAll()` pass `amount0Min: 0` and `amount1Min: 0` to `NonfungiblePositionManager.mint()`, `increaseLiquidity()`, and `decreaseLiquidity()` without accepting user-supplied bounds
- Position manager deposit functions that route across multiple Uniswap V3 fee tiers select a pool at transaction time without enforcing a minimum liquidity threshold, allowing front-runners to thin the target pool first

**Detection Heuristics**
- Search for `amountOutMinimum: 0` or `minAmountsOut` filled with zeros in DEX swap call structs
- Check if swap minimum is computed by calling `quoter.quoteExactInput()` or reading `getReserves()` within the same transaction as the swap
- Look for `deadline: block.timestamp` which is always satisfied and provides no staleness protection
- Identify functions callable by any address that internally execute swaps without accepting a `minOut` parameter
- Check all `NonfungiblePositionManager.increaseLiquidity()` and `decreaseLiquidity()` struct parameters in manager contracts for `amount0Min` and `amount1Min` hardcoded to zero
- Verify that `init()` and `rebalance()` entry points in Arrakis-style or Talos-style manager contracts expose `slippage` and `deadline` as explicit caller parameters

**False Positives**
- Swap minimum is derived from a time-lagged oracle price with a tight deviation threshold, providing equivalent or stronger protection than a user-specified value
- The function is only accessible to a privileged keeper that routes through a private mempool
- The pool is a stableswap with essentially no price impact for the swap size in question

**Notable Historical Findings**
Derby's vault swap functions across two separate findings both executed swaps with `amountOutMinimum` hardcoded to zero, making every vault harvest fully sandwichable. Blueberry's IchiVaultSpell withdrawals lacked slippage protection, allowing front-runners to steal a portion of the withdrawn ICHI rewards. Notional's vault settlement slippage was either bypassable or computed incorrectly in multiple findings, with one path allowing the calculated slippage bound to always be exceeded. Redacted Cartel's AutoPxGmx `compound()` was callable by anyone with caller-controlled slippage parameters, directly enabling sandwich attacks on the compound operation.

**Remediation Notes**
Require callers to supply `minAmountOut` and `deadline` parameters for all DEX-interacting functions. For automated keeper flows, compute the floor from an oracle price with a bounded tolerance rather than from pool state.

---

### Incorrect Math Calculations (ref: fv-sol-3)

**Protocol-Specific Preconditions**
- Interest rate or fee divisor uses a hardcoded constant with the wrong decimal scale (e.g., `1e17` where `1e18` is needed)
- Fee deduction is applied to the total position size rather than only the incremental delta being added
- AMM pool invariant or custom pricing formula deviates from the specification due to operator precedence or wrong variable substitution
- Velodrome-style forks use hardcoded Uniswap V2 fee values (0.3%) in `getAmountIn` rather than reading the pool's custom fee

**Detection Heuristics**
- Cross-reference all divisors and multipliers in financial formulas against the intended decimal precision
- Check fee calculations: the fee base should be `newAmount`, not `existingAmount + newAmount`, unless the specification explicitly states otherwise
- For Uniswap V2 forks with custom fees, check whether `getAmountIn` / `getAmountOut` use hardcoded `997/1000` instead of the pool's dynamic fee
- Verify reward distribution formulas against the protocol specification or whitepaper

**False Positives**
- The apparent wrong constant is a deliberate approximation whose bounded error is documented and economically immaterial
- The formula is a simplified equivalent of the specification with provably equivalent output

**Notable Historical Findings**
Astaria's strategist interest rewards were calculated with a divisor of `1e17` instead of `1e18`, producing interest ten times higher than intended. Velodrome Finance's `UniswapV2Library.getAmountIn` used hardcoded 0.3% fees from the original Uniswap V2 code despite Velodrome pools having configurable custom fees, causing incorrect quoted amounts. Tigris Trade had an incorrect new price calculation when adding to a position, because the price update formula used the wrong variable as the price base. Rage Trade's `DnGmxJuniorVaultManager._totalAssets` didn't correctly optimize or minimize in certain rebalance states due to a wrong price calculation path.

---

### Rounding and Precision Loss (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Reward share calculations perform division before multiplication: `(userShare / totalShares) * totalReward` truncates to zero when `userShare < totalShares`
- Deposit share minting uses `assets * totalSupply / totalAssets` where `totalAssets` can be inflated by a direct token donation, causing victim deposits to mint zero shares
- Taker fee rounding up across constituent order components produces a total fee that exceeds the collected amount
- Long-term swap accumulator values lose precision in pools where token decimals differ significantly (e.g., 8-decimal vs. 18-decimal)
- Fee growth calculations inside tick ranges use `uint256` arithmetic that wraps by design under the Uniswap V3 spec; applying Solidity 0.8 checked arithmetic to `feeGrowthInside` deltas causes spurious reverts
- `tickCumulatives` calculations in position range validation use a hardcoded fee tier constant instead of reading `IUniswapV3Pool.fee()`, producing incorrect TWAP values for non-standard fee tiers (500, 3000, 10000 bp pools)

**Detection Heuristics**
- Look for division operations followed by multiplication in the same expression; reverse the order
- Check share minting formulas for the case where `totalAssets` has been inflated by a direct transfer: can the result round to zero for a normal deposit amount?
- Verify rounding direction: deposits and mints should round shares DOWN (fewer shares minted), withdrawals and redeems should round assets UP (more assets required)
- Check `mulDiv` usages for correct rounding mode (`ROUND_DOWN` vs. `ROUND_UP`) relative to the operation's direction
- Verify that `feeGrowthInside0LastX128` and `feeGrowthInside1LastX128` subtraction in fee collection math is wrapped in `unchecked` blocks, matching Uniswap V3's overflow-by-design semantics
- Search for hardcoded fee tier values (`500`, `3000`, `10000`) used in `observe()` calls or fee growth arithmetic where `pool.fee()` should be read dynamically instead

**False Positives**
- Precision loss is bounded to 1-2 wei per operation and has no compounding effect
- The protocol uses a fixed-point math library (PRBMath, FixedPoint96) that handles rounding correctly by design

**Notable Historical Findings**
Caviar's first depositor could break share minting by depositing 1 wei, then donating a large token amount directly to the vault, causing subsequent depositors to receive zero shares for non-trivial deposit amounts. Astaria's first vault deposit caused excessive rounding that allowed the first depositor to extract value from subsequent depositors. CLOBER's taker fee rounding up across constituent orders could produce a total fee exceeding the collected amount, causing an invariant violation. Alchemix's misuse of Curve pool return values produced both precision loss and unintended reversions due to incorrect handling of the pool's output scaling.

---

### Flash Loan Attacks (ref: fv-sol-8-c1, fv-sol-10-c3)

**Protocol-Specific Preconditions**
- Reward eligibility or governance voting power is based on instantaneous balance rather than a time-weighted or checkpointed snapshot
- Staking functions have no minimum lock duration, allowing stake-claim-unstake in a single transaction
- BPT or LP token balance thresholds are checked at call time rather than at a prior block snapshot
- Liquidity pool share price or exchange rate can be temporarily moved by a large single-block deposit or withdrawal

**Detection Heuristics**
- Check if reward calculations read `stakedBalance[msg.sender]` or `balanceOf(msg.sender)` without verifying a minimum stake duration has elapsed
- Look for governance threshold checks on live token balances: `require(token.balanceOf(msg.sender) >= THRESHOLD)` without using `getPriorBalance()` or equivalent
- Verify that `exchangeRateStored()` or share price values used for collateral or reward calculations are not updatable within the same block by large deposits
- Identify flash loan callback entry points that allow arbitrary operations before repayment

**False Positives**
- The protocol uses `getPriorVotes()` or block-snapshot checkpoints that are immune to same-block manipulation
- Reward calculations use time-weighted balances accumulated over multiple blocks

**Notable Historical Findings**
Telcoin allowed flash loan of TEL tokens to stake and exit within a single block, enabling an attacker to claim rewards proportional to the entire flash-loaned amount without having staked for any meaningful duration. Notional's Balancer vault integration was vulnerable to an attacker bypassing BPT thresholds by flash-loaning the required BPT balance, satisfying the threshold check, and then returning the BPT before the block ended. Carapace had a sybil/flash loan vector on withdrawal requests that allowed leveraged manipulation of a vault's leverage factor by coordinating multiple flash-borrowed withdrawal requests. Union Finance's `exchangeRateStored()` could be front-run immediately after a repayment to extract the rate change before the on-chain state settled.

---

### Griefing and Denial of Service (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- Public functions iterate over arrays that grow unboundedly with user interaction (deposit lists, order queues, reward token arrays)
- A single failing token transfer in a batch distribution reverts the entire transaction
- Protocol operations have a gas budget that can be exhausted by an attacker creating dust positions at low cost
- Pool interest rate parameters can be manipulated by an attacker to make borrowing economically unviable for honest users
- Liquidity managers that support multiple tick ranges iterate over all managed positions in a single `rebalanceAll()` call; cheap dust positions added by an attacker can inflate the array to exceed the block gas limit

**Detection Heuristics**
- Look for `for` loops iterating over `deposits.length`, `positions.length`, or similar user-influenced arrays without a maximum iteration bound
- Check batch token distribution functions for `transfer()` calls without `try/catch`; a single reverting token locks all distributions
- Check Ajna-style interest rate manipulation: can an attacker add and remove liquidity at extreme rate bands to push rates above market?
- Search for `permit()` calls without `try/catch` that revert the entire function when the permit is front-run
- Check if `rebalanceAll()` or equivalent multi-range iteration functions have a maximum batch size or pagination mechanism

**False Positives**
- Arrays have an enforced maximum length that is small enough to safely iterate within block gas limits
- The griefing attack costs more in gas than the damage inflicted on the victim
- Batch operations emit failure events per item rather than reverting on partial failure

**Notable Historical Findings**
Ajna's interest rate mechanism could be raised above market levels as a griefing attack by repeatedly manipulating rate bands, disabling the pool for legitimate borrowers at low attacker cost. Biconomy's `handleOps` and `multiSend` logic was vulnerable to griefing via failing operations in a batch that caused the entire multi-operation to revert. Stakehouse Protocol's giant pool ETH bringback function allowed any caller to cause pool DOS by exploiting the idle ETH accounting, orphaning other users' LP positions. Predy's `_removePosition` could be permanently DoS'd by a specific sequence of position interactions, locking the user's position in the protocol.

---

### Token Decimal Mismatch (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Protocol assumes all tokens have 18 decimals but accepts USDC (6), USDT (6), or WBTC (8)
- Oracle price (returned in 18 decimals) is multiplied directly by a raw token amount without normalizing the token amount to 18 decimals first
- Cross-pool or cross-token calculations (collateral vs. debt in different tokens) mix raw amounts from tokens of different decimal scales
- UniswapV3 position valuations combine `token0` and `token1` amounts without accounting for their individual decimal scales

**Detection Heuristics**
- Search for hardcoded `1e18` or `10**18` in token amount formulas; verify the token's actual decimal count
- Check if `IERC20Metadata(token).decimals()` is called and used to normalize before financial calculations
- Look for `claim()` or `redeem()` paths that would underflow or return dust for 6-decimal tokens
- Verify that collateral valuation formulas correctly scale between oracle decimals (typically 8 for Chainlink) and token decimals

**False Positives**
- The protocol's token whitelist exclusively allows 18-decimal tokens, enforced at admission time
- Decimal normalization is applied in an oracle adapter layer so all prices reaching the core protocol are already normalized

**Notable Historical Findings**
Blueberry's IchiVaultSpell transferred too few ICHI v2 reward tokens to users because the decimal precision of ICHI v2 differed from the hardcoded assumption. Taurus assumed 18 decimals for collateral throughout its core logic, causing catastrophic mispricing for any non-18 decimal collateral. Astaria's `claim()` function underflowed and reverted for tokens with fewer than 18 decimals because the amount calculation assumed 18-decimal scaling. ParaSpace wrongly valued UniswapV3 positions when the underlying token pair contained tokens of different decimal scales, leading to incorrectly triggered liquidations.

---

### First Depositor and Vault Share Inflation (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Vault has no virtual shares or virtual asset offset, and no minimum initial deposit requirement
- `totalAssets()` includes the vault's own token balance, making it susceptible to inflation via direct token donation
- First depositor receives shares at exactly 1:1 before any pooled state exists, then can inflate `totalAssets` to make subsequent share minting round to zero
- The vault is ERC-4626 compatible but does not implement the OpenZeppelin virtual offset pattern

**Detection Heuristics**
- Check if `totalSupply() == 0` receives special handling; if not, check if the standard formula `assets * totalSupply / totalAssets` can produce zero shares for a reasonable first deposit
- Verify that `totalAssets()` does not include direct token balance of the vault contract (i.e., is immune to donation)
- Look for absence of `_decimalsOffset()` override in ERC-4626 implementations
- Check if dead shares are minted to `address(0xdead)` or a similar sink at first deposit

**False Positives**
- The vault uses OpenZeppelin ERC-4626 with a non-zero `_decimalsOffset()`, which introduces virtual shares
- A minimum first deposit amount makes the inflation attack economically infeasible
- Internal accounting uses a separate accumulator that is not influenced by direct token transfers

**Notable Historical Findings**
Caviar's first depositor could break share minting for all subsequent depositors by performing a 1 wei seed deposit followed by a large direct token transfer. Redacted Cartel's AutoPxGmx and AutoPxGlp vaults were vulnerable to share price manipulation via this pattern, allowing an attacker to drain depositor assets. Mycelium had an explicit finding where an attacker could manipulate `pricePerShare` to profit from future deposits. Maverick's `getOrCreatePoolAndAddLiquidity` in the router could be front-run to create the pool with a manipulated initial price, distorting the first liquidity provider's position.

---

### ERC-4626 Vault Compliance (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- `maxWithdraw()` or `maxRedeem()` return the total asset balance without accounting for time-locked funds, withdrawal queues, or epoch-based restrictions
- `previewDeposit()` and `previewRedeem()` do not account for protocol fees, causing integrators to receive fewer shares or assets than previewed
- The vault's ERC-4626 router calls `vault.deposit()` but the router has not approved the vault to pull its tokens, causing permanent reversion
- USDT-style tokens require resetting allowance to zero before setting a new non-zero value, breaking router approve flows
- `totalAssets()` does not include uncollected Uniswap V3 position fees (`tokensOwed0`, `tokensOwed1`), causing share price to be understated until `collect()` is explicitly called and creating sandwich opportunities around fee collection events
- A vault wrapping a concentrated liquidity position has a `convertToAssets()` value that changes with every swap in the underlying pool; share issuance is not idempotent within a block

**Detection Heuristics**
- Verify that `maxWithdraw(owner)` reflects only the liquid, immediately withdrawable portion, not the total claimed balance
- Check `previewDeposit` and `previewWithdraw` implementations for fee inclusion and correct rounding direction
- Look for router patterns where `asset.approve(vault, amount)` is called without first pulling the tokens to the router
- Check for `safeApprove` usage with USDT where a non-zero-to-non-zero approval revert would lock the router
- Check whether `totalAssets()` reads `tokensOwed0` and `tokensOwed1` from the NonfungiblePositionManager or explicitly calls `collect()` before computing share price
- Verify that `previewDeposit()` and `previewRedeem()` account for the full economic value of the underlying position including accrued-but-uncollected Uniswap V3 fees

**False Positives**
- The vault intentionally deviates from the ERC-4626 specification in a documented and audited way
- The integration layer wraps the non-compliant vault with an adapter that normalizes behavior for downstream integrators

**Notable Historical Findings**
Astaria's ERC4626Router functions always reverted because the router approved the vault to pull tokens from itself but never pulled the tokens from the user first, breaking the deposit flow entirely. A separate Astaria finding showed that WithdrawProxy allowed redemptions before the public vault had called `transferWithdrawReserve`, enabling early withdrawers to claim funds not yet allocated to the proxy. Maia DAO's vMaia implementation did not correctly reflect locked funds in `maxWithdraw` and `maxRedeem`, causing integrators relying on strict EIP-4626 compliance to compute incorrect withdrawal limits.

---

### Signature and Replay Vulnerabilities (ref: fv-sol-4-c4, fv-sol-4-c10, fv-sol-4-c11)

**Protocol-Specific Preconditions**
- Signed messages omit the chain ID from the digest, allowing signatures from one chain to be replayed on any other chain the protocol is deployed on
- EIP-712 structured hash omits fields that affect execution (price, expiry, deadline), allowing a relayer to substitute values without invalidating the signature
- `ecrecover()` return value is not checked for `address(0)`, accepting null signatures
- The domain separator is computed once at deployment and not recomputed if the contract is deployed on a chain after a fork changes the chain ID

**Detection Heuristics**
- Check all `keccak256(abi.encodePacked(...))` signatures for inclusion of `block.chainid` or use of EIP-712 domain separator that includes `chainId`
- Verify that the EIP-712 type hash includes every execution-relevant field; compare the `abi.encode` arguments in `_hashTypedData` against the struct definition
- Search for `ecrecover()` return value usage without `require(signer != address(0))`
- Check if `DOMAIN_SEPARATOR` is a state variable set at construction; it should be recomputed if `block.chainid != initialChainId`

**False Positives**
- The protocol is deployed on a single chain with no cross-chain plans and the domain separator includes the contract address
- Nonce management is correctly implemented and prevents replay regardless of chain ID absence

**Notable Historical Findings**
Astaria's typed structured data hash for signing commitments was computed incorrectly, such that the hash did not match what signers believed they were authorizing. SeaDrop's `mintSigned` digest was not computed according to EIP-712, and `mintAllowList` and `mintSigned` both lacked replay protection across different drop contracts. Biconomy had a cross-chain signature replay vulnerability where a valid meta-transaction signature on one chain could be replayed on another. Connext's domain separator was not updated after a name/symbol change, potentially invalidating or misidentifying signed messages.

---

### Token Approval Issues (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Protocol calls `IERC20.approve(spender, amount)` on USDT or other tokens that revert on non-zero-to-non-zero allowance changes
- Older OpenZeppelin `safeApprove()` reverts if the current allowance is non-zero, permanently breaking the function after first use
- Approval targets are user-controlled or come from user-supplied calldata, allowing tokens to be approved to attacker addresses
- Allowances are set to `type(uint256).max` and never revoked, leaving the protocol permanently exposed if the approved contract is compromised
- After `NonfungiblePositionManager.decreaseLiquidity()`, the residual approval granted to the NonfungiblePositionManager for `token0` and `token1` is never revoked, leaving the protocol exposed if the position manager contract is later compromised or upgraded

**Detection Heuristics**
- Search for `IERC20(token).approve(spender, amount)` without a preceding `approve(spender, 0)` reset; check if USDT or similar tokens are in scope
- Look for `safeApprove()` calls from OpenZeppelin < v4.9; these revert if current allowance is non-zero
- Check if approval targets are hardcoded/whitelisted or can be influenced by user parameters
- Verify that post-swap or post-operation residual allowances are explicitly revoked
- Verify that token approvals granted to `NonfungiblePositionManager` are reset to zero after each `increaseLiquidity()` or `decreaseLiquidity()` call completes

**False Positives**
- The protocol uses `SafeERC20.forceApprove()` (OpenZeppelin v5+) which handles non-zero-to-non-zero allowance changes correctly
- The token whitelist excludes USDT and any token with non-standard approve behavior
- `safeIncreaseAllowance` and `safeDecreaseAllowance` are used in place of `approve`

**Notable Historical Findings**
LI.FI's proxy facets approved arbitrary user-supplied addresses for ERC-20 tokens in two separate findings, one allowing direct token theft via generic call execution and one where decreasing allowance on an already-non-zero value caused reverts. Astaria's ERC4626Router functions always reverted in part because the approval flow did not account for USDT-style tokens requiring a zero-reset before a new non-zero approval. Notional had a finding explicitly titled "Did Not Approve To Zero First" for a Balancer integration path that would permanently break on second use with USDT.

---

### Fee-on-Transfer Token Handling (ref: fv-sol-2-c7)

**Protocol-Specific Preconditions**
- The protocol calls `token.transferFrom(user, address(this), amount)` and credits `amount` to internal accounting, but the token charges a transfer fee so the contract receives less than `amount`
- Subsequent swaps, loans, or withdrawals rely on the internally recorded amount, which exceeds the actual balance
- Flashloan repayment validation checks a recorded amount rather than measuring the actual balance change
- The protocol does not explicitly reject fee-on-transfer tokens and accepts them without accommodation

**Detection Heuristics**
- Look for `balances[user] += amount` immediately after `transferFrom(user, address(this), amount)` without a balance snapshot
- Check if `IERC20(token).balanceOf(address(this))` is read before and after `transferFrom` to verify actual receipt
- Verify whether USDT (which has a fee flag that can be enabled), STA, or PAXG are in scope or admitted by the token whitelist
- Check flashloan implementations for whether the return check measures `actualBalance >= expectedBalance` vs. comparing against a parameter

**False Positives**
- The protocol explicitly reverts when `actualReceived != amount`, effectively blocking fee-on-transfer tokens at the deposit boundary
- The token whitelist is enforced on-chain and excludes all tokens with transfer fee mechanisms

**Notable Historical Findings**
Blueberry's lending integration with IchiVault did not measure actual received tokens, so fee-on-transfer tokens produced inflated internal balances that diverged from real holdings, understating debt repayment amounts. Ajna's flashloan implementation did not verify the actual end-state balance after the loan callback, meaning a fee-on-transfer token could satisfy the repayment check while leaving the pool short. Numoen and Redacted Cartel each had separate fee-on-transfer findings where deposit or GMX vault deposit paths credited the full `amount` parameter rather than the measured received amount.

---

### Unsafe External Calls (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Aggregator or bridge facets accept a user-supplied call target address and arbitrary calldata, then execute the call while holding approved token balances
- The protocol grants a token approval to a user-controlled address before making a call to that address, enabling the call target to drain the approved tokens
- Diamond proxy fallback functions forward arbitrary calldata to facets without validating the function selector against a whitelist
- Low-level `.call()` return values are not checked, silently ignoring failures

**Detection Heuristics**
- Search for `.call(data)` where `data` or the target address originates from function parameters or calldata
- Check if `IERC20.approve(userSuppliedAddress, amount)` precedes a `.call()` to that same user-supplied address
- Look for bridge facets that accept `bridgeContract` as a parameter and approve tokens to it before calling
- Verify that all `.call()`, `.delegatecall()` return values are checked

**False Positives**
- The call target is from an immutable, on-chain whitelist of trusted protocol addresses
- The call is a `staticcall` and cannot modify any state
- The function is only callable by an admin address protected by a multisig and timelock

**Notable Historical Findings**
LI.FI's GenericBridgeFacet allowed callers to specify arbitrary call targets with arbitrary calldata while the facet held approved token balances from the user, enabling direct token theft. A second LI.FI finding showed that the bridge Axelar facet similarly allowed a malicious external call path that could steal tokens. Biconomy had an arbitrary transaction execution finding where insufficient signature validation allowed a paymaster's ETH balance to be drained via crafted meta-transaction payloads. Optimism's migration path was bricked by sending a message directly to the LegacyMessagePasser, exploiting the absence of a call target guard.

## reference/solidity/protocols/governance.md

# Governance Protocol Security Patterns

> Applies to: DAO governance, on-chain voting, governance token voting, timelocks, proposal execution, treasury management, Compound Governor-style, OpenZeppelin Governor-style

## Protocol Context

Governance protocols derive authority from token-weighted or NFT-weighted voting power, creating a distinct attack surface where vote manipulation translates directly into protocol control. Flash loans allow an attacker to borrow governance tokens for a single block, making snapshot timing the primary defense boundary; if snapshots are taken at the proposal creation block rather than a prior block, same-block attacks collapse that boundary entirely. Delegation chains introduce a second class of risk: the mapping from token holder to effective voter can accumulate stale state, phantom power, and circular dependencies that standard ERC20Votes implementations do not guard against by default.

---

## Bug Classes

### Voting Checkpoint Overwrite in Same Block (ref: fv-sol-5)

**Protocol-Specific Preconditions**

`_writeCheckpoint` updates an existing entry when `block.number` or `block.timestamp` matches the last checkpoint rather than appending. Multiple state-changing operations in one block (mint, stake, transfer, delegate) each call `_writeCheckpoint`, and each overwrites the previous value instead of accumulating the delta. Binary search in `getPastVotes` then returns the final overwritten value, which may be lower than the true aggregate.

**Detection Heuristics**

- Find `_writeCheckpoint` or `_writeCheckpoint`-equivalent implementations. Check the same-block branch: does it assign `newVotes` directly (`cp.votes = newVotes`) or does it add a delta?
- Trace every call site that invokes `_writeCheckpoint` and determine whether two of them can fire in the same transaction (e.g., `_afterTokenTransfer` called twice via `transferFrom` + `delegate`).
- Verify that the storage reference in the same-block branch is a `storage` pointer, not a `memory` copy - a common Solidity footgun where the update does not persist.
- Check `getPastVotes` binary search edge cases: multiple checkpoints sharing the same `fromBlock` value produce ambiguous results.

**False Positives**

- When the overwrite is intentional and the contract never issues more than one checkpoint-modifying operation per block by design.
- When the checkpoint implementation is an unmodified, well-tested upstream library (e.g., OpenZeppelin `ERC20Votes` 4.x+) that uses additive deltas.
- When external ordering guarantees (one-action-per-block limits) make same-block conflicts impossible.

**Notable Historical Findings**

Nouns Builder suffered multiple high-severity findings where `ERC721Votes._writeCheckpoint` created a new entry on every call regardless of block, causing `getPastVotes` binary search to return incorrect historical values; an attacker who minted two NFTs in one block would have their second mint's vote count overwrite rather than add to the first. Golom's `_writeCheckpoint` failed to persist the update to storage when accessing the same-block branch through a `memory`-cached struct, leaving the checkpoint unchanged despite the assignment. FrankenDAO and Telcoin exhibited similar overwrite semantics where `stakedByAt()` reported erroneous values after multiple operations in one block.

**Remediation Notes**

The same-block branch must compute and store the full correct total, not assign `newVotes` blindly. Prefer a pattern where the caller passes `oldVotes` and `newVotes`, allowing the branch to verify internal consistency. Assign through a `storage` pointer, not a locally cached variable. Where possible, use OpenZeppelin's `ERC20Votes` or `ERC721Votes` without modification.

---

### Flash Loan Vote Manipulation (ref: fv-sol-5-c6)

**Protocol-Specific Preconditions**

Governance tokens are available on external lending markets or DEXes. Voting power snapshots are taken at proposal creation block rather than a prior block, or delegation/undelegation can occur atomically in the same transaction as voting. The flash loan mitigation (if any) checks direct token balance but not delegated balance.

**Detection Heuristics**

- Confirm whether `castVote` retrieves power via `getPriorVotes(account, proposalSnapshot)` using a snapshot block strictly before proposal creation, or whether it reads current balance.
- Check if `delegate` + `vote` + `undelegate` can be composed in one transaction. A mitigation that only checks the voter's own balance ignores the case where a flash-loaned amount is deposited, delegated to a proxy contract, and the proxy votes.
- Look for `deposit`/`delegate`/`undelegate`/`withdraw` in governance pool contracts without a cooldown mapping keyed on `msg.sender`.
- Check whether NFT-based `totalPower` is recalculated before the snapshot is written at `createProposal`; a stale denominator yields an artificially low quorum threshold.

**False Positives**

- When the governance token is non-transferable (soulbound) or not available on any lending venue.
- When a mandatory cooldown between delegation and withdrawal (tracked per address per block) prevents same-block unwind.
- When commit-reveal voting schemes break the information asymmetry required for a profitable flash loan attack.

**Notable Historical Findings**

Dexe received a high-severity finding where an attacker bypassed the protocol's direct-vote flash loan check by depositing tokens, delegating to a slave contract, having the slave vote, then undelegating and withdrawing in one transaction - the check applied to the voter account but not the delegatee. A second Dexe finding showed that `ERC721Power::totalPower` was not recalculated before the proposal snapshot, letting an attacker destroy the denominator and manufacture artificially low quorum. PartyDAO allowed any participant to contribute to an ETH crowdfund using a flash loan and then control the resulting party's governance.

**Remediation Notes**

Require that voting power be checkpointed at a block strictly before proposal creation (e.g., `block.number - 1` minimum). Track a `lastDelegationBlock` per address and reject `withdraw` until a cooldown has elapsed. Apply flash loan checks symmetrically to both direct voters and delegatees by locking deposited tokens from withdrawal whenever any proposal voted on by the depositor or their delegatees remains unfinalized.

---

### Delegation State Corruption (ref: fv-sol-5)

**Protocol-Specific Preconditions**

Re-delegation does not remove the old delegatee from the delegations list before inserting the new one, leaving phantom voting power. Delegation to `address(0)` is permitted and triggers `_moveDelegates` toward the zero address, which may revert on mint/burn internally or permanently block token transfers. Self-delegation in a naive implementation adds voting power from the delegator's own balance without subtracting from the prior self-held amount.

**Detection Heuristics**

- In any `delegate(fromTokenId, toTokenId)` function, check that `delegatedTo[fromTokenId]` is read, the old delegatee's power is decremented, and only then the new delegatee is set.
- Search for `delegate(address to)` where `to` is not validated against `address(0)`.
- For self-delegation, trace `_moveDelegateVotes(prevDelegate, to, balanceOf(from))` when `from == to` and `prevDelegate == address(0)` (the implicit default): the call adds `balanceOf(from)` votes to `to` without removing from anywhere, doubling effective power.
- Check NFT-based systems for the case where a burned or withdrawn token remains in a delegatee's list and contributes permanent phantom power.
- Verify that `VoteEscrowDelegation._writeCheckpoint` handles `nCheckpoints == 0` without an underflow on `nCheckpoints - 1`.

**False Positives**

- When delegation is restricted to a trusted set of addresses that are all known and non-zero.
- When the contract explicitly checks `require(to != address(0))` before delegation.
- When self-delegation is implemented via a separate `selfDelegate()` path that routes through a different code branch with correct accounting.

**Notable Historical Findings**

Nouns Builder's `ERC721Votes` had three related high-severity findings: delegation to `address(0)` blocked all transfers and burns for the delegator; self-delegation through `_transferFrom` allowed indefinitely increasing voting power; and explicitly self-delegating via `delegate()` doubled the voting weight by adding balance without removing the implicit prior self-delegation. Golom's `VoteEscrowDelegation` exhibited a related cluster: old delegatees were never removed during re-delegation, NFT withdrawal left stale delegations, and the `_writeCheckpoint` underflowed on the first delegation.

**Remediation Notes**

Always read and clear the previous delegatee before writing the new one. Reject `address(0)` as a delegation target unless the protocol explicitly uses it to mean "undelegate," in which case the handler must decrement without attempting to credit the zero address. Encode the initial state as explicit self-delegation rather than implicit `address(0)` to avoid the dual-path accounting error.

---

### Voting Power Accounting Desync (ref: fv-sol-5)

**Protocol-Specific Preconditions**

A separate `totalCommunityVotingPower` accumulator is maintained alongside per-address balances. Delegation logic only updates the total in specific branches (e.g., when delegating to/from self) and misses the general case of re-delegation between two non-self addresses. Staking records token power at `stake()` time but reads it fresh at `unstake()` time; if a multiplier (e.g., `baseVotes`, `maxStakeBonusTime`) changes between the two calls, the subtraction underflows or over-removes power. The `proposalsCreated` counter is incremented instead of `proposalsPassed` in `queue()`, corrupting the community voting power threshold calculation.

**Detection Heuristics**

- Trace all paths through the `_delegate` function and enumerate every combination of (old delegatee == self, new delegatee == self, old == new). Verify `totalCommunityVotingPower` is adjusted correctly in each branch.
- Find every call to `getTokenVotingPower(tokenId)` and determine whether it is deterministic over time or depends on mutable settings. If mutable, check that `unstake` uses a stored original value.
- Look for counters named `proposalsCreated`, `proposalsPassed`, `proposalsQueued` and verify they are incremented at the correct lifecycle stage.
- Verify `castVote` checks `votingPower > 0` before accepting the vote.

**False Positives**

- When voting power is derived entirely from a single on-chain balance and there is no separate accumulator.
- When stake parameters are immutable after initialization and the power calculation is therefore stable.

**Notable Historical Findings**

FrankenDAO produced four high-severity findings in this class: `totalCommunityVotingPower` was updated incorrectly when a user delegated to a third party (neither self), `unstake` subtracted the current multiplier-adjusted power rather than the original staked power causing underflow, `_unstake` removed votes from `msg.sender` rather than the NFT owner when called by an approved operator, and `queue()` incremented `proposalsCreated` instead of `proposalsPassed`. Alchemix's veCHECKPOINT was found to be completely broken, with voting multiplier rounding errors and unbounded unlock-time extension compounding to allow arbitrary voting power inflation.

**Remediation Notes**

Store the original voting power at stake time in a `mapping(uint256 => uint256)` keyed by token ID and use it exclusively during unstake. In `_delegate`, resolve the four cases (both self, both non-self, self-to-other, other-to-self) explicitly rather than relying on branch fallthrough. Add an invariant test asserting that `sum(tokenVotingPower[a] for all a) == totalCommunityVotingPower` after every state-changing operation.

---

### Quorum and Threshold Manipulation via Live Supply (ref: fv-sol-5-c6)

**Protocol-Specific Preconditions**

`quorum()` or `proposalThreshold()` reads `token.totalSupply()` at call time rather than using a checkpointed historical value. An attacker can mint tokens after proposal creation to inflate the denominator (raising quorum beyond reach) or burn tokens to lower the threshold. NFT-based protocols that compute quorum from `totalPowerInTokens` without recalculating before snapshot allow the denominator to be stale. Protocols that set no minimum voting power requirement for proposal creation allow proposals to be created and passed before any meaningful token distribution has occurred.

**Detection Heuristics**

- Find `quorum()` and `proposalThreshold()` implementations. Check whether they call `totalSupply()` (live) or `getPastTotalSupply(snapshotBlock)` (checkpointed).
- Search for any mint or burn function callable by untrusted actors that executes within the same block as `propose()`.
- In NFT governance, find where `totalPowerInTokens` or equivalent is read for quorum calculation; check whether `recalculateNftPower()` or equivalent is called before the snapshot.
- Check whether `propose()` enforces a non-zero proposer balance requirement and whether that check uses a prior-block snapshot.

**False Positives**

- When minting is permissioned and cannot be triggered by an adversary.
- When the governance token supply is fixed and there is no burn mechanism.
- When a mandatory voting delay ensures the quorum snapshot and the proposal creation block are separated.

**Notable Historical Findings**

Nouns Builder had multiple medium-severity findings related to quorum: burned tokens were not excluded from the denominator, causing quorum to be higher than intended; precision loss in the `quorumThresholdBps` calculation made quorum lower than intended for collections with large supplies; and the protocol allowed a proposal to pass with zero votes in favor during early DAO stages before meaningful distribution. Maia DAO was found to rely on current `bHermes.totalSupply()` for `proposalThresholdAmount`, which could be gamed by minting to block legitimate proposals. Dexe's governance pool used a stale `nftInfo.totalPowerInTokens` as the quorum denominator when `recalculateNftPower()` had not been called prior to snapshot.

**Remediation Notes**

Replace `totalSupply()` calls in quorum and threshold calculations with `getPastTotalSupply(snapshotBlock)`. For NFT-based systems, force recalculation of aggregate NFT power immediately before writing the proposal snapshot. Add a protocol-minimum `proposalThreshold` that ensures no proposals can be submitted before a baseline token distribution has occurred.

---

### Proposal Threshold Bypass via Signature Aggregation (ref: fv-sol-4)

**Protocol-Specific Preconditions**

`proposeBySigs()` accepts an array of signers and sums their voting power, but does not verify that the aggregate sum meets the proposal threshold at the snapshot block. Alternatively, each signer is validated individually (ensuring their signature is valid) but no combined-power check is performed. This allows many low-balance accounts to collectively submit proposals that no single account would be authorized to create.

**Detection Heuristics**

- Find `proposeBySigs` or equivalent signature-based proposal submission functions. Check whether a `require(totalVotingPower >= proposalThreshold())` or equivalent guard exists after the loop over signers.
- Verify that the threshold is read at the proposal's snapshot block, not at the time of transaction execution.
- Check if any signer can unilaterally cancel a pending proposal - a high-severity variant where the `cancel()` function checks only that the caller is among the original signers.

**False Positives**

- When `proposeBySigs` is a governance-only function callable only by a trusted multisig.
- When there is an additional on-chain veto or guardian that prevents malicious proposals from executing.

**Notable Historical Findings**

Nouns DAO received a medium-severity finding where `proposeBySigs()` did not verify that combined voting power met the proposal threshold, enabling low-balance accounts to collectively spam proposals. A separate high-severity finding in the same audit showed that any single signer from the original set could call `cancel()` to grief any pending or active proposal, regardless of whether the other signers agreed. Alchemix received a medium-severity finding where a malicious proposer could front-run and inflate `proposalThreshold` to block legitimate proposals from being submitted.

**Remediation Notes**

After iterating over signers and accumulating `totalVotingPower`, add `require(totalVotingPower >= proposalThreshold(), "below threshold")` using `getPastVotes` at the proposal snapshot block. Restrict `cancel()` to require either the original proposer or a majority of signers, not any single signer.

---

### Delegation Griefing via MAX_DELEGATES DoS (ref: fv-sol-9)

**Protocol-Specific Preconditions**

The governance contract enforces a maximum number of token IDs delegated to any single address (`MAX_DELEGATES`, commonly 1024). There is no minimum token balance required to perform a delegation. An attacker creates many dust positions (1 wei each) and delegates all of them to a target address, filling the limit and preventing any legitimate user from delegating to the target. The target cannot reset the limit by self-delegating.

**Detection Heuristics**

- Find the `MAX_DELEGATES` constant and the `delegate()` function. Check whether a minimum balance (`MIN_DELEGATION_BALANCE`) is enforced.
- Check whether the delegation limit check uses `ownerToTokenCount[owner]` or `balanceOf(owner)` - count-based limits are more easily exhausted with dust than balance-based limits.
- Verify that `_moveAllDelegates` or equivalent does not allow an attacker to atomically move hundreds of dust positions to a victim in one transaction.

**False Positives**

- When the cost of acquiring 1024 distinct positions (gas + token cost) is prohibitive relative to the griefing value.
- When the victim can self-delegate to clear the delegate list.
- When delegation is restricted to accounts that hold above a meaningful minimum balance.

**Notable Historical Findings**

Alchemix received multiple related medium-severity findings: `DOS attack by delegating tokens at MAX_DELEGATES = 1024` appeared in both the VotingEscrow and standard token contexts; a griefing variant showed any account could fill a victim's delegate limit at 100x lower cost than the victim's transfer cost; and a separate finding showed the same pattern allowed arbitrary asset freezing. Velodrome Finance received the same finding category, with `MAX_DELEGATES = 1024` fillable through dust delegation with no minimum balance guard.

**Remediation Notes**

Enforce a `MIN_DELEGATION_BALANCE` check in `delegate()` that requires the delegating account to hold above a meaningful threshold. Alternatively, limit the number of distinct delegations per source account (not just per target) so a single attacker address cannot farm positions across many wallets. Consider using a balance-weighted cap rather than a count-based cap.

---

### Unbounded Lock Duration Inflating Voting Power (ref: fv-sol-5)

**Protocol-Specific Preconditions**

Voting power is calculated as `baseVotes + (unlockTime - block.timestamp) * MULTIPLIER`. No upper bound is enforced on `_unlockTime`. An attacker passes `type(uint256).max` as `_unlockTime`, producing an astronomically large `stakedTimeBonus` that overflows or dominates all other voting power in the system, enabling unilateral governance control.

**Detection Heuristics**

- Find staking or lock functions that accept a `_unlockTime` or `lockDuration` parameter. Check for `require(_unlockTime <= block.timestamp + MAX_LOCK_DURATION)` or equivalent.
- Check whether `stakingSettings.maxStakeBonusTime` is actually enforced in the staking function or is merely a stored value that is never validated against the input.
- Trace the arithmetic: does `(unlockTime - block.timestamp) * MULTIPLIER` use SafeMath or checked arithmetic? An unchecked multiplication with `type(uint256).max` will overflow silently in Solidity <0.8.

**False Positives**

- When the lock duration is derived from a fixed enum (e.g., 1 week / 1 month / 1 year) and the user cannot supply an arbitrary value.
- When overflow protection (Solidity 0.8+ or SafeMath) causes the transaction to revert before the inflated power is recorded.

**Notable Historical Findings**

FrankenDAO's `_stakeToken` accepted an arbitrary `_unlockTime` and multiplied the uncapped duration by `STAKED_TIME_MULTIPLIER`, allowing an attacker to set `_unlockTime = type(uint256).max` and acquire a voting bonus large enough to pass any proposal unilaterally. The same pattern appeared across both liquid-staking and yield categories, in every case because `stakingSettings.maxStakeBonusTime` was stored but the staking function never compared the user input against it.

**Remediation Notes**

Add `require(_unlockTime <= block.timestamp + stakingSettings.maxStakeBonusTime, "exceeds max lock")` as the first check in the staking function. Store the computed voting power at stake time and use the stored value at unstake time to prevent desync if the max lock duration is later reduced.

---

### Governance Parameter Manipulation and Veto Loss (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**

Governance parameters (quorum thresholds, fork periods, veto rights, `forkThresholdBPS`) are settable by a privileged role or by governance itself without a timelock. A malicious or compromised owner can set parameters to values that trap token holders: setting `forkPeriod` to a near-zero value prevents exit, setting `forkThresholdBPS` to 100% prevents reaching the fork threshold. A vetoer address that is renounced or set to zero eliminates the last line of defense against a 51% attack.

**Detection Heuristics**

- Find all governance parameter setters. Check whether they are gated behind a timelock of meaningful length (>= the voting period).
- Identify whether a vetoer, guardian, or emergency pause role exists. Verify it cannot be unilaterally renounced by a single key without a governance vote.
- Check `cancel()` logic: can proposals be cancelled by accounts other than the original proposer without a majority of voting power backing the cancellation?
- Look for missing timelock checks on `diamondCut` or upgrade functions callable directly by a governor without delay.

**False Positives**

- When all parameter changes require a full governance proposal with a standard timelock.
- When the guardian role requires a multisig with a distributed key set.
- When the protocol is not yet live and parameters are being configured during initialization.

**Notable Historical Findings**

Nouns DAO received a cluster of medium-severity findings where a malicious DAO could manipulate `forkThresholdBPS`, set `forkPeriod` to an extremely low value trapping token holders, mint arbitrary fork DAO tokens, and update a proposal's content after inattentive voters had already cast their votes. Nouns Builder and Velodrome Finance both received findings that loss of the vetoer role opens a 51% attack path: once a sufficiently large token holder acquires a majority, no on-chain mechanism prevents proposal execution in the absence of a veto. ZkSync received a medium finding where the governor could immediately execute diamond upgrades without any timelock.

**Remediation Notes**

All governance parameter changes should be routed through a timelock whose duration is at minimum equal to the voting period, preventing the parameter from taking effect before any ongoing proposal concludes. The vetoer or guardian role should require multi-party authorization to transfer or revoke. Proposal content should be hashed and committed at creation time; any update should invalidate existing votes.

### Governance Flash-Loan Proxy Upgrade Hijack (ref: pashov-90)

**Protocol-Specific Preconditions**

Proxy contract upgrades are authorized by governance votes that read vote weight from the current block (`balanceOf` or `getPastVotes(account, block.number)`) rather than a prior checkpoint. No voting delay forces a waiting period between proposal creation and voting. No timelock delays execution after a vote passes. An attacker can flash-borrow sufficient governance tokens, create a proposal, vote, and execute an upgrade to a malicious implementation within a single transaction.

**Detection Heuristics**

- Find the vote weight lookup in `castVote` or equivalent; verify it uses `getPastVotes(account, block.number - 1)` or a snapshot block strictly before the proposal's creation block, not the current block.
- Check whether a `votingDelay` parameter is non-zero and enforced, preventing a proposal from being voted on in the same block it was created.
- Verify that a timelock of meaningful duration (24 hours minimum) sits between vote execution authorization and the actual upgrade call.
- Check whether governance token staking has a lock-up period that prevents flash loan acquisition.

**False Positives**

- Vote weight is read from `getPastVotes(account, block.number - 1)` with a voting delay enforced in addition.
- A timelock of at least 24 hours is interposed between vote finalization and execution.
- A quorum threshold is set high enough that flash-loan capital cannot reach it without exhausting the available lending market for the governance token.
- Governance tokens require staking with a lock period, blocking flash loan-based participation.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Use `getPastVotes(account, block.number - 1)` as the minimum snapshot offset and enforce a non-zero `votingDelay` so that snapshot and voting blocks are strictly separated. Require a timelock of at least 24 to 72 hours between vote execution authorization and any upgrade or parameter-change execution. For protocols whose governance tokens are available on lending markets, consider requiring staked governance tokens with a lock period for voting power.

---

### Flash Loan Vote Manipulation for Arbitrary Proposals (ref: pashov-131)

**Protocol-Specific Preconditions**

Voting power is read from `token.balanceOf(msg.sender)` or `getPastVotes(account, block.number)` at the time of the vote, allowing a flash loan of governance tokens to provide voting power within the same transaction. There is no minimum holding period between token acquisition and voting, and no timelock between vote finalization and execution. An attacker can borrow tokens, vote on or pass a proposal, and repay the loan within one atomic transaction.

**Detection Heuristics**

- Check the vote power source in `castVote` or `_countVote`; any read of `balanceOf` or `getPastVotes(account, block.number)` is exploitable via flash loan.
- Verify whether a cooldown or lock period prevents a newly acquired token balance from being used for voting until at least the next block.
- Check whether a meaningful timelock separates vote finalization from execution.
- Simulate the flash loan path: borrow tokens, delegate if necessary, vote, and verify whether the proposal is executable within the same transaction.

**False Positives**

- `getPastVotes(account, block.number - 1)` is used with a snapshot taken strictly before the proposal's creation block.
- A mandatory timelock sits between a passed vote and execution, making intra-transaction execution impossible.
- The governance token is non-transferable or unavailable on any lending venue that would supply flash loan liquidity.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Replace any `balanceOf` or current-block `getPastVotes` call with `getPastVotes(account, proposalSnapshot)` where `proposalSnapshot` is set to a block strictly before proposal creation. Enforce a non-zero `votingDelay` in the governor contract. Add a timelock between vote finalization and execution to ensure that governance decisions cannot be atomically proposed, voted, and executed within a single transaction.

---

## reference/solidity/protocols/indexes.md

# Index Protocol Security Patterns

> Applies to: on-chain index protocols, basket tokens, index rebalancing, Set Protocol-style, Index Coop-style, tokenized portfolios

## Protocol Context

Index protocols hold a basket of ERC-20 components and issue a single token representing proportional ownership. Every operation-mint, redeem, rebalance-must iterate over all components and price each independently, compounding per-token rounding error and gas exposure. The attack surface is unusually broad: each component carries its own decimal precision, fee-on-transfer behavior, callback potential, and oracle dependency, and any single component anomaly can cascade to corrupt the entire basket's invariants.

---

### Arithmetic Overflow and Underflow (ref: fv-sol-3)

**Protocol-Specific Preconditions**
- Reserves or supply accumulators are stored in `uint128` or `uint64` while intermediate math uses `uint256`, producing silent truncation on downcast
- Exponential decay reward functions receive inputs outside their safe numerical range
- Weighted basket math multiplies component quantities by prices before summing, with intermediate products exceeding `type(uint256).max` for high-value components

**Detection Heuristics**
- Search for explicit downcasts: `uint128(x)`, `uint64(x)`, `int256(x)` from `uint256` without prior bounds check or `SafeCast`
- Find subtraction on unsigned types where the second operand may exceed the first (e.g., sold exceeds total supply in order books)
- Audit `unchecked` blocks for arithmetic in accounting-critical paths
- Check loop counter types against the realistic upper bound of the collection being iterated
- For Solidity < 0.8.0, confirm SafeMath is used on all arithmetic in index math

**False Positives**
- Solidity >= 0.8.0 without `unchecked` blocks provides automatic overflow protection
- Downcast preceded by a validated upper bound check is safe
- Hash computations that intentionally wrap around

**Notable Historical Findings**
Caviar's reserve update silently overflowed when `netInputAmount` exceeded `type(uint128).max`, corrupting the virtual reserve tracking used for NFT index pricing. Knox Finance's withdrawal preview function underflowed when `totalContractsSold` exceeded `totalContracts` due to an order processing bug, permanently bricking withdrawals. Bancor's compounding rewards used an exponential function that overflowed when the time-to-half-life ratio exceeded the safe input range for `exp2`.

**Remediation Notes**
- Use `SafeCast.toUint128(x)` instead of bare `uint128(x)` for all reserve and supply downcasts
- Validate inputs to fixed-point exponential functions against a protocol-defined `MAX_SAFE_EXP_INPUT` before calling
- For index rebalance math, keep all intermediate values at full `uint256` precision and downcast only at the final storage write

---

### Decimal and Precision Mismatch (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Index basket contains components with heterogeneous decimals (WBTC at 8, USDC at 6, WETH at 18)
- Price adapters assume WAD (1e18) precision for all component prices but receive values in the component's native decimal scale
- Fixed-point library functions (`expWad`, `lnWad`, `powWad`) receive inputs that are not normalized to 1e18, returning near-linear instead of exponential curves

**Detection Heuristics**
- Identify all hardcoded `1e18` divisors in index math and verify the operand token actually has 18 decimals
- Audit `preciseMul` / `preciseDiv` call sites for inputs not in WAD precision
- Check exponential price adapter arguments for scale; a `timeCoefficient` expressed in 1e6 rather than 1e18 produces silent linearization
- Find `buyQuote` or similar pricing functions where a small numerator divided by a large denominator can round to zero, enabling zero-cost token acquisition

**False Positives**
- Protocol normalizes all amounts to a common precision at ingress before any math
- Only 18-decimal tokens are supported and a whitelist enforces this
- Rounding loss is bounded to sub-cent dust values with no amplification vector

**Notable Historical Findings**
Index Coop's BoundedStepwiseExponentialPriceAdapter received `timeCoefficient` in the wrong scale, causing the auction price curve to degrade to a nearly flat line and mispricing rebalance trades significantly. Caviar's `buyQuote` rounded down to zero for certain reserve ratios, allowing buyers to acquire fractional NFT tokens for free. ParaSpace's Uniswap V3 position valuation applied 18-decimal math to token pairs containing WBTC, producing collateral values off by up to 10 orders of magnitude.

**Remediation Notes**
- Normalize price inputs: `priceWad = componentPrice * 10**(18 - quoteDecimals + componentDecimals)` before passing to WAD math
- Add a `require(inputAmount > 0, "rounds to zero")` guard after any division that feeds into a swap output
- Document the expected precision for each oracle price feed in the NatSpec of every price adapter

---

### Fee-on-Transfer Token Accounting (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Index composition includes deflationary or tax tokens
- Basket minting credits the nominal transfer amount rather than the actual post-fee amount received
- Swap functions inside the index router compute output from the declared `amountIn` rather than measuring the real received balance

**Detection Heuristics**
- Trace every `transferFrom` into the contract: if the subsequent balance credit uses the `amount` parameter rather than `balanceOf(after) - balanceOf(before)`, the pattern is present
- Check minimum output guards for off-by-fee errors: a `require(amountOut >= minAmountOut)` that uses the pre-fee input will pass even when the user receives fewer tokens than expected
- Verify that token whitelists explicitly exclude deflationary tokens if the balance-difference pattern is not implemented

**False Positives**
- Protocol enforces a whitelist of known non-fee tokens (WETH, USDC, DAI) and reverts on unsupported tokens
- Balance-before/after snapshot pattern is already implemented
- Internal function is only reachable with pre-validated tokens from a restricted caller

**Notable Historical Findings**
OpenLeverage's `uniClassSell` passed the nominal `amountIn` to the AMM's output calculation even when fee-on-transfer tokens reduced the actual transferred amount, causing trades to execute against incorrect input amounts and leaving systematic shortfalls. InsureDAO's Vault credited depositors for the full nominal amount on fee-on-transfer deposits, meaning the contract's actual token balance was always less than the sum of recorded balances, making later withdrawals underfunded.

**Remediation Notes**
- Measure received amount with `uint256 received = token.balanceOf(address(this)) - balanceBefore` and use `received` for all subsequent accounting
- For index minting, perform this snapshot for every component transfer in the basket loop

---

### Flash Loan Exploitation in Rebalance Operations (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Flash loan functions accept a user-supplied `token` address rather than using the contract's known NFT/ERC-20 address
- Health factor or auction validity checks can be satisfied temporarily within a single transaction by borrowing collateral
- Asset transfers in batch buy functions occur before payment collection, creating an implicit free flash-swap

**Detection Heuristics**
- Check if flash loan callbacks validate the return value equals `keccak256("ERC3156FlashBorrower.onFlashLoan")` per ERC-3156
- Identify functions that transfer assets before verifying payment (implicit flash-swap vulnerability)
- Verify health/solvency checks require the condition to persist across multiple blocks, not just within the transaction
- Look for fee collection that targets the wrong address (e.g., collects fee from caller rather than receiver)
- Confirm flash loan fee is distributed to protocol treasury and not silently discarded

**False Positives**
- Flash loan function accepts only the contract's own whitelisted token address, not user-supplied
- Health checks use time-weighted or multi-block measurements
- CEI pattern is followed: payment is verified before asset transfer

**Notable Historical Findings**
Caviar's `PrivatePool.flashLoan` accepted a user-supplied token address, allowing an attacker to pass a malicious ERC-721 address that did nothing on transfer, effectively borrowing the real pool NFT for free while paying a fee denominated in the worthless fake token. ParaSpace users exploited flash loans to temporarily inflate their health factor above the recovery threshold, invalidating in-progress auctions on their own positions. Multiple Caviar fee-accounting bugs meant flash loan fees were either collected from the wrong address or never forwarded to the factory's protocol fee pool.

**Remediation Notes**
- Hardcode the NFT/token address within the flash loan function rather than accepting it as a parameter
- Require health factors to persist across a configurable number of blocks before allowing auction cancellation
- Collect payment before transferring any assets in buy functions

---

### Front-Running and MEV in Rebalance (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- Rebalance auctions operate via Dutch auction or open-bid mechanisms where pending price updates are visible in the mempool
- Authorization delegation functions allow the authorized party to counter-front-run by granting new delegates before revocation lands
- Oracle price setter transactions expose the next price to searchers, who buy or sell before the price update applies

**Detection Heuristics**
- Identify authorization revocation functions and check whether the target can front-run by adding a new delegate from their still-valid authorization
- Confirm that swap and auction functions include both a `minAmountOut` (slippage) and a `deadline` parameter
- Look for oracle price-setter functions lacking commit-reveal protection
- Check Dutch auction bid functions for missing increment protection that would allow sniping with a minimal bid

**False Positives**
- Transactions are routed through private mempools (Flashbots Protect, MEV Blocker)
- Commit-reveal schemes prevent information extraction from pending transactions
- Time-locks or batched execution prevent atomic front-running
- Slippage tolerance adequately limits extractable value

**Notable Historical Findings**
Drips Protocol's `unauthorize` could be front-run by the about-to-be-revoked user who called `authorize` on an accomplice address before the revocation transaction landed, preserving effective access. Index Coop's auction mechanism allowed front-runners to observe favorable Dutch auction prices and purchase before other bidders, and separately allowed full-inventory purchases to be DoS'd by front-running with a minimal competing bid. ParaSpace's admin `setPrice` was observable in the mempool, giving searchers a window to borrow or trade against the old oracle value before the update confirmed.

**Remediation Notes**
- Restrict `authorize` to the owner directly, not to delegated callers, to prevent self-re-authorization front-running
- Enforce `deadline` and `minAmountOut` parameters on all auction and swap entry points
- Use commit-reveal for oracle price updates in protocols where the price update itself carries exploitable information

---

### Missing Access Controls (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Index component management functions (add/remove components, set oracle addresses) are externally callable without role checks
- Keeper or feeder addresses default to `address(0)` and are never validated, allowing zero-address to satisfy `require(msg.sender == keeper)`
- Oracle feeder removal has no minimum-feeder guard, allowing complete oracle DoS
- `renounceOwnership` is accessible on a contract where admin functions must remain callable post-deployment

**Detection Heuristics**
- Enumerate all external/public functions that modify component lists, oracle addresses, fee rates, or controller addresses and confirm access modifiers
- Check `address(0)` reachability: if a privileged address is never set, does the zero-address pass a `require(msg.sender == role)` check?
- Search for OpenZeppelin `Ownable` with `renounceOwnership` not overridden to revert
- Verify proxy initialization functions are callable only once and only by the deployer or factory

**False Positives**
- Function is intentionally permissionless by design (e.g., public liquidation)
- Access control is enforced at a higher contract layer that restricts callers
- `renounceOwnership` is explicitly overridden to `revert`

**Notable Historical Findings**
InsureDAO's `setController` lacked an access control modifier, allowing any caller to redirect vault withdrawals to an arbitrary address and drain all deposited funds. ParaSpace's oracle feeder removal function had no ownership check, enabling any user to remove all feeders and cause every price query to revert, permanently blocking liquidations. 1inch's governance contract allowed unauthorized stake creation on behalf of arbitrary users due to a missing access check on `notifyFor`.

**Remediation Notes**
- Add zero-address validation alongside every role-based `require`
- Override `renounceOwnership` to revert on all index contracts where admin functions must remain available
- Require at least two active feeders before permitting feeder removal

---

### Oracle Price Manipulation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Component pricing uses AMM spot prices from low-TVL Uniswap V3 pools, which can be seeded with minimal liquidity by an attacker
- Chainlink `latestRoundData` is called without validating `updatedAt`, `answeredInRound >= roundId`, or `price > 0`
- Fallback oracle is unreachable because primary oracle reverts are not caught with try/catch
- Floor oracle for NFT index components uses an append-only data structure that can be corrupted by feeder data

**Detection Heuristics**
- Confirm no AMM `getReserves()` or `getAmountsOut()` call is used for valuation; require TWAP or Chainlink
- For every `latestRoundData()` call, verify: `price > 0`, `updatedAt > 0`, `answeredInRound >= roundId`, `block.timestamp - updatedAt < STALENESS_THRESHOLD`
- Trace the fallback oracle call path with a primary oracle that reverts; confirm try/catch is present
- Review feeder-based floor oracles for data structure integrity (sorted insertion, removal safety, feeder count guardrails)

**False Positives**
- TWAP oracles with windows of 30+ minutes are used and documented
- Protocol operates on high-liquidity Chainlink-covered pairs only
- Multiple independent oracle sources are aggregated with median selection

**Notable Historical Findings**
ParaSpace's collateral pricing for Uniswap V3 LP positions used pools where an attacker could create their own low-liquidity pair and manipulate the reported price by orders of magnitude, enabling under-collateralized borrowing. The NFT floor oracle used an array-based structure that became corrupted when feeders were removed mid-round, causing out-of-bounds reads and a DoS on all price queries. Marginswap used `getAmountsOut` (a spot-price function) as its sole price source, making every collateral decision vulnerable to flash-loan manipulation.

**Remediation Notes**
- Reject any price source that can be influenced within a single transaction; require Chainlink or a Uniswap V3 TWAP with a minimum observation period
- Add try/catch around all external oracle calls and route failures to a validated fallback
- Store feeder indices in a mapping rather than a packed array to allow O(1) removal without structural corruption

---

### Privileged Function Abuse (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Controller address can be changed to a malicious contract that then calls vault withdrawal functions
- "Rescue" or "redundant withdrawal" functions can withdraw any token including user-deposited components
- Cover or compensation functions have no idempotency guard, allowing repeated application to drain index reserves
- Admin can bypass timelocks by resetting `lastUpdated` fields or calling override functions directly

**Detection Heuristics**
- Enumerate all `onlyOwner`/`onlyAdmin` functions and model whether each can redirect user funds to an arbitrary address
- Check "rescue" functions for missing exclusion logic on protocol-managed tokens
- Confirm one-shot operations (cover application, settlement) use a `mapping(id => applied)` idempotency guard
- Verify critical parameter changes (oracle address, fee rate, collateral factor) are behind a timelock of at least 24–48 hours

**False Positives**
- Protocol is explicitly custodial and users accept admin trust in documented terms
- Admin functions sit behind a timelock + multisig that gives users time to exit
- "Rescue" functions correctly exclude all protocol-managed tokens via a whitelist check

**Notable Historical Findings**
InsureDAO's `Vault.setController` allowed the owner to atomically redirect all vault withdrawals to an attacker-controlled address, draining all deposited funds in a single transaction. The same protocol's `withdrawRedundant` function accepted any token address, making it a direct backdoor to withdraw user deposits rather than only accidentally sent tokens. Holdefi's owner could reset per-asset price data and bypass the time-checks on market and collateral assets, overriding the timelock guarantees users relied on.

**Remediation Notes**
- Require `isProtocolToken[_token] == false` in any token rescue function
- Wrap all controller and oracle address changes in a two-step propose/execute pattern with a minimum delay
- Mark one-shot operations with a storage flag keyed to the specific incident ID, not a global counter

---

### Reentrancy via Token Callbacks (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Index basket contains ERC-777 components whose `tokensReceived` hooks trigger re-entry before reserve state is updated
- NFT-backed index protocols transfer ERC-721 tokens before collecting payment, exposing `onERC721Received` re-entry
- Grant or distribution functions update a `complete` flag after the external transfer rather than before

**Detection Heuristics**
- Identify all external calls in basket operation functions; flag any state variable written after those calls
- Check for missing `nonReentrant` on `buy`, `sell`, `swap`, and grant finalization functions
- Verify that reserve/balance state is updated before any token transfer, not after
- For multi-step operations (quote → transfer → update), confirm quoted values cannot become stale by re-entering between steps

**False Positives**
- `nonReentrant` is applied and covers all relevant entry points for the shared state
- Only WETH/USDC/DAI (no callback tokens) are supported and enforced via whitelist
- All state changes precede external calls throughout the function (strict CEI)

**Notable Historical Findings**
Caviar's `buy` function transferred ERC-777 fractional tokens to the buyer before updating the virtual reserves used to calculate prices, allowing a re-entrant callback to execute another buy against stale (lower) prices and acquire tokens at a substantial discount. Marginswap's balance accounting could be inflated by re-entering during an ETH transfer, as the balance write happened after the `call{value}`. Endaoment violated CEI in a grant finalization flow, setting `grant.complete = true` after the token transfer rather than before.

**Remediation Notes**
- Apply `nonReentrant` to all buy/sell/swap functions in the index contract
- Follow strict CEI: update all reserve/balance state before any token transfer or external call
- If ERC-777 tokens must be supported, add explicit `tokensReceived` guard logic that reverts re-entrant execution

---

### Royalty and Fee Distribution Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Batch NFT purchases divide total price equally across items regardless of individual item weights, producing incorrect royalty bases
- Royalty recipient addresses are not validated for zero-address or ETH-receivability
- Royalty payment callbacks within batch loops allow malicious recipients to re-enter or steal excess ETH held during the loop

**Detection Heuristics**
- Check every batch buy function for `salePrice = totalPrice / tokenIds.length`; this is wrong when NFTs have heterogeneous weights
- Verify `royaltyInfo()` is called with the per-item price, not an averaged price
- Confirm royalty payment failures are handled via a pull-payment escrow rather than a bare `.call{value}` that reverts the entire batch
- Check split royalty implementations for correct pro-rata logic across multiple creator recipients

**False Positives**
- All NFTs in the collection are fungible ERC-1155 items with equal value
- Protocol only supports single-item transactions
- Royalty recipients are validated and whitelisted at collection registration time

**Notable Historical Findings**
Caviar's batch buy computed royalties on `totalPrice / count` for each item, systematically underpaying royalties on high-value NFTs and overpaying on low-value ones, and separately allowed a malicious royalty recipient to drain the pool's excess ETH by re-entering during the royalty payment call. Foundation's multi-recipient royalty split used incorrect proportionality math, causing some creators to receive less than their entitled share while others received more. Foundation also failed to validate that `creatorRecipients` addresses were non-zero, burning fees when a zero-address was present in the recipients array.

**Remediation Notes**
- Calculate per-item sale price as `totalPrice * weights[i] / totalWeight` when items have heterogeneous values
- Use a gas-limited `.call{gas: 10000}` for royalty payments and escrow failures rather than reverting the entire batch transaction
- Validate all royalty recipients are non-zero and capable of receiving the payment token before executing the batch

---

### Signature Replay Attacks (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- Off-chain private-sale signatures lack a nonce, meaning if the seller re-acquires the NFT the old buyer signature becomes replayable
- Nonce is bound to the transaction relayer rather than the identity or signer, allowing the same signed payload to be submitted for different target identities
- EIP-712 domain separator does not include the contract address or chain ID, enabling cross-contract and cross-chain replay

**Detection Heuristics**
- Trace all `ecrecover` / `ECDSA.recover` call sites; verify the signed hash includes: nonce, contract address, chain ID, and a unique-per-use identifier
- Confirm the nonce is incremented on the identity or signer (not the caller/relayer) after each use
- Check whether the signed action can recur (e.g., seller re-acquires asset); if so, a nonce alone is insufficient without also tracking used digests
- Verify domain separator is not an immutable constructor value that becomes stale after a hard fork

**False Positives**
- Signature includes a monotonically increasing nonce on the correct entity, preventing reuse
- Signed message includes a unique order hash tracked in a mapping
- Action is idempotent and replay produces no additional state change

**Notable Historical Findings**
Foundation's private-sale signatures for NFTs contained no nonce; if the seller re-acquired the NFT after a sale, the original buyer's signature remained valid and could be replayed to forcibly purchase the asset again below market price. Ambire's recovery system allowed `SigMode.OnlySecond` recoveries to be submitted repeatedly because no used-signature tracking existed, enabling an attacker to replay a cancelled recovery indefinitely. A separate Ambire finding showed that nonces were tracked on the calling relayer rather than the target identity, allowing the same signed operation to be replayed across different wallets that shared a common authorized signer.

**Remediation Notes**
- Track used signature digests in `mapping(bytes32 => bool) public usedSignatures` regardless of nonce freshness
- Bind nonces to the identity being acted upon, not the caller address
- Compute domain separator dynamically using `block.chainid` to remain correct after forks

---

### Unbounded Loops and Denial of Service (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- Index basket component arrays or order books grow without a hard cap, and critical functions iterate the full array
- NFT-backed index pools iterate over all tokenId holders for EOS (end-of-sale) distributions without pagination
- Reward distribution loops contain external calls that can each individually revert, blocking the entire batch for all recipients

**Detection Heuristics**
- Find all `for` loops whose upper bound is a storage variable or unbounded dynamic array length
- Check whether the loop contains external calls, storage reads, or oracle queries that compound per-iteration cost
- Verify that at least one paginated alternative path exists for any unbounded loop in a critical redemption or withdrawal function
- Confirm that a single reverting recipient in a distribution loop cannot block all other recipients' payments

**False Positives**
- Loop has a hard cap enforced at insertion time (e.g., maximum 25 components per basket)
- Data structure is writable only by a trusted admin with manual size management
- Pagination is available as an equivalent alternative execution path

**Notable Historical Findings**
Foundation's `_getCreatorPaymentInfo` iterated an unbounded creator-recipients array; sufficiently large arrays caused the function to exceed the block gas limit, permanently preventing any marketplace sale from completing. Knox Finance's `_previewWithdraw` and `_redeemMax` functions looped over the entire order book, allowing the accumulation of many small orders to brick all withdrawal functionality. Marginswap's reward withdrawal function iterated a data structure that grew to a size exceeding the block gas limit, making reward claims permanently inaccessible.

**Remediation Notes**
- Enforce a maximum component/recipient count at insertion time, documented as a protocol invariant
- Implement pagination parameters (`offset`, `limit`) on any view or state function that iterates a dynamic collection
- Use the pull-payment pattern for distributions so a single failed recipient does not block others

---

### Unsafe External Calls and Token Transfers (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Component withdrawals use `address.transfer()` with the 2300-gas limit, which fails for multisig or proxy wallets holding index tokens
- Raw `IERC20.transfer()` / `transferFrom()` calls do not check return values on non-reverting tokens (USDT, BNB)
- `send()` return value is ignored, silently losing ETH when the recipient rejects it

**Detection Heuristics**
- Search for `.transfer(` on `address payable`; these always fail for contract recipients requiring more than 2300 gas
- Search for `IERC20(...).transfer()` and `IERC20(...).transferFrom()` calls not using `SafeERC20`
- Verify that failed transfers leave the contract in a consistent state and do not decrement balances before confirming success
- Check whether withdrawal loops use push or pull pattern; push loops with rigid failure semantics can permanently block all recipients

**False Positives**
- `SafeERC20.safeTransfer` is used exclusively for all token operations
- `.call{value: amount}("")` is used with proper return-value check
- Recipients are guaranteed to be EOAs and the token list is fully audited for standard revert behavior

**Notable Historical Findings**
OpenLeverage's `doTransferOut` used `payable.transfer()`, making the entire withdrawal path unusable for smart contract wallets and multisigs-a critical gap in a protocol where DAO treasuries are common users. Endaoment's token transfer functions silently returned `false` on failure rather than reverting, causing the protocol to update internal accounting as if the transfer succeeded while the tokens never moved. Aave's push-payment pattern for ETH deposits meant that a single non-payable contract address in the recipient set could permanently prevent all ETH deposits from being redeemed.

**Remediation Notes**
- Replace `payable.transfer()` and `send()` with `(bool success, ) = recipient.call{value: amount}("")` and require success
- Wrap all ERC-20 interactions with `SafeERC20` from OpenZeppelin to handle non-standard return values
- Adopt the pull-payment pattern for any multi-recipient distribution to isolate individual failures

## reference/solidity/protocols/insurance.md

# On-Chain Insurance Security Patterns

> Applies to: on-chain insurance protocols, coverage protocols, risk pools, parametric insurance, claims processing, Nexus Mutual-style, InsurAce-style

## Protocol Context

On-chain insurance protocols pool capital from coverage providers, underwrite risk against defined trigger conditions, and pay out claims via smart contract execution with no human intermediary. The attack surface spans three interacting systems: vault accounting (EIP-4626 share math, deposit/withdrawal queues), oracle-driven trigger conditions (price depegs, protocol hacks, parametric events), and staking or reward distribution that incentivizes capital providers over long epochs. Because insurance payouts must be reliable under adversarial conditions-including the very events they insure against-precision errors, oracle manipulation, and queue deadlocks carry outsized severity compared to typical DeFi protocols.

---

### Reentrancy in External Calls (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Redemption function executes a user-supplied swap path (e.g., Uniswap V2 multi-hop) to convert yield tokens before updating `s_yieldTokenBalance`; a malicious intermediary token in the path can reenter the redemption function, causing double subtraction from the yield token balance
- Fee distribution pushes tokens to the manager address in a loop; if the manager is a contract with an ERC777 `tokensReceived` hook, it can reenter before the loop finishes and claim more than its share
- ERC1155 safe transfer in a reward distribution loop invokes `onERC1155Received` on each recipient; a malicious recipient can block all subsequent distributions or reenter the distributor

**Detection Heuristics**
- Search for external calls (`safeTransfer`, `.call`, swap router invocations) that precede state variable updates in redemption, withdrawal, or fee-claiming functions
- Identify functions missing `nonReentrant` that interact with user-controlled token addresses or swap paths
- Look for user-controlled exchange data parameters (`bytes calldata redeemData`) that are decoded into swap paths; check if path length is validated to prevent malicious intermediary tokens
- Verify that vault, router, and request manager contracts use a common reentrancy lock, not independent locks that allow cross-contract reentry

**False Positives**
- All external calls made only to known, audited contracts (WETH, Aave aTokens) that do not propagate arbitrary callbacks
- Swap paths validated to contain exactly two tokens (single-hop), preventing malicious intermediary insertion
- Functions that strictly follow checks-effects-interactions without any external call before state updates

**Notable Historical Findings**
Notional Finance's `redeemNative()` executed a swap through a user-supplied path before updating `s_yieldTokenBalance`; a malicious intermediary token could reenter the function, causing the balance to be decremented twice per actual redemption and permanently freezing funds in the vault. Gauntlet's managed vault allowed a malicious manager to cause vault fund inaccessibility through ERC1155 callback reentrancy in distribution loops. Bridge Mutual's LiquidityMining contract had an ERC1155 reentrancy issue that allowed single-token transfers to trigger the full single-receive callback, creating unexpected state transitions. Notional Finance additionally discovered that nested `nonReentrant` guards on allocation wrappers could cause legitimate operations to revert, a secondary impact of an overly broad reentrancy guard scope.

**Remediation Notes**
- Add `nonReentrant` to all redemption, withdrawal, and fee-distribution entry points; if multiple contracts share state, they must share or coordinate a single reentrancy lock
- Validate swap paths in `redeemData` to allow only single-hop swaps (exactly two token addresses) before executing any external trade
- Replace push-based fee distribution with a pull pattern: accumulate fee entitlements in a mapping during accounting, and let managers claim via a separate `nonReentrant` function

---

### Precision Loss and Rounding Errors (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Fee calculation uses `amount / 10000 * feeRate` order, which evaluates to zero for amounts below 10000 scaled by `feeRate`
- Reward rate is computed as `reward / rewardsDuration` where `rewardsDuration` is a multi-year value; integer truncation discards up to `rewardsDuration - 1` tokens per reward period, permanently locking them
- `accrueInterest()` is callable at arbitrary frequency; single-second calls compute `principal * rate * 1 / SECONDS_PER_YEAR` which rounds to zero for principals below a threshold, silently zeroing interest for small positions
- EIP-4626 `price()` function divides by `totalSupply()` which reverts or returns an incorrect value when the vault is empty

**Detection Heuristics**
- Search for `a / b * c` expressions in fee, reward, or price functions; flag division before multiplication
- In reward notifiers, compute `rewardRate * rewardsDuration` and compare against `reward`; any shortfall is permanently lost to rounding
- Find interest accrual functions callable without a minimum elapsed time; check whether a per-second interest amount can evaluate to zero for the smallest supported position
- Search for `totalAssets() / totalSupply()` or equivalent without a zero-supply guard
- In price ratio calculations between oracle feeds with different decimals, verify normalization precedes any arithmetic comparison

**False Positives**
- Protocols using PRBMath, DSMath, or similar fixed-point libraries consistently throughout all arithmetic paths
- Rounding errors provably below one wei for all realistic input ranges and documented as accepted dust
- `totalSupply == 0` structurally prevented by a minimum deposit, dead shares, or initial liquidity seeded at deployment

**Notable Historical Findings**
Y2k Finance's staking rewards suffered significant precision loss because the reward rate division discarded a material fraction of tokens over a four-year reward period, and a separate finding showed receivers getting nothing when `amount / 10000 * feeRate` evaluated to zero for small deposits. Accountable Protocol's open-term loan accumulated interest in per-second increments; because `accrueInterest()` could be called at any frequency, high-frequency callers reduced effective interest to zero and the loan principal could not be repaid once it reached zero due to a division-by-zero revert. Notional Finance exposed a division-by-zero in `price()` when `totalSupply == 0` immediately after vault initialization, blocking the first depositor's preview calculation.

**Remediation Notes**
- Always multiply before dividing: replace `amount / 10000 * feeRate` with `amount * feeRate / 10000`
- Store reward rates in a higher-precision scaled integer (`reward * PRECISION / duration`); scale back to base units only at claim time to preserve per-period precision
- Enforce a minimum accrual interval (e.g., one hour) in `accrueInterest()` to prevent compounding rounding loss from high-frequency calls
- Handle `totalSupply() == 0` in `price()` by returning a default value (e.g., `1e18`) rather than dividing

---

### Staking Reward Calculation Errors (ref: fv-sol-3)

**Protocol-Specific Preconditions**
- `notifyRewardAmount` called mid-period dilutes remaining rewards across the extended duration instead of layering them cleanly; the balance check compares contract token balance against future obligations but does not subtract already-earned but unclaimed rewards, overstating available funds
- Shared reward token contract serves multiple pools; balance queries return the total contract balance rather than the pool-specific allocation, making each pool appear to have access to all reward tokens
- After vault epoch expiration, `getReward()` continues to allow claims against the next epoch's allocation rather than requiring rollover enrollment
- `recoverERC20()` is callable by the owner without excluding the reward token, functioning as an undisclosed withdrawal backdoor

**Detection Heuristics**
- In `notifyRewardAmount`, trace the balance check: does it subtract `totalUnclaimed()` from `rewardsToken.balanceOf(address(this))` before comparing against `rewardRate * rewardsDuration`?
- Find protocols with a single contract holding rewards for multiple pools; check if reward balance queries are scoped per pool via a `poolId` mapping or simply use `balanceOf(address(this))`
- Look for `getReward()` or `claimRewards()` functions without epoch expiry guards
- Search for `recoverERC20` or equivalent rescue functions; verify they cannot be used to extract reward or staking tokens

**False Positives**
- Protocols that call `updateReward(address(0))` as the first line of `notifyRewardAmount`, ensuring all accrued rewards are checkpointed before the new rate is set
- Separate contract deployments per pool with isolated token balances
- Dedicated vesting contracts that hold reward tokens and release them over a schedule independent of the staking contract

**Notable Historical Findings**
Y2k Finance's `StakingRewards` had a reward rate dilution bug where calling `notifyRewardAmount` mid-period extended the reward duration and reduced the effective rate, and a separate `recoverERC20()` function allowed the owner to withdraw reward tokens, effectively acting as a rug vector. Notional Finance found that claims from the Curve gauge were blocked by a nested reentrancy guard, making rewards permanently inaccessible. Neptune Mutual's staking system conflated reward balances across multiple pools, meaning large rewards deposited for one pool were visible to reward calculators in other pools, enabling disproportionate claims. Audius's reward calculation was incorrect when a pending decrease-stake request was in flight, causing the protocol to distribute more rewards than intended.

**Remediation Notes**
- Before computing the new rate in `notifyRewardAmount`, call `_updateReward(address(0))` to checkpoint all accrued rewards; then compute `available = balance - _totalUnclaimed()` and assert `newRate * duration <= available`
- Deploy separate reward token contracts per pool or maintain a `poolRewardBalances[poolId]` mapping that is decremented on each deposit and claim
- Add `require(block.timestamp <= epochEnd, "Epoch expired")` to `getReward()`; provide a rollover function for transferring unclaimed rewards to the next epoch

---

### Privileged Role Abuse (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Owner can call `setController(newController)` which immediately migrates all vault funds to an address they control with no timelock or multi-party approval
- `sweep(token, amount)` does not exclude the vault's own BPT (Balancer Pool Token) or underlying asset, allowing the owner to drain the liquidity pool
- A `withdrawRedundant` function callable by keeper or controller allows withdrawing tokens without checking whether they belong to users
- Registry or factory admin can register arbitrary market contract addresses; registered markets gain the ability to call `addValue()` on the vault, enabling fund transfers from any user who has approved the vault

**Detection Heuristics**
- Find `setController`, `setManager`, or `setKeeper` functions; check if they are behind a timelock and whether they trigger fund migration
- Search for `sweep` or `recoverERC20` functions; trace whether they include restrictions on pool tokens, underlying assets, or staking tokens
- Look for `withdrawRedundant`, `emergencyWithdraw`, or similar functions with weak access control (keeper or single EOA) that can move protocol-critical tokens
- Identify registry patterns where an admin-registered address gains `onlyMarket`, `onlyStrategy`, or similar roles that allow interaction with user funds

**False Positives**
- Privileged roles held by a multi-sig with adequate threshold and a mandatory timelock delay
- `sweep` functions that explicitly iterate over a `managedTokens` array and revert if the sweep target matches any managed token
- Protocols in early bootstrap phase with disclosed centralization and a public schedule for decentralization

**Notable Historical Findings**
InsureDAO had at least four separate privileged-role exploits: `setController()` migrated all funds to a new address without any guard, `withdrawRedundant()` allowed the keeper to drain user deposits, wrong permission control on a separate function allowed the admin to steal funds directly, and a malicious registry admin could register a market that drained any vault. Y2k Finance's `changeController()` and `recoverERC20()` were independently identified as rug vectors in the same codebase. Gauntlet's vault manager could call `setSwapFees` to create internal arbitrage opportunities at the expense of depositors, and `sweep()` did not exclude BPT tokens, enabling the treasury to drain the Balancer pool.

**Remediation Notes**
- Gate all fund-migrating calls (`setController`, `setManager`) behind a two-step propose-then-accept pattern with at minimum a 48-hour timelock
- In `sweep()`, iterate `managedTokens` and revert if the target token matches any pool token, underlying asset, or staking token
- Replace keeper-callable `withdrawRedundant` with an emergency function requiring multi-sig approval and restricted to genuinely excess tokens (balance minus all user liabilities)

---

### ERC Standard Non-Compliance (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Vault claims EIP-4626 compliance but is missing `mint()` and `redeem()` functions, or `maxDeposit()` returns `type(uint256).max` even when deposits are paused or the epoch is closed
- `totalAssets()` returns only `balanceOf(address(this))`, excluding assets paid out during a depeg event or locked in an outstanding claim, understating the vault's true liability
- `previewDeposit()` does not subtract the deposit fee from its return value, causing integrators to over-estimate share amounts
- `safeApprove` used for USDT-like tokens reverts when the current allowance is non-zero, blocking any deposit or withdrawal path that calls `approve` more than once
- ERC1155 `safeTransferFrom` is used in a distribution loop; any recipient contract that reverts its `onERC1155Received` callback blocks all subsequent distributions in the loop

**Detection Heuristics**
- Check the deployed ABI against the full EIP-4626 interface: `deposit`, `mint`, `withdraw`, `redeem`, `totalAssets`, `convertToShares`, `convertToAssets`, `maxDeposit`, `maxMint`, `maxWithdraw`, `maxRedeem`, `previewDeposit`, `previewMint`, `previewWithdraw`, `previewRedeem`
- For each `max*` function, verify it returns 0 when the corresponding operation is blocked (paused, epoch closed, deposits locked)
- For each `preview*` function, verify fee deduction is reflected in the return value
- Search for `safeApprove` calls; replace with `forceApprove` (OZ v5) or a zero-then-set pattern
- Search for ERC1155 `safeTransferFrom` in loops; assess whether a single revert can block all subsequent recipients

**False Positives**
- Protocols that explicitly document deviations from EIP-4626 and do not present themselves as standard-compliant vaults
- Integrations that use the vault only internally and have no external EIP-4626 aggregator dependencies
- Reward distribution to a controlled set of known-compliant receiver contracts

**Notable Historical Findings**
Y2k Finance's `SemiFungibleVault` claimed EIP-4626 compliance but was missing `mint()` and `redeem()`, had non-compliant `maxWithdraw()` that ignored pause state, and did not include depeg payouts in `totalAssets()`, making share price calculations incorrect during the protocol's primary trigger scenario. Bridge Mutual's LiquidityMining contract could not accept single ERC1155 tokens because it did not implement `onERC1155Received`, blocking a core interaction path. Accountable Protocol's vault had an invalid `maxWithdraw()` check that could allow withdrawals to exceed actual holdings. Multiple protocols independently discovered that `safeApprove` with USDT caused silent reverts in deposit paths that ran after any partial approval.

**Remediation Notes**
- Inherit from OpenZeppelin's `ERC4626` base contract rather than implementing the interface from scratch; override only the functions that require protocol-specific logic
- In `maxDeposit` and `maxWithdraw` overrides, return 0 as the first branch if `paused()`, if the epoch is outside its active window, or if any other blocking condition is true
- Replace all `safeApprove` calls with `token.forceApprove(spender, amount)` (OpenZeppelin v5+)

---

### Missing Input Validation (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Constructor or initializer accepts `manager_`, `validator_`, `noticePeriod_`, and `managementFee_` without validating that addresses are non-zero, that cross-contract invariants hold (e.g., validator token count matches vault token count), or that numeric parameters fall within safe ranges
- `noticePeriod_` has only a maximum check (`<= MAX`), allowing zero, which enables instant finalization and bypasses the intended withdrawal protection
- L2 deployments do not check Arbitrum/Optimism sequencer uptime in price validation helpers, allowing stale prices from before a sequencer outage to be used as current
- Signature replay is possible because signed messages do not include a nonce; the same signature can be submitted multiple times

**Detection Heuristics**
- Review every constructor and `initialize()` function; check each address parameter against `address(0)` and against related contracts' state (e.g., `require(validator.count() == tokens.length)`)
- For numeric parameters, verify both minimum and maximum bounds are enforced; a missing minimum is as dangerous as a missing maximum
- For `string` parameters with functional significance (descriptions, URIs), check `require(bytes(param).length > 0)`
- On L2 deployments, search for `latestRoundData` calls without a preceding sequencer uptime feed check
- Find all signature verification paths; confirm each signs over at minimum `(target, data, nonce, chainId, verifyingContract)`

**False Positives**
- Parameters validated downstream by third-party contracts (e.g., Balancer's own fee validation rejects out-of-range values)
- Factory contracts that perform cross-contract validation centrally at deployment, preventing misconfigured pairs from ever being created
- Immutable parameters set by a trusted deployer in a controlled deployment process with off-chain verification

**Notable Historical Findings**
Gauntlet's AeraVault had two distinct input validation failures in the same constructor: the validator's token count could mismatch the vault's asset count (causing all withdrawals to revert with an array index error), and multiple constructor parameters including manager address and notice period had no minimum constraints. InsureDAO had a signature replay vulnerability because policy purchase signatures did not include a nonce, allowing the same signed transaction to purchase multiple policies at the same price. Bridge Mutual's liquidity mining contract had no whitelist check, allowing anyone to claim all mining rewards by providing zero DAI. Audius's governance contract accepted quorum parameters that could be set so low that sybil accounts trivially reached quorum.

**Remediation Notes**
- Use a factory pattern for deploying coordinated contract pairs; validate cross-contract invariants (validator count, token lists) inside the factory before returning contract addresses
- Every numeric parameter with a practical minimum must assert `require(value >= MIN_VALUE, "...")` alongside any maximum check
- Use EIP-712 with per-user nonces for all signed messages; increment `nonces[signer]++` on every successful verification

---

### Fee-on-Transfer Token Incompatibility (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Insurance protocol accepts coverage tokens that may implement, or later enable, transfer fees (USDT's fee switch, protocol-controlled ERC20s)
- `addValue(amount, from, beneficiary)` credits `amount` to the beneficiary's attribution but the contract received `amount - fee` due to a transfer tax, overstating the beneficiary's share
- Withdrawal path calls `tokens[i].safeTransfer(owner(), amounts[i])` after receiving tokens from a pool; if the pool itself deducted fees on its transfer, the contract does not hold `amounts[i]` and the transfer reverts, blocking all withdrawals

**Detection Heuristics**
- Search for `safeTransferFrom` followed immediately by `balance += amount` or any form of accounting that uses the passed-in amount rather than a balance delta
- Find contracts that receive tokens from one source and forward the same nominal amount to another address; any fee taken by the source breaks the forwarding assumption
- Check protocol documentation for the list of supported tokens; if it includes any token with a fee switch (USDT), trace all deposit and withdrawal paths for balance-delta handling

**False Positives**
- Protocols that explicitly whitelist only tokens with verified zero-fee implementations and enforce this in the contract's token registration function
- Deposit functions that compute `actualReceived = post_balance - pre_balance` and use that value for all downstream accounting

**Notable Historical Findings**
Y2k Finance's vault did not account for fee-on-transfer mechanics in multiple deposit and withdrawal functions, creating discrepancies between recorded deposits and actual holdings that compounded over time. InsureDAO's vault had the same issue in the premium payment path. Gauntlet's integration found that fee-on-transfer tokens could block entire function families because a withdrawal function assumed the received amount equaled the forwarded amount, causing revert chains when fees were non-zero. A common pattern across all affected protocols was that the code was written assuming standard ERC20 behavior and tested only with compliant tokens.

**Remediation Notes**
- Universally adopt the balance-delta pattern in deposit functions: `uint256 before = token.balanceOf(address(this)); token.safeTransferFrom(...); uint256 actual = token.balanceOf(address(this)) - before;` and use `actual` for all accounting
- In withdrawal paths that receive tokens from external pools, measure the balance before and after the pool withdrawal; forward only the actual received amount, not the requested amount

---

### Front-Running and Sandwich Attacks (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- `finalize()` withdraws all holdings from an AMM pool with no minimum output amounts; an attacker front-runs by manipulating pool composition, then back-runs to profit from the distorted withdrawal
- Pool deposit function does not accept price boundary parameters; attacker can sandwich the deposit, moving the spot price before the trade and reverting it after
- Initial pool deposit (liquidity seeding) is a separate transaction from pool creation; an attacker who sees the creation can front-run the seed deposit and capture the favorable initial price
- Claims or fraud proof submissions are observable in the mempool and can be front-run by the challenged party to nullify the claim before it is processed

**Detection Heuristics**
- Search for pool withdrawal functions (`exitPool`, `returnFunds`, `removeAllLiquidity`) that do not accept `minAmountsOut[]` parameters
- Find deposit functions with no `maxPricesIn[]` or `minSpotPrice` / `maxSpotPrice` guard
- Check initial liquidity seeding transactions; if pool creation and seeding are separate calls, the seeding step is front-runnable
- Look for `block.timestamp` comparisons in time-triggered operations (claim deadlines, fraud proof windows) that are observable before execution

**False Positives**
- Protocols that disable pool swaps before executing large withdrawals (e.g., `setSwapEnabled(false)` before `exitPool`)
- Functions submitted via Flashbots private relays as documented protocol procedure
- Atomic deployment-and-seed operations that leave no window between pool creation and initial liquidity

**Notable Historical Findings**
Gauntlet's managed Balancer vault had both a front-runnable `finalize()` that withdrew all holdings without minimum output protection, and a deposit function susceptible to sandwich attacks that shifted the token composition at the depositor's expense. InsureDAO's initial pool deposit was exploitable because pool creation and the first liquidity deposit were separate transactions; an attacker who observed the creation could front-run the seed to steal initial LP tokens at the deployer's expense. Thesis/tBTC had multiple state transitions (fraud proofs, relay entry timeouts) that created race conditions where multiple parties competed to be the first reporter, and the fraud proof reporter was not bound to the transaction that triggered it. Bridge Mutual's withdrawal queue `RequestPrice` could be front-run in the event of a default, allowing well-positioned actors to exit before the price impact of the default was reflected.

**Remediation Notes**
- All pool withdrawal functions must accept a `minAmountsOut[]` parameter; compute reasonable minimums off-chain and supply them in every call
- Disable pool swaps (`setSwapEnabled(false)`) atomically before withdrawing liquidity; re-enable after, or keep disabled if the vault is winding down
- Combine pool creation and initial liquidity seeding into a single factory transaction to eliminate the front-runnable seeding window

---

### Unbounded Loops and Gas Exhaustion (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- Compensation or payout function iterates over all registered insurance indexes or claimants in a single transaction; as the index list grows through normal operation, the function approaches and eventually exceeds the block gas limit
- Queue removal shifts all subsequent array elements left in an O(n) operation; a queue with many entries can make removal prohibitively expensive or cause it to revert
- Staking delegation maps track an unbounded array of delegators per service provider; reward distribution iterates this array, enabling a griefing attack through cheap micro-delegations
- Withdrawal queue processing iterates all pending entries up to available liquidity without a per-transaction batch limit

**Detection Heuristics**
- Search for `for (uint256 i = 0; i < array.length; i++)` in storage arrays with no hard upper bound on array length
- Find array removal operations that use element-shifting (copying `array[i] = array[i+1]` in a loop); estimate gas for worst-case array sizes
- Identify delegation or staking functions with no minimum amount requirement; trace where the resulting array is later iterated
- For withdrawal queue processing, check whether a `maxIterations` or `batchSize` parameter limits per-transaction work

**False Positives**
- Arrays with a hard-cap enforced at insertion time (e.g., `require(indexList.length < MAX_INDEXES)`)
- Protocols deployed on high-gas-limit L2 chains where the practical risk of gas exhaustion is orders of magnitude lower
- Operations restricted to trusted administrators whose incentive alignment prevents deliberate inflation

**Notable Historical Findings**
InsureDAO's `compensate()` function iterated every registered index twice per call to compute shares; as the protocol onboarded more indexes, this function crept toward the block gas limit and would eventually become permanently uncallable. Bridge Mutual's queue removal function shifted all elements after the removed index, making removal of early-queue entries O(n) in queue length; combined with the `_updateWithdrawalQueue` function iterating all pending entries, a filled queue could exceed block gas limits. Audius's delegation contract allowed zero-amount delegations, enabling a malicious delegator to fill the delegators array at near-zero cost and subsequently prevent all other delegators from delegating or claiming rewards.

**Remediation Notes**
- Replace unbounded storage arrays with linked-list structures or pagination-supporting index mappings; expose a `process(startIndex, batchSize)` pattern for any function that must iterate many entries
- For queue removal, use a linked-list (`head`, `tail` pointers with a `next` mapping) to achieve O(1) removal instead of O(n) shifting
- Enforce a minimum stake or delegation amount that makes spam economically infeasible; `require(amount >= MIN_DELEGATION)` in every entry-point function

---

### Withdrawal Queue Denial of Service (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- Cancellation of a queue entry sets a `pendingCancelRedeemRequest` flag but does not advance the `nextRequestId` pointer; when the cancelled entry is encountered during processing, the loop reaches a zero-shares entry and breaks, deadlocking the queue permanently
- Array-based queue uses a `length` field that is decremented on removal but the last element is not popped, leaving a ghost entry that consumes a slot and is iterable but logically absent
- Multiple fulfillment paths (manual, instant, batch) each independently verify available liquidity against `totalAssets()` without subtracting a shared `reservedLiquidity` counter, allowing overlapping reservations that the vault cannot honor
- Partial redemption fulfillment updates `claimableAssets` correctly but does not decrement the request's `shares` to match, causing `fulfillCancelRedeemRequest` to compute a mismatched delta on the stale share count

**Detection Heuristics**
- Trace all operations that modify `nextRequestId`: confirm that cancellation, timeout, and zero-share skipping all advance this pointer rather than breaking out of the processing loop
- In queue removal functions, verify the final element is explicitly `pop()`d or that the length field is decremented atomically with the element removal
- Find all locations that check liquidity before fulfilling a redemption; confirm a shared `reservedLiquidity` variable is decremented at claim time and incremented at reservation time, preventing double-booking
- Cross-check `fulfillRedeemRequest` and `fulfillCancelRedeemRequest`: verify that both operate on the same version of the request's `shares` and `claimableAssets` fields

**False Positives**
- Queue implementations using a mapping with head/tail pointers rather than a sequential array; these avoid most shifting and stale-entry issues
- Protocols with an off-chain operator responsible for queue processing where the operator's incentives and capabilities prevent queue abandonment
- Cancellations that are synchronous and immediately clean up all queue state in one transaction

**Notable Historical Findings**
Accountable Protocol had at least five distinct withdrawal queue vulnerabilities reported in a single audit: cancelling a redeem request permanently blocked the withdrawal queue by leaving `nextRequestId` pointed at a zero-shares entry that the processing loop could not advance past; partial redemptions could be exploited to steal assets by re-processing a request whose shares had not been decremented; the queue amount variable was used inconsistently between queuing and dequeueing operations; manual and instant fulfillment paths did not reserve liquidity, enabling concurrent fulfillments to over-commit the vault's holdings; and `fulfillCancelRedeemRequest` used stale share data causing a state desync. Bridge Mutual had three separate queue bugs: the remove function did not fully remove items (missing pop), `_updateWithdrawalQueue` could exhaust block gas on large queues, and an inconsistent `aggregatedQueueAmount` tracking variable caused accounting drift.

**Remediation Notes**
- Cancellation must advance `nextRequestId` past all cancelled (zero-shares) entries before returning; implement a `while (nextRequestId < lastRequestId && requests[nextRequestId].shares == 0) { nextRequestId++; }` cleanup loop in the cancellation function
- All fulfillment paths must use a single shared `reservedLiquidity` variable; check `require(assets <= totalAssets() - reservedLiquidity)` and then increment `reservedLiquidity += assets` atomically before updating the request state
- Bound queue processing loops with a `maxIterations` parameter to prevent gas exhaustion; persist the `nextRequestId` after each bounded run so processing can resume in subsequent transactions

---

### Oracle Price Feed Misconfiguration (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Insurance protocol deployed on Arbitrum or Optimism uses Chainlink price feeds without checking the sequencer uptime feed; during a sequencer outage, stale prices can trigger incorrect depeg events or prevent legitimate claims
- `PegOracle` combines two Chainlink feeds of different decimal precisions using a hardcoded `10000` multiplier; the resulting ratio is incorrect by a factor of `10^(decimals_delta)`, causing the depeg threshold to be hit at the wrong price
- Oracle timeout returns `(0, FIX_MAX)` rather than reverting; downstream logic interprets the zero as a valid price and executes a sell-off of the protocol's RSR or collateral at near-zero
- Risk users are required to pay out if the pegged asset's price goes higher than the peg, which is the inverse of the intended trigger condition, due to inverted comparison logic in the depeg check

**Detection Heuristics**
- Search for `latestRoundData()` calls; verify `answeredInRound >= roundId`, `price > 0`, `updatedAt > 0`, and `block.timestamp - updatedAt < maxStaleness`
- For protocols on L2, verify a sequencer uptime feed is consulted and a grace period (typically one hour) is enforced after sequencer restart before price consumption resumes
- Find oracle timeout handling; trace whether it returns a zero, a sentinel, or reverts; if it returns a zero, find all downstream uses and verify they treat zero as invalid
- In depeg oracle comparisons, verify the inequality direction: a USD-pegged asset depegs when its price falls below `$1 - threshold`, not above it

**False Positives**
- L1 Ethereum deployments where sequencer uptime is irrelevant
- Oracle aggregator contracts that centralize all staleness, sequencer, and decimal checks before exposing a validated price to the protocol
- TWAP-based parametric triggers where staleness is inherently bounded by the TWAP window

**Notable Historical Findings**
Y2k Finance had a cluster of oracle bugs: incorrect `pricefeed.decimals()` handling in `PegOracle` produced an off-by-factor-of-scale ratio; the depeg trigger fired when the asset went above peg rather than below it, requiring risk users to pay out in the wrong scenario; the oracle combination function had a loss-of-precision path that output the wrong price ratio; and a stale price timeout path caused `endEpoch` to be uncallable, permanently trapping winner funds. Bond Protocol on Arbitrum did not check the sequencer uptime feed, exposing covered positions to stale price settlement during sequencer outages. Reserve Protocol's `Asset.lotPrice()` used an incorrect price during oracle timeout, not the most recent valid price, leading to below-market auction settlement.

**Remediation Notes**
- Implement a single `getValidatedPrice(AggregatorV3Interface feed)` function used by all price consumers: check sequencer uptime (L2 only), validate all five `latestRoundData` fields, normalize to 18 decimals using `feed.decimals()`, and revert with a named error on any validation failure
- Replace oracle timeout return values of `(0, FIX_MAX)` with a revert or a transition to a `PAUSED` state; never allow a zero price to flow into settlement or auction logic
- When combining two oracle feeds in a ratio, compute decimals dynamically: `uint256 price1Normalized = price1 * 10**(18 - feed1.decimals())` before dividing

## reference/solidity/protocols/launchpad.md

# Token Launchpad Security Patterns

> Applies to: token launchpads, IDO platforms, token sales, fair launches, vesting contracts, token distribution protocols, Fjord-style, Camelot-style

## Protocol Context

Token launchpad contracts manage the full lifecycle of token distribution: from whitelist verification and sale mechanics (Dutch auctions, fixed-price, LBPs) through vesting schedules and governance bootstrapping. They concentrate both ETH and newly issued tokens in contracts that are often upgradeable, admin-controlled, and interact with AMMs at the moment of initial liquidity. Because launchpad contracts frequently act as both token issuer and primary market maker simultaneously, a single vulnerability can expose both sale proceeds and the entire circulating supply.

---

### Reentrancy via External Calls (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Sale or vesting contract transfers ETH refunds or ERC20 tokens to buyers before updating internal accounting (sold counts, refund balances, vesting schedules)
- Protocol interacts with callback tokens (ERC777, ERC721 with `onERC721Received`) in distribution or rage-quit flows
- A custom reentrancy guard reuses a business-logic variable (e.g., `rageQuitTimestamp`) that a privileged function can reset mid-execution
- Cross-contract message processing increments counters after looping over external calls, enabling replay via reentrancy

**Detection Heuristics**
- Search for `.call{value:}`, `.transfer()`, `safeTransfer()`, and `safeTransferFrom()` in sale, claim, or withdraw functions; verify state updates precede these calls
- Look for `rageQuit`, `claimRefund`, or `distributeTokens` functions using a timestamp or status flag as a reentrancy guard rather than a dedicated lock variable
- Check if any privileged role (owner, operator) can invoke a function that resets the guard variable during an active transfer loop
- Identify ERC777 token support in staking or crowdfund contracts where `tokensReceived` or `tokensToSend` hooks hand control to an arbitrary address

**False Positives**
- External calls made exclusively to immutable, trusted addresses (WETH contract) that do not propagate callbacks
- Functions protected by OpenZeppelin `ReentrancyGuard` with a dedicated `_status` lock variable
- Post-call state updates that are idempotent (e.g., marking an already-zero balance as zero)

**Notable Historical Findings**
In PartyDAO's governance contracts, a rage-quit function used `rageQuitTimestamp` as its reentrancy guard; because the protocol also exposed a `setRageQuit()` function callable by a party host, an attacker reentrant via an ERC20 transfer could reset that timestamp mid-execution and drain treasury tokens multiple times. A separate PartyDAO audit found that `TokenDistributor` was vulnerable to ERC777 `tokensToSend` hooks, allowing a malicious token to reenter and drain the distributor before balances were zeroed. In SKALE's message bridge, an incoming message counter was updated only after iterating all external calls, enabling replay of messages through reentrancy. Reserve Protocol's `redeem()` path similarly updated yield-token balances after a user-controlled swap, allowing a double-subtraction via reentrant snapshot corruption.

**Remediation Notes**
- Apply the checks-effects-interactions pattern in every sale, claim, and refund path; update `totalSold`, `refunds[user]`, and vesting state before any external transfer
- Replace custom guard variables with OpenZeppelin `ReentrancyGuard`; never reuse a business-logic timestamp or status as a lock
- For rage-quit or multi-token distribution loops, zero out per-user balances at the top of the function before iterating token transfers

---

### Precision Loss and Rounding Errors (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Dutch auction price calculations perform division before multiplication, producing zero when elapsed time is small relative to the price drop rate
- Share-fraction migration applies `oldBalance * newSupply / oldSupply`, rounding small holders to zero when supply ratios are extreme
- Reward accumulator uses `rewardRate = reward / duration` where `duration` is years-long, discarding significant token amounts to integer truncation
- Launchpad supports tokens with fewer than 18 decimals (e.g., USDC at 6) in pricing calculations alongside 18-decimal collateral without normalization
- Voting power calculations use `(a / b) * c` order, causing unanimous-vote thresholds to be unachievable

**Detection Heuristics**
- Search for `(a / b) * c` expressions in price, reward, or voting power functions; flag any case where `a` can be smaller than `b`
- In Dutch auction contracts, verify `getPrice()` multiplies `dropPerSecond * timeElapsed` before subtracting from `startPrice`; check for a floor price guard
- In reward pools, calculate the effective distributed amount as `rewardRate * duration` and compare against `reward` to measure truncation loss
- In migration or fraction conversion functions, check for zero-result outcomes when `newSupply << oldSupply`
- Look for unsafe `uint80(msg.value)` or `uint48(timestamp)` downcasts without prior bounds checks

**False Positives**
- Protocols that enforce a minimum purchase amount large enough to guarantee non-zero results at all supported price points
- Fixed-point math libraries (PRBMath, DSMath) used consistently throughout the calculation chain
- Downcast targets validated with `require(value <= type(uintN).max)` or `SafeCast` before the cast

**Notable Historical Findings**
Nouns Builder's auction contract contained a precision error in `_computeTotalRewards` that allowed an adversary to permanently brick future auctions by manipulating the founder ownership percentage to trigger an arithmetic edge case. In Fractional Protocol, migration reduced the fraction supply so drastically that small holders received zero new tokens, effectively confiscating minority stakes. Ajna Protocol's `calculateNewRewards` divided before multiplying in a reward calculation, meaning stakers with small relative interest earned zero rewards despite legitimate participation. In veToken Finance, `notifyRewardAmount` suffered rounding loss where `reward / duration * duration < reward`, permanently locking the difference in the staking contract.

**Remediation Notes**
- In Dutch auction price functions, compute the drop as `startPrice - min(dropPerSecond * elapsed, startPrice - floorPrice)` to prevent underflow and maintain a floor
- Store `rewardRate` in a higher-precision scaled integer (e.g., `reward * 1e18 / duration`) and scale back when distributing
- In migration and fraction functions, assert `oldBalance == 0 || newBalance > 0` to surface precision loss before it silently confiscates user funds

---

### Unsafe Type Casting and Downcasting (ref: fv-sol-3)

**Protocol-Specific Preconditions**
- Sale contract stores `msg.value` in a `uint80` field without checking that payment exceeds ~1.2M ETH
- Partial-fill tracking in order matching accumulates a `uint120` numerator across multiple fills; overflow resets the fill counter, enabling overselling
- Founder percentage stored as `uint8` truncates any percentage value above 255 supplied in a `uint256` parameter
- `int8` decimals cast from `uint8` token decimals converts values 128-255 to negative numbers, breaking all price math for those tokens

**Detection Heuristics**
- Search for explicit casts to types narrower than the source: `uint80(`, `uint48(`, `uint32(`, `uint120(`, `int8(`
- For each cast, trace whether the source value is bounded by a prior `require` or `SafeCast`; flag unbounded user-supplied values (`msg.value`, function parameters)
- In order-matching contracts, check if partial-fill accumulators can overflow their storage type across multiple calls
- Look for `int8(uint8(token.decimals()))` patterns where decimals could exceed 127

**False Positives**
- Casts protected by `require(value <= type(uintN).max)` immediately before the cast
- Use of `SafeCast.toUintN()` which reverts on out-of-range values
- Values bounded by protocol invariants (e.g., percentage stored as uint8 after `require(pct <= 100)`)

**Notable Historical Findings**
OpenSea's Seaport had a `uint120` truncation in `OrderValidator` that, after enough partial fills accumulated, could reset the fill counter to zero and allow the same order to be re-filled indefinitely, enabling sellers to exceed their stated order quantity. Escher's LPDA contract stored `msg.value` as `uint80`, silently truncating payments over the type maximum and under-recording sale proceeds. Nouns Builder's founder minting loop could be manipulated via truncation in the ownership percentage cast to cause a founder to receive all base tokens rather than their stated share. Reserve Protocol's `issue()` function used an unsafe downcast in a critical amount path that, when exploited, caused a permanent denial of service on issuance.

**Remediation Notes**
- Replace all unguarded downcasts with `SafeCast` from OpenZeppelin; this is non-negotiable for `msg.value`, user-supplied amounts, and accumulated counters
- In order-fill tracking, perform the accumulation in `uint256` and only store the final value after verifying it fits in the target type
- For founder percentages, enforce `require(percentage <= 100)` before casting to `uint8`

---

### Centralization and Privileged Role Risks (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- A single EOA owner controls `setTokenAddress`, `setOperator`, or equivalent functions with no timelock, allowing them to swap underlying token mappings and drain bridge or sale proceeds
- Owner can invoke an unrestricted `execute(to, value, data)` on a voter proxy or treasury contract, enabling arbitrary token drains
- Ownership transfer is single-step: one call to `transferOwnership(newAddress)` immediately changes the owner with no confirmation from the new address
- Proxy or diamond upgrades are callable by the owner without any delay, allowing instant logic replacement

**Detection Heuristics**
- List all `onlyOwner` / `onlyAdmin` / `onlyOperator` functions; for each, determine whether it can transfer, mint, burn, or redirect user funds
- Look for `execute(address, uint256, bytes)` patterns on treasury or proxy contracts without target whitelisting
- Check `transferOwnership` for a two-step pattern (propose + accept); single-step is a red flag in any protocol holding user funds
- Verify that upgrade functions (`upgradeTo`, `diamondCut`) have a timelock enforcing a minimum delay between proposal and execution

**False Positives**
- Admin role held by a governance contract or multisig with adequate signer count and a meaningful threshold
- Admin functions gated behind a timelock with delay sufficient for users to exit (typically 48+ hours)
- Protocols in explicit bootstrap phase with disclosed admin keys that are scheduled for renouncement

**Notable Historical Findings**
SKALE's `TokenManagerEth` allowed the admin to remap any mainnet token to a schain token they controlled, then drain the deposit box by calling `exitToMain` with the remapped token. In veToken Finance, the `VoterProxy` operator could call arbitrary contract methods including `ERC20.transfer`, enabling complete drain of all protocol token holdings. Baton Launchpad's admin had unrestricted access to change fees, pause the protocol, and withdraw collected ETH without any time delay or governance check. PartyDAO's auction crowdfund allowed the NFT owner to simultaneously hold the NFT being auctioned and act as a party participant, creating an irreconcilable conflict that could permanently lock crowdfund contributor funds.

**Remediation Notes**
- Require all admin functions affecting user funds to use a timelock of at least 48 hours with on-chain proposing and execution steps
- Replace single-step `transferOwnership` with a propose-then-accept pattern; never allow `owner = newOwner` in one transaction
- Whitelist target addresses in generic `execute()` functions; do not allow arbitrary calldata to be forwarded to token contracts

---

### Auction Mechanism Flaws (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Dutch/LPDA auction uses `startPrice - (dropPerSecond * elapsed)` without a floor price, enabling underflow revert or, in an `unchecked` block, wrap-around to an astronomically high price
- Sale contract calls `selfdestruct` on completion; buyers who transact in the same block as destruction still credit ETH to the now-deleted contract
- Auction parameters (`duration`, `startPrice`, `dropPerSecond`) are mutable by the owner with no check for whether an auction is currently active
- Sale finalization path only distributes proceeds when `totalSold == maxSupply`; partial sales permanently lock ETH with no refund path

**Detection Heuristics**
- In every price function, check the subtraction `startPrice - drop`; if `drop` is not clamped before subtraction, flag as underflow risk
- Search for `selfdestruct` in sale contracts; verify no purchase path can be called after destruction in the same block or transaction
- Find all setter functions for auction parameters (duration, price curve, fee receiver); confirm they revert if `block.timestamp >= auctionStart && block.timestamp <= auctionEnd`
- In finalization logic, verify funds are distributed proportionally even when `totalSold < maxSupply`; look for refund mechanisms for overpayments

**False Positives**
- Solidity 0.8+ checked arithmetic that causes a revert (not wrap) on underflow; still may brick the contract but does not silently corrupt state
- Auction parameters settable only before `auctionStart`, enforced by an immutable start time set at deployment
- Contracts with admin-callable emergency withdrawal covering stuck funds from partial sales

**Notable Historical Findings**
Escher Protocol's LPDA contract had a price underflow path where extreme `dropPerSecond` and `startPrice` settings caused the price calculation to either revert (bricking the auction) or wrap to a near-maximum value (overcharging buyers). The same codebase allowed the `saleReceiver` to receive zero-value `buy(0)` calls after sale completion, siphoning refunds that should have returned to buyers. Nouns Builder's auction had an adversary-triggerable precision error in reward computation that permanently corrupted the auction state. PartyDAO crowdfunds exposed a scenario where the NFT owner could grief the auction by simultaneously holding the target NFT and the winning bid, causing the crowdfund to lose its NFT after settlement.

**Remediation Notes**
- Add a `floorPrice` parameter to every Dutch auction; compute `drop = min(dropPerSecond * elapsed, startPrice - floorPrice)` before subtraction
- Replace `selfdestruct` with a `saleEnded` boolean flag and a `call{value: address(this).balance}()` transfer to the receiver
- In finalization, compute proceeds as `totalSold * finalPrice` regardless of whether the cap was reached; implement per-buyer refund claims for partial sales

---

### Delegation Logic Vulnerabilities (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Governance token uses `delegates[account] == address(0)` to mean "implicitly self-delegated," but `_moveDelegateVotes` treats `address(0)` as "no votes to subtract," allowing a user's first explicit self-delegation to add votes without removing any
- Token transfers call `_afterTokenTransfer` which moves delegate votes for the receiver; if the receiver has never delegated, `delegates[to] == address(0)` causes votes to be silently destroyed
- `unstake()` requires `delegates[msg.sender] == msg.sender` before withdrawing, but `undelegate()` reverts if the delegate has an active proposal, enabling a malicious delegate to trap stakers indefinitely

**Detection Heuristics**
- In `_moveDelegateVotes(from, to, amount)`, check: if `from == address(0)`, does it skip the subtraction? If yes, first-time delegation creates votes from nothing
- In `_afterTokenTransfer`, check whether `delegates[to]` is explicitly initialized before moving votes; if not, transfers to new addresses destroy voting power
- Find all `undelegate` or delegation-reversal functions; check if they can be permanently blocked by delegate activity
- Verify that `unstake()` and `undelegate()` are independent operations with no circular dependency

**False Positives**
- Protocols that auto-delegate to self in `_mint`, ensuring `delegates[account]` is never `address(0)` for any token holder
- OpenZeppelin ERC721Votes implementations that correctly consolidate checkpoints within the same block
- Protocols where transfers are explicitly disabled (soulbound tokens)

**Notable Historical Findings**
Nouns Builder's `ERC721Votes` had three simultaneous delegation bugs: first-time self-delegation doubled voting power by skipping the subtraction from `address(0)`; `_afterTokenTransfer` destroyed voting power for recipients who had never delegated; and `_transferFrom` could be called repeatedly to increase a user's voting power indefinitely without acquiring new tokens. FrankenDAO's staking contract allowed a delegate to maintain a perpetual active proposal, permanently preventing delegators from undelegating and withdrawing their staked tokens. PartyDAO's crowdfund allowed self-delegated users' delegation to be hijacked during the contribution phase, silently transferring their governance weight to another party.

**Remediation Notes**
- Auto-delegate to self in `_mint` and in `_afterTokenTransfer` when `delegates[to] == address(0)`; never use `address(0)` as a meaningful delegation state
- Remove all conditions on `undelegate()` that depend on the delegate's state; undelegation must always be available unconditionally
- In `_moveDelegateVotes`, treat `from == address(0)` as equivalent to `from == account` (implicit self-delegate) to ensure votes are properly subtracted

---

### Flash Loan Checkpoint Manipulation (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Checkpoint or snapshot mechanism records voting power at a block number; `getAtBlock()` returns the first checkpoint value in the block rather than the last, making flash-loan-inflated snapshots queryable after the loan is repaid
- Staking contract allows same-block stake and exit with no minimum holding period, enabling a flash loan to create and immediately destroy a large checkpoint
- Collateral status or price-sensitive decisions use AMM spot prices (immediately manipulable by a flash loan trade) rather than TWAP
- ETH crowdfund contribution snapshot is taken at block number rather than excluding the current block

**Detection Heuristics**
- In `getAtBlock(blockNumber)`, check whether the binary search returns the first or last checkpoint at that block number; if first, same-block manipulations are queryable
- Look for `stake()` / `exit()` pairs callable within the same transaction with no `require(block.number > lastStakeBlock[msg.sender])` guard
- Find all price queries in collateral status or auction settlement code; check whether they use `getReserves()` (spot) or a time-weighted average
- In ETH crowdfund contracts, verify contribution snapshots exclude the current block to prevent same-transaction contribution and proposal control

**False Positives**
- Checkpoint implementations that update the last entry for the current block rather than creating a new one (correct OpenZeppelin behavior)
- A `require(block.number > lastStakeBlock[msg.sender])` guard between deposit and first-use
- TWAP oracles with windows long enough that a single flash-loan trade moves them by a negligible amount

**Notable Historical Findings**
Telcoin's staking contract allowed flash-borrowed TEL tokens to be staked and exited in the same block; because the checkpoint returned the first (pre-exit) value for that block, a querier after the block saw a large staked balance that no longer existed, enabling reward manipulation. PartyDAO's ETH crowdfund had a flash-loan attack path where an attacker contributed with flash-loaned ETH to obtain enough voting power to unilaterally control the party, then repaid the loan while retaining governance control. Reserve Protocol's `CurveVolatileCollateral` used a spot price for collateral status checks; a flash loan could temporarily push the price below the depeg threshold, triggering a basket rebalance at unfavorable rates.

**Remediation Notes**
- Fix `getAtBlock` to return the last (most recent) checkpoint at a given block; this is `checkpoints[account][pos - 1].votes` where `pos` is found via binary search for the last entry `<= blockNumber`
- Add `require(block.number > lastStakeBlock[msg.sender], "Same-block exit not allowed")` in any exit or withdrawal function following a deposit
- For collateral status checks, use a TWAP oracle with a window of at least 30 minutes; never use `getReserves()` spot price for decision-making

---

### Governance Voting Power Manipulation (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- `propose()` snapshots quorum votes using `token.totalSupply()` at the current block rather than a checkpointed past supply, allowing a proposer to mint or acquire tokens in the same transaction to reduce effective quorum
- Multiple state changes in a single block (stake, unstake, transfer) each write a new checkpoint rather than updating the existing one, causing `getPastVotes` to return incorrect intermediate values
- NFT transfer is not restricted during an active vote; a voter can transfer the NFT to a second wallet in the same block as the proposal, voting twice against a single snapshot
- `unstake()` decreases voting power by the token's current base value rather than the value recorded at stake time, creating discrepancies when staking multipliers change

**Detection Heuristics**
- In `propose()` or `quorum()`, look for `totalSupply()` calls without a `getPastTotalSupply(block.number - 1)` offset
- In `_writeCheckpoint`, check whether the function creates a new array entry on every call or updates the existing entry when `blockNumber == block.number`; the former enables multi-checkpoint manipulation
- Look for `accept()` or `castVote()` that reads voting power at `proposal.voteStart` without requiring `block.timestamp > voteStart`
- In staking contracts, trace `stake()` to confirm it records `stakedVotingPower[tokenId] = getTokenVotingPower(tokenId)` so `unstake()` can use the stored value rather than the current multiplied value

**False Positives**
- Proposals that read `getPastVotes(account, block.number - 1)` or equivalent, ensuring current-block manipulation is excluded
- Checkpoint writers that update `checkpoints[id - 1]` when `checkpoints[id - 1].timestamp == block.timestamp`
- Governance contracts with a vetoer role or guardian capable of canceling proposals created via manipulation

**Notable Historical Findings**
Nouns Builder had at least five simultaneous voting-power exploits including double-delegation, infinite-power-via-transfer, and a quorum calculation that did not account for burned tokens, meaning a sufficiently motivated attacker could pass proposals with no legitimate support. PartyDAO suffered a critical bug where `totalVotingPower` was inflated in `_finalize()` by counting contributor voting weight twice, and a separate bug allowed a user to veto the same proposal repeatedly by transferring and reclaiming their governance NFT. FrankenDAO's community voting power calculation was subject to precision loss, and delegates could arbitrarily lower quorum by manipulating the delegation graph. Livepeer's vote-override path incorrectly identified transcoders, allowing a delegator to reduce another participant's vote tally without any legitimate basis.

**Remediation Notes**
- Always snapshot quorum and threshold values at `block.number - 1` or via `getPastTotalSupply`; never read live supply during proposal creation
- Checkpoint writers must update the most recent entry when `checkpoints[last].blockNumber == block.number`; adding a new entry for the same block is the root cause of most multi-checkpoint exploits
- Record each staker's voting power at stake time in a `stakedVotingPower[tokenId]` mapping; use that stored value, not a recomputed current value, in `unstake()`

---

### Reward Distribution Accounting Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Reward accumulator is never updated during the period when `totalSupply == 0` (between sale end and first staker), permanently locking that interval's rewards in the contract
- `claimRewards(fromEpoch, toEpoch)` accepts a `toEpoch` parameter without validating it against the current epoch, allowing users to mark future epochs as claimed before those rewards are allocated
- Admin changes reward rate, weight, or duration without first calling `updateReward(address(0))`, recalculating historical accruals at the new rate retroactively
- Reward token address is the same as the staking token address, creating circular accounting that inflates reported reward balances

**Detection Heuristics**
- In `rewardPerToken()`, look for `if (totalSupply() == 0) return rewardPerTokenStored` - this is correct for per-token rate but rewards emitted during that window are lost; check whether there is a recovery or queuing mechanism
- Find all `setRewardWeight`, `setRewardRate`, `notifyRewardAmount` functions; verify each one calls `updateReward(address(0))` or equivalent before changing the parameter
- In epoch-based claim functions, check whether `toEpoch <= currentEpoch` is asserted
- Search for `extraRewards.push(rewardToken)` without a duplicate-check loop; also check `rewardToken != stakingToken`

**False Positives**
- Protocols where `totalSupply == 0` is structurally impossible (minimum stake enforced at launch, or protocol treasury always holds tokens)
- Epoch boundaries strictly enforced by block timestamps that prevent future-epoch claims
- Admin functions that include `updateReward` calls as their first line

**Notable Historical Findings**
veToken Finance's staking pool permanently lost rewards whenever `totalSupply` was zero during a reward period, and separately allowed duplicate entries in the `extraRewards` array, meaning a reward token could be distributed multiple times. Ajna Protocol's epoch-based reward system allowed claiming from future epochs, which pre-emptively marked those epochs as claimed and prevented users from ever receiving those rewards when the epoch actually arrived. Reserve Protocol's staking contract allowed rewards to be claimed during a pause or frozen state, letting an actor who staked just before an unfreeze absorb the bulk of rewards that had accrued during the frozen period. Locke Protocol's stream reward calculation used truncated division, consistently rounding reward amounts to zero for users with small relative stake.

**Remediation Notes**
- During zero-supply periods, queue emitted rewards in a `queuedRewards` accumulator; distribute queued rewards to the first stakers, or allow the owner to recover them to the treasury
- Gate `claimRewards(fromEpoch, toEpoch)` with `require(toEpoch <= currentEpoch)` and `require(!isEpochClaimed[msg.sender][epoch])` for each epoch in the range
- Every reward parameter setter must call `_updateReward(address(0))` as its first line, before modifying any rate or weight variable

---

### Unsafe ETH Transfer Patterns (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Sale or refund contract calls `payable(recipient).transfer(amount)`, forwarding only 2300 gas; fails silently or reverts for recipients that are multisigs (Gnosis Safe), proxy contracts, or any contract with a non-trivial `receive()` function
- Withdrawal function has no fallback path: if the ETH send fails, the user's recorded balance is never credited to an alternative mechanism and the funds become permanently inaccessible
- Assembly blocks in cross-chain bridge or relay contracts use a hardcoded gas stipend in `call` instructions, producing the same 2300-gas restriction as `.transfer()`

**Detection Heuristics**
- Search for `.transfer(` and `.send(` patterns on `payable` addresses throughout the codebase
- Identify every location where an ETH transfer failure causes the entire transaction to revert without a pull-based fallback option; trace whether user funds are recoverable if that path is permanently broken
- Look for `assembly { let success := call(2300, ...) }` blocks that hardcode the gas stipend

**False Positives**
- Recipients that are provably EOAs (extremely rare to guarantee in practice)
- Contracts that wrap failed ETH transfers by depositing WETH and transferring that instead
- Pull-based refund patterns where users call a separate `claimRefund()` function

**Notable Historical Findings**
Multiple launchpad-adjacent protocols (Escher, Fractional, Forgotten Runes, LarvaLabs Meebits) independently used `.transfer()` or `.send()` for refund and withdrawal logic; in each case, multisig treasury wallets or proxy recipients failed to receive funds because 2300 gas was insufficient for their `receive()` implementations. Post-EIP-2929 storage access cost increases make `.transfer()` increasingly likely to fail even for contracts that previously worked. The fix is universally the same but was rediscovered in every codebase rather than addressed as a known pattern.

**Remediation Notes**
- Replace all `.transfer()` and `.send()` calls with `(bool success, ) = payable(recipient).call{value: amount}("")`; check `success`
- Pair low-level `call` with `ReentrancyGuard` since forwarding all gas reintroduces reentrancy risk
- For batch refund flows, implement a pull pattern: store amounts in a mapping and provide a `claimRefund()` function; do not push ETH to arbitrary addresses in loops

---

### Unsafe ERC20 Token Handling (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Sale contract calls `IERC20(token).transfer(recipient, amount)` with a `require()` wrapper; tokens like USDT on mainnet do not return a boolean, causing the call to revert for an unexpected ABI reason rather than a logical failure
- Deposit function credits `amount` to the user's balance without measuring the actual received amount; fee-on-transfer tokens (or tokens that later enable fees) result in the contract owing more than it holds
- `approve()` called on a token like USDT with a non-zero current allowance reverts; contract cannot interact with USDT after any partial-use of an existing approval

**Detection Heuristics**
- Search for `IERC20(token).transfer(`, `IERC20(token).transferFrom(`, and `IERC20(token).approve(` that are not wrapped by `SafeERC20`
- Find deposit functions that record `userDeposits[msg.sender] += amount` without a `balanceBefore` / `balanceAfter` delta check
- Look for `IERC20(token).approve(spender, amount)` calls without a preceding `approve(spender, 0)` reset

**False Positives**
- Protocols that explicitly support only WETH, DAI, or USDC v2 and enforce this at the smart contract level with a token whitelist mapping
- Codebases that import and use `SafeERC20` consistently with `using SafeERC20 for IERC20`
- Protocols on chains where all relevant tokens conform to the ERC20 boolean return requirement

**Notable Historical Findings**
Multiple launchpad protocols (Holograph, Telcoin, Forgotten Runes, veToken Finance) independently called non-safe `IERC20.transfer` or `transferFrom`, breaking compatibility with USDT and other tokens that omit the boolean return. The veToken Finance audit found that fee-on-transfer discrepancies caused accounting overstatements across multiple functions, meaning the protocol could owe users more than it held. SKALE's bridge explicitly did not handle rebasing or deflationary tokens, creating a class of tokens that could be deposited but never withdrawn at their true value.

**Remediation Notes**
- Use `SafeERC20` from OpenZeppelin for every token interaction; replace `require(token.transfer(...))` with `token.safeTransfer(...)` unconditionally
- In any deposit function, compute `actualReceived = balanceAfter - balanceBefore` and use that value for all accounting rather than the passed-in `amount`
- For USDT-style approval resets, use `IERC20(token).forceApprove(spender, amount)` (OpenZeppelin v5) or manually set allowance to zero first

---

### Selfdestruct and Implementation Destruction Risks (ref: fv-sol-7)

**Protocol-Specific Preconditions**
- Fixed-price sale contract uses `selfdestruct(payable(receiver))` at sale completion; calls made to the contract address in the same block (before end-of-block execution) still succeed and send ETH to the now-empty address
- Factory deploys implementation contracts for EIP-1167 minimal proxies without initializing them; any caller can invoke `initialize()` on the bare implementation, claim ownership, and call `execute(selfdestruct payload)` via `delegatecall` to destroy the logic for all clones
- Protocol's correctness relies on `selfdestruct` returning ETH and zeroing code; EIP-4758 deprecation changes this behavior, breaking assumptions in production contracts

**Detection Heuristics**
- Search for `selfdestruct(` in any contract that is or could be an implementation behind a proxy
- Check all factory contracts: for each deployed implementation, verify `initialize()` is called atomically in the same deployment transaction, or that the constructor calls `_disableInitializers()`
- Search for `delegatecall` on user-supplied targets in any privileged function of an implementation contract
- Check contract documentation or comments for reliance on `selfdestruct` ETH-forwarding behavior under EIP-4758

**False Positives**
- `selfdestruct` used in a contract that is never a delegatecall target and holds no user funds after destruction
- Implementation contract initialized in the same deployment transaction, with no window for a front-run
- Contracts that use OpenZeppelin `_disableInitializers()` in the constructor to permanently prevent initialization of the bare implementation

**Notable Historical Findings**
Escher Protocol's fixed-price sale contract called `selfdestruct` at the end of a completed sale; because Solidity executes `selfdestruct` at end-of-transaction, a buyer who called `buy()` in the same block as completion successfully sent ETH to the destroyed address with no corresponding token mint. Fractional Protocol's vault implementation was left uninitialized after factory deployment, allowing an attacker to call `initialize()` directly on the implementation, become its owner, and execute a `delegatecall` to a contract containing `selfdestruct`, destroying the logic for all existing vaults.

**Remediation Notes**
- Remove all `selfdestruct` usage from sale contracts; replace with a `saleEnded = true` flag, a state-check guard at the top of `buy()`, and an explicit `call{value: balance}()` transfer to the receiver
- In every factory pattern, call `implementation.initialize(address(this))` or `_disableInitializers()` in the same deployment transaction; never deploy an uninitialized implementation

---

### Frontrunning and MEV Exploitation (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- Two-step NFT deposit (external `offerForSale` then protocol `addCollateral`) does not verify `msg.sender == nftOwner` in the second step; any observer can front-run and deposit someone else's offered NFT to their own account
- Slashing or penalty function is callable by the owner and publicly visible in the mempool; the target can observe the transaction and front-run with a full withdrawal
- Authorization revocation is a single transaction; the soon-to-be-unauthorized actor can front-run by performing their malicious action before the revocation lands
- State-dependent validation (hash check, balance check) can be invalidated by a concurrent transaction, causing legitimate user transactions to fail while a front-runner's transaction succeeds

**Detection Heuristics**
- Find two-step deposit or approval flows where step 2 is callable by any address; check whether step 2 validates `msg.sender` against the intent of step 1
- Find `slash`, `kick`, `penalize`, and `revoke` functions callable by a single privileged address without a prior pause or freeze; these are universally front-runnable
- Look for hash-based state validation (e.g., `require(currentHistoryHash == historyHash)`) where a concurrent transaction by another party changes the hash
- Check whether sandwich attack vectors exist on AMM interactions at launch (liquidity seeding, initial price setting)

**False Positives**
- Protocols that route transactions through Flashbots or a private mempool, making front-running economically infeasible
- Administrative functions guarded by a timelock that provides adequate notice to users
- L2 deployments with a centralized sequencer providing FIFO ordering guarantees

**Notable Historical Findings**
Ajna Protocol's CryptoPunks pool was vulnerable to deposit front-running because the two-step offer-then-deposit flow did not verify NFT ownership in the second call, allowing any observer to steal a depositor's CryptoPunk by front-running the `addCollateral` transaction. In Holograph, operators who were selected for a bridging job could be front-run by other operators who bribed validators for priority inclusion, stealing the bond amount. Baton Launchpad had no protection against a malicious NFT creator front-running a user's NFT creation transaction to take over the NFT configuration. Reserve Protocol's `Furnace.melt()` function was sandwichable, allowing an attacker to profit by placing buy-melt-sell transactions around a legitimate melt call.

**Remediation Notes**
- In two-step NFT deposit flows, verify `nftContract.ownerOf(tokenId) == msg.sender` inside the second step rather than relying on the external offer mechanism alone
- Implement exit cooldowns (`requestExit()` + delay + `executeExit()`) to prevent front-running of slash or penalty transactions
- For functions with state-dependent hash checks, add a caller-supplied `deadline` parameter and revert if `block.timestamp > deadline`

---

### Gas Griefing and EIP-150 Exploitation (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- `try/catch` block catches failures from a high-gas external call (`token.mint()`, batch operation) and performs a critical state change (`_pause()`) in the `catch` branch; an attacker can supply exactly enough gas for 1/64 to cover the `catch` branch while deliberately causing the `try` branch to run out of gas
- Cross-chain job execution checks `gasleft() >= gasLimit` but the EIP-150 63/64 rule means the inner call receives `gasleft() * 63 / 64`, not `gasleft()`, making the check insufficient
- Recursive `getImageURIForHat` or similar tree-traversal functions have no maximum depth, allowing a sufficiently deep hierarchy to exhaust gas and permanently brick the function
- User-supplied `gasLimit` parameter for cross-chain bridge messages has no upper bound relative to the destination chain's block gas limit

**Detection Heuristics**
- Search for `try/catch` blocks; for each, identify what the `catch` branch does. If it pauses the protocol, slashes, or performs any irreversible state change, the `try` branch is a griefing vector via deliberate OOG
- Calculate the gas cost of the `catch` branch; if it is less than `(block.gaslimit / 64)`, the attack is feasible
- Search for `gasleft() >= gasLimit` before forwarded calls; check whether the comparison accounts for the 63/64 reduction
- Search for recursive functions without a `maxDepth` parameter or explicit depth counter

**False Positives**
- `catch` branches that only emit events or perform no state changes
- Bounded external call gas costs that cannot be inflated regardless of input
- Recursion bounded by design (e.g., maximum tree depth enforced at node creation time)

**Notable Historical Findings**
Nouns Builder's `_createAuction()` used `try token.mint()` with `_pause()` in the catch; an attacker who provided carefully calculated gas could cause `mint()` to fail with OOG while the remaining 1/64 sufficed for `_pause()`, effectively bricking the auction contract without any tokens being minted. Holograph's bridge execution framework had a gas check that did not account for EIP-150, meaning an operator could intentionally fail jobs and get slashed opponents by providing gas amounts just below the true requirement. Hats Protocol had an unbounded recursive URI lookup that traversed parent hats up the tree with no depth limit, enabling any hat tree that exceeded the gas limit to permanently lose URI resolution.

**Remediation Notes**
- In `try/catch` blocks with critical state changes in `catch`, differentiate error types: only act on known error selectors and revert on unknown errors (which include out-of-gas); replace generic `catch { _pause(); }` with `catch (bytes memory err) { if (bytes4(err) == KNOWN_ERROR_SELECTOR) { _pause(); } else { revert(...); } }`
- For gas forwarding checks, use `require(gasleft() * 63 / 64 >= gasLimit + OVERHEAD)` rather than `require(gasleft() >= gasLimit)`
- Bound all recursive functions with an explicit `maxDepth` counter passed as a function argument or defined as a protocol constant

---

### Oracle and Price Feed Vulnerabilities (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Launchpad or staking protocol deployed on Arbitrum or Optimism does not check the L2 sequencer uptime feed before consuming Chainlink prices; stale prices served during a sequencer outage drive incorrect liquidations or trade settlements
- Oracle timeout handling returns `(0, FIX_MAX)` rather than reverting or pausing, causing assets to be sold at an effective price of zero when the feed goes stale
- `refresh()` or price-update function reverts entirely when the underlying Chainlink feed is deprecated, bricking all protocol functionality that depends on price updates
- Collateral valuation uses an AMM spot price (`getReserves()`) that can be moved within a single transaction via flash loan

**Detection Heuristics**
- Search for `latestRoundData()` calls; verify all five return values are consumed and validated (`roundId`, `answer > 0`, `updatedAt > 0`, `answeredInRound >= roundId`, `block.timestamp - updatedAt < MAX_STALENESS`)
- On Arbitrum/Optimism deployments, verify the sequencer uptime feed is queried before any price consumption and that a grace period is enforced after sequencer restart
- Find any price function that returns a zero or sentinel value on oracle failure and trace whether that value propagates into trade or auction settlement logic
- Look for `catch { revert(...) }` around oracle calls; the protocol should gracefully degrade (mark collateral as IFFY, pause trading) rather than bricking

**False Positives**
- L1 Ethereum deployments with no sequencer concern
- Protocols using TWAP oracles with sufficiently long windows that flash-loan manipulation has negligible impact
- Oracle wrapper contracts that centralize all validation checks before the protocol consumes prices

**Notable Historical Findings**
Reserve Protocol had multiple oracle-related findings: `lotPrice()` returned the initial price rather than the most recent valid price during a timeout window, causing assets to be sold far below fair value; `refresh()` reverted entirely on Chainlink feed deprecation, permanently disabling the affected collateral plugin; and an oracle timeout path explicitly returned `(0, FIX_MAX)` which triggered a sell-off of RSR at zero price. Bond Protocol's integration on Arbitrum did not check sequencer uptime, allowing price data from before a sequencer outage to be used as if current. Reserve's CurveVolatileCollateral used a Curve spot price that was vulnerable to the well-known read-only reentrancy via Curve's `remove_liquidity` callback.

**Remediation Notes**
- Implement a single, reusable `getValidatedPrice(feed)` internal function that checks sequencer uptime, validates all five `latestRoundData` return values, normalizes decimals dynamically via `feed.decimals()`, and reverts with a specific error rather than returning a sentinel
- On oracle timeout, mark collateral as `IFFY` and pause trading rather than returning zero or a stale price
- For collateral backed by Curve LP tokens, check for the Curve read-only reentrancy condition before consuming pool prices

---

### Signature and Hash Verification Issues (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- `ecrecover` is called without checking whether the returned address is `address(0)`; an invalid signature with non-standard `v` value returns `address(0)`, and if a hat or governance role is owned by `address(0)` (burned/unassigned), the check passes
- Signatures are verified against `keccak256(abi.encodePacked(target, data))` without including `nonce`, `chainId`, or `verifyingContract`, enabling replay across chains, deployments, or after state changes
- Order or proposal IDs are derived from `bytes4(keccak256(...))` (truncated to 4 bytes), making collision attacks feasible for sufficiently motivated adversaries
- Multisig threshold is checked by counting valid signers, but the counting loop does not reject `address(0)` returns from `ecrecover`, allowing invalid signatures to count toward threshold

**Detection Heuristics**
- Search for raw `ecrecover(` calls not wrapped by OpenZeppelin `ECDSA.recover()`; verify the result is compared against `address(0)` before use
- Find all signature verification functions; check for EIP-712 domain separator including `chainId` and `verifyingContract`; check for nonce increment on each use
- Look for `bytes4(keccak256(...))` or any sub-32-byte hash used as a unique identifier in security-critical contexts (proposal IDs, order hashes)
- In multisig signer-counting loops, verify that `ecrecover` return values of `address(0)` are explicitly rejected

**False Positives**
- Signature verification using `ECDSA.recover()` from OpenZeppelin, which handles `address(0)` returns internally
- Full EIP-712 typed structured data with domain separator containing all required fields
- Replay protection handled at a higher protocol layer (e.g., per-user nonce incremented on every signed action)

**Notable Historical Findings**
Hats Protocol's multisig integration passed any signature whose recovered signer was `address(0)` as valid if `address(0)` happened to wear the requisite hat (a common state for unassigned hats), allowing an attacker to construct entirely invalid signatures that satisfied the safe's threshold. OpenSea's Seaport had incorrect pointer arithmetic in order hash encoding that caused the 0x04 `sha256` precompile to process wrong data, resulting in orders being matched against corrupted hashes. zkSync's bridge did not enforce EIP-155 chain ID in transaction signatures, allowing operators to replay transactions across chains at favorable times. Holograph's bridged job recovery mechanism could not recover failed jobs because the job data hash was tied to the original submission block, making re-execution structurally impossible.

**Remediation Notes**
- Replace all `ecrecover(` calls with `ECDSA.recover()` from OpenZeppelin; additionally, explicitly reject `address(0)` as a valid signer in all verification logic
- All signature schemes must include `chainId`, `verifyingContract`, `nonce`, and a typed action identifier following EIP-712; never sign over only `(target, data)` without these fields
- Use full 32-byte keccak256 hashes for all unique identifiers; truncated hashes are only acceptable for non-security-critical purposes such as event indexing

## reference/solidity/protocols/lending.md

# Lending and Borrowing Security Patterns

> Applies to: lending protocols, borrowing protocols, CDP (collateralized debt position), collateral-backed loans, uncollateralized lending, flash loans, money markets, Aave-style, Compound-style, MakerDAO-style

## Protocol Context

Lending protocols maintain a dual-ledger of supply shares (representing depositor claims on pooled assets) and debt shares (representing borrower obligations scaled by a compounding borrow index), where interest accrual continuously shifts the exchange rate between shares and underlying assets. Liquidation mechanics depend on oracle-reported collateral values being accurate and timely enough that undercollateralized positions can be closed before bad debt accumulates, making oracle reliability and liquidation incentive math critical invariants. Share-based deposit accounting introduces a class of inflation and rounding vulnerabilities unique to these protocols, where the relationship between total assets and total shares can be manipulated by the first depositor or by direct token donations to the pool.

## Bug Classes

### Reentrancy (ref: fv-sol-1)

**Protocol-Specific Preconditions**

- Token transfers in deposit, withdraw, repay, or liquidate fire ERC-777 `tokensReceived` or ERC-721 `onERC721Received` callbacks before position state is updated
- Protocols integrated with Balancer or Curve expose view functions (LP price, `virtual_price`) that are read by external liquidators during a callback window where pool balances and token supply are transiently desynchronized (read-only reentrancy)
- ETH-handling vaults send native ETH via `call` before burning shares or updating debt
- OpenZeppelin `initializer` modifier versions prior to 4.3.2 are reentrancy-unsafe during proxy initialization

**Detection Heuristics**

- Find every external call (token transfer, ETH `call`, swap router interaction) and check whether any accounting variable - share balance, borrow balance, liquidity index - is read before the call and written after it
- Check that all state-mutating functions share a reentrancy lock; a guard on `deposit` alone does not protect `borrow` if an attacker can reenter through a callback
- For Balancer and Curve LP collateral oracles, check whether the consuming contract calls `VaultReentrancyLib.ensureNotInVaultContext` or the Curve equivalent before reading `virtual_price` or BPT supply
- Search for `receive()` and `fallback()` functions in contracts that hold ETH positions; any ETH send before share accounting is a reentrancy surface

**False Positives**

- Protocols that exclusively handle standard ERC-20 tokens with no callback hooks and no ETH
- Functions where the Checks-Effects-Interactions pattern is strictly followed with a `nonReentrant` guard
- Read-only reentrancy when the consuming contract verifies the source vault's reentrancy lock state before reading

**Notable Historical Findings**

Balancer-integrated lending protocols have been exploited via read-only reentrancy: an attacker enters a Balancer join callback at the moment BPT supply is updated but token balances are not, causing LP price oracles consumed by the lending protocol to return an inflated value that prevents correct liquidation. Protocols built on JPEG'd experienced a classic deposit reentrancy where the share minting step occurred after the token pull, allowing ERC-777 tokens to re-enter `deposit` with the same stale `balanceBefore`, minting unbounded extra shares. ZeroLend's NFTPositionManager had multiple reentrancy-adjacent issues where repay and reward claim paths shared mutable state without consistent locking.

**Remediation Notes**

Apply `nonReentrant` to every external-facing function that modifies supply or borrow accounting, not just the primary deposit path. For LP token price consumers, integrate the source protocol's reentrancy guard check as the first statement. For ETH vaults, burn shares and update balances before executing the ETH send.

---

### Precision and Rounding Errors (ref: fv-sol-2)

**Protocol-Specific Preconditions**

- Share-to-asset and asset-to-share conversions involve division by a dynamic index (liquidityIndex, borrowIndex) that grows over time; truncation at each operation compounds across the life of a pool
- Collateral value calculations combine token amounts from pools with different decimals (USDC at 6, WETH at 18) against oracle feeds that may return 8 or 18 decimal prices
- Rounding direction in ERC-4626-style vaults must favor the protocol: shares minted on deposit should round down, assets owed on withdraw should round up - inverting this direction allows users to extract value over many operations
- Interest accrual formulas that use linear approximations instead of compound formulas accumulate material divergence from expected values at high utilization over long periods

**Detection Heuristics**

- Find all expressions of the form `(a / b) * c`; the division truncates before the multiplication, losing precision. Rewrite as `(a * c) / b` or use `mulDiv`
- Check every oracle consumption site for a hardcoded decimal assumption (`price / 1e8`, `price / 1e18`); verify it matches the actual feed decimals for every supported asset, including assets added after deployment
- In share conversion functions, confirm the rounding direction parameter (`Math.Rounding.Down` vs `Math.Rounding.Up`) is consistent with what the protocol's security invariant requires
- For fee calculations using basis points, verify that small pool sizes do not cause fee amounts to round to zero across many calls, allowing fees to be permanently avoided

**False Positives**

- Precision loss bounded to 1 wei per operation with no cumulative economic path
- Protocols that explicitly document a supported decimal range for tokens and reject others via a whitelist
- `mulDiv` with rounding-up variants applied correctly to critical conversions

**Notable Historical Findings**

Notional Leveraged Vaults had a wrong decimal precision issue where inflated prices resulted from mismatched decimal normalization between the oracle feed and the underlying token. JPEG'd suffered a `pricePerShare` calculation that truncated to zero for non-18-decimal tokens, breaking share valuation for any pool using USDC-denominated assets. ZeroLend's `GenericLogic` assumed all Chainlink feeds return the same number of decimals, causing incorrect health factor calculations when different asset feeds used 8 versus 18 decimal representations. Numoen had a division-before-multiplication precision loss in its invariant function that allowed attacks to drain funds on low-decimal token pairs.

**Remediation Notes**

Normalize all oracle prices to a common internal precision (typically 1e18) at the point of consumption, storing the feed's decimal count separately from its raw value. For share math, adopt the ERC-4626 virtual offset pattern or explicitly use `mulDiv` with correct rounding direction at every conversion boundary.

---

### Access Control and Authorization Failures (ref: fv-sol-4)

**Protocol-Specific Preconditions**

- `onBehalf` parameters in repay, liquidate, or redeem functions are checked only for non-zero address, not for ownership of the position being acted upon
- NFT collateral auctions allow any caller to claim proceeds or the NFT itself after time expiry without verifying they are the winning bidder
- L2 deployments consuming Chainlink feeds do not check sequencer uptime before accepting price data; a sequencer outage causes oracle staleness that can be exploited in the grace period after restart
- Position mode changes (e.g., isolated mode, e-mode) do not verify that the new mode is compatible with the account's current debt and collateral state

**Detection Heuristics**

- For every function accepting `onBehalf` or `for` parameters, trace whether `msg.sender`'s relationship to the named position is validated beyond null checks
- Audit functions in liquidation paths (auction claim, collateral seizure, NFT unlock) for caller authorization
- On Arbitrum and Optimism deployments, check for `sequencerUptimeFeed.latestRoundData()` calls with a grace period enforcement before any Chainlink price is accepted
- Review `setPosMode` and similar parameter change functions to verify health checks are re-run after the mode transition

**False Positives**

- Permissionless repayment of another user's debt when the protocol design explicitly allows it as a feature
- Access control enforced upstream in a router contract that always mediates protocol entry
- L2 sequencer checks implemented at the oracle contract level rather than individually at each consumer

**Notable Historical Findings**

BendDAO had two high-severity access control failures: `isolateRepay` accepted any `onBehalf` address without verifying NFT ownership, allowing an attacker to corrupt another user's borrow accounting and trigger underflows on subsequent liquidation; separately, the `claimAuctionNFT` function transferred the NFT to any caller after auction expiry without checking that the caller was the highest bidder. ZeroLend's NFTPositionManager enforced address symmetry that broke Account Abstraction wallets where the signing address differs from the execution address. Blueberry Update's oracle layer did not check whether the Arbitrum sequencer was active, exposing the protocol to stale price attacks during sequencer downtime windows.

**Remediation Notes**

For `onBehalf` parameters, require that the caller is either the position owner or holds an explicit delegation grant stored on-chain. For L2 oracle consumers, implement the sequencer uptime pattern as a shared library or base contract to ensure consistent enforcement across all markets.

---

### Slippage Protection (ref: fv-sol-8)

**Protocol-Specific Preconditions**

- Position open, close, and liquidation flows perform DEX swaps (Uniswap, Curve, Balancer) where the minimum output is hardcoded, set to zero, or calculated from a potentially stale oracle price
- Reward token harvests swap externally claimed rewards (CRV, AURA, CVX) without user-configurable slippage, making these transactions predictable MEV targets
- Protocols using UniswapV3 may rely on `sqrtPriceLimitX96` for price control, which causes partial fills rather than reverts when the limit is reached - leaving residual tokens in the contract
- `block.timestamp` passed as a deadline provides no protection; validators or searchers can hold transactions and execute them at a later, disadvantageous block

**Detection Heuristics**

- Search for router calls (`swapExactTokensForTokens`, `exactInputSingle`, `swap`) where `amountOutMin` or its equivalent is a literal `0` or a constant
- Identify UniswapV3 `swap()` calls relying solely on `sqrtPriceLimitX96`; confirm there is a post-swap check that the full intended amount was swapped
- Verify that deadline parameters passed by users are forwarded to the swap call, not replaced with `block.timestamp`
- Check reward harvest functions for per-reward-token slippage parameters; broad "harvest all" calls without individual minimums are sandwich targets

**False Positives**

- Swaps executed via private relays (Flashbots, MEV-Share) where mempool exposure is eliminated
- Protocol-controlled admin swaps with off-chain price verification and governance oversight
- Swap amounts that are trivially small relative to pool depth, making sandwich attacks unprofitable after gas

**Notable Historical Findings**

Blueberry Update had multiple slippage issues: the UniswapV3 `sqrtRatioLimit` was used as slippage protection but causes partial fills, leaving tokens stuck; reward token swaps had no minimum output at all; and a deadline check using `block.timestamp` was recognized as non-functional. Wise Lending had a hardcoded Uniswap fee tier that did not adapt to pool conditions. Notional Leveraged Vaults lacked slippage control on PT redemption and sUSDe liquidation paths, creating sandwich attack vectors. JOJO Exchange's flash loan liquidator had no slippage control when converting seized collateral to USDC.

**Remediation Notes**

Expose a `minAmountOut` and `deadline` parameter from every user-facing entry point that triggers a swap, including indirect triggers like liquidation or harvest. For reward harvests, accept a per-token minimum array. Replace any use of `sqrtPriceLimitX96` as a slippage guard with a post-swap balance check.

---

### Oracle Manipulation and Flash Loan Price Attacks (ref: fv-sol-10, fv-sol-10-c5, fv-sol-10-c6, fv-sol-10-c7)

**Protocol-Specific Preconditions**

- Collateral value is computed from AMM spot reserves or LP token prices that can be moved within a single transaction using flash-borrowed capital
- LP token pricing based on Curve `virtual_price` or Balancer BPT supply/balance is vulnerable during reentrancy windows as described in the reentrancy section
- Governance voting power or borrow limits are derived from current token balances rather than time-weighted or checkpoint-based snapshots, enabling flash loan-driven manipulation
- Reward distribution uses `rewardsPerShare += reward / totalStaked` at the time of distribution, allowing an attacker to flash-stake before the distribution call and withdraw immediately after

**Detection Heuristics**

- Identify all oracle consumption sites and classify each price source as spot, TWAP, or Chainlink; any spot AMM price is a flash loan attack surface
- Check governance and staking contracts for balance checks using `token.balanceOf(account)` at execution time rather than `getPriorVotes(account, block.number - 1)` or equivalent checkpointed values
- Look for staking deposit and withdraw in the same block without a cooldown; this enables zero-cost reward extraction around distribution events
- For CDP protocols that accept LP tokens as collateral, verify the oracle uses a TWAP of the underlying component prices rather than the raw LP reserve ratio

**False Positives**

- Price sources using Chainlink aggregators with heartbeat validation, not AMM spot prices
- Checkpoint-based voting where stakes from the current block are ineligible
- Flash loan guard patterns that check `tx.origin == msg.sender` for governance calls (acceptable in governance contexts)

**Notable Historical Findings**

Sentiment suffered a direct oracle manipulation through the ERC-4626 oracle being vulnerable to deposit-inflate-query-withdraw in the same transaction, allowing an attacker to distort collateral values and borrow against inflated positions. Blueberry's IchiLpOracle was exploited because it computed LP token value from the IchiVault's instantaneous token balance ratios, which are trivially manipulable with a flash swap. Curve LP-backed lending protocols have been attacked by manipulating `virtual_price` during remove_liquidity callbacks (read-only reentrancy), causing the collateral oracle to return a value inconsistent with the post-withdrawal state.

**Remediation Notes**

For LP token collateral, price the underlying components individually from Chainlink feeds and compute LP value from the invariant formula rather than from on-chain reserves. For governance, require checkpoint-based snapshots at a prior block for all voting weight queries.

---

### Denial of Service (ref: fv-sol-9)

**Protocol-Specific Preconditions**

- Liquidation functions transfer the seized collateral asset directly to the liquidator; if the collateral reserve has been drained by concurrent borrows or withdrawals, the transfer reverts, blocking liquidation
- Borrowers can front-run liquidations by repaying a single debt share, changing the share count and causing the liquidator's calculated `amount <= borrowShares` check to stale-fail
- Interest accrual functions iterate over all pools or all user positions without a gas bound
- Withdrawal queues in uncollateralized lending protocols allow any depositor to add entries without limit, making queue processing and refund iteration prohibitively expensive
- Chainlink oracle heartbeat checks with a fixed threshold applied across all markets fail for assets with different update frequencies, causing healthy markets to revert on oracle calls

**Detection Heuristics**

- Check liquidation transfer paths: is the transferred asset pulled from the pool's live token balance? If yes, verify that the available balance is checked before attempting the transfer, with graceful fallback to aToken/share seizure
- Search for repay functions that check `amount <= currentBorrowShares` using a value computed before any reentrancy or front-run window; consider whether 1-share repayments can invalidate a pending liquidation
- Identify all unbounded loops and verify the iteration count is bounded by a governance-set constant, never by user-controlled array length
- For oracle staleness checks, verify each market uses the heartbeat appropriate for that specific asset feed, not a single global constant

**False Positives**

- Protocols that seize aTokens/shares rather than underlying tokens, which are immune to liquidity drain DoS
- Emergency admin functions that can manually clear stuck liquidations or override oracle failures
- Liquidation functions with partial fill semantics that cap seized collateral to available balance

**Notable Historical Findings**

ZeroLend had a liquidation DoS where the collateral reserve's available liquidity could be reduced to zero by other users borrowing the same asset before the liquidation executed, reverting the collateral transfer. Wise Lending allowed borrowers to DoS their own liquidation by repaying as little as one debt share immediately before the liquidation transaction, invalidating the liquidator's calculated parameter. BendDAO's oracle had a single heartbeat value applied to all feeds, causing a DoS on markets where the asset's native feed update frequency exceeded that threshold. Notional Leveraged Vaults exhibited a withdrawal-queue-adjacent DoS where griefing calls at high frequency on L2 caused reward accrual rounding losses that accumulated into material user harm.

**Remediation Notes**

For liquidation transfers, check available pool balance before transferring and fall back to seizing share tokens when insufficient underlying is available. For borrow-share-based repayment checks, use a slippage tolerance rather than an exact equality, or accept shares directly as the unit rather than converting from an amount.

---

### Accounting Share Mismatch (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Supply and borrow share totals are modified by multiple code paths (supply, borrow, repay, withdraw, liquidate, treasury mint, interest accrual) and any path that misattributes a sign or targets the wrong token causes a persistent imbalance
- Treasury share minting from accrued fees should increase total supply shares; inverting the sign causes a supply deficit that prevents the last suppliers from withdrawing
- Liquidation protocol fees are transferred out of the pool as tokens but the corresponding collateral supply shares are not reduced by the fee amount, creating a divergence between share liabilities and token assets
- `balanceOf` calls during position opening may query the wrong token (underlying vs. vault share token) when a wrapped or vault-based collateral type is used

**Detection Heuristics**

- For every function that mints treasury shares, verify the operator is `+=` not `-=` on the total supply counter
- For liquidation fee transfers, verify that shares are burned for the combined amount (collateral to liquidator + protocol fee), not just the liquidator's portion
- Search for `balanceOf(address(this))` calls in complex position-opening flows and trace whether the queried token matches the token actually held by the contract at that point
- After any "full" operation (full repay, full liquidation, full withdrawal), verify that the resulting balance is truly zero and that flags like `setBorrowing(false)` are conditioned on a zero-balance check, not set unconditionally

**False Positives**

- Rounding remainder of 1 wei in full repayment that is absorbed by protocol design
- Accounting mismatches that are display-only and do not affect token transfer amounts or solvency invariants

**Notable Historical Findings**

ZeroLend's `executeMintToTreasury` had a subtraction where addition was required, causing a progressive reduction of total supply shares each time fees were claimed; suppliers who remained in the pool long enough could not withdraw because share totals had been driven below zero. The same protocol's `_burnCollateralTokens` during liquidation did not account for the liquidation protocol fee amount in the share burn, so the pool held fewer tokens than shares represented after every fee-paying liquidation. Blueberry Update's `openPositionFarm` queried `uToken.balanceOf` instead of `vault.balanceOf`, depositing zero collateral while the vault tokens sat uncollateralized in the contract.

**Remediation Notes**

Add invariant assertions in test suites that verify `totalSupplyShares * liquidityIndex >= totalPoolAssets` after every operation. Flag any arithmetic operation on `totalSupplies.supplyShares` or `totalSupplies.debtShares` for manual sign review.

---

### Bad Debt and Protocol Insolvency (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- No minimum borrow amount is enforced in USD or ETH terms, allowing dust positions where the liquidation bonus is smaller than the liquidator's gas cost
- Minimum deposit checks can be bypassed by depositing above the minimum and then withdrawing down to dust, after which borrowing against the remaining collateral is unrestricted
- After a full liquidation where seized collateral is worth less than the outstanding debt, the remaining debt is left on the position with no socialization mechanism, and bad debt continues to accrue interest
- Protocol pauses, collateral parameter changes (LTV reductions, liquidation threshold changes), or oracle failures create windows where undercollateralized positions cannot be liquidated

**Detection Heuristics**

- Verify that both the per-transaction borrow amount and the resulting total position borrow value are checked against a minimum USD threshold after every borrow
- Check whether the minimum deposit enforcement function can be circumvented: is there a corresponding minimum check on withdrawal that prevents leaving sub-minimum collateral?
- Search for post-liquidation code: if `remainingDebt > 0` and `collateralValue == 0`, is the debt zeroed and distributed, or does it remain as phantom debt?
- Audit collateral parameter change functions for instant application without grace periods; a governance-executed LTV reduction should not immediately trigger mass liquidations

**False Positives**

- Protocols with an insurance fund or treasury backstop explicitly sized to absorb bad debt
- Bad debt socialized via liquidity index reduction at the time it arises, not deferred
- Protocols that operate on a single collateral type with stable oracle prices where bad debt risk is structurally bounded

**Notable Historical Findings**

Wise Lending had no minimum borrow amount, allowing the creation of positions too small to liquidate profitably; the resulting bad debt gradually increased the utilization ratio, distorting interest rates for all borrowers. JPEG'd had bad debt positions that continued compounding interest indefinitely rather than being frozen, causing the total debt figure to diverge from realizable value. INIT Capital lacked a mechanism to handle partially repaid bad debt after liquidation, leaving undercollateralized residual positions in an unliquidatable state. BendDAO's insolvency risk was compounded by never handling bad debt at all, meaning accumulated bad debt was an invisible liability socialised entirely onto the last suppliers to withdraw.

**Remediation Notes**

Implement a bad debt write-off path triggered at the end of any liquidation that leaves the position with zero collateral: zero out the remaining debt shares, reduce total debt shares by the same amount, and emit a bad debt event. Enforce minimum position size in both USD value and share terms to prevent dust positions from accumulating.

---

### Liquidation Logic Errors (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Liquidation accounting must atomically update supply shares, borrow shares, interest rates, and liquidity indices; any partial update leaves the protocol in an inconsistent intermediate state
- Interest rate recalculation depends on utilization, which depends on the post-liquidation debt and supply totals; calling `updateInterestRates` before completing share burns produces stale utilization
- Liquidation of a position whose NFT or token collateral requires a transfer to the liquidator can be DoSed if the pool's available balance of that collateral asset has been depleted
- A borrower can front-run their own liquidation by repaying a minimal amount, invalidating the liquidator's pre-computed parameters

**Detection Heuristics**

- In `executeLiquidationCall`, verify the call ordering: (1) calculate amounts, (2) burn debt shares, (3) burn collateral shares including protocol fee portion, (4) transfer tokens, (5) update interest rates with the correct post-state
- Check that `setBorrowing(false)` or equivalent debt flag clearing only executes when actual debt balance is zero, not when the intended repayment amount equals the pre-computed debt
- Verify that liquidation of positions still accruing rewards resets or transfers the reward accumulator for the liquidated user; if not, the liquidated user continues receiving rewards on collateral they no longer hold
- For protocols with NFT collateral, check the `lockerAddr` or equivalent linkage is cleared on liquidation to prevent the NFT from being locked in an unrecoverable state

**False Positives**

- Protocols where liquidation seizes aToken/share representations rather than underlying tokens, which are unaffected by pool balance DoS
- Protocols with a maximum partial liquidation cap that bounds the seized amount to available balance, gracefully handling partial liquidation when full is impossible

**Notable Historical Findings**

ZeroLend had at least five distinct liquidation accounting errors in a single audit cycle: the collateral share burn omitted the protocol fee amount; interest rate updates occurred before debt share reduction; borrow rate was materially decreased after liquidation due to ordering; liquidated positions continued accruing rewards; and full liquidations left dust debt with the borrowing flag incorrectly cleared. BendDAO's `erc721DecreaseIsolateSupplyOnLiquidate` failed to clear the `lockerAddr` field, leaving liquidated NFT collateral locked. Wise Lending's liquidation could be front-run with a one-share repayment that caused the liquidator's parameter to exceed the updated borrow shares, reverting the transaction.

**Remediation Notes**

Treat the liquidation execution as a single atomic state machine: define the canonical step order explicitly and enforce it with intermediate assertions in test suites. Consider accepting shares directly as the liquidation unit to eliminate the share-to-amount calculation race condition.

---

### Interest Accrual Errors (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Time-weighted interest accrual functions that use integer division for small time deltas can round the increment to zero while still advancing the checkpoint timestamp, permanently suppressing interest for sub-threshold intervals
- Rate parameters (APR, reserve factor, fee rate) changed by governance or admin do not trigger an accrual first, retroactively applying the new rate to the period that occurred under the old rate
- Bad debt positions continue compounding interest after collateral is exhausted, inflating the total debt figure and distorting utilization-based rate calculations
- Treasury share accrual uses a formula that includes treasury shares in the denominator when computing the supply interest rate, causing treasury to earn supply-side returns on top of its reserve-factor allocation

**Detection Heuristics**

- Check every setter that modifies `debtInterestApr`, `reserveFactor`, `borrowRate`, or equivalent: does it call `accrueInterest()` or `updateState()` before the assignment?
- For sub-second or sub-minute polling scenarios on L2, calculate the minimum time delta required to produce a non-zero interest increment at the protocol's rate; if callers can advance the timestamp without advancing the accrual, they can suppress interest
- Verify that bad debt positions (debt > collateral value) have their interest accrual explicitly paused or written off
- Check the supply interest rate formula: is the denominator `totalSupplyShares - accruedToTreasuryShares` (correct) or `totalSupplyShares` (causes double-dipping)?

**False Positives**

- Rate changes executed through a timelock where governance calls `updateState` as a prerequisite in the execution payload
- Minor compound-vs-linear divergence that creates a surplus smaller than the rounding unit
- Uncollateralized lending protocols that document best-effort interest accrual for very short intervals

**Notable Historical Findings**

JPEG'd had two related findings: `setDebtInterestApr` did not accrue pending interest first, allowing retroactive rate changes; and bad debt continued accruing interest after a position became insolvent, inflating the protocol's stated debt. ZeroLend's `updateState` accrued supply interest on `accruedToTreasuryShares` by including them in the total supply denominator, resulting in the treasury collecting more than the intended reserve factor. Accountable's open-term loan contracts had an interest accrual function where the timestamp advanced even when the calculated increment was zero, making frequent permissionless calls to `accrueInterest` an effective interest suppression attack. JOJO Exchange's JUSD borrow fee rate was computed with simple multiplication instead of a compound formula, understating fees materially over long periods.

**Remediation Notes**

For protocols where accrual functions are permissionless, guard them with a minimum interval check: if `block.timestamp - _accruedAt < MIN_ACCRUAL_INTERVAL`, return early without updating the timestamp. Emit an event when a bad debt position is frozen to provide observability for protocol health monitoring.

---

### Interest Rate Update Ordering (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Aave-style protocols maintain a cached state object passed through a transaction; `updateInterestRates` must be called with the post-operation cached state, not the pre-operation state
- In repayment flows, `updateInterestRates` is called before the debt shares are reduced, causing the rate model to observe higher utilization than the post-repayment reality and compute an elevated rate
- In withdrawal flows, supply-side rate recalculation before the supply is reduced overstates the remaining liquidity, computing a lower borrow rate than correct
- Governance-executed reserve factor changes that do not call `updateState` first retroactively apply the new factor to accrued but unsettled interest

**Detection Heuristics**

- In `executeRepay`, verify: (1) debt shares are reduced, (2) cache is updated with the new `debtShares`, and then (3) `updateInterestRates` is called - not (1) update rates, (2) reduce shares
- In `executeWithdraw`, the same ordering applies to supply shares
- In `executeLiquidationCall`, verify that `updateInterestRates` is called after the last token transfer (including fee transfers to treasury), not before
- For all admin setter functions touching rate model or reserve factor, verify `updateState` is called first

**False Positives**

- Protocols that intentionally compute rates at the start of each block and accept one-block-lagged rate effects as a design choice
- High-activity pools where a stale rate persists for less than one block before the next interaction corrects it

**Notable Historical Findings**

ZeroLend had at least four rate update ordering bugs in a single audit: repay updated interest rates before reducing debt shares; withdraw did not update rates after supply reduction; liquidation did not update rates after the fee transfer to treasury; and reserve factor changes were not preceded by a state update. BendDAO's pool configurator module allowed rate model changes without triggering an immediate rate recalculation, producing stale rates until the next user interaction.

**Remediation Notes**

Establish and document a canonical operation order as a code comment at the top of each execution function: (1) cache current state, (2) update indices, (3) modify balances, (4) recalculate rates. Enforce this order with a suite of unit tests that assert post-operation utilization equals the expected value.

---

### Position Health Check and Valuation Errors (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Health factor computation includes pending rewards from third-party protocols; if any reward token lacks a configured oracle, the entire health check reverts, blocking all dependent operations (borrow, withdraw, liquidation)
- Protocols use weighted collateral (collateral value times LTV factor) for liquidation eligibility checks but bare collateral (full value without factor) for withdrawal checks, allowing users to withdraw from positions that are already liquidatable
- Position managers or proxy contracts that wrap core lending logic may lack health checks after position adjustments because they delegate to core functions that each check individually but not the aggregate post-adjustment state
- Borrow index staleness in health checks: if the index used to compute outstanding debt is not updated to the current block, health factors appear better than they are

**Detection Heuristics**

- Trace every path through `getPositionValue` or equivalent; identify all token loops and check whether a missing oracle for any token causes a hard revert versus a skip
- Compare the collateral valuation formula used in `checksWithdraw` versus `checksLiquidate`; they must use the same weighting
- For position managers accepting batched operations (adjust, add collateral, borrow), verify that a single health check is performed after all sub-operations complete, not between them
- Check oracle decimal validation: if the health factor formula uses price * amount, verify both are normalized to a consistent decimal base before comparison

**False Positives**

- Reward tokens explicitly excluded from collateral valuation by design, with a documented rationale
- Position adjustments that can only improve health (e.g., add collateral only), where a missing post-check is a gas optimization with no security impact

**Notable Historical Findings**

Blueberry Update's `getPositionValue` reverted when any reward token in the position had no oracle configured, DoSing all operations requiring a health check for affected users. Wise Lending had an inconsistency where liquidation eligibility used weighted collateral but uncollateralized withdrawal used bare collateral, allowing users to withdraw from positions that the liquidation module would consider eligible for seizure. ZeroLend's NFTPositionManager lacked a health check after position adjustments, allowing users to adjust their positions into undercollateralization without being blocked.

**Remediation Notes**

Wrap oracle calls in health check functions with a `try/catch` or a `hasOracle()` pre-check; missing oracles should cause the asset's contribution to be treated as zero value rather than reverting. Centralize the collateral weighting logic in a single library function shared by both the liquidation eligibility check and the withdrawal eligibility check.

---

### Reward Distribution Errors (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Reward token lists in wrapped position contracts (WAura, WConvex) can have tokens added dynamically by the underlying protocol; rewards for newly added tokens may never be claimed if the wrapper's internal list is not synchronized
- Reward debt accumulators updated before the corresponding transfer means a failed transfer causes permanent reward loss for the user; the debt records the claim as satisfied even though no tokens were received
- Liquidation paths that seize collateral shares do not checkpoint and transfer the reward accumulator for the liquidated user; the liquidated user continues accruing rewards on positions they no longer hold
- Epoch boundary crossing in accumulator-based reward models uses the wrong epoch index, applying one reward rate to the entire cross-boundary period instead of prorating correctly

**Detection Heuristics**

- Check `removeRewardToken` functions: is `claimReward(token)` called for all users, or for the protocol's own position, before the token is removed from the list?
- Trace the reward claim flow: does `accountRewardDebt` update happen before or after the `safeTransfer`? Post-transfer update is correct; pre-transfer update loses rewards on failure
- After any liquidation that seizes collateral, check whether the reward accumulator for the seized collateral type is reset or proportionally transferred to the liquidator
- For epoch-based systems, identify the boundary crossing calculation: `nextEpoch = epoch + EPOCH_LENGTH` is correct; `nextEpoch = lastRewardTime + EPOCH_LENGTH` is typically wrong when `lastRewardTime` is not epoch-aligned

**False Positives**

- Reward claiming wrapped in `try/catch` that gracefully handles transfer failures by deferring rather than losing rewards
- Protocols with an admin rescue function that can recover stuck reward tokens for manual distribution to affected users

**Notable Historical Findings**

Blueberry Update had rewards stuck in the spell contract because the Convex spell claimed rewards to `address(this)` but only forwarded the primary token to the user, leaving all secondary reward tokens permanently locked in the contract. ZeroLend's NFTPositionManager continued distributing rewards to liquidated positions after collateral seizure because the reward accumulator was not reset during liquidation. Notional Leveraged Vaults updated `accountRewardDebt` before the reward transfer; when the underlying yield token was temporarily paused, users permanently lost their accrued rewards for that period. OlympusDAO had removed reward tokens become permanently unclaimable, causing loss for users who had not claimed before removal.

**Remediation Notes**

Adopt the `Effects-then-Interactions` pattern specifically for reward accounting: transfer tokens first, then update the debt accumulator using a `safeTransfer` that reverts on failure. Any reward token removal function must call a full claim for all outstanding balances before the removal takes effect.

---

### Treasury and Fee Accounting Errors (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Treasury shares represent newly issued supply created from protocol-owned interest; their net effect on `totalSupplyShares` must be additive, not subtractive
- Liquidation protocol fees are a second collateral outflow from the pool in addition to the amount sent to the liquidator; both must be reflected in the supply share burn
- Reserve factor changes during an active pool must be preceded by a state update; applying a new factor retroactively to the unsettled accrual period overstates or understates the treasury's entitlement
- Fee calculation chains involving basis point division followed by multiplication are prone to intermediate truncation that silently zeroes fees for small pools

**Detection Heuristics**

- In `executeMintToTreasury` or equivalent, confirm the operation on `totalSupplies.supplyShares` is `+=` not `-=`
- In `executeLiquidationCall`, confirm shares are burned for `actualCollateralToLiquidate + liquidationProtocolFeeAmount`, not just `actualCollateralToLiquidate`
- Verify that `setReserveFactor` calls `updateState` or `accrueInterest` before modifying the parameter
- Check fee share calculations for division-before-multiplication: `feeAmount / (totalPool / totalShares)` loses precision; rewrite as `feeAmount * totalShares / totalPool`

**False Positives**

- Treasury shares held in a completely separate accounting ledger with no interaction with supplier share math
- Precision loss in fee calculations bounded to amounts smaller than the protocol's economic floor

**Notable Historical Findings**

ZeroLend's `executeMintToTreasury` subtracted accrued shares from total supply instead of adding them, a sign error that progressively starved the last suppliers of their withdrawal capacity. The same protocol's liquidation flow transferred the protocol fee to the treasury address without burning the corresponding collateral supply shares, creating a persistent asset-liability mismatch in the collateral market. ZeroLend also accrued supply interest on treasury shares by including them in the total supply denominator, causing treasury to compound above its entitled reserve factor allocation. Wise Lending had a fee precision loss from division-before-multiplication that allowed `claimFeesBeneficial` to permanently revert once accumulated rounding errors pushed the calculated fee shares below the transferable minimum.

**Remediation Notes**

Add a post-mint assertion: `totalSupplyShares after mint == totalSupplyShares before mint + mintedShares`. Add a post-liquidation assertion: tokens transferred out == supply shares burned times current index. Both are cheap to enforce in tests and prevent sign-error classes from surviving code review.

---

### Vault Share Inflation / First Depositor Attack (ref: fv-sol-2-c6)

**Protocol-Specific Preconditions**

- The vault has no minimum liquidity lock or dead shares mechanism, allowing the first depositor to hold exactly one share
- `totalAssets()` is computed from `token.balanceOf(address(this))` or a similar live balance query, making it manipulable by direct token donation without share minting
- After the share price is inflated to a large value per share, subsequent depositors receive zero shares due to rounding down, forfeiting their entire deposit to the first depositor
- Share price manipulation in uncollateralized lending protocols can also occur via partial redemption: filling part of a redemption queue without reducing the queue's tracked `totalValue` inflates the share price used to compute future requests

**Detection Heuristics**

- Check the first-deposit path: if `totalSupply() == 0`, are shares minted 1:1 with no dead share lock or minimum liquidity requirement?
- Verify `totalAssets()` implementation: does it use `balanceOf(address(this))` (vulnerable) or internal accounting variables (resistant)?
- Check whether `shares == 0` is asserted after the share calculation; a zero-share deposit silently forfeits the depositor's assets
- For protocols implementing ERC-4626, check for `_decimalsOffset()` override; if absent, virtual share protection may not be in use

**False Positives**

- Vaults using OpenZeppelin's ERC-4626 with a non-zero `_decimalsOffset()` (e.g., 3), which makes inflation attacks require at least `10**offset` times as much capital as the victim's deposit
- Vaults with a minimum initial deposit enforced in the constructor or `initialize` function
- Vaults using internal balance tracking rather than `balanceOf`, immune to donation inflation

**Notable Historical Findings**

JPEG'd's yVault was the first widely noted instance of this attack pattern: 1 wei deposit followed by a large token donation inflated the price per share, causing subsequent depositors to receive zero shares. Wise Lending's PendlePowerFarmToken had the same vulnerability specific to its PendleLP position token. ZeroLend's CuratedVaults were found not to use virtual shares, making them vulnerable to inflation despite awareness of the attack pattern at the time of deployment. Accountable's uncollateralized lending protocol had a partial redemption queue bug where partial fills did not reduce `totalValue`, allowing manipulation of the average share price used for subsequent requests.

**Remediation Notes**

Use OpenZeppelin ERC-4626 with `_decimalsOffset()` returning at least 3 for all new vault deployments. For protocols not using ERC-4626, adopt the Uniswap V2 minimum liquidity burn pattern: on first deposit, mint `MINIMUM_LIQUIDITY` shares to `address(0)` and subtract them from the user's allocation.

---

### External Protocol Integration Errors (no fv-sol equivalent)

**Protocol-Specific Preconditions**

- Balancer pool join and exit operations require `userData` encoded to match the specific `JoinKind`/`ExitKind` enum value; using the wrong kind causes silent mispricing or revert
- Convex and Aura wrappers expose `extraRewards(i)` arrays that grow dynamically; a wrapper contract that snapshots the array length at deployment will miss reward tokens added later
- External liquid staking tokens (stETH, weETH, rsETH) assumed to trade 1:1 with their underlying collateral cause collateral overvaluation when the peg weakens during market stress
- Pendle Principal Tokens (PT) assumed to redeem at exactly 1.0 of the underlying post-maturity cause overvaluation if the actual redemption rate diverges; function signatures in Pendle's router changed between V2 versions

**Detection Heuristics**

- For Balancer join/exit calls, trace the `JoinKind`/`ExitKind` enum value in `userData` and verify it matches the function semantics documented in Balancer's ABI
- Search for calls to `extraRewards(i)` or equivalent dynamic reward arrays; verify the contract tracks the array length and handles newly added entries
- Identify all hardcoded 1:1 peg assumptions for liquid staking tokens; replace with oracle-sourced price ratios
- For Pendle, EtherFi, Lido, and similar protocol integrations, verify function signatures against the deployed contract ABI at the target address, not against older documentation

**False Positives**

- Integrations where the external protocol version is pinned by an immutable address and the ABI is contractually frozen
- Peg assumptions bounded by an on-chain deviation check that reverts when the depeg exceeds a configurable threshold

**Notable Historical Findings**

Blueberry Update's Aura spell used `JoinKind.INIT` in the `userData` encoding when it should have used `JoinKind.EXACT_TOKENS_IN_FOR_BPT_OUT`, causing the pool join to fail silently or produce wrong BPT amounts. JPEG'd's `balanceOfJPEG` called `earned(address)` on Convex extra reward pools where the actual function signature was `earned()` with no parameter, causing reward balances to be permanently understated. Wise Lending's farm exit assumed stETH redeems 1:1 with ETH, overstating position value when closing a farm during periods of stETH depeg. Notional Leveraged Vaults assumed Pendle PTs redeem at exactly 1.0 post-maturity, causing mispriced collateral and incorrect health factor calculations.

**Remediation Notes**

Maintain a dedicated integration test for each external protocol that runs against a mainnet fork, calling the actual deployed contract at its current address. Pin all external protocol interface versions explicitly in the protocol's dependency manifest and include interface version validation in the integration's constructor.

---

### ERC-4626 Vault Compliance (ref: fv-sol-2-c6)

**Protocol-Specific Preconditions**

- External integrators (aggregators, yield routers, meta-vaults) rely on ERC-4626 invariants: `previewDeposit(assets) <= actual shares minted`, `previewRedeem(shares) <= actual assets returned`; violations cause silent losses for integrators
- `maxDeposit` and `maxWithdraw` returning non-zero values when the vault is paused causes integrators to submit transactions that revert, potentially bricking their logic
- Withdrawal fees not reflected in `previewRedeem` cause integrators that use the preview for slippage checks to accept worse outcomes than expected
- Inconsistent use of `totalAssets()` between `deposit` and `withdraw` paths (e.g., one includes accrued yield and the other does not) breaks the convertibility invariant

**Detection Heuristics**

- Verify the five ERC-4626 invariants mechanically: `previewDeposit >= deposit` (shares), `previewMint <= mint` (assets), `previewWithdraw >= withdraw` (shares), `previewRedeem <= redeem` (assets), `convertToShares(convertToAssets(x)) <= x`
- Check `maxDeposit`, `maxWithdraw`, `maxMint`, `maxRedeem` for pause and cap conditions; they must return 0 under any condition where the corresponding operation would revert
- Verify `totalAssets()` is consistent across all function paths; it must not return different values depending on call context
- Check ERC-4626 vaults used as CDP collateral: if the vault's `totalAssets` is manipulable, it constitutes an oracle manipulation surface

**False Positives**

- Vaults documented as intentionally non-standard with explicit deviation notes in the interface
- Rounding differences within the 1-wei tolerance the specification permits

**Notable Historical Findings**

Y2k Finance's vault was not ERC-4626 compliant despite inheriting the interface, causing integrators that relied on standard preview functions to compute incorrect expected outputs. Astaria's `ERC4626Router` had multiple functions that always reverted due to calling internal methods that had been removed from the underlying vault implementation. GoGoPool's `ggAVAX` had `maxWithdraw` and `maxRedeem` returning values larger than what was actually withdrawable, causing integrators to construct transactions that failed on execution.

**Remediation Notes**

Add a dedicated ERC-4626 compliance test suite that exercises every specified invariant against fuzz inputs, including edge cases at zero supply, maximum deposit cap, and paused state. Treat compliance test failures as build-blocking errors.

---

### Unsafe Token Interactions (ref: fv-sol-6)

**Protocol-Specific Preconditions**

- Lending protocols that accept multiple collateral types must handle tokens that do not conform to ERC-20: USDT requires approve-to-zero before a non-zero approval, some tokens do not return a boolean from `transfer`, and fee-on-transfer tokens deliver less than the requested amount
- Solmate's `SafeTransferLib` does not verify that the token address has deployed bytecode; calling `safeTransferFrom` on an address with no code succeeds silently, crediting a deposit that never arrived
- Fee-on-transfer tokens deposited as collateral credit the protocol-stated transfer amount rather than the amount actually received, creating a phantom collateral balance

**Detection Heuristics**

- Check whether `SafeTransferLib` is from Solmate (no code check) or OpenZeppelin `SafeERC20` (includes code check); for protocols accepting user-specified token addresses, the OpenZeppelin variant or an explicit code-length check is required
- Search for `IERC20.approve(spender, amount)` calls without a preceding `approve(spender, 0)`; for protocols that may be deployed with USDT as a supported collateral, this causes reverts on approval renewal
- For deposit functions that credit `amount` rather than `balanceAfter - balanceBefore`, verify the protocol explicitly excludes fee-on-transfer tokens or handles them with a balance-diff pattern

**False Positives**

- Protocols with an explicit collateral whitelist limited to known well-behaved tokens (DAI, WETH, WBTC, USDC)
- Protocols using OpenZeppelin `SafeERC20` throughout, which handles both the return-value problem and the code-existence check

**Notable Historical Findings**

Morpho had a vulnerability where Solmate's `SafeTransferLib` was used against a token that had not yet been deployed at the time of the call; the transfer succeeded silently, and the protocol credited the deposit, creating a claim against a non-existent balance. Notional and Connext both had USDT compatibility failures from direct `approve` calls without the zero-first reset. Backed Protocol's Papr Controller used an incorrect variant of `safeTransferFrom` that trapped fee tokens within the controller contract.

**Remediation Notes**

Establish a standard internal `_safeTransfer` library that (1) verifies code existence at the token address, (2) uses OpenZeppelin `SafeERC20`, and (3) applies a balance-diff check for collateral deposits. This library should be the sole approved method for all token interactions across the protocol.

---

### Signature and Replay Vulnerabilities (ref: fv-sol-4-c4, fv-sol-4-c10, fv-sol-4-c11)

**Protocol-Specific Preconditions**

- Lending protocols that accept signed commitments (e.g., for loan origination, collateral approval, strategy authorization) must include chain ID, contract address, nonce, and expiry in the signed digest
- EIP-712 struct hashes that omit fields from the type definition produce digests that are valid for more contexts than intended, allowing a signature issued for one purpose to authorize a different operation
- `ecrecover` returning `address(0)` for a malformed signature must be explicitly checked; failing to check allows any signature with invalid parameters to pass validation against a zero-address strategist or vault
- Meta-transaction and EIP-2612 permit flows that do not increment a nonce allow the same signature to be submitted multiple times

**Detection Heuristics**

- For every signature verification call, confirm the digest includes: `chainid`, `address(this)`, a nonce incremented on use, and a `deadline`
- Check the EIP-712 domain separator for all four required fields: name, version, chainId, verifyingContract
- Verify that `ecrecover` / `ECDSA.recover` return values are checked against both `address(0)` and the expected signer address in the same require
- For lending strategy or vault authorization signatures, verify the struct hash includes all parameters that distinguish one authorization from another (vault address, strategy type, rate limits)

**False Positives**

- Protocols deployed exclusively on a single chain with no cross-chain messaging and no future multi-chain plans
- Signatures that are inherently one-time-use through a consumed state flag independent of a nonce

**Notable Historical Findings**

Astaria had two signature-related highs: strategy signatures were forgeable because the struct hash omitted the vault address and deadline fields, allowing a valid signature for one strategy to be replayed against a different vault; and `ecrecover` was not checked against `address(0)`, allowing any malformed signature to pass validation. Biconomy had a cross-chain signature replay attack where signed meta-transactions lacked chain ID in the digest, allowing signatures intended for one chain to execute on another. SeaDrop's signed mint lacked replay protection, allowing the same permit to mint multiple times.

**Remediation Notes**

Use OpenZeppelin's `EIP712` base contract for domain separator construction; it correctly includes all four required fields and uses the current `block.chainid`, which prevents cross-chain replay even after a hard fork. Always use `SignatureChecker.isValidSignatureNow` rather than raw `ecrecover` to handle both EOA and EIP-1271 contract wallet signers correctly.

### Depeg of Pegged or Wrapped Asset Breaking Collateral Valuation (ref: pashov-13)

**Protocol-Specific Preconditions**

The lending protocol accepts pegged or wrapped assets as collateral (stETH, wstETH, WBTC, rETH, USDC-pegged stablecoins) and prices them using the underlying asset's oracle or assumes a fixed 1:1 exchange rate. No independent price feed exists for the derivative asset itself. During a depeg event, the collateral's actual market value diverges from the assumed value, overstating collateral backing and allowing undercollateralized borrows to persist or new ones to be opened.

**Detection Heuristics**

- Find all oracle price lookups for collateral assets and identify any that use the underlying asset's feed rather than a feed for the derivative itself (for example, an ETH/USD feed used to price stETH collateral).
- Identify hardcoded 1:1 ratios or assumptions such as `stETHPrice = ETHPrice` in collateral valuation or LTV computation.
- Check whether a configurable depeg threshold exists that triggers protective measures (LTV reduction, borrowing pause) when the derivative's price diverges from the peg beyond a tolerance.
- Verify that protocol documentation explicitly identifies the depeg assumption and its accepted risk level.

**False Positives**

- An independent price feed exists for the derivative asset (such as a dedicated stETH/USD feed) and is used in all collateral valuations.
- A configurable depeg tolerance triggers automatic LTV reduction or pool pause when the derivative/underlying ratio deviates beyond a defined threshold.
- Protocol documentation explicitly acknowledges and accepts depeg risk as a known limitation.

**Notable Historical Findings**

Wise Lending's farm exit assumed stETH redeems 1:1 with ETH, overstating position value when closing a farm during a period of stETH depeg. Notional Leveraged Vaults assumed Pendle PTs redeem at exactly 1.0 post-maturity, causing mispriced collateral and incorrect health factor calculations when redemption rates diverged.

**Remediation Notes**

Use a dedicated price feed for each derivative asset rather than assuming parity with the underlying. For assets where no on-chain feed exists, implement a deviation circuit breaker that compares a freshly queried exchange rate (from the protocol itself, such as `stETH.getPooledEthByShares(1e18)`) against the assumed value and pauses or adjusts LTV when the deviation exceeds a configured threshold.

---

### Small Positions Unliquidatable Due to Insufficient Incentive (ref: pashov-41)

**Protocol-Specific Preconditions**

Liquidation rewards are proportional to the collateral seized, meaning positions below a threshold USD size pay out a liquidation bonus insufficient to cover the gas cost of the liquidation transaction. No minimum position size is enforced at borrow time. Liquidators operating rationally skip these dust positions, allowing them to accumulate unchecked bad debt as collateral values decline.

**Detection Heuristics**

- Compute the minimum collateral value at which the liquidation bonus exceeds the estimated gas cost of a liquidation transaction at current gas prices. Verify the protocol enforces a minimum borrow size above this threshold.
- Check whether a minimum position size (`minBorrowAmount`, `dustThreshold`) is validated in `borrow` or `openPosition` entry points.
- Verify whether the protocol operates a liquidation bot that handles dust positions regardless of profitability.
- Review the protocol's bad debt socialization mechanism: is there an insurance fund, are losses haircut across depositors, or does bad debt accumulate indefinitely?

**False Positives**

- A minimum position size is enforced at borrow time set materially above the gas-cost break-even point for liquidation.
- The protocol operates a keeper network or liquidation bot that processes all undercollateralized positions regardless of profit.
- A socialized bad debt mechanism (insurance fund or depositor haircut) bounds the protocol's exposure to unliquidatable positions.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Enforce a minimum borrow size at origination that exceeds the gas cost of liquidation by a comfortable safety margin, accounting for gas price variability. When dust positions do accumulate (for example, through collateral value decline), implement a bad debt socialization mechanism or a protocol-operated liquidation that clears positions without requiring external liquidator incentive.

---

### Self-Liquidation Profit Extraction (ref: pashov-43)

**Protocol-Specific Preconditions**

The liquidation function does not prevent the borrower from liquidating their own position using a second address or a flash loan. The liquidation bonus or discount makes it profitable to deliberately allow a position to become slightly undercollateralized, liquidate it from a second address, and capture the incentive net of repayment cost.

**Detection Heuristics**

- Find the liquidation function and check for `require(msg.sender != borrower)` or equivalent that blocks self-liquidation.
- Compute whether the liquidation incentive minus the cost of being undercollateralized by the minimum threshold yields a net positive profit for the position owner.
- Check whether a flash loan can be used to fund the liquidation repayment, making capital requirements for self-liquidation effectively zero.
- Verify whether a liquidation penalty or fee applied to the borrower (not just a bonus to the liquidator) closes the profit window.

**False Positives**

- `require(msg.sender != borrower)` is present and validated for all liquidation entry points.
- The liquidation incentive is small enough (below gas cost threshold) that self-liquidation is net-negative after gas.
- A liquidation penalty charged to the borrower's collateral exceeds any discount or bonus the borrower would capture as liquidator.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Add `require(msg.sender != borrower)` to all liquidation functions. If `onBehalf` or proxy liquidation patterns are used, validate that neither the caller nor any direct beneficiary of the liquidation is the borrower. Calibrate the liquidation incentive to be large enough to attract liquidators in adverse conditions but small enough that self-liquidation is never profitable.

---

### Accrued Interest Omitted from Health Factor Calculation (ref: pashov-147)

**Protocol-Specific Preconditions**

The protocol's health factor or loan-to-value ratio is computed using the principal debt balance without first applying outstanding accrued interest. The health factor formula reads `collateralValue / principalDebt` rather than `collateralValue / (principalDebt + accruedInterest)`. Positions that are technically insolvent when interest is included appear healthy, delaying liquidations and accumulating bad debt.

**Detection Heuristics**

- Locate the health factor or LTV computation function. Check whether it calls an interest accrual function (`accrueInterest()`, `updateIndex()`) before reading the debt balance, or whether it reads a cached principal directly.
- Verify that `getDebt(user)` or equivalent returns the principal plus accrued interest, not principal only.
- Check whether the borrow index (interest multiplier) is applied to the stored debt shares before the health check compares against collateral value.
- Simulate a position that is healthy by principal alone but insolvent when interest is added; confirm the protocol's health check correctly identifies it as insolvent.

**False Positives**

- `getDebt()` already incorporates accrued interest through share-times-index multiplication before being returned.
- Interest accrual (`accrueInterest()`) is called unconditionally as the first statement of every health check function.
- The protocol compounds interest on every state-changing interaction, meaning the stored debt balance is always current.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Call interest accrual before any health factor or LTV check: place `accrueInterest()` at the top of `getHealthFactor()` and all liquidation trigger functions. Ensure `getDebt()` multiplies stored debt shares by the current borrow index rather than returning raw principal. Add an integration test that deposits collateral, borrows at the health limit, advances time to accrue interest, and confirms the position is correctly identified as liquidatable.

---

## reference/solidity/protocols/liquidity-manager.md

# Liquidity Manager Security Patterns

> Applies to: concentrated liquidity position managers, Arrakis-style, Gamma-style, Uniswap v3 position wrappers, automated liquidity rebalancers, vault-wrapped LP positions, tick-range managers

## Protocol Context

Liquidity managers sit as an abstraction layer on top of concentrated AMMs such as Uniswap v3, wrapping individual NFT positions into fungible vault shares that represent a range-bound LP strategy. Their correctness depends on external DEX state - tick price ranges, pool slot0, fee accrual checkpoints - that can be manipulated or go stale between rebalance operations. Because rebalancing events trigger collect, burn, and mint sequences on the underlying pool, they accumulate protocol fees and expose multiple re-entry and slippage surfaces within a single transaction. Many protocols additionally wrap positions in ERC-4626 vaults and couple governance, cross-chain bridges, or reward distribution to the same contract surface, compounding the attack area significantly.

## Bug Classes

### Reentrancy (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Collect/rebalance sequences call `nonfungiblePositionManager.collect()` before updating internal share accounting
- ERC-721 callbacks (`onERC721Received`) are triggered when minting or transferring position NFTs mid-transaction
- Wrapped position vaults accept ERC-777 or callback-capable tokens as deposit assets
- Cross-function re-entry paths exist between `rebalance`, `deposit`, and `claimRewards` when they share state variables such as `initialGas` or per-epoch accumulators

**Detection Heuristics**
- Identify all external calls in the rebalance flow: `collect`, `decreaseLiquidity`, `burn`, `mint`, `increaseLiquidity` - check that internal accounting is fully updated before any outbound call
- Search for `transfer`/`safeTransfer` calls preceding state updates in withdraw or close paths
- Check `anyExecute` or bridge callback functions for storage variables written early and read again after re-entry is possible
- Verify `nonReentrant` is applied to all user-facing entry points that touch LP state

**False Positives**
- Protocols that use `nonReentrant` at a router or facet level that covers all entry paths
- Functions that only read state and emit events
- Rebalance functions gated behind a `onlyOperator` modifier that limits who can trigger them

**Notable Historical Findings**
In Maia DAO, `RootBridgeAgent.retrySettlement()` lacked reentrancy protection, allowing an attacker to re-enter and reset `initialGas` storage before the gas payment logic executed, effectively stealing gas budget from the bridge agent. In Carapace, protection sellers exploited the withdrawal sequence to bypass the time-delay mechanism by re-entering `lockCapital` from an external call made mid-loop. In multiple Sudoswap audits, router callbacks and `assetRecipient` hooks were shown to allow re-entry that drained pair funds across swap and NFT batch operations. In Debt DAO, lenders exploited the check-effects-interactions violation in `_close` to drain more tokens than their credit balance by re-entering before the position was deleted.

**Remediation Notes**
Apply `nonReentrant` to every function in the rebalance and withdrawal surface. Follow check-effects-interactions strictly in collect sequences: update all share and fee accumulators before calling `collect` on the NFT manager. For callback-capable tokens used as vault assets, consider a reentrancy lock at the vault deposit/withdraw level. Never cache `initialGas` or epoch state in storage variables readable by re-entrant paths.

---

### Math and Precision Errors (ref: fv-sol-2 / fv-sol-3)

**Protocol-Specific Preconditions**
- Uniswap v3 `slot0` returns `sqrtPriceX96` in Q64.96 fixed-point; incorrect scaling produces catastrophic precision loss
- Fee accumulator updates in `_deposit` and `_withdraw` compound rounding errors across many small operations
- `tickCumulatives` from `observe()` require careful sign handling; hardcoded pool fee values break the TWAP derivation
- Uniswap v3 `swap()` returns signed `int256` amounts where exact-input produces negative output amounts; failing to negate before casting to `uint256` wraps to near-`type(uint256).max`
- `unchecked` blocks used in credit/debt accounting allow underflow when repayment exceeds outstanding principal

**Detection Heuristics**
- Search for `uint256(amount0)` or `uint256(amount1)` immediately following a `IUniswapV3Pool.swap()` call; verify negation is applied
- Check all fee accumulator updates in deposit and withdraw paths for division-before-multiplication
- Audit decimal normalization between token pairs with different `decimals()` values; verify `decimals()` is queried dynamically rather than hardcoded
- Scan `unchecked` subtraction blocks involving user-supplied amounts against internal balances

**False Positives**
- Negation before cast is correct when swap direction guarantees a positive return value
- Precision loss acknowledged as dust when bounded by a documented maximum tick range
- `unchecked` subtraction preceded by a `require(a >= b)` guard

**Notable Historical Findings**
RealWagmi contained a hardcoded pool fee in the `tickCumulatives` calculation, making the TWAP derivation wrong for all non-standard fee tiers. In the same audit, `slot0` was used as the price source for deposit decisions, making the vault trivially front-runnable. Maia DAO's `_gasSwapIn` omitted negation of the signed Uniswap v3 return value, causing the output amount to overflow into an astronomically large number that crashed downstream arithmetic. Notional Leveraged Vaults reported a decimal precision error that inflated prices by several orders of magnitude, enabling unauthorized liquidations and mis-valued vault shares.

**Remediation Notes**
Always negate Uniswap v3 `swap()` signed output before casting: `uint256(-(zeroForOne ? amount1 : amount0))`. Never hardcode fee tiers in TWAP or `tickCumulatives` math; derive them from the pool. Multiply before divide in all reward and fee accumulators. Use `SafeCastLib` for any narrowing cast and verify the upstream value cannot overflow the target type.

---

### Access Control (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Rebalance and range-adjustment functions that modify tick bounds or trigger collect-burn-mint sequences must be operator-only; unrestricted access lets any caller force costly rebalances or drain accrued fees
- Fee withdrawal functions on vault contracts often lack a recipient whitelist, enabling the operator to redirect protocol fees
- Approval-granting functions for external bridge or swap integrations are frequently left unguarded, allowing any caller to approve arbitrary spenders

**Detection Heuristics**
- Audit all `external` or `public` functions that call `collect`, `decreaseLiquidity`, `burn`, `mint`, or `increaseLiquidity` on the position manager
- Check fee setters and protocol fee withdrawal functions for unbounded fee rate parameters
- Search for functions that call `approve` or `safeApprove` on vault tokens where the spender address is caller-supplied
- Verify that `initialize` functions can only be called once and by the expected deployer or factory

**False Positives**
- Permissionless rebalance functions that are correct by design when tick-drift conditions are enforced on-chain before execution
- Operator-role functions where the operator is a secured multisig with timelocks

**Notable Historical Findings**
Maia DAO's `BoostAggregator` allowed the owner to set fees to 100%, directing all user rewards to the owner. The same audit found that `withdrawProtocolFees()` could be called to drain all accumulated rewards without a recipient whitelist. In LI.FI, `setApprovalForBridges` was callable by any address and could approve any token to any bridge contract, enabling total fund drainage. In Talos (Maia DAO), protocol fees accumulated inside vault contracts with no corresponding `withdrawProtocolFees` function, permanently locking them.

**Remediation Notes**
Gate all rebalance entry points with `onlyOperator` or equivalent. Add explicit upper bounds to all admin-settable fee parameters. Restrict approval-granting functions to owner-only and require the spender to be on a whitelist. Add a `withdrawProtocolFees` function to all vault contracts that accumulate fees. Never use caller-supplied addresses as sole authorization evidence.

---

### Logic Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Fund Lock: rebalance operations that transfer LP NFTs or underlying tokens through intermediate steps can strand assets if a step reverts or a destination contract lacks a `receive()` fallback
- Fee/Royalty Distribution in LP context: fee double-counting between collect and mint steps; protocol fees trapped inside position vault contracts without a withdrawal mechanism
- Tick-range rebalance flaws: strategy contracts that compute new tick ranges without validating against current `slot0` or TWAP allow ranges to be set outside valid Uniswap tick bounds; `init()` and `rebalance()` paths often lack slippage guards on the resulting liquidity amounts

**Detection Heuristics**
- Trace every fund flow through rebalance: verify that collect proceeds, burnt liquidity, and minted liquidity deltas are fully reconciled with vault share accounting
- Check if `amount0Min` / `amount1Min` parameters on `increaseLiquidity` and `decreaseLiquidity` calls are set to zero (hardcoded) or derived from on-chain spot price
- Look for multi-step operations (swap-then-bridge, collect-then-reinvest) where partial failure at step N does not return step 1..N-1 assets to users
- Verify excess `msg.value` is refunded in payable rebalance or deposit functions

**False Positives**
- Zero minimum amounts are acceptable when the caller is a trusted operator contract that enforces slippage off-chain
- Intermediate custody during rebalance is acceptable when the full sequence executes atomically in a single transaction with no external calls that can revert midway

**Notable Historical Findings**
Maia DAO Talos vault contracts trapped protocol fees permanently because the fee accumulation logic in `rerange()` called `collect()` but there was no function to withdraw the resulting balance. In LI.FI, cross-chain operations that routed through Axelar had no recovery path for failed destination executions, permanently locking bridged tokens. In multiple Maia DAO bridge agent findings, partial failure during multi-step settlement left user deposits in an irrecoverable intermediate state without a valid nonce for retry. RealWagmi's `rebalanceAll` had no slippage protection on either the withdraw or the deposit leg of the rebalance, making it profitable to sandwich.

**Remediation Notes**
Ensure every `collect` call is matched by a corresponding fee accounting update and that accumulated protocol fees have an owner-callable withdrawal path. Set `amount0Min` and `amount1Min` using TWAP-derived bounds rather than zero. Implement recovery maps for cross-chain operations keyed by sender and transfer timestamp. Verify tick bounds against `TickMath.MIN_TICK` / `MAX_TICK` and the pool's `tickSpacing` before submitting to the position manager.

---

### ERC-4626 Vault Flaws (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Liquidity manager vaults that wrap LP positions in ERC-4626 inherit share price manipulation risk at first deposit, but additionally face the edge case where `totalAssets()` drops to zero if all liquidity is burned and fees not yet collected
- `maxWithdraw` and `maxRedeem` must account for rebalance lock periods and operator-imposed withdrawal gates; returning non-zero when the vault is in a rebalancing state violates EIP-4626 and causes integration failures
- Conversion rate mechanisms relying on governance token balances within the vault (e.g., vMaia) are vulnerable to dilution by internal minting sequences

**Detection Heuristics**
- Check that `previewDeposit` and `previewRedeem` do not divide by zero when `totalAssets()` is zero but `totalSupply()` is non-zero (possible after a full liquidity burn)
- Verify `maxWithdraw` returns zero during rebalance lockout windows and not just during explicit pause states
- Check `convertToShares` rounds down and `convertToAssets` rounds down, favouring the vault
- Audit any logic that mints extra governance or utility tokens inside the vault for conversion rate side-effects

**False Positives**
- Virtual shares offset (OpenZeppelin `_decimalsOffset`) correctly mitigates first-depositor inflation and is a known-good pattern
- Non-zero `maxWithdraw` during a pause is acceptable when the pause only affects new deposits, not existing withdrawals

**Notable Historical Findings**
Maia DAO's `vMaia` `maxWithdraw` and `maxRedeem` did not return zero during the monthly withdrawal window restriction, violating EIP-4626. The same audit found that internal governance token minting within the vault disrupted the conversion rate mechanism, allowing dilution of existing share holders. In Y2k Finance, the vault was verified non-compliant with EIP-4626 because `previewDeposit` did not account for the zero-supply edge case. Carapace reported a freeze condition where `totalSTokenUnderlying` dropped to zero while shares remained outstanding, causing all subsequent deposit previews to return zero.

**Remediation Notes**
Guard `previewDeposit` and `previewRedeem` against zero-`totalAssets` by treating the vault as 1:1 when either supply or assets are zero. Override `maxWithdraw` and `maxRedeem` to reflect all protocol-level restrictions including rebalance locks and epoch gates. Use virtual shares (offset by at least `10^decimalsOffset`) to prevent first-depositor inflation without relying on minimum deposit enforcement.

---

### Slippage and MEV (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- Automated rebalancers must trigger `decreaseLiquidity` → `collect` → `mint` with no user present to supply slippage bounds; protocols that hardcode `amount0Min = 0` on these calls are fully sandwichable
- `slot0.sqrtPriceX96` is used as the reference price for computing new tick range mid-points; this is trivially manipulated within a block
- `deadline: block.timestamp` on position manager calls provides no real deadline protection for pending transactions sitting in the mempool
- Publicly callable premium accrual or reward distribution functions can be sandwiched to extract value from the resulting state change

**Detection Heuristics**
- Grep for `amount0Min: 0` and `amount1Min: 0` in `IncreaseLiquidityParams` and `DecreaseLiquidityParams` structs
- Search for `slot0()` calls where the result feeds into a swap price limit or a tick range calculation
- Check all swap router calls for `deadline` parameter sourced from `block.timestamp` only, with no user-supplied expiry
- Look for permissionless epoch-advance or accrual functions that materially change token exchange rates

**False Positives**
- `deadline: block.timestamp` is acceptable when the function is restricted to a trusted keeper or operator that submits atomically
- Zero minimum amounts are acceptable when the caller validates slippage in the same transaction using a pre/post balance check

**Notable Historical Findings**
RealWagmi's `rebalanceAll` set both `amount0Min` and `amount1Min` to zero for both the withdraw and deposit legs, making every rebalance a profitable sandwich target. Maia DAO's `TalosBaseStrategy.init()` similarly omitted slippage protection entirely during initial liquidity provisioning, allowing an attacker to front-run the initialization and steal a portion of deposited assets. The Maia DAO `_gasSwapIn` used `slot0` to derive `sqrtPriceLimitX96`, making every gas swap trivially manipulable. Carapace's `accruePremiumAndExpireProtections()` was exploitable via sandwich because it was public and modified the protection pool exchange rate.

**Remediation Notes**
Never hardcode `amount0Min = 0` on operator-triggered rebalances; derive minimum amounts from a TWAP with a configurable tolerance (e.g., 1%). Replace `slot0` price references with `observe()`-based TWAP for all swap price limits used in rebalance logic. Accept a caller-supplied `deadline` parameter distinct from `block.timestamp` for any operation that can remain in the mempool. Restrict premium accrual and distribution functions to keepers with a minimum interval between calls.

---

### Denial of Service (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- Protocols that iterate over all active positions to accrue premiums or compute epoch totals are vulnerable when position count grows without bound
- External calls to lenders, protection buyers, or reward recipients inside loops allow a single malicious actor to block the entire iteration by deploying a reverting receiver
- Rebalance or liquidation functions that depend on full-collection iteration over positions cannot complete if the collection has been grown by a griefing attacker

**Detection Heuristics**
- Search for `for` loops whose upper bound is `activePositions.length` or equivalent dynamic storage array length
- Verify that any user can increase the iterable collection size for a cost lower than the gas saved by disruption
- Check if liquidation or epoch-settlement functions can be individually skipped or if a single revert aborts the entire batch
- Look for external token transfers to arbitrary recipient addresses within loop bodies

**False Positives**
- Collections bounded by a protocol constant below the block gas limit
- Batch functions with pagination parameters that allow the caller to split work across multiple transactions

**Notable Historical Findings**
Carapace's `accruePremiumAndExpireProtections` iterated over an unbounded array of active protections; an attacker could cheaply create enough positions to push the function past the block gas limit, freezing premium accrual permanently. Maia DAO's `_decrementWeightUntilFree` contained a possible infinite loop that could halt gauge weight removal. Debt DAO's lender callback pattern allowed a malicious lender to deploy a reverting contract as their address, preventing any position closure. Accountable's withdrawal queue was permanently blocked by a sequence of cancelled redeem requests that left the queue head pointing at a non-processable entry.

**Remediation Notes**
Add a `MAX_BATCH_SIZE` constant and paginate all collection-iterating functions. Use a pull-payment pattern for lender and protection buyer payouts rather than pushing inside loops. Enforce a minimum cost to add entries to iterable collections. Design epoch-settlement functions to be skippable per-entry so a single bad actor cannot block the entire epoch.

---

### Oracle / Price Feed Manipulation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- `slot0.sqrtPriceX96` is the most-used price source in Uniswap v3 position managers and is manipulable within a single block with sufficient capital
- TWAP derivation from `tickCumulatives` requires correct fee-tier-specific tick spacing; hardcoding the fee causes miscalculation for pools with non-default fees
- Chainlink feeds used for vault share valuation or rebalance triggers must be validated for staleness and decimal consistency with the paired asset feed

**Detection Heuristics**
- Find all `slot0()` calls; determine if the result is used for any valuation, swap limit, or liquidation decision
- Verify that `latestRoundData()` return values are fully validated: `price > 0`, `answeredInRound >= roundID`, `block.timestamp - updatedAt < MAX_STALENESS`
- Check that `priceFeed.decimals()` is queried dynamically before any normalization arithmetic
- Audit compositions of two price feeds to confirm intermediate normalization to a common scale before division

**False Positives**
- `slot0` used purely for range visualization in a `view` function with no on-chain economic effect
- Chainlink staleness tolerance intentionally relaxed for ETH/USD feeds with documented justification

**Notable Historical Findings**
RealWagmi used `slot0` for both deposit price decisions and tick range calculations, enabling profitable sandwich attacks on every deposit and rebalance. Y2k Finance's `PegOracle` composed two Chainlink feeds without normalizing decimals, causing the ratio to be off by a factor of `10^10` for 18-decimal feeds. Maia DAO used `slot0` to derive `sqrtPriceLimitX96` in its gas swap path, making the swap price trivially manipulable. Notional Leveraged Vaults reported that wrong decimal precision in vault share valuation inflated reported prices and caused incorrect liquidation thresholds.

**Remediation Notes**
Replace `slot0` with a TWAP derived from `IUniswapV3Pool.observe()` for all economically consequential price reads; use a minimum observation window of at least five minutes. Validate all Chainlink responses against a staleness threshold. Normalize both feeds to 18 decimals before composing them into a ratio. Query `decimals()` dynamically and validate that the result is within expected bounds before using it in arithmetic.

---

### Fee and Royalty Distribution in LP Context (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- LP fee collection from Uniswap v3 positions (`nonfungiblePositionManager.collect`) produces two token amounts that must be correctly split between protocol, operators, and depositors
- Fee accounting in `_deposit` and `_withdraw` must be updated atomically with share minting/burning; deferred updates allow new depositors to claim a share of fees they did not earn
- Protocol fee accumulators inside position vault contracts often have no corresponding withdrawal path, causing permanent fund lock

**Detection Heuristics**
- Trace the output of every `collect()` call: verify the two token amounts are accounted for exactly once in the vault's internal fee ledger
- Check if the protocol fee percentage setter has an upper bound; an unbounded setter allows the operator to extract 100% of collected fees
- Verify that a `withdrawProtocolFees` function exists and is restricted to a whitelisted recipient
- Check if fee updates in `_deposit` and `_withdraw` run before share changes to prevent new depositors claiming historic fees

**False Positives**
- Protocols where all collected fees are immediately reinvested and no protocol fee is taken
- Vaults that distribute fees proportionally at the share level with no separate accumulator

**Notable Historical Findings**
RealWagmi reported multiple fee calculation errors: fees were incorrectly updated in both `_deposit` and `_withdraw` functions, causing new depositors to dilute accrued fees and existing depositors to receive less than their entitlement. Maia DAO's Talos vault contracts permanently trapped protocol fees because `rerange()` called `collect()` but no withdrawal mechanism existed. In Golom, the protocol fee was double-counted - subtracted from seller payout and added to buyer cost simultaneously - doubling the effective fee rate. Carapace's sandwich vulnerability in `accruePremiumAndExpireProtections` allowed an attacker to enter and exit just before and after premium accrual, extracting fees intended for long-term protection sellers.

**Remediation Notes**
Update fee accumulators before minting or burning shares in every deposit and withdraw path. Add an explicit `withdrawProtocolFees(address token, address recipient) external onlyOwner` function to all position vaults. Cap the protocol fee rate with an immutable constant. Validate that `collect()` output tokens are reconciled with the vault's internal token0/token1 balance tracking within the same transaction.

---

### Fund Lock in Position NFTs (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- LP position NFTs represent illiquid, range-bound capital; if the NFT is transferred to a contract that does not implement `onERC721Received`, all underlying liquidity becomes permanently inaccessible
- Multi-step rebalance operations that burn a position NFT and attempt to mint a new one can fail at the mint step, leaving the vault without a valid position NFT and the underlying tokens sitting in the vault contract without a way to re-enter a position
- Cross-chain bridge operations initiated from a liquidity manager during yield-routing or fee-compounding have no recovery path if the destination execution fails

**Detection Heuristics**
- Check what happens to vault state if `mint` fails after a successful `burn` in the rebalance path; verify there is an emergency recovery function that can re-enter a position using the held token balances
- Trace excess `msg.value` paths in payable rebalance or deposit functions; unspent ETH must be refunded or tracked
- Check all cross-chain dispatch calls for a corresponding recovery or retry mechanism indexed by the originating sender

**False Positives**
- Vaults where rebalance is atomic (burn+mint in same call to position manager) and the manager reverts the entire call on failure
- Protocols with a `rescueTokens` or `emergencyWithdraw` owner function that can recover stranded assets

**Notable Historical Findings**
Maia DAO reported multiple cases where bridge agent multi-step operations (deposit → bridge → settle) left user funds in irrecoverable intermediate states when any step failed, with no valid nonce available for retry. LI.FI's Axelar integration transferred tokens cross-chain with no recovery mechanism for failed destination execution. In Notional Leveraged Vaults, Lido and EtherFi withdrawal limitations caused `_finalizeCooldown` to revert in edge cases, permanently bricking withdrawal for affected users. The LI.FI `WithdrawFacet` used `payable.transfer()` for ETH sends, which fails silently for contract recipients with non-trivial fallback logic, locking ETH inside the facet.

**Remediation Notes**
Make rebalance atomic by using a single `multicall` to the position manager where possible. Add an emergency function `recoverPosition(uint256 amount0, uint256 amount1, int24 tickLower, int24 tickUpper)` callable only by the owner when no active position exists. Replace all `payable.transfer()` with `(bool ok,) = recipient.call{value: amount}("")` and require `ok`. Implement a keyed recovery map for all cross-chain transfers indexed by sender and block timestamp.

---

### Non-Standard ERC-20 Token Handling (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Vaults that accept arbitrary deposit tokens without enforcing a whitelist are exposed to fee-on-transfer tokens that reduce actual received amounts below recorded deposits
- Rebasing tokens (stETH, aTokens) used as underlying assets cause vault `totalAssets` to drift from internal accounting over time
- USDT-style tokens require the allowance to be zeroed before setting a new non-zero approval; `safeApprove` reverts if called with a non-zero existing allowance

**Detection Heuristics**
- Search for `transferFrom` calls where the `amount` parameter feeds directly into state accounting without a before/after balance measurement
- Check for `safeApprove` calls that may execute when residual allowance is non-zero (e.g., after a partial bridge consumption)
- Verify that `totalAssets()` in any ERC-4626 wrapper over staked or rebasing tokens updates to reflect rebasing rather than using a cached deposit sum

**False Positives**
- Vaults with an explicit token whitelist that excludes fee-on-transfer and rebasing tokens
- `forceApprove` (OZ v5) or explicit zero-then-set patterns that handle USDT correctly

**Notable Historical Findings**
Y2k Finance's vault accepted fee-on-transfer tokens in multiple critical paths, causing the depeg trigger that transferred the full recorded balance to revert because actual balance was lower. Cally vaults allowed rebasing tokens, causing the vault to silently accumulate or lose value relative to share accounting between operations. Notional Leveraged Vaults reported that EtherFi rebase tokens transferred 1–2 wei less than requested, causing `_initiateWithdrawImpl` to revert during the withdrawal finalization. In Backed Protocol, `PaprController` paid Uniswap swap fees from protocol funds rather than the user's input, effectively subsidizing trades from vault reserves.

**Remediation Notes**
Measure actual received amounts using before/after `balanceOf` snapshots on every `transferFrom`. Exclude fee-on-transfer and rebasing tokens via an explicit allowlist enforced in the token registration function. Use `forceApprove` or zero-then-set for all allowance updates to bridge and swap integrations. Document clearly in the vault interface which token behaviors are not supported.

---

### Reward Distribution (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- Gauge-based reward systems that couple LP position size to voting-escrow token balances require atomic updates to both the position and the gauge weight; desyncing these creates phantom gauge weight or loss of boost
- New LP depositors who are not initialized with the current `rewardPerToken` accumulator immediately earn a share of all historically undistributed rewards
- Removing a bribe flywheel from a gauge without removing the reward asset from the rewards depot leaves orphaned reward tokens that can be claimed by front-running the next `addBribeFlywheel` call
- Gauge reward queuing must occur every epoch; missed epochs permanently slash the unqueued rewards

**Detection Heuristics**
- Check that `deposit` and `mint` functions call `_updateReward(account)` before minting new shares
- Look for `notifyRewardAmount` functions callable repeatedly with dust amounts that extend the reward period and dilute rate
- Audit gauge deprecation and re-addition sequences for epoch-boundary accounting gaps
- Verify that `userRewardPerTokenPaid` is initialized to `rewardPerTokenStored` at the moment of first deposit, not left at zero

**False Positives**
- Protocols where new depositors explicitly share in undistributed rewards by documented design
- Reward distribution guarded by Merkle proofs computed off-chain at epoch snapshot time

**Notable Historical Findings**
Maia DAO reported that re-adding a deprecated gauge before calling `updatePeriod()` in a new epoch left some rewards permanently unclaimable due to an off-by-one in the epoch boundary check. Removing a `BribeFlywheel` from a gauge did not remove the associated reward asset from the depot, allowing a malicious user to front-run the next `addBribeFlywheel` call and steal all accumulated bribe rewards. GrowthDeFi WHEAT showed that new depositors immediately received a share of rewards accumulated before their deposit, which was exploited by sandwiching `gulp()` calls. Y2k Finance's `StakingRewards` reward rate could be dragged out indefinitely by calling `notifyRewardAmount` with tiny amounts, diluting the effective rate for existing stakers.

**Remediation Notes**
Call `_updateReward(msg.sender)` as the first line of every `deposit`, `mint`, and `withdraw` function. Initialize `userRewardPerTokenPaid[account]` to `rewardPerTokenStored` at first stake. Add a minimum reward notification amount to prevent rate dilution griefing. Design gauge deprecation to atomically remove both the gauge and all associated bribe flywheel reward assets. Ensure epoch-advance functions can be called permissionlessly to prevent missed-epoch slashing due to keeper failure.

## reference/solidity/protocols/nft-gaming.md

# NFT and Gaming Protocol Security Patterns

> Applies to: NFT marketplaces, NFT minting contracts, play-to-earn games, on-chain games, gamefi, NFT staking, trait-based NFTs, SeaDrop-style, OpenSea-style

## Protocol Context

NFT and gaming protocols combine token ownership semantics with game-state logic, creating a broader attack surface than pure DeFi. ERC-721 and ERC-1155 receiver callbacks (`onERC721Received`, `onERC1155Received`) make any external call during minting or transfer a potential reentrancy vector, and the fact that recipients can be contract wallets compounds this. Randomness is structurally adversarial in this domain: on-chain entropy is manipulable, VRF draw mechanics can be gamed by controlling subscription funding or redraw timing, and deterministic attribute generation in the same transaction as minting enables selective revert attacks. Royalty enforcement and metadata mutability (centralized URIs, mutable trait registries) represent trust assumptions that are frequently underspecified and often exploited.

---

## Bug Classes

### Reentrancy in Reward and NFT Claiming (ref: fv-sol-1)

**Protocol-Specific Preconditions**

The claiming function mints or transfers ERC-721/ERC-1155 tokens to `msg.sender` or an arbitrary address before finalizing state. The recipient is not guaranteed to be an EOA. Nonces, round counters, or claim flags are written after the external call. No `nonReentrant` guard is present on the function or its cross-entry equivalents.

**Detection Heuristics**

- Identify all functions invoking `_safeMint`, `safeTransferFrom`, or delegated minting helpers (e.g., `mintFromMergingPool`) and check whether state checkpoints precede the call.
- Trace nonce or claim-counter writes relative to token transfers in order-matching flows; a nonce set after the loop is a reliable indicator.
- Check whether a `nonReentrant` modifier is applied to all execution paths that ultimately call into the same state (separate entry points such as `takeOrders` can re-enter `matchOneToManyOrders`).

**False Positives**

- All relevant state (nonces, claim counters, balances) is finalized before any external call, satisfying Checks-Effects-Interactions.
- `_mint` is used instead of `_safeMint`, eliminating the callback.
- The recipient is verified to be an EOA at the call site.
- A `nonReentrant` modifier is applied consistently across all entry points touching shared state.

**Notable Historical Findings**

In the AI Arena audit, a `claimRewards` function iterated over unclaimed rounds and called an external `mintFromMergingPool` helper for each winning round before advancing `numRoundsClaimed`. A contract-wallet recipient could re-enter through `onERC721Received` and claim the same rounds repeatedly. The Infinity NFT Marketplace had a parallel pattern where an order nonce was written after all token and payment transfers in `matchOneToManyOrders`, allowing a re-entrant call to the same function to replay the settlement with the same nonce still unset.

**Remediation Notes**

Apply `nonReentrant` to all claim and order-execution functions, and move all state writes (nonces, round pointers, claim flags) above the first external call. Prefer `_mint` over `_safeMint` when the callback is not functionally required; this eliminates the reentry surface entirely without breaking token receipt for EOAs.

---

### Staking Rounding and Dust Amount Exploits (ref: fv-sol-2)

**Protocol-Specific Preconditions**

The protocol computes a staking factor or multiplier from the staked amount using integer division with a large denominator (e.g., `/ 10**18`) or a `sqrt` operation. A floor-to-one pattern rounds zero results up to one, granting disproportionate rewards for dust stakes. No minimum stake amount is enforced. Share-based staking pools may be vulnerable to first-depositor inflation if share calculation depends on current pool balance without dead-share protection.

**Detection Heuristics**

- Locate staking factor calculations using `sqrt` or large-denominator division on the staked amount; evaluate the minimum stake that avoids truncation to zero.
- Check for `if (factor == 0) { factor = 1; }` patterns that assign rewards to dust positions.
- Verify whether `curStakeAtRisk` or a loss penalty also truncates to zero for small stakes, creating asymmetric risk/reward.
- For vault/pool patterns, evaluate whether an attacker can donate tokens directly to the contract before the first deposit to inflate the share price to zero.

**False Positives**

- A minimum stake threshold is enforced at a level that prevents the divisor from reducing to zero.
- The staking factor is not used in reward distribution for zero-result inputs (protocol separately gates on minimum stake).
- Dead-shares pattern is used: initial shares minted to a burn address prevent inflation attack on first deposit.

**Notable Historical Findings**

In AI Arena, the staking factor was computed as `sqrt((amountStaked + stakeAtRisk) / 10**18)` with a floor of one. A user staking one wei obtained `stakingFactor = 1`, identical to a user with up to 3.99 tokens staked, yet `curStakeAtRisk` rounded to zero for such a tiny position, producing zero downside and positive upside. A separate inflation attack finding in the Stakepet protocol showed that a direct token donation to the contract before the first depositor allowed share issuance to round to zero, effectively stealing the deposit.

**Remediation Notes**

Enforce a minimum stake amount that guarantees the staking factor exceeds zero without artificial rounding. Remove floor-to-one behavior; revert or skip reward accounting for positions below threshold. For vault patterns, mint initial dead shares to a burn address in the constructor to make donation-based inflation economically infeasible.

---

### ETH Handling and Overpayment Loss (ref: fv-sol-6)

**Protocol-Specific Preconditions**

The contract accepts raw ETH via `payable` functions for NFT order execution. Pricing is dynamic (Dutch auction or reverse Dutch auction), making exact payment amounts unpredictable at submission time. The same function handles both ETH and ERC-20 payment paths without rejecting `msg.value > 0` on the ERC-20 path. A rescue or withdrawal function exists but mistakenly operates on `msg.value` of the rescue call rather than `address(this).balance`.

**Detection Heuristics**

- Identify `payable` functions that check `msg.value >= price` but do not refund `msg.value - price`.
- Check all ETH rescue or fee-withdrawal functions: verify they use `address(this).balance`, not `msg.value`.
- For functions supporting both ETH and ERC-20 currencies, verify that the ERC-20 path explicitly requires `msg.value == 0`.
- In auction contexts, estimate the maximum spread between submitted ETH and final price to quantify exposure.

**False Positives**

- The contract enforces exact ETH amounts and reverts on overpayment (no dynamic pricing).
- Separate functions exist for ETH and ERC-20 paths with no shared `payable` entry point.
- A WETH wrapping pattern is used throughout, eliminating raw ETH handling.
- The rescue function correctly references `address(this).balance`.

**Notable Historical Findings**

The Infinity NFT Marketplace contained three ETH-handling failures reported together. Overpayment on `takeOrders` was silently accepted without refund. The `rescueETH` function sent `msg.value` from the rescue call itself rather than the contract's accumulated balance, leaving all protocol fees permanently locked. A third path allowed ETH to be sent alongside ERC-20 orders with no rejection, causing the ETH to be irrecoverably trapped in the contract.

**Remediation Notes**

After computing the total price for an order, calculate and refund the excess: `if (excess > 0) msg.sender.call{value: excess}("")`. Require `msg.value == 0` on all ERC-20 execution paths. Implement `rescueETH` as a non-`payable` function that sends `address(this).balance` to the destination.

---

### Irrevocable Privileged Roles (ref: fv-sol-4)

**Protocol-Specific Preconditions**

The contract uses role-based access control (custom mapping or OpenZeppelin `AccessControl`) but provides functions only to grant roles, not revoke them. `DEFAULT_ADMIN_ROLE` is never assigned in the constructor, preventing governance over role assignment. Boolean permission mappings are only set to `true` with no mechanism to restore `false`. The deprecated `_setupRole` is used instead of `_grantRole`, bypassing the admin system.

**Detection Heuristics**

- For every `addX` or `setAllowed` function that grants a role or sets a permission to `true`, check whether a corresponding revocation function exists.
- In `AccessControl` contracts, verify that `DEFAULT_ADMIN_ROLE` is granted to an appropriate address during construction.
- Search for `_setupRole` usage; it is deprecated and does not propagate admin relationships correctly.
- Check permission mappings for write-only `true` assignments with no matching `false` path.

**False Positives**

- The contract is intentionally immutable and roles are assigned once at deployment with no operational need for revocation.
- A proxy or upgrade pattern allows role corrections via an upgrade transaction.
- `DEFAULT_ADMIN_ROLE` is properly assigned and the standard `revokeRole` interface is accessible.
- A governance timelock can force role changes through proposals.

**Notable Historical Findings**

In the AI Arena contracts, `Neuron` (the ERC-20 token) exposed `addMinter`, `addStaker`, and `addSpender` functions but no corresponding removal functions. The `DEFAULT_ADMIN_ROLE` was never granted, so even the owner could not revoke roles through the `AccessControl` interface. The `GameItems` contract had a `setAllowedBurningAddresses` function that wrote `true` to a mapping but provided no way to revoke burning access once granted. A separate finding in OnchainHeroes documented a case where a missing access check on a burn function allowed unauthorized token destruction.

**Remediation Notes**

Grant `DEFAULT_ADMIN_ROLE` to the owner in the constructor and use `_grantRole`/`revokeRole` throughout. Replace one-directional `addX` functions with toggle functions accepting an `bool access` parameter. Replace all `_setupRole` calls with `_grantRole`.

---

### Admin Timelock and Recovery Abuse (ref: fv-sol-4)

**Protocol-Specific Preconditions**

The contract has admin-controlled recovery functions gated by a timelock. The timelock is anchored to contract initialization rather than to the relevant game event (e.g., draw completion). Time constants are calculated incorrectly, skewing the intended duration. Admin-changeable parameters (fees, gas cost multipliers) apply retroactively to existing orders with no upper bound.

**Detection Heuristics**

- Check what event anchors recovery timelocks; verify that the anchor is updated when the event recurs (e.g., each new draw resets the recovery window).
- Audit constant time-unit calculations manually: `(3600 * 24 * 7) * 30` produces a 7-month value, not a 1-month value.
- For every admin-settable parameter (fees, gas units, thresholds), verify an upper bound exists and that changes do not retroactively affect in-flight orders.
- Check if admin can block required protocol functions (e.g., refusing to fund VRF subscription, not calling `startDraw`).

**False Positives**

- The admin is a DAO or multisig with a transparent governance process and its own timelock.
- Parameter changes only apply to newly created orders or future rounds.
- Upper bounds on all changeable parameters prevent economically meaningful exploitation.

**Notable Historical Findings**

In the Forgeries raffle protocol, the `lastResortTimelockOwnerClaimNFT` function allowed the draw organizer to reclaim the escrowed NFT after a cooldown anchored to contract initialization, not draw completion. Because the draw could be started well after deployment, the organizer could claim the NFT before any draw result was finalized. A separate constant bug in the same protocol produced a `MONTH_IN_SECONDS` value seven times larger than intended because the weekly multiplier was applied to the monthly calculation. The Infinity NFT Marketplace had `updateWethTransferGas` with no upper bound, allowing the owner to inflate gas costs charged to buyers arbitrarily.

**Remediation Notes**

Reset the recovery timelock inside `fulfillRandomWords` or the equivalent draw-completion callback, not at initialization. Compute time constants using base units: `(3600 * 24) * 30` for one month. Cap all admin-settable parameters and consider a commit-delay before parameter changes take effect for values that affect existing orders.

---

### Incomplete Transfer Restriction Bypass (ref: fv-sol-5)

**Protocol-Specific Preconditions**

Custom transfer restrictions are implemented by overriding only a subset of transfer functions in an ERC-721 or ERC-1155 base contract. ERC-721 exposes three public transfer entry points; ERC-1155 exposes `safeTransferFrom` and `safeBatchTransferFrom`. Tokens have protocol-meaningful transfer-blocking properties such as staking locks, non-transferability flags, or in-game state dependencies.

**Detection Heuristics**

- For ERC-721 contracts with custom transfer checks, confirm all three variants are overridden: `transferFrom`, `safeTransferFrom(address,address,uint256)`, and `safeTransferFrom(address,address,uint256,bytes)`.
- For ERC-1155, confirm `safeBatchTransferFrom` is overridden with the same restriction logic as `safeTransferFrom`.
- Prefer audit via `_beforeTokenTransfer` / `_update` hooks, which intercept all transfer paths from a single override point.
- Check whether any staking or lock state is maintained in a separate mapping and verify it is checked on all transfer paths.

**False Positives**

- `_beforeTokenTransfer` or `_update` hooks are used (these intercept all transfer variants).
- The base contract only exposes one transfer function.
- Transfer restrictions are purely cosmetic and do not affect protocol invariants or game state.

**Notable Historical Findings**

In the AI Arena audit, `FighterFarm` overrode `transferFrom` and the three-argument `safeTransferFrom` with an `_ableToTransfer` check but left the four-argument `safeTransferFrom(address,address,uint256,bytes)` inherited and unchecked, allowing locked fighters to be transferred freely. A second finding on the same codebase showed that `GameItems` overrode `safeTransferFrom` to enforce a `transferable` flag but omitted an override for `safeBatchTransferFrom`, so non-transferable items could be moved in batch without restriction. A third variant showed that the daily allowance replenishment check could be bypassed by using `safeTransferFrom` with an alias account.

**Remediation Notes**

Override `_beforeTokenTransfer` (OZ v4) or `_update` (OZ v5) instead of individual public functions; this single hook intercepts every transfer path including future standard extensions. If individual overrides are unavoidable, maintain a checklist against the base contract's full interface and add a CI test that calls each variant.

---

### NFT Transfer Standard Interface Confusion (ref: fv-sol-5)

**Protocol-Specific Preconditions**

The contract handles transfers of NFTs that may implement ERC-721, ERC-1155, or both simultaneously. Interface detection via `supportsInterface` selects the transfer path. The dispatch logic falls through silently when no recognized interface is detected. Some production NFT collections (e.g., The Sandbox ASSET token) implement both ERC-721 and ERC-1155, triggering the wrong dispatch branch when ERC-721 is checked first.

**Detection Heuristics**

- Verify that a `revert` is present when neither `0x80ac58cd` (ERC-721) nor `0xd9b67a26` (ERC-1155) is detected; silent return on an unrecognized collection means buyer pays but receives nothing.
- Check the priority of interface checks: ERC-1155 should be checked before ERC-721 for dual-standard tokens.
- Confirm that `numTokens` from ERC-1155 orders is respected if execution falls into the ERC-721 path.
- Verify that duplicate token IDs within the same collection are detected and rejected to prevent inflated order quantities.

**False Positives**

- The contract maintains a whitelist of supported collections validated at order creation.
- The fallthrough case contains an explicit `revert`.
- The marketplace explicitly documents that dual-standard tokens are unsupported and enforces this with a collection registry.

**Notable Historical Findings**

The Infinity NFT Marketplace checked for ERC-721 support before ERC-1155, so a dual-standard token (a real category in production) would always enter the ERC-721 path and discard quantity information. A separate finding showed that the dispatch returned without reverting when neither interface was detected, meaning payments were settled and sellers received proceeds while buyers received no NFTs. A third variant demonstrated that the `canExecTakeOrder` matching function could be bypassed by supplying duplicate token IDs, allowing inflated order sizes to be validated.

**Remediation Notes**

Check ERC-1155 before ERC-721 in all dispatch logic to handle dual-standard tokens correctly. Always add a terminal `revert("Unsupported NFT standard")` branch. Validate that token ID arrays contain no duplicates within a single collection before executing any order.

---

### Order Matching Validation Gaps (ref: fv-sol-5)

**Protocol-Specific Preconditions**

An NFT marketplace matches buy orders with sell orders, either on-chain or via an off-chain engine. Order matching logic validates token count, price, and intersection but allows empty `tokenIds` arrays, omits a `seller != buyer` check, or measures item count against buy-order constraints rather than the number of items being actually constructed and transferred.

**Detection Heuristics**

- Trace order execution end-to-end and verify that the final transfer count equals the validated item count.
- Check whether an empty `tokens` array on either side of an order causes `doItemsIntersect` to return `true` (wildcard semantics can satisfy intersection without any actual transfer).
- Verify that `seller != buyer` is enforced in all matching paths; its absence enables self-matching exploits such as wash trading or fee farming.
- Confirm that the Complication contract is consulted on every execution path, including convenience entry points like `takeMultipleOneOrders`.
- Validate that `numConstructedItems` (the count of tokens actually dispatched) is checked against both buy and sell constraints, not only one side.

**False Positives**

- The off-chain matching engine validates all orders before submission and only submits fully specified, non-duplicate orders.
- The protocol exclusively supports fixed-price, fully-specified orders with no wildcard semantics.
- A single canonical matching function handles all paths and is exhaustively validated.

**Notable Historical Findings**

In Infinity NFT Marketplace, a buyer with an empty `tokens` array could have their order fulfilled: the intersection check returned true (wildcards match anything), and the item count check compared against the buy-order constraint rather than the actual transfer count, so the buyer paid but received no NFTs. A second finding showed that omitting a `seller != buyer` check allowed an actor to match their own orders, enabling wash trading and draining protocol fees. A third path, `takeMultipleOneOrders`, skipped the Complication contract check entirely, bypassing all order validation logic.

**Remediation Notes**

Require `tokenIds.length > 0` for all order entries before executing any transfer. Enforce `sell.signer != buy.signer` across all matching entry points. Validate `numConstructedItems` against both buy and sell constraints, not just one. Audit all execution entry points to ensure the Complication contract is invoked on each.

---

### Raffle and Randomness Manipulation (ref: fv-sol-5-c11)

**Protocol-Specific Preconditions**

The contract uses Chainlink VRF or similar oracle randomness for draw outcomes. The draw organizer controls VRF subscription funding and can trigger redraws before the oracle responds. Alternatively, attribute generation is deterministic and occurs within the same transaction as minting, allowing contract-wallet recipients to observe and selectively revert unfavorable results via `onERC721Received`.

**Detection Heuristics**

- Check whether the VRF subscription is funded or controlled by the same entity that can initiate redraws; this creates a selective-abort capability.
- Verify that the minimum redraw cooldown exceeds the maximum Chainlink VRF pending time (24 hours for V2 under normal network conditions).
- Identify minting functions where attribute determination and `_safeMint` occur in the same transaction with no commit-reveal separation.
- Scan for on-chain entropy sources (`block.timestamp`, `block.prevrandao`, `msg.sender`, `blockhash`) used as the sole randomness input.
- Check whether a pending VRF request can be superseded by a new request before `fulfillRandomWords` is called.

**False Positives**

- The VRF subscription is funded by a trusted third party or the protocol treasury with adequate pre-committed balance.
- The redraw cooldown is greater than 24 hours, exceeding the maximum VRF pending window.
- Attribute generation uses a separate commit-reveal or delayed oracle callback, not the mint transaction.
- `_mint` rather than `_safeMint` is used, eliminating the callback revert vector.

**Notable Historical Findings**

In the Forgeries raffle protocol, the draw organizer controlled the VRF subscription funding and held the power to call `redraw`. By withholding subscription funds until observing an unfavorable pending VRF response, or by calling `redraw` just before `fulfillRandomWords` executed (invalidating the current request ID), the organizer could selectively abort draws that would produce unwanted winners. In AI Arena, fighter attributes were generated deterministically from a hash of sender and token ID within the same transaction as `_safeMint`, so a contract wallet could check the resulting attributes inside `onERC721Received` and revert the entire transaction to retry for desired traits.

**Remediation Notes**

Separate attribute assignment from minting: mint first with `_mint` (no callback), then fulfill attributes in the VRF callback. Set the minimum redraw cooldown to strictly greater than 24 hours and enforce it on-chain. Ensure the VRF subscription is funded by the protocol, not the draw organizer, or use a pull-funding model where the subscription cannot be drained selectively.

---

### Unbounded Loop DoS in Reward Claims (ref: fv-sol-9)

**Protocol-Specific Preconditions**

A reward claiming function iterates from the user's last-claimed round to the current round with no upper bound per transaction. The loop contains nested iterations (e.g., scanning all winners per round) or non-trivial per-iteration cost (storage reads, external calls, minting). The protocol can advance many rounds without requiring user participation, allowing the gap to grow until a future claim hits the block gas limit.

**Detection Heuristics**

- Identify claim functions with a loop bounded by `currentRound < roundId` or `currentEpoch < epochId` where the gap is user-controlled (infrequent claimers).
- Estimate gas cost per round iteration: a single SLOAD costs 2100 gas; an external mint call costs 20k+. Compute the round count at which the block gas limit is reached.
- Check for nested loops (rounds × winners per round) which create quadratic gas growth.
- Verify whether the protocol can advance rounds without any user interacting (keeper-triggered advancement).

**False Positives**

- The total number of rounds is strictly bounded and small enough that worst-case gas is below the block gas limit.
- Per-iteration gas cost is minimal (only memory operations) and the protocol enforces regular claiming.
- Users are required to claim every round by protocol design, preventing accumulation.
- An off-chain keeper claims for all users periodically, preventing gaps from forming.

**Notable Historical Findings**

In AI Arena, both `claimRewards` in the MergingPool and `claimNRN` in RankedBattle iterated over all unclaimed rounds since the user's last claim. The `claimRewards` function contained a nested loop scanning all winners per round, making gas cost proportional to `rounds × winners`. Because rounds advanced on a fixed schedule regardless of user activity, a user who skipped many rounds could permanently lose the ability to claim their rewards. Separately in OnchainHeroes Fishingvoyages, an uninitialized `stakeDuration` allowed users to bypass the intended fishing lock duration and unstake immediately.

**Remediation Notes**

Add a `totalRoundsToConsider` parameter allowing users to claim in bounded batches, with a check that `lowerBound + totalRoundsToConsider <= roundId`. Alternatively, track accumulated rewards per user in a rolling mapping updated at round advancement (push model), eliminating the need for per-round iteration at claim time.

---

### Uninitialized State Variable Exploits (ref: fv-sol-5)

**Protocol-Specific Preconditions**

Critical state variables are set by admin setter functions after deployment rather than in the constructor. Functions depending on these variables do not check for zero/uninitialized values before use. Default zero values bypass time-lock checks (duration of zero means the lock has already expired), cause division-by-zero panics for uninitialized modular arithmetic divisors, or grant incorrect defaults. Mappings are partially initialized (e.g., only for generation zero), leaving subsequent generations with zero values.

**Detection Heuristics**

- Enumerate all state variables written by post-deployment setter functions and verify that consuming functions guard against uninitialized (zero) values.
- Look for duration or cooldown checks of the form `block.timestamp < stakeAt + stakeDuration` where `stakeDuration == 0` makes the condition trivially false.
- Scan for `% denominator` operations where `denominator` is a mapping value that may not be initialized for all keys.
- Verify that the deployment/initialization sequence is atomic or that the contract is paused until configuration is complete.

**False Positives**

- All required state variables are initialized in the constructor or in an `initializer` function called atomically in the deployment transaction.
- A factory contract handles initialization in the same transaction as deployment.
- The contract is paused by default and only unpaused after admin configuration is complete.
- An `initializer` modifier from a proxy pattern ensures all variables are set before the contract is operational.

**Notable Historical Findings**

In the OnchainHeroes Fishingvoyages contract, `stakeDuration` was left at zero after deployment. The unstaking check `block.timestamp < stakeAt + stakeDuration` always evaluated to false when `stakeDuration == 0`, so users could unstake immediately regardless of the intended fishing duration. In AI Arena's `FighterFarm`, `numElements` was only set in the constructor for generation zero; any fighter creation for subsequent generations triggered a division-by-zero panic because the divisor for the element calculation was uninitialized, making generation advancement non-functional.

**Remediation Notes**

Add zero-value guards to all functions that depend on post-deployment configuration: `if ($.stakeDuration == 0) revert NotInitialized()`. When incrementing game state (e.g., generation), initialize all dependent mappings for the new state in the same transaction. Consider a two-phase deployment pattern where the contract begins in a paused state and a single initialization transaction sets all required values before unpausing.

---

### Unvalidated User-Supplied NFT Attributes (ref: fv-sol-5)

**Protocol-Specific Preconditions**

NFT minting or re-roll functions accept user-controlled parameters (fighter type, element, weight, DNA, custom attributes) that directly determine token traits. Trait generation is deterministic: the same inputs always produce the same outputs. Type-specific limits (e.g., max re-rolls per fighter type) are enforced using the user-supplied type rather than the on-chain type of the token. The resulting traits affect in-game mechanics, rarity, or economic value.

**Detection Heuristics**

- Identify all minting and re-roll functions that accept user-controlled parameters influencing attributes.
- Check whether `fighterType` or equivalent type discriminators are validated against the actual on-chain state of the token being modified.
- Verify that numeric attribute ranges (element, weight, generation-specific bounds) are validated with explicit `require` statements before use.
- For mint-pass or claim-based minting, check whether a server-side signature is required to authorize the specific attribute set, or whether users can supply arbitrary values.
- Determine whether the same DNA or attribute input always produces the same output, making brute-force or revert-based selection trivially feasible.

**False Positives**

- Attribute generation uses Chainlink VRF with commit-reveal, making the output unpredictable at mint time.
- All user-supplied inputs are validated against expected ranges and cross-checked against on-chain token state.
- A trusted backend signature is required to authorize the attribute set for each mint or re-roll.
- Attributes are purely cosmetic and do not affect game mechanics or token value.

**Notable Historical Findings**

In AI Arena, the `reRoll` function accepted a user-supplied `fighterType` parameter and used it to look up `maxRerollsAllowed[fighterType]` without verifying that the supplied type matched the actual type of the token being re-rolled. A Dendroid token owner could pass `fighterType = 0` to circumvent the Dendroid re-roll limit and apply champion-type generation logic. The `redeemMintPass` function allowed callers to freely specify `fighterType` and copy DNA strings from existing rare fighters, enabling on-demand production of high-rarity tokens. A third variant showed that `mintFromMergingPool` accepted `customAttributes` as a raw two-element array with no range validation, allowing callers to assign any element or weight to a newly minted fighter.

**Remediation Notes**

Validate `fighterType` against the token's on-chain `dendroidBool` field before applying any type-specific logic. Enforce explicit range checks on all numeric attributes at the point of minting and re-rolling. Require a server-signed message authorizing the specific attribute set for any mint function where the caller supplies trait inputs; this prevents brute-force selection by making the authorized output opaque until claim time.

### ERC721Consecutive Balance Corruption with Single-Token Batch (ref: pashov-2)

**Protocol-Specific Preconditions**

The gaming or NFT contract inherits OpenZeppelin `ERC721Consecutive` and calls `_mintConsecutive(to, 1)` to mint individual tokens during a batch or claim phase. The contract runs on a version of OpenZeppelin prior to 4.8.2. Downstream game logic, access control, or marketplace integrations rely on `balanceOf` to determine ownership status or gate game actions.

**Detection Heuristics**

- Locate all `_mintConsecutive` call sites and check whether any call with a batch size of 1 exists.
- Confirm the OpenZeppelin library version in `package.json` or `foundry.toml`; any version below 4.8.2 is affected.
- Check whether any downstream function (`balanceOf`, `tokensOfOwner`, or similar) is used to gate game mechanics or reward eligibility.
- If batch minting is mixed with single-token claims, verify the code paths use different base minting functions for the two cases.

**False Positives**

- The contract uses OpenZeppelin version 4.8.2 or later, which patches this behavior.
- All batch mints use a minimum size of 2 tokens.
- The contract uses standard `ERC721._mint` rather than `ERC721Consecutive._mintConsecutive`.
- No game or protocol logic depends on `balanceOf` returning a correct value immediately after minting.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Upgrade to OpenZeppelin 4.8.2 or later. When a batch size of 1 is a valid use case, use the standard `_mint` function rather than `_mintConsecutive`, as the consecutive batch mechanism requires at least two tokens to correctly increment the balance mapping.

---

### Missing onERC1155BatchReceived Causes Token Lock (ref: pashov-14)

**Protocol-Specific Preconditions**

A gaming contract holds or receives ERC-1155 tokens representing in-game items, equipment, or currencies. The contract implements `onERC1155Received` to handle individual transfers but does not implement `onERC1155BatchReceived`, or its implementation returns an incorrect selector. Settlement, reward distribution, or bulk crafting operations use `safeBatchTransferFrom` to send multiple item types in one transaction.

**Detection Heuristics**

- Search for `onERC1155Received` implementations and verify whether `onERC1155BatchReceived` is also present and returns `this.onERC1155BatchReceived.selector`.
- Check whether the contract inherits `ERC1155Holder` from OpenZeppelin, which implements both callbacks correctly.
- Identify all code paths that call `safeBatchTransferFrom` toward this contract; any such call will revert if the batch callback is missing or incorrect.
- Verify the return value of `onERC1155BatchReceived` equals `0xbc197c81` rather than a custom or hardcoded value.

**False Positives**

- The contract inherits OpenZeppelin `ERC1155Holder`, which provides both callbacks with correct selectors.
- The protocol exclusively uses single-item `safeTransferFrom` and never calls `safeBatchTransferFrom` toward this contract.
- The contract is itself an ERC-1155 token contract, which inherits the batch receiver interface by default.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Inherit `ERC1155Holder` from OpenZeppelin rather than implementing receiver callbacks manually. If implementing callbacks manually, verify both `onERC1155Received` and `onERC1155BatchReceived` are present and return their respective correct selectors (`0xf23a6e61` and `0xbc197c81`).

---

### ERC1155 URI Missing id Substitution (ref: pashov-19)

**Protocol-Specific Preconditions**

The contract implements ERC-1155 `uri(uint256 id)` and returns a fully resolved, token-specific URL or a static base URL without the literal `{id}` placeholder required by EIP-1155. NFT metadata clients and marketplaces call `uri(id)` and expect to perform client-side substitution of the literal string `{id}` with the zero-padded hexadecimal token ID. A static or fully resolved return collapses distinct tokens to a single metadata record or causes parsing failures.

**Detection Heuristics**

- Read the `uri()` implementation and verify the returned string contains the literal substring `{id}`.
- If the contract returns a per-ID resolved URL, verify this is explicitly documented as a deviation from EIP-1155's substitution-based metadata standard.
- Check whether `uri()` returns an empty string for any valid token ID, which causes metadata to be unavailable.
- Confirm that marketplaces and game frontends integrating with this contract are tested against the actual `uri()` output format.

**False Positives**

- The contract returns a string containing the literal `{id}` placeholder per EIP-1155 specification.
- Per-ID on-chain metadata is returned directly and the deviation from the substitution standard is explicitly documented in the interface specification.
- The contract is intentionally off-specification and all downstream clients are built to handle the custom format.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Return a string containing the literal `{id}` substring as required by EIP-1155: for example, `"https://game.example/metadata/{id}.json"`. Clients will substitute the lowercase hex representation of the token ID, zero-padded to 64 characters. If per-ID resolution is needed on-chain, document the deviation and verify all consuming clients handle it explicitly.

---

### ERC1155 Fungible and Non-Fungible Token ID Collision (ref: pashov-65)

**Protocol-Specific Preconditions**

The gaming contract uses a single ERC-1155 deployment to represent both fungible resources (currencies, consumables) and unique items (characters, legendary equipment) under different token IDs. No enforcement exists at the contract level to prevent minting additional copies of an ID intended to be supply-1. Multiple mintings to different users for the same NFT-designated ID are possible.

**Detection Heuristics**

- Identify all mint functions and check whether they enforce `require(totalSupply(id) + amount <= maxSupply(id))` or equivalent before minting.
- For IDs designated as unique items, verify `maxSupply[id] == 1` is set and enforced.
- Check whether fungible and non-fungible ID ranges are disjoint by design and whether the boundary is validated in mint functions.
- Verify that role or access tokens represented as ERC-1155 IDs are non-transferable if their uniqueness underpins access control.

**False Positives**

- `require(totalSupply(id) + amount <= maxSupply(id))` is enforced with `maxSupply = 1` for all NFT-designated IDs.
- Fungible and non-fungible token IDs occupy explicitly separated and enforced ranges.
- Role tokens are non-transferable via an override in `_beforeTokenTransfer` that reverts on non-mint/burn operations.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Define an immutable maximum supply per token ID at mint time using a `maxSupply[id]` mapping set in a single authorized function. Enforce `require(totalSupply(id) + amount <= maxSupply[id])` in all mint paths. For NFT IDs, set `maxSupply[id] = 1` and verify this is set before any mint of that ID can occur. Separate ID namespaces for fungible and non-fungible tokens using explicit range checks.

---

### ERC721Enumerable Index Corruption on Burn or Transfer (ref: pashov-81)

**Protocol-Specific Preconditions**

The gaming contract inherits `ERC721Enumerable` for on-chain enumeration of token ownership (for example, to list all fighters, characters, or items owned by an address). A custom override of `_beforeTokenTransfer` (OpenZeppelin v4) or `_update` (OpenZeppelin v5) is present for game logic such as attribute updates, staking locks, or cooldown enforcement. The override does not call `super._beforeTokenTransfer` or `super._update` as its first statement, preventing the enumerable index from being updated on transfer or burn.

**Detection Heuristics**

- Find all `_beforeTokenTransfer` and `_update` overrides in contracts inheriting `ERC721Enumerable`. Verify each calls `super._beforeTokenTransfer(from, to, tokenId, batchSize)` or `super._update(to, tokenId, auth)` before any other logic.
- After simulating a transfer or burn, verify `tokenOfOwnerByIndex(previousOwner, ...)` no longer returns the transferred token ID.
- Check that `totalSupply()` decrements correctly after a burn operation.
- Verify that `_ownedTokens` and `_allTokens` are consistent after a sequence of mint, transfer, and burn operations.

**False Positives**

- The contract's override unconditionally calls `super` as its first statement.
- The contract does not inherit `ERC721Enumerable` and uses an alternative enumeration mechanism.
- The override is only reached on mint paths and the enumerable data structures are independently correct for transfer and burn.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Place `super._beforeTokenTransfer(from, to, tokenId, batchSize)` (or `super._update(to, tokenId, auth)` in OZ v5) as the unconditional first statement of any override. Never rely on compiler-enforced super call ordering in multi-inheritance graphs; be explicit. Add integration tests that verify `tokenOfOwnerByIndex`, `tokenByIndex`, and `totalSupply` return consistent values after a full sequence of mint, transfer, and burn operations.

---

### ERC721A Lazy Ownership Uninitialized in Batch Range (ref: pashov-116)

**Protocol-Specific Preconditions**

The gaming contract uses ERC721A or `ERC721Consecutive` for gas-efficient batch minting, which writes ownership for only the first token in a minted batch and lazily resolves subsequent token IDs by scanning backward. Access control logic elsewhere in the game checks `nft.ownerOf(tokenId) == msg.sender` for freshly minted tokens in the middle of a batch range. Before any transfer of a mid-batch token, `ownerOf` may return `address(0)` depending on implementation version, causing the access check to fail.

**Detection Heuristics**

- Identify all `ownerOf(tokenId) == msg.sender` or `ownerOf(tokenId) == address(0)` checks on contracts using ERC721A or `ERC721Consecutive`.
- Verify whether `ownerOf` is called on tokens immediately after a batch mint without an intervening transfer that would initialize the packed slot.
- Check whether the contract's ERC721A version resolves mid-batch ownership correctly or requires a transfer to trigger lazy initialization.
- Test access control functions with token IDs in the middle of a batch range that have never been transferred.

**False Positives**

- The contract uses standard OpenZeppelin `ERC721`, which writes `_owners[tokenId]` individually per mint.
- An explicit transfer or initialization step is always called before any `ownerOf`-dependent logic executes.
- The ERC721A version used correctly resolves mid-batch ownership through its backward scan without returning `address(0)`.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

When using ERC721A or `ERC721Consecutive`, avoid relying on `ownerOf` returning correct values for mid-batch tokens before any transfer has occurred. Use the explicit packed ownership initialization provided by ERC721A if per-token ownership reads are needed immediately post-mint. For access control over freshly minted tokens, read the batch owner from the minting record rather than querying `ownerOf` per token ID.

---

### NFT Staking Records msg.sender Instead of ownerOf (ref: pashov-126)

**Protocol-Specific Preconditions**

An NFT staking contract records the depositor for each staked token using `depositor[tokenId] = msg.sender` without verifying that `msg.sender` is the actual owner. The NFT transfer succeeds because `msg.sender` holds operator approval for the owner, but the depositor mapping credits the operator rather than the owner. Reward claims, unstaking rights, and in-game privileges are tied to the depositor mapping.

**Detection Heuristics**

- Find all staking deposit functions that call `nft.transferFrom(msg.sender, address(this), tokenId)` or `nft.safeTransferFrom` and then assign `depositor[tokenId] = msg.sender`.
- Check whether `nft.ownerOf(tokenId)` is read before the transfer and used as the depositor rather than `msg.sender`.
- Verify that approved operators (non-owners) cannot call the deposit function and be credited as the depositor.
- Test the deposit function when called from an approved-but-not-owner address to confirm the recorded depositor is the actual token owner.

**False Positives**

- The deposit function includes `require(nft.ownerOf(tokenId) == msg.sender)`, preventing non-owners from staking.
- The deposit function reads `nft.ownerOf(tokenId)` before the transfer and stores that address rather than `msg.sender`.
- The staking contract is designed to allow operator deposits and credits the operator intentionally for protocol-specific reasons.

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

Replace `depositor[tokenId] = msg.sender` with `depositor[tokenId] = nft.ownerOf(tokenId)` called before the transfer executes, or add `require(nft.ownerOf(tokenId) == msg.sender)` to prevent approved operators from initiating deposits on behalf of owners. The latter approach is stricter and eliminates the operator deposit path entirely.

---

## reference/solidity/protocols/nft-marketplace.md

# NFT Marketplace Security Patterns

> Applies to: NFT marketplaces, order book marketplaces, Seaport-style, LooksRare-style, Blur-style, on-chain NFT auctions, NFT lending markets, ERC-721/ERC-1155 trading protocols

## Protocol Context

NFT marketplaces combine off-chain order signing with on-chain settlement, meaning the on-chain execution layer must rigorously validate maker/taker signatures, nonces, and order parameters that were assembled in a context the contract never controlled. The use of `safeTransferFrom` on ERC-721 and ERC-1155 tokens introduces reentrancy vectors through `onERC721Received` and `onERC1155Received` callbacks, which fire mid-settlement before order state is finalized. Royalty enforcement, excess ETH refund patterns, ERC-1155 quantity accounting, and support for non-standard tokens like CryptoKitties each introduce protocol-specific correctness requirements not present in simpler token contracts.

## Bug Classes

### Reentrancy via NFT Callbacks (ref: fv-sol-1)

**Protocol-Specific Preconditions**

- Settlement calls `safeTransferFrom` on ERC-721 or ERC-1155 tokens before marking order nonces as used
- Fee distribution functions invoke receiver callbacks (`receiveRewards`) between token transfers
- DAO quit or rage-quit functions perform ERC20 transfers using ERC-777 tokens that trigger callbacks mid-loop
- Cross-contract reentrancy is possible when token accounting state is shared between a vault and a logic contract

**Detection Heuristics**

- Audit every `safeTransferFrom` call site and confirm nonce/state updates precede it
- Search for fee distribution loops that call external `receiveRewards`-style hooks between balance reads and transfers
- Check `quit()`/`ragequit()` patterns for `totalSupply` reads followed by external ERC20 transfers before burns complete
- Verify `nonReentrant` is applied to all settlement entry points, not just the outermost dispatcher

**False Positives**

- When `nonReentrant` is applied to all entry points of the contract and state updates strictly precede interactions
- When the only external call is to WETH or another known non-reentrant token
- When read-only reentrancy has no influence on settlement math or invariants

**Notable Historical Findings**

Infinity NFT Marketplace had a reentrancy path through `matchOneToManyOrders` where ERC-721 `safeTransferFrom` triggered an attacker-controlled `onERC721Received` callback before the maker's nonce was marked used, allowing the same order to be re-settled. NFTX's fee distributor called `receiveRewards` on each fee receiver mid-loop without a reentrancy guard, enabling a malicious receiver to reenter `distribute` and collect fees multiple times from the same vault balance snapshot. Nouns DAO's `quit()` function was vulnerable to cross-contract reentrancy through ERC-777 token callbacks between the `totalSupply` read and burn operations.

**Remediation Notes**

Apply the checks-effects-interactions pattern strictly: mark nonces used and burn tokens before any transfer. Add `nonReentrant` to all settlement, distribution, and quit entry points. For DAO treasury distributions, complete all burns before iterating over ERC20 transfers.

---

### Signature Validation and Replay (ref: fv-sol-4-c4, fv-sol-4-c10, fv-sol-4-c11)

**Protocol-Specific Preconditions**

- Order signatures are created off-chain and validated on-chain via `ecrecover` or `ECDSA.recover`
- EIP-712 domain separator is computed once in the constructor and caches `block.chainid`
- Signed messages do not include a nonce, expiry, or the domain separator is not recomputed after a chain fork
- `delegateBySig` or custom permit-style functions do not enforce expiry or nonce invalidation

**Detection Heuristics**

- Search for direct `ecrecover` calls and confirm the return value is checked against `address(0)`
- Check all EIP-712 implementations for whether `DOMAIN_SEPARATOR` is a storage variable set at construction versus recomputed dynamically
- Look for `withdraw`, `delegate`, or `cancel` functions where the signed payload omits a nonce
- Verify the `v` parameter is validated to 27 or 28 and that `s` is in the lower-half of the secp256k1 order to block malleability

**False Positives**

- When OpenZeppelin `ECDSA.recover` is used throughout, as it checks for zero-address and malleability internally
- When OpenZeppelin `EIP712` base contract is used with `_domainSeparatorV4()`, which recomputes on chain fork
- When the protocol operates on a single finalized chain with no plans for multi-chain deployment

**Notable Historical Findings**

Golom's `validateOrder` passed the ecrecover return directly into an equality check against `o.signer` without a zero-address guard, allowing invalid signatures to authenticate against uninitialized order structs. Golom also hardcoded the chain ID in the domain separator at deploy time, making all signed orders replayable on forked networks. Taiko's `withdraw()` function signed over only the recipient and amount with no nonce, enabling the same signature to be submitted repeatedly to drain accumulated balance. Nouns DAO's `cancelSig` was incomplete due to signature malleability - a transformed `s` value produced a distinct bytes signature that the cancellation mechanism did not recognize.

**Remediation Notes**

Use OpenZeppelin `EIP712` and `ECDSA` exclusively. Nonces must be part of every signed payload and must be invalidated atomically with execution. For order-book marketplaces specifically, expiry timestamps should be mandatory fields in the order struct and validated before signature recovery.

---

### Order Validation and Matching Flaws (ref: fv-sol-8-c5)

**Protocol-Specific Preconditions**

- The matching engine accepts arrays of orders or items with no explicit uniqueness or self-match constraint
- Order validation logic compares wrong item arrays (e.g., raw order items vs. constructed settlement items)
- Gas reimbursement tied to order execution is calculated from measured `gasleft()` deltas per loop iteration
- Partial-fill tracking relies on nonce state that is only committed after a full batch completes

**Detection Heuristics**

- Confirm all matching functions enforce `makerOrder.signer != takerOrder.signer` (self-match prevention)
- Audit `canExecMatchOrder` and equivalent validators to confirm they compare the correct item arrays for each side
- Check gas refund mechanisms for position-dependency (first match in a batch paying different gas than subsequent ones)
- Verify that order nonces or fill counters are updated before external token transfers, not after

**False Positives**

- When self-matching is an explicit protocol feature documented for inventory management or testing
- When gas refund differences are bounded and explicitly acknowledged in the fee model
- When order validation is delegated to an audited external complication contract

**Notable Historical Findings**

Infinity NFT Marketplace did not prevent `seller == buyer` in any of its matching functions, enabling wash trading and fee manipulation through self-matched orders. A separate validation bug caused `canExecMatchOrder` to compare `sell.nfts` directly against `buy.nfts` rather than checking the constructed settlement items against both sides, rejecting valid orders. Golom's matching engine double-counted the protocol fee inside `_settleBalances`, reducing the taker payout by the fee amount twice, effectively stealing funds on every matched trade. Seaport's `executeMatchOrders` reverted when unused native tokens needed to be returned to the caller because no refund path was implemented for leftover ETH in matched order batches.

**Remediation Notes**

Enforce seller/buyer inequality as an explicit require at the top of all matching functions. Gas refunds should use a fixed per-match estimate, not live `gasleft()` measurement. Validate constructed settlement item arrays against both maker and taker order constraints, not the raw order fields against each other.

---

### ERC-1155 Quantity Accounting Errors (ref: no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**

- Protocol supports ERC-1155 alongside ERC-721 and applies shared royalty or fee logic to both
- Royalty calculations divide total sale proceeds by `ids.length` rather than by total quantity transferred
- Order matching validates token ID sets for intersect without checking for duplicates across buyer and seller arrays
- Random selection from an ERC-1155 vault picks a token ID uniformly rather than weighting by deposited quantity

**Detection Heuristics**

- Search for royalty calculation functions that receive `uint256[] ids` and `uint256[] amounts` and check if `amounts` is actually used in the per-unit price derivation
- Look for order intersection checks that iterate `ids` arrays for equality without a nested uniqueness guard
- Audit random selection from vaults that store ERC-1155 holdings to confirm quantity-weighted sampling
- Check `safeBatchTransferFrom` call sites to confirm the `amounts` array matches actual quantities and not a constant `1`

**False Positives**

- When the protocol enforces that all ERC-1155 tokens are deposited with quantity exactly 1, effectively treating them as ERC-721
- When duplicate ID validation is performed in an upstream routing or validation layer before reaching the matching engine

**Notable Historical Findings**

NFTX's `_deductRoyalty1155` computed per-token sale price by dividing total proceeds by `ids.length`, completely ignoring the `amounts` array. Selling 100 units of a single token ID would massively overstate the per-unit sale price, causing royalty recipients to receive far more than owed and draining proceeds from sellers. Infinity NFT Marketplace's `doTokenIdsIntersect` had no duplicate ID check, allowing an attacker to include the same token ID multiple times in an order to steal additional NFTs during settlement. NFTX's `getRandomTokenIdFromFund` gave equal probability to every stored token ID regardless of deposited quantity, allowing an attacker to game vault redemptions toward higher-value IDs by controlling deposit ratios.

**Remediation Notes**

Royalty calculations for ERC-1155 must sum all quantities across IDs first, then compute per-unit price as `totalProceeds / totalQuantity` before calling `royaltyInfo` per token ID. Order matching must explicitly validate that no token ID appears more than once in either side of the trade.

---

### NFT Transfer Standard Compliance (ref: fv-sol-6-c9)

**Protocol-Specific Preconditions**

- Protocol uses `transferFrom` instead of `safeTransferFrom` for ERC-721 transfers, dropping the receiver callback check
- `safeTransferFrom` implementations validate receiver support via `supportsInterface` rather than by calling `onERC721Received` and verifying the return selector
- Protocol handles dual-standard tokens that implement both ERC-721 and ERC-1155 interfaces
- Non-standard NFTs (e.g., CryptoKitties) use custom transfer functions not conforming to EIP-721

**Detection Heuristics**

- Search for `IERC721(*.transferFrom(` call sites that should be `safeTransferFrom`
- Audit custom `safeTransferFrom` implementations for whether they call `onERC721Received` and compare the return value against `0x150b7a02`
- Identify NFT collections in scope that implement both `IERC721` and `IERC1155` interfaces and trace which branch of transfer logic is taken
- Check for hardcoded CryptoKitties address handling in multi-collection marketplace routers

**False Positives**

- When `transferFrom` is intentionally used to avoid callback reentrancy and the recipient is a known, trusted EOA or contract
- When the protocol explicitly restricts its supported collection set to exclude non-standard tokens

**Notable Historical Findings**

Infinity NFT Marketplace's `_transferNFTs` did not handle dual-standard tokens: collections implementing both ERC-721 and ERC-1155 would be processed by whichever interface was checked first, producing incorrect transfer semantics. NFTX's CryptoKitties-specific transfer path called `transferFrom(msg.sender, address(this), tokenId)` routing the NFT to the contract itself rather than to the intended recipient. Holograph implemented `safeTransferFrom` by calling `supportsInterface(onERC721Received.selector)` on the receiver rather than actually invoking the callback and checking its return value, making the safety check meaningless for contracts that do not self-declare that interface.

**Remediation Notes**

Use `safeTransferFrom` for all ERC-721 transfers to untrusted addresses. When implementing `safeTransferFrom` directly, call `IERC721Receiver(to).onERC721Received(...)` and assert the return value equals `IERC721Receiver.onERC721Received.selector`. For multi-standard tokens, check `IERC1155` interface support before `IERC721` to avoid ambiguous behavior.

---

### Excess ETH Not Refunded (ref: no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**

- Payable settlement functions check `msg.value >= required` rather than `msg.value == required`
- Batch purchase functions accumulate total cost but do not refund the `msg.value - totalCost` remainder
- Cross-chain bridge fee estimation functions accept ETH, forward the exact fee to the bridge, and retain the surplus

**Detection Heuristics**

- Search for `payable` functions with `require(msg.value >= ...)` and confirm a refund path exists
- Audit batch execution loops: after the loop, verify that any ETH remainder is returned to `msg.sender`
- Look for `rescueETH` or admin-only ETH withdrawal functions - their presence signals awareness of the accumulation issue without actually fixing it for users
- Check bridge fee functions to ensure `msg.value - fee` is refunded after the bridging call

**False Positives**

- When excess ETH is explicitly documented as a voluntary tip to the protocol
- When `msg.value == exact` is enforced and overpayment reverts
- When a user-accessible refund function is available and the refund period is reasonable

**Notable Historical Findings**

Infinity NFT Marketplace's `fillAsk` accepted ETH with a `>=` check, meaning any overpayment by the buyer was permanently locked in the contract with no refund mechanism. Golom's `fillAsk` had the same pattern. Holograph's LayerZero module miscalculated the gas fee estimate passed to the bridge, causing callers to send more ETH than required and lose the excess to the contract. Taiko's `processMessage` allowed a malicious caller to pocket the bridge fee while forcing the guarded external call to fail by manipulating gas, combining excess ETH retention with a griefing vector.

**Remediation Notes**

After computing exact payment requirements, always return `msg.value - required` to `msg.sender` via a low-level call. Alternatively, enforce exact payment with `msg.value == required`. Never use a `rescueETH` admin function as a substitute for per-transaction refunds.

---

### Access Control and Privilege Escalation (ref: fv-sol-4)

**Protocol-Specific Preconditions**

- Factory-only or owner-only functions lack access control modifiers and are callable by any address
- Functions accepting a `from` address parameter combined with `transferFrom` allow arbitrary token drainage from approving users
- Admin role changes (ownership, operator, fee manager) occur in a single step without a two-step acceptance pattern
- Migration or burn functions during token upgrades do not restrict the `account` parameter to `msg.sender`

**Detection Heuristics**

- Search for `external` functions where NatSpec or naming implies a privileged caller but no modifier enforces it
- Identify `transferFrom(parameterAddress, ...)` call sites where the `from` address comes from a function argument
- Check admin role transfer functions for two-step acceptance patterns
- Audit fee assignment and protocol parameter functions for any caller being able to reset values to factory defaults

**False Positives**

- When access control is enforced at a trusted router or proxy layer and the implementation is intentionally unrestricted
- When the function is genuinely permissionless by design (e.g., anyone can trigger a keeper action)
- When the `from` parameter is validated against `msg.sender` or requires an explicit delegation before use

**Notable Historical Findings**

LooksRare had a function through which the protocol owner could call `transferFrom` with any user's address as the `from` parameter, draining any token balance approved to the contract. Reality Cards' `sponsor` function was intended to be callable only by the factory but had no modifier, allowing anyone to force arbitrary approved token holders to sponsor a market. NFTX's `assignFees` was callable by any address, enabling anyone to reset custom vault fee configurations back to factory defaults at will. NFTX's ERC-20 migration `burn` function accepted an arbitrary `account` parameter, allowing anyone to burn tokens held by a contract (such as an LP pool) and mint them to a new address.

**Remediation Notes**

Every function with a privileged intended caller must be enforced with an explicit modifier checked against a stored address. Functions that call `transferFrom` must never accept the `from` address as an unconstrained parameter. Ownership and role transfers must follow a two-step propose-and-accept pattern.

---

### Admin Centralization Risks (ref: fv-sol-4)

**Protocol-Specific Preconditions**

- Protocol fee rate or royalty configuration is controlled by a single EOA owner with no timelock
- Fee changes apply immediately to all existing unfilled orders, retroactively altering settlement economics
- No upper bound is enforced on fee parameters during owner-controlled updates
- Governance contracts lack deadlock recovery mechanisms

**Detection Heuristics**

- Search for `onlyOwner` functions that modify `protocolFeeRate`, `royaltyRate`, or equivalent and confirm timelock enforcement
- Check whether the owner address is a multisig or an EOA
- Identify governance parameter setters (`forkThresholdBPS`, `forkPeriod`, `voteSnapshotBlockSwitchProposalId`) that are not gated behind the full proposal lifecycle
- Verify that governance cannot enter a state where no proposal can pass (quorum/threshold deadlock)

**False Positives**

- When the owner is a Gnosis Safe with documented signer set and threshold
- When all parameter changes go through a timelocked governance module
- When the protocol is in a documented guarded launch phase with explicit centralization acknowledgment

**Notable Historical Findings**

Infinity NFT Marketplace allowed the owner to change the protocol fee rate at any time with no timelock and no cap, immediately affecting all outstanding orders whose signers had no recourse. Nouns DAO's `forkThresholdBPS` and `forkPeriod` were settable by the DAO admin outside the standard proposal flow, allowing a malicious DAO to prevent token holders from forking or force them to fork under unfavorable conditions. zkSync's governance module had no resolution path for proposal deadlocks, meaning the protocol could become permanently ungovernable under certain voting configurations.

**Remediation Notes**

Fee rate changes must go through a timelock of at least 48 hours with a published maximum cap enforced on-chain. All fork-related and governance-critical parameters must be modified only through the full proposal lifecycle, not through direct owner calls. Governance contracts should include a last-resort emergency path that does not rely on a single key.

---

### Governance Voting Manipulation (ref: fv-sol-5)

**Protocol-Specific Preconditions**

- NFT-based or token-weighted voting uses per-token checkpoints that can be overwritten rather than accumulated in same-block updates
- Delegation functions do not remove the delegated token from the previous delegatee's list before assigning to a new one
- Proposal creation allows signature aggregation from multiple signers without a snapshot-block threshold check
- Fork escrow mechanisms allow escrowed tokens to be used to manipulate treasury split calculations

**Detection Heuristics**

- Search for `_writeCheckpoint` implementations and confirm same-block updates accumulate rather than overwrite
- Audit `delegate` functions for removal of old delegatee state before writing new delegation
- Check `proposeBySigs` and equivalent for threshold validation at the proposal's snapshot block, not at submission time
- Verify that escrowed tokens in fork mechanisms cannot vote in the parent DAO simultaneously

**False Positives**

- When same-block checkpoint overwrite is correct because the protocol only cares about end-of-block state
- When delegation cleanup is handled by a lazy-delete garbage collection sweep with correct semantics

**Notable Historical Findings**

Golom's `_writeCheckpoint` overwrote the existing checkpoint when the block number matched instead of accumulating the delta, meaning multiple delegations in the same block would discard intermediate voting power state. Golom's `delegate` function did not remove the token from the old delegatee's index, leaving phantom voting power in the previous delegatee's balance indefinitely. Nouns DAO allowed any co-signer of a proposal created via `proposeBySigs` to cancel it unilaterally, enabling a single hostile co-signer to grief any multi-sig proposal. Changing `voteSnapshotBlockSwitchProposalId` in Nouns DAO mid-governance allowed double-counting of votes for proposals that spanned the switch boundary.

**Remediation Notes**

Same-block checkpoint updates must compute the delta and add it to the existing checkpoint value, not replace it. Delegation must atomically remove from the old delegatee and assign to the new one within a single transaction. Proposal cancellation by signers should be restricted to the original proposer or require a governance vote.

---

### Cross-Chain Bridge Message Integrity (ref: fv-sol-5)

**Protocol-Specific Preconditions**

- Bridge message status can be manipulated by a privileged watchdog role without requiring cryptographic proof of source-chain origin
- Failed L1-to-L2 transactions have no on-chain refund path for the ETH locked in the bridge
- Message recall for ERC-20 bridges returns tokens to `message.from` without verifying the recipient can receive tokens on the current chain
- Bridge fee estimation functions allow callers to supply more ETH than required with no refund

**Detection Heuristics**

- Check bridge watchdog functions that toggle message status (`SUSPENDED` / `NEW`) for whether any proof of source-chain signal is required
- Audit L2 transaction request functions for refund mechanisms covering failed L2 execution
- Look for `recallMessage` paths that transfer tokens to contract addresses that may lack receive functions on the recall chain
- Verify that all `processMessage` implementations validate the message hash against a merkle proof or signal service before execution

**False Positives**

- When the watchdog role is a multisig with hardware-secured keys and monitoring infrastructure
- When message verification relies on ZK proofs of source-chain state roots
- When the bridge only supports a fixed set of message types with known, bounded effects

**Notable Historical Findings**

Taiko's bridge watchdog could set a message status directly to `NEW` by first suspending then unsuspending an arbitrary hash it constructed, allowing it to forge messages that would be processed as legitimate by `processMessage`. A separate Taiko finding showed that a malicious `processMessage` caller could forward the bridge fee to themselves while forcing the protected `excessivelySafeCall` to fail by providing insufficient gas. zkSync's `Mailbox.requestL2Transaction` checked the deposit limit of the L1 WETH bridge instead of the actual depositor, allowing certain callers to bypass deposit caps. Multiple zkSync findings documented loss of ETH when L2 bootloader execution failed with no on-chain recovery mechanism.

**Remediation Notes**

All bridge message processing must require a merkle proof or signal service attestation against the source chain's finalized state root before changing message status. Failed L2 transactions must be claimable by the original sender via a proof-of-failure mechanism. Watchdog roles should be limited to suspension only, never to unsuspension without proof.

---

### Unsafe ERC-20 Token Transfers (ref: fv-sol-6)

**Protocol-Specific Preconditions**

- Protocol uses `IERC20.transfer()` or `IERC20.transferFrom()` directly on configurable or arbitrary token addresses
- Token set includes USDT or similar non-standard tokens that do not return a `bool`, causing `require(token.transfer(...))` to revert on success
- Protocol ignores the return value of `transferFrom`, silently accepting failed transfers as successful

**Detection Heuristics**

- Search for `.transfer(` and `.transferFrom(` calls on IERC20 interfaces not wrapped in `SafeERC20`
- Check for `require(IERC20(token).transfer(...))` patterns that will revert with USDT
- Identify any ERC-20 interaction where the return value is not captured or not validated
- Verify whether the protocol claims to support arbitrary tokens in its documentation

**False Positives**

- When the protocol whitelists only tokens with well-known compliant implementations (WETH, DAI, USDC)
- When the token is protocol-native and its transfer behavior is fully controlled

**Notable Historical Findings**

Reality Cards used bare `IERC20.transfer` calls without checking return values, allowing transfers to silently fail and locking user funds in the contract permanently. NFTX used `transfer` in multiple vault functions, ignoring the boolean return and accepting no-op transfers for tokens that return false on failure. Nouns DAO's fork and quit mechanisms failed when the treasury included non-standard ERC-20 tokens, causing entire fork/quit operations to revert and locking participants out of their proportional treasury share. Holograph's `_payoutToken` used `require(token.transfer(...))`, which reverted for USDT-style tokens even when the transfer succeeded, making payouts non-functional for that token class.

**Remediation Notes**

Replace all direct `IERC20.transfer` and `IERC20.transferFrom` calls with `SafeERC20.safeTransfer` and `SafeERC20.safeTransferFrom`. This handles non-returning tokens, false-returning tokens, and revert-on-failure tokens uniformly. For protocols supporting arbitrary payment tokens, the token whitelist should be the last line of defense, not the only one.

---

### Fee-on-Transfer Token Incompatibility (ref: fv-sol-5)

**Protocol-Specific Preconditions**

- Protocol accepts configurable ERC-20 tokens for deposits, order payments, or staking
- Accounting variables (per-user deposits, `totalDeposits`) are incremented by the nominal transferred amount rather than the actual received amount
- Protocol does not measure balance before and after each incoming transfer

**Detection Heuristics**

- Search for `deposits[user] += amount` or `totalStaked += amount` patterns immediately after `transferFrom(user, address(this), amount)` without a balance snapshot
- Check if protocol documentation states support for all ERC-20 tokens or "arbitrary" payment tokens
- Look for cumulative accounting variables that could drift from actual contract balances over time

**False Positives**

- When the protocol restricts supported tokens to an explicit whitelist that excludes fee-on-transfer tokens
- When balance-before/after snapshots are taken on every incoming transfer

**Notable Historical Findings**

Reality Cards' deposit function credited users with the full `_amount` parameter passed to `transferFrom` regardless of how much the contract actually received, creating a balance deficit that grew with every deposit of a deflationary token. Over time the deficit rendered the contract insolvent for later withdrawers, who could not be paid out because the contract's actual token balance was less than the sum of credited deposits.

**Remediation Notes**

For any protocol that accepts tokens it does not fully control, measure `balanceOf(address(this))` before and after every inbound `transferFrom` and use the difference as the credited amount. This pattern is correct for fee-on-transfer, rebasing, and standard tokens alike.

---

### Unchecked Return Values (ref: fv-sol-6)

**Protocol-Specific Preconditions**

- Contract uses low-level `.call()` for settlement execution or fee forwarding without checking the `bool success` return
- Internal helper functions contain code paths where the return variable is never assigned, defaulting to `false`
- Specific order types (e.g., CONTRACT orders in Seaport) bypass fraction validation applied to all other order types

**Detection Heuristics**

- Search for `target.call(data)` without a follow-up `require(success, ...)` or equivalent branch
- Identify internal functions with non-void return types and audit all code paths for explicit return statements
- Check for `abi.decode(returnData, ...)` without a prior `returnData.length >= 32` guard
- Audit order validation for type-conditional branches that skip numeric invariant checks

**False Positives**

- When failure of the external call is an acceptable, handled outcome (fire-and-forget keeper pattern)
- When default `false` return is the semantically correct answer for the calling code
- When the callee is a known contract that cannot fail under the given conditions

**Notable Historical Findings**

NFTX's `_sendForReceiver` had a code path for non-contract receivers that fell through without an explicit `return true`, causing the function to return `false` even on successful transfers. The calling code interpreted this as a delivery failure and redirected fees to the next receiver in the list, effectively double-paying. Seaport's `AdvancedOrder` validation skipped the `numerator <= denominator` and `denominator > 0` invariants for CONTRACT order types, allowing a denominator of zero to be submitted, which would cause a division-by-zero during fill calculation.

**Remediation Notes**

All code paths in non-void functions must have explicit return statements. Low-level calls must check `success` unconditionally. Order validation invariants must apply to all order types without conditional carve-outs.

### ERC721 and ERC1155 Type Confusion in Dual-Standard Marketplace (ref: pashov-104)

**Protocol-Specific Preconditions**

- The marketplace handles both ERC-721 and ERC-1155 tokens through a shared `buy`, `fill`, or `execute` function that dispatches on a type flag in the order struct
- ERC-721 orders accept a `quantity` field with no requirement that it equals 1
- `price * quantity` payment calculation is performed before type dispatch, allowing a `quantity = 0` order to yield zero required payment
- Settlement proceeds to execute the transfer without validating the payment amount against the actual token type being transferred

**Detection Heuristics**

- Find the shared execution function and check whether ERC-721 order branches include `require(quantity == 1)`
- Verify that `price * quantity` cannot yield zero for an ERC-721 order where `quantity` is caller-controlled
- Check that the type dispatch happens before any payment calculation, not after
- Verify separate code paths exist for ERC-721 and ERC-1155, or that the shared path validates type-specific invariants

**False Positives**

- ERC-721 branches enforce `require(quantity == 1)` unconditionally before any arithmetic
- Payment and transfer logic is fully separated between ERC-721 and ERC-1155 code paths with no shared arithmetic
- `quantity` is not a user-supplied field for ERC-721 orders; it is hardcoded to 1 in the order construction

**Notable Historical Findings**

TreasureDAO suffered a zero-payment NFT theft in 2022 where the shared marketplace fill function accepted ERC-1155-style `quantity` parameters for ERC-721 orders. Setting `quantity = 0` caused the `price * quantity` calculation to yield zero, allowing an attacker to transfer any listed NFT without payment. The fix required adding explicit `require(quantity == 1)` for ERC-721 order types.

**Remediation Notes**

Add `require(quantity == 1)` as the first check in all ERC-721 settlement branches. Prefer fully separate code paths for ERC-721 and ERC-1155 to eliminate cross-type confusion at the cost of some code duplication. Any shared arithmetic over `quantity` must be gated behind a type check.

---

### EIP-2981 Royalty Signaled But Never Enforced (ref: pashov-107)

**Protocol-Specific Preconditions**

- The NFT contract or marketplace implements `royaltyInfo(uint256 tokenId, uint256 salePrice)` and returns true for `supportsInterface(0x2a55205a)` (EIP-2981)
- The marketplace settlement function does not call `royaltyInfo()` or does not route the royalty portion of proceeds to the returned receiver address
- Royalty recipients depend on on-chain enforcement rather than platform-level enforcement for payment

**Detection Heuristics**

- Locate the settlement or transfer execution function. Search for any call to `royaltyInfo(tokenId, salePrice)` and a subsequent payment to the returned receiver address
- Check whether `supportsInterface(0x2a55205a)` returns true; if so, trace whether `royaltyInfo` is ever consumed in settlement
- Verify whether the protocol's documentation accurately represents royalty enforcement as on-chain or platform-dependent
- For marketplace contracts that process arbitrary NFT contracts, confirm the settlement flow queries and respects `royaltyInfo` for any EIP-2981-compliant token

**False Positives**

- Royalties are explicitly set to zero and documented as such; enforcement of zero royalties is a no-op
- The protocol documents that EIP-2981 is implemented for display purposes only and royalty enforcement is handled at the platform layer
- Settlement code calls `royaltyInfo()` and forwards the royalty amount to the royalty receiver before forwarding remaining proceeds to the seller

**Notable Historical Findings**

No specific historical incidents cited in source.

**Remediation Notes**

In the settlement function, call `IERC2981(tokenAddress).royaltyInfo(tokenId, salePrice)` when `supportsInterface(0x2a55205a)` returns true, transfer the returned royalty amount to the returned receiver address, and forward only the remaining proceeds to the seller. If royalties are intentionally not enforced on-chain, remove the `royaltyInfo` implementation and return false for `supportsInterface(0x2a55205a)` to avoid misleading on-chain signals.

---

## reference/solidity/protocols/privacy.md

# Privacy Protocol Security Patterns

> Applies to: privacy protocols, on-chain mixing, zero-knowledge proof systems, shielded pools, Tornado Cash-style, Aztec-style, zk-privacy applications

## Protocol Context

Privacy protocols maintain shielded state-balances, notes, commitments-that must remain consistent with the underlying token balances they represent. The key security invariant is that the public contract state never leaks information about individual users while still enforcing economic correctness: every withdrawal must correspond to a valid prior deposit, and the aggregate shielded balance must equal the aggregate deposited tokens. This dual requirement-cryptographic correctness and economic soundness-means that standard DeFi vulnerabilities (reentrancy, oracle manipulation, rounding) interact with privacy-specific concerns (proof malleability, nullifier handling, commitment ordering) in ways that are often more severe than in transparent protocols.

---

### Fee-on-Transfer Token Mishandling (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Privacy protocol supports arbitrary ERC-20 deposits and claims support for "any token pair"
- Internal shielded balance is credited the full nominal deposit amount, but the vault's actual token balance is less due to transfer fees
- Callback-based token intake patterns verify that exactly the expected amount arrived and revert for any shortfall, making fee-bearing tokens permanently unusable even if they are otherwise valid assets

**Detection Heuristics**
- Identify all `safeTransferFrom(sender, address(this), amount)` calls where `amount` is used directly to update internal shielded balances
- Look for `require(token.balanceOf(address(this)) >= balanceBefore + amount)` patterns that enforce exact amounts; these hard-revert for fee-on-transfer tokens
- Check privacy protocol whitepapers for claims of "any ERC-20 support" without a corresponding fee-on-transfer exclusion
- Find `_increaseInternalBalance(recipient, token, amount)` called immediately after `safeTransferFrom` without a balance diff measurement

**False Positives**
- Protocol maintains an explicit whitelist of supported tokens that excludes all fee-bearing assets
- Protocol clearly documents that fee-on-transfer tokens are unsupported and input validation enforces this
- The protocol only interacts with USDC, WETH, DAI, or other tokens with no transfer fee

**Notable Historical Findings**
Beanstalk's internal balance system (used by its privacy and composability features) credited users the full nominal amount on `LibTransfer` operations, but the actual token balance fell short by the accumulated fee on every transfer, creating a growing insolvency gap across the affected token pools. Timeswap's mint and convenience contracts reverted with "Insufficient token transfer" for any fee-on-transfer token because the callback verification enforced exact amounts, making those tokens permanently unusable in the protocol despite no explicit exclusion.

**Remediation Notes**
- Measure actual received amount as `token.balanceOf(address(this)) - balanceBefore` and use that value for all internal balance credits
- For callback-based systems, pass `actualReceived` to the verification step rather than the original `amount` parameter
- Alternatively, enforce the token whitelist at the smart contract level with an on-chain allowlist rather than relying on documentation

---

### Flash Loan Price Manipulation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Privacy protocol uses spot Curve pool balance ratios to detect collateral depeg rather than oracle-reported prices
- NFT-gated access to private participation rounds checks `nft.balanceOf(msg.sender)` at the time of the call rather than at a prior snapshot
- Liquidity valuation for collateral uses a spot AMM swap output (`getAmountOut`) rather than a TWAP or oracle-reported price

**Detection Heuristics**
- Identify collateral or depeg checks that compute a ratio from `curvePool.balances(i) / totalBalance` within the same transaction that could have those balances manipulated
- Search for `require(nft.balanceOf(msg.sender) > 0)` gates in time-sensitive participation functions
- Check if `router.getAmountOut` is used for any valuation that feeds into collateral, liquidation, or reward calculations
- Look for melt or burn-rate functions where the economic outcome depends on current token supply ratios

**False Positives**
- Protocol uses TWAP pricing exclusively for all collateral and depeg checks
- Protocol uses Chainlink feeds that cannot be influenced by within-block actions
- NFT balance gate uses a historical snapshot (`balanceOfAt(user, snapshotBlock)`) rather than current balance
- Flash-loan protection (same-block transfer restrictions) is in place

**Notable Historical Findings**
Reserve Protocol's `_anyDepeggedInPool` function measured Curve pool balance ratios at spot to determine if an asset had depegged; a flash loan could skew those balances beyond the deviation threshold, triggering a false depeg detection and locking collateral operations. Boot Finance's privacy-adjacent participation round used a live `nft.balanceOf` check that could be satisfied by flash-borrowing an eligible NFT for the duration of the transaction, bypassing the intended access restriction entirely. Reserve's `Furnace.melt` was vulnerable to sandwich attacks because the melt rate was computed from a spot price that could be moved by a large preceding swap.

**Remediation Notes**
- Replace spot balance ratio checks with Chainlink oracle comparisons per token to a peg price
- Replace live `balanceOf` gates with `balanceOfAt(user, snapshotBlock)` checks using a block snapshot captured before the participation window opens
- Use a TWAP from the specific pool (`oracle.consult(token1, amount, USDC, TWAP_PERIOD)`) rather than spot swap output for any liquidity valuation

---

### Front-Running Initialization and Setup Functions (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Proxy-based privacy protocol deploys an implementation and calls `initialize()` in a separate transaction, leaving a window for an attacker to front-run the initialization and claim ownership
- Pool or note-commitment initialization accepts a first-caller that can set the initial price ratio or interest rate with negligible capital, distorting all subsequent operations
- Approval-based group-buy contracts interact with USDT-style tokens that revert on non-zero to non-zero approval changes, permanently blocking all group operations after the first instance completes

**Detection Heuristics**
- Search for `initialize()` functions that are `public` or `external` without `initializer`, `onlyOwner`, or factory-enforced access control
- Identify proxy contracts where the implementation's `initialize()` is callable directly on the implementation address
- Look for first-minter or first-depositor paths that set critical pool parameters without minimum deposit requirements or locked minimum liquidity
- Check USDT (and similar token) approval sequences in group or shared contracts for the approve-without-zero-reset vulnerability that blocks second use

**False Positives**
- Initialization is called atomically within a factory `create` function in the same transaction as deployment
- `initializer` modifier from OpenZeppelin is applied and the implementation is also initialized in its constructor
- The front-running window exists but has no exploitable consequence because parameters are fixed by governance regardless of caller

**Notable Historical Findings**
Unlock Protocol's `PublicLock.initialize()` was `public` with no access control and no deployer check, allowing an attacker to front-run any deployment and claim the lock's creator role-effectively taking over the economic parameters of the newly deployed lock. Timeswap's pool initialization was front-runnable: an attacker could observe a pending `mint` transaction and insert their own `mint` with extreme asset/collateral ratios at minimal cost, permanently distorting the initial interest rate for the affected pool. Reserve Protocol suffered from multiple initialization-adjacent vulnerabilities where early users could manipulate the stakeRate and basketsNeeded/supply ratio by calling `issue` followed by `melt` before other participants entered.

**Remediation Notes**
- Call `_disableInitializers()` in every upgradeable implementation's constructor and deploy through a factory that initializes atomically
- Enforce a minimum initial deposit and burn `MINIMUM_LIQUIDITY` shares to `address(0)` on first mint
- For group contracts using USDT, reset approvals to zero and then re-approve at the start of each new group operation

---

### Incorrect Collateral Valuation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Collateral value computation omits external protocol exchange fees (Synthetix exchange fee, Lyra withdrawal fee) that would reduce the actual liquidation proceeds
- Withdrawal fee from an external LP is applied unconditionally in the valuation, but the external protocol only charges the fee under certain conditions (e.g., only when live option boards exist)
- Stablecoin collateral is valued at a hardcoded 1:1 peg without consulting an oracle, leaving the protocol exposed to depeg events
- Debt calculation uses principal-only balance (`isoUSDLoaned`) instead of the principal-plus-accrued-interest balance, allowing users to borrow against interest they already owe

**Detection Heuristics**
- Compare the collateral valuation function against the actual liquidation path on the underlying protocol; any fee or cost incurred in liquidation must be reflected in the valuation
- Search for conditional fee application logic and verify it matches the external protocol's actual fee schedule precisely
- Identify hardcoded `USDC = $1` or equivalent peg assumptions; require oracle validation for all stablecoins
- Check all debt read sites: does the code read `loanPrincipal` or `loanPrincipalPlusInterest`?

**False Positives**
- Fee discrepancy falls within the over-collateralization buffer, making the valuation error economically harmless
- Protocol intentionally undervalues collateral as a conservative safety measure
- External fee structure is fixed and immutable, making the valuation approximation reliably accurate

**Notable Historical Findings**
Isomorph's `Vault_Synths` priced Synthetix synthetic collateral at face value without deducting the Synthetix exchange fee that would be charged on liquidation, causing the protocol to believe collateral was worth more than its actual liquidation value and enabling under-collateralized positions. The Lyra vault variant incorrectly applied the withdrawal fee unconditionally, but Lyra only charges the fee when there are active option boards; this caused Lyra collateral to be systematically undervalued during quiet periods, blocking healthy users from accessing their full collateral value. A separate Isomorph finding showed that `isoUSDLoaned` (principal only) was used instead of `isoUSDLoanAndInterest` for total debt calculation, allowing borrowers to take out new loans against the outstanding interest they already owed.

**Remediation Notes**
- For each external protocol used as collateral, explicitly model every fee charged during liquidation and deduct it from the valuation
- Make fee application conditional on the same runtime conditions the external protocol uses (e.g., `optionMarket.getNumLiveBoards() != 0` for Lyra fees)
- Always use an oracle price for stablecoin collateral; never assume a 1:1 peg at the smart contract level

---

### Missing Access Control on Sensitive Functions (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Withdrawal function transfers tokens to `msg.sender` but only requires approval over a deposit NFT, not that `msg.sender` is the NFT owner
- Vesting function accepts any beneficiary address from any caller, enabling griefing attacks that fill the beneficiary's timelock array with dust entries and cause out-of-gas on legitimate claims
- Initialization function is `external` without access control and can be front-run between deployment and initialization

**Detection Heuristics**
- Identify external functions that transfer tokens to `msg.sender`; verify `msg.sender` is the NFT owner or rightful beneficiary, not merely someone who triggers the function
- Look for `timelocks[_beneficiary].push(...)` or equivalent unbounded array appends where `_beneficiary` is caller-supplied
- Search for `initialize()` without `initializer`, `onlyOwner`, or factory-based access control
- Trace all `burn()` call paths: does the burning contract verify both that the caller is approved AND is the intended depositor?

**False Positives**
- Function is intentionally permissionless (e.g., public liquidation that anyone should be able to trigger)
- Operation is harmless regardless of caller (e.g., anyone can trigger a public price update)
- Function only operates on `msg.sender`'s own data, making caller identity implicit

**Notable Historical Findings**
Isomorph's `withdrawFromGauge` burned the deposit NFT and sent the underlying AMM tokens to `msg.sender` without verifying that `msg.sender` owned the NFT; any approved operator could trigger the withdrawal and redirect the tokens to themselves rather than the legitimate depositor. Boot Finance's vesting function accepted any caller-supplied beneficiary address with no minimum amount, allowing an attacker to fill a victim's timelock array with thousands of dust entries until the legitimate claim function exceeded the block gas limit. Reserve Protocol's redemption function during undercollateralization could be hot-swapped by a searcher who front-ran the redemption transaction to substitute their own token for a more valuable basket asset.

**Remediation Notes**
- Add `require(depositReceipt.ownerOf(_NFTId) == msg.sender)` to all withdrawal functions before executing any transfer
- Restrict vesting to `msg.sender` as the beneficiary and enforce a minimum vest amount to prevent array-stuffing griefing
- Use `initializer` modifier or deploy-and-initialize atomically in a factory to close the initialization front-running window

---

### Missing Event Emissions After Sensitive Actions (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- Administrative parameter changes (fee rates, scanner registration, whitelist modifications) execute silently without emitting events
- Off-chain monitoring systems and indexers rely on events to detect protocol state changes; missing events mean unauthorized changes may go undetected
- Fund distribution operations do not emit per-recipient events, making it impossible to audit payout history without replaying the full transaction calldata

**Detection Heuristics**
- Enumerate all external and public functions with `onlyOwner`, `onlyAdmin`, or similar modifiers; verify each emits an appropriate indexed event
- Focus on parameter change functions: fee rates, oracle addresses, access control roles, pause state
- Check upgrade and initialization functions for event emissions
- Verify that token transfers emit standard ERC-20/721 Transfer events from downstream calls, not just internal state changes

**False Positives**
- Function is a pure computation with no state changes
- Event is emitted by a downstream OpenZeppelin function (e.g., `_transfer` already emits `Transfer`)
- State change is trivially visible on-chain without event indexing (e.g., a storage slot update to a public variable)

**Notable Historical Findings**
Forta Protocol's scanner registration and configuration functions executed without event emissions, making it impossible for off-chain monitoring systems to detect unauthorized registrations or configuration changes without polling every storage slot. Notional's governance contracts updated critical parameters (voting thresholds, proposal weights) without events, creating a situation where governance attacks could modify protocol behavior with no observable on-chain trace beyond the raw transaction. Futureswap's admin functions similarly changed operational parameters without events, undermining the transparency guarantees that DeFi protocols rely on for user trust.

**Remediation Notes**
- Emit an indexed event for every admin-controlled parameter change, including the old and new values
- Define events for all role assignments and revocations with the granting address, receiving address, and role identifier
- For fund distributions, emit per-recipient events that allow reconstruction of the full payout history from event logs alone

---

### Missing Two-Step Ownership Transfer (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Privacy protocol uses `transferOwnership(newOwner)` that immediately replaces the owner with no confirmation step
- A typo in the new owner address or transfer to an uncontrolled contract permanently removes admin capability
- Diamond proxy patterns use a single-step `setContractOwner` without a pending/accept workflow

**Detection Heuristics**
- Search for `transferOwnership` functions that immediately assign the new owner without a `pendingOwner` pattern
- Confirm the contract inherits from `Ownable2Step` rather than `Ownable` for two-step transfer semantics
- Check for `renounceOwnership()` accessibility; this function permanently removes admin control
- Verify that all critical protocol contracts (treasury, governance, vault admin) use two-step transfer

**False Positives**
- Ownership is managed by a multisig that inherently provides confirmation before execution
- Contract is immutable and ownership is non-transferable by design
- Timelock provides a delay window sufficient for detecting and cancelling erroneous transfers

**Notable Historical Findings**
Beanstalk's diamond proxy used a single-step `transferOwnership` for the contract owner role, which controls the ability to add and remove diamond facets; a single erroneous transaction could have permanently locked the entire protocol's upgradeability. Boot Finance had no ownership transfer pattern at all-the owner address was immutable and the protocol had no path for governance succession. Reserve Protocol discovered that a `transferOwnership` flow without confirmation could leave the `StRSR` contract permanently unusable if ownership was transferred to a contract that could not call `acceptOwnership`.

**Remediation Notes**
- Use OpenZeppelin's `Ownable2Step` instead of `Ownable` for all protocol contracts with meaningful admin functions
- Override `renounceOwnership` to `revert` on contracts where admin functions must remain callable
- For diamond proxies, implement a `proposeOwner` / `acceptOwner` pattern at the `LibDiamond` level

---

### Operations Blocked During Pause or Freeze (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- `whenNotPaused` or `_checkIfCollateralIsActive` is applied equally to loan closure, collateral addition, and liquidation-blocking protective actions the same way it blocks new borrowing
- Interest or fee accrual continues during the paused period, penalizing users who cannot interact to protect their positions
- A single collateral's oracle going stale blocks all operations across the entire protocol, including full-repayment paths that do not require price information

**Detection Heuristics**
- Identify functions that should remain available during a pause (close loan, repay debt, add collateral) and verify they are not gated by the same pause modifier as new-loan functions
- Check if interest accrual (virtual price updates) continues when the protocol is paused; if so, positions degrade silently during the freeze
- Verify that full-repayment paths skip the collateral valuation check when the repayment amount covers all debt
- Look for parameter changes (fee rates, collateral factors) that can be applied during a pause without user ability to respond

**False Positives**
- Pause duration is extremely short by design and interest accrual during that window is negligible
- Emergency withdrawal function remains active during pause and provides an exit path
- Interest accrual is explicitly frozen during the pause period

**Notable Historical Findings**
Isomorph's `_checkIfCollateralIsActive` was called by all four core functions including `closeLoan` and `increaseCollateralAmount`, meaning a stale Lyra oracle price circuit-breaker permanently blocked every user action-including full repayments that required no price information-leaving borrowers unable to exit positions while interest continued to compound. Reserve Protocol's staking contract allowed new stake deposits during paused/frozen states, but withdrawals were blocked; this asymmetry allowed stake to enter but not exit, trapping new depositors. A separate Reserve finding showed that governance changes to `unstakingDelay` affected users who had already submitted withdrawal requests, retroactively extending their wait time.

**Remediation Notes**
- Remove the pause guard from `closeLoan`, `repayDebt`, and `increaseCollateralAmount`; these functions only improve position health and do not require price validation when the repayment covers all outstanding debt
- Freeze interest accrual atomically with the pause by updating `lastUpdateTime` to the pause timestamp and resetting it to `block.timestamp` on unpause
- Block parameter changes that worsen user positions (increased fees, reduced collateral factors) during a paused state

---

### Reentrancy via Token Callbacks (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Privacy protocol's redeem function transfers multiple tokens in a loop before all internal state (total supply, basket state, nullifier commitments) is finalized
- ERC-777 tokens with `tokensReceived` hooks are accepted as shielded assets, enabling re-entry between the balance decrement and the nullifier registration
- Callback-based borrow, lend, or mint functions invoke `msg.sender` before updating note commitments or liquidity state

**Detection Heuristics**
- Identify all external token transfers in redemption and withdrawal loops; verify that all internal state is finalized before the loop begins, not after
- Check if `nonReentrant` is applied to all functions that invoke external callbacks (`ITimeswapMintCallback`, `onERC1155Received`, etc.)
- Verify that note commitments and nullifiers are registered before any external token transfer in deposit/withdrawal flows
- Search for ERC-777 token support; any `tokensReceived` hook can re-enter before state is committed

**False Positives**
- Protocol supports only well-known tokens (USDC, WETH) with no callback mechanisms, enforced by an allowlist
- Strict CEI is maintained throughout and all state is finalized before any external call
- `nonReentrant` covers all relevant entry points globally, not just individual functions

**Notable Historical Findings**
Reserve Protocol's `redeem` function transferred basket tokens to the user in a loop before finalizing `basketsNeeded` and other supply invariants, allowing an ERC-777 token recipient to re-enter `redeem` with stale supply state and extract more than their proportional share. Beanstalk's `FarmFacet` allowed re-entry during multi-step pipeline execution, enabling an attacker to drain intermediate value that accumulated during the pipeline handoff. Timeswap's `mint`, `lend`, `borrow`, and `pay` functions all invoked callbacks to `msg.sender` before completing their critical state updates (liquidity balances, fee accruals, debt positions), making every core function a reentrancy vector.

**Remediation Notes**
- Finalize all state updates (burn, supply reduction, nullifier/commitment updates) before beginning any external transfer loop
- Apply `nonReentrant` to every callback-invoking function (`mint`, `lend`, `borrow`, `redeem`, `pay`)
- For batch redemptions, split the function into a pure effects phase (update all internal state) and a pure interactions phase (execute all transfers)

---

### Rounding and Truncation Errors (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Two different code paths compute the same aggregate collateral value using different aggregation orders (sum-then-price vs. price-each-then-sum), producing inconsistent results that can be exploited in comparison checks
- Virtual price (interest accumulator) updates use `block.timestamp` rather than truncating to the nearest interval boundary, permanently losing fractional time that should have been rolled forward
- Token precision multipliers computed as `10**(18 - decimals)` round to zero for tokens with more than 18 decimals, breaking pool math entirely

**Detection Heuristics**
- Find pairs of functions that compute the same logical value and verify they use identical aggregation order and rounding direction
- Search for `_updateVirtualPrice` or equivalent that stores `block.timestamp` after a truncated interval calculation; the stored time should be `truncatedIntervals * interval`, not `block.timestamp`
- Look for `customPrecisionMultipliers = 10**(18 - decimals)` without a guard against `decimals > 18`
- Identify all division operations where the result is later used in a comparison; ensure rounding direction is consistent between the two compared values

**False Positives**
- Truncation errors are within a documented dust threshold and cannot be amplified
- Protocol explicitly uses `mulDiv` for full-precision arithmetic throughout
- The aggregation-order difference is known and the comparison uses a dust-tolerance allowance to absorb the discrepancy

**Notable Historical Findings**
Isomorph's liquidation check compared `proposedAmount` (computed by pricing each NFT individually, truncating N times) against `totalCollateralValue` (computed by summing all NFTs then pricing once, truncating once), producing systematic differences that could prevent full liquidation even when the loan was deeply underwater. The same protocol's `_updateVirtualPrice` stored `_currentBlockTime` after computing interest for only truncated intervals, permanently discarding the fractional seconds remainder and under-accruing interest on every update cycle. Boot Finance's `customPrecisionMultipliers` calculation produced zero for any token with more than 18 decimals, causing division-by-zero or zero-value transfers that completely broke the pool for those token pairs.

**Remediation Notes**
- Standardize all collateral valuation paths to use the same aggregation order: sum all units first, then apply a single price conversion
- Store `(block.timestamp / interval) * interval` as the updated time, not `block.timestamp`, to ensure the accumulator advances in exact interval steps
- Guard precision multiplier calculations with `require(decimals <= 18, "Unsupported decimals")` or compute the inverse for tokens with more than 18 decimals

---

### Stale Oracle Data (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Oracle bounds (`tokenMinPrice`, `tokenMaxPrice`) are fetched from the Chainlink aggregator at deployment and stored as immutables; if Chainlink deploys a new aggregator with different bounds, the cached values become permanently stale
- Heartbeat staleness threshold is set to 24 hours for a feed that updates every 1 hour, allowing prices that are effectively stale to pass validation for 23 hours
- Oracle deprecation causes `latestRoundData()` or `refresh()` to revert unconditionally, permanently disabling the protocol path that depends on it

**Detection Heuristics**
- Search for `aggregator.minAnswer()` and `aggregator.maxAnswer()` stored in `immutable` variables in the constructor
- Verify staleness thresholds match the actual heartbeat for each specific Chainlink feed (not a single generic large value)
- Check `round completeness`: `answeredInRound >= roundID` must be verified in every `latestRoundData()` call
- Test oracle deprecation path: if the primary feed's `latestRoundData()` reverts, does the protocol halt permanently or fall back gracefully?

**False Positives**
- Oracle is used only for non-critical display purposes
- Protocol has admin-controlled manual price overrides that activate during oracle failures
- Staleness window is generous but acceptable within the protocol's epoch duration

**Notable Historical Findings**
Isomorph's oracle bounds were cached at construction time; when Chainlink deprecated an aggregator and deployed a replacement with updated min/max bounds, the cached bounds no longer matched the live feed-causing the protocol to either reject valid prices or accept invalid ones indefinitely. Reserve Protocol's `refresh()` function propagated an oracle deprecation exception upward without catching it, disabling the entire basket recollateralization path the first time any oracle in the basket was deprecated. A separate Reserve finding showed that `Asset.lotPrice()` did not fall back to a safe low price on oracle timeout, instead using a potentially stale cached price for asset sales, enabling significant underpayment to the protocol.

**Remediation Notes**
- Fetch aggregator bounds dynamically (`priceFeed.aggregator().minAnswer()`) on every oracle query rather than caching them at construction
- Set heartbeat thresholds per-feed based on documented update frequencies, not a single global constant
- Wrap all oracle calls in `try/catch` and route failures to a fallback price source or a safe degraded mode that halts new operations while allowing existing position closures

---

### Stuck or Permanently Locked Funds (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- The only withdrawal path calls `priceCollateralToUSD` which reverts when the oracle price falls outside its min/max bounds, permanently locking all collateral for affected users
- A failed prior proposal in an NFT fractionalization or escrow contract leaves the contract in a state where no new proposal can be executed and funds cannot be recovered
- A single collateral type whose oracle becomes permanently unavailable prevents `rebalance()` from executing, blocking the entire basket

**Detection Heuristics**
- Trace all withdrawal and redemption paths and verify at least one path remains functional when external dependencies (oracle, external protocol) revert
- Check if oracle out-of-bounds conditions block full-repayment paths where collateral pricing is not economically necessary
- Search for contracts that receive tokens with no `rescueTokens` or `sweep` function for accidentally deposited assets
- Verify that a single failing basket component is handled gracefully (try/catch continuation) rather than blocking all basket operations

**False Positives**
- Admin emergency withdrawal function exists and bypasses normal checks
- Stuck condition is temporary and self-resolving (oracle will update within the heartbeat window)
- Governance mechanism can migrate or rescue stuck funds within a bounded time frame

**Notable Historical Findings**
Isomorph's `closeLoan` always called `_calculateProposedReturnedCapital` even when the user was repaying their entire debt and no collateral pricing was needed; when the oracle price moved outside bounds, this single check permanently locked all collateral for every user of the affected collateral type. Tessera's `OptimisticListingSeaport` could enter a permanently stuck state if a new proposal was created while an active proposal was being executed, leaving both proposals irreconcilable and all fractionalized NFT proceeds inaccessible. Reserve Protocol identified that if a single collateral asset in the basket behaved unexpectedly (oracle revert, non-standard behavior), the entire `RToken` would become permanently insolvent and unusable.

**Remediation Notes**
- For full-repayment closure paths, skip collateral valuation when `outstandingDebt == 0`; allow withdrawal of all collateral without any price check
- Add a `rescueTokens(IERC20 token, address to)` function that excludes the protocol's primary tokens and can recover accidentally deposited assets
- In basket rebalance loops, use `try/catch` around individual collateral price queries and emit a failure event for the affected asset rather than reverting the entire operation

## reference/solidity/protocols/reserve-currency.md

# Reserve Currency Protocol Security Patterns

> Applies to: protocol-owned liquidity, reserve currency protocols, OHM-style, bonding mechanisms, treasury-backed tokens, (3,3) staking models, Olympus-style

## Protocol Context

Reserve currency protocols build protocol-owned liquidity through bonding, where users sell LP tokens or collateral assets to the treasury at a discount in exchange for vested governance tokens. Treasury depth provides a backing floor price, while staking mechanics distribute seigniorage income to token holders. The critical attack surface lies in the state aggregators that cache collateral ratios and treasury metrics: stabilization and profit distribution decisions read these cached values without re-syncing, so any sequence of operations that modifies underlying balances before the aggregator is refreshed causes stabilization to act on incorrect information.

Registry-style data structures managing swing traders, market participants, and protocol contracts introduce a second class of vulnerabilities: add/remove function asymmetry, silent role revocation failures, and array/mapping state divergence all corrupt the iteration logic that drives stabilization buy/sell decisions. These structural bugs are compounded by the sensitivity of the staking model to correct epoch accounting and APR calculation, where an ordering error in a single update function can permanently corrupt cumulative reward metrics.

## Bug Classes

---

### Access Control State Mismatch (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Contract maintains a registry of swing traders or other protocol actors tracked in both a mapping (struct with fields including an `active` boolean) and an auxiliary array used for iteration. Addition functions push to the array unconditionally, regardless of the `active` parameter value. A custom `hasRole` override adds a `validRoles[role]` condition beyond the base implementation, causing role revocation to silently fail when `validRoles` is false for the target role. The same registry allows duplicate registrations of the same underlying contract address.

**Detection Heuristics**
Identify registry-style contracts maintaining both a mapping and an array for tracking active entities. Check if add/register functions unconditionally push to the array regardless of the `active` parameter. Look for `hasRole` or similar override functions that add conditions beyond the base implementation. Trace role revocation, renouncement, and transfer paths to see if they depend on the overridden `hasRole` returning true. Check for uniqueness validation on the underlying contract address when adding entries.

**False Positives**
The array is purely informational and not used for balance calculations or iteration in critical paths. The `active` parameter is always `true` in practice, enforced by calling conventions or deployment scripts. Role override behavior is intentional and documented, with separate admin paths for cleanup.

**Notable Historical Findings**
Malt Protocol's SwingTraderManager.addSwingTrader always pushed the `traderId` to the `activeTraders` array even when the `active` parameter was explicitly set to false, corrupting iteration over active traders and causing incorrect balance calculations used in stabilization decisions. A companion finding showed the same contract allowed duplicate trader contract addresses, which compounded the iteration corruption. MaltRepository's overridden `_revokeRole` silently failed when the role being revoked had `validRoles[role] == false`, because the override's `hasRole` returned false even for legitimately-granted roles, making those roles effectively irrevocable.

**Remediation Notes**
Condition the `activeTraders.push()` call on `active == true`. Validate uniqueness of the underlying contract address before adding a new trader entry. When overriding `hasRole`, ensure the override does not break revocation, renouncement, or transfer paths; provide an explicit admin path for cleaning up roles that bypass the `validRoles` guard.

---

### Reward Accounting Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol distributes rewards, profits, or yield over epochs or vesting periods. Accounting variables (cumulative APR, vested amounts, profit totals) are updated conditionally or at the end of a function. Early returns, zero-amount edge cases, or cap-bound logic cause accounting updates to be skipped. Downstream logic depends on the accuracy of these cumulative tracking variables for computing APR averages, vesting schedules, and profit distributions.

**Detection Heuristics**
Look for functions that update cumulative or running-total variables and check for early-return paths or zero-amount guards that skip these updates. Identify cap/clamp logic (e.g., `if (distributed > balance) distributed = balance`) and verify whether the tracking variable adjusts to match the actual distributed amount. Search for `return` statements that occur before state variable assignments, especially in loops processing multiple epochs. Verify that dust-check guards do not skip profit or reward accounting updates.

**False Positives**
Zero-amount epochs genuinely should not contribute to cumulative metrics by design. The skipped update is for a metric that is never read again. A separate reconciliation mechanism corrects drift periodically.

**Notable Historical Findings**
Malt Protocol's RewardThrottle had several reward accounting errors found together: `_sendToDistributor` returned early when the distribution amount was zero, skipping the cumulative APR update for that epoch, which caused `checkRewardUnderflow()` to track cumulative APRs incorrectly across subsequent epochs. A related finding showed that an epoch without profit would fail to carry its cumulative APR checkpoint into the next epoch, so the following epoch's APR calculation started from an incorrect base. LinearDistributor set `previouslyVested` to `currentlyVested` even when the distributed amount was capped by available balance, permanently losing the unclaimed portion of the vesting schedule.

**Remediation Notes**
Decouple accounting variable updates from the distribution guard: update cumulative metrics unconditionally before any zero-amount early return. When distribution is capped by available balance, adjust `previouslyVested` proportionally to the actual distributed amount rather than advancing it to the full `currentlyVested` value. Write invariant tests asserting that the sum of all epoch APR contributions matches the total rewards emitted.

---

### Stale State Dependency (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Contract reads collateral ratios, price targets, or balance-derived metrics from a global state aggregator that caches values from the previous sync. The aggregator is not synchronized before the consuming function executes its core logic. Intermediate operations within the same transaction (token transfers, swaps, auction finalizations) modify the underlying balances that the aggregator should reflect. The stale value influences critical protocol decisions such as stabilization, profit distribution, or price target calculations.

**Detection Heuristics**
Identify global state aggregators that cache balances or derived metrics (e.g., `collateralRatio`, `totalCollateral`). Trace all functions that read from these aggregators and check if a `sync` call precedes the read. Look for intermediate operations between the last sync and the read that modify underlying balances. Check whether the stale value feeds into branching logic, price calculations, or distribution ratios. Pay attention to multi-step functions where early steps modify state that later steps depend on through the aggregator.

**False Positives**
The aggregator uses live `balanceOf` calls instead of cached values. The magnitude of staleness is negligible relative to the decision threshold. Sync is guaranteed to happen in the same transaction via a modifier or hook before any read.

**Notable Historical Findings**
Malt Protocol's `stabilize()` called `auction.checkAuctionFinalization()` (which modifies balances) before calling `impliedCollateralService.syncGlobalCollateral()`, meaning the price target derived from `collateralRatio()` reflected pre-auction-finalization balances and caused incorrect stabilization buy/sell decisions. A companion finding showed `_distributeProfit` read `swingTraderCollateralDeficit` and `swingTraderCollateralRatio` from the same stale service. A third related finding noted that `stabilize()` could incorrectly include undistributed rewards sitting in the overflow pool as part of the collateral calculation, further inflating the apparent collateral ratio.

**Remediation Notes**
Call `impliedCollateralService.syncGlobalCollateral()` (or equivalent) as the first action in any function that derives critical values from the aggregator. Establish a consistent ordering contract: all balance-modifying operations complete, then sync, then read derived values. Enforce this with a modifier or a clearly-named internal helper that combines sync and read.

---

### State Update Ordering (ref: fv-sol-5)

**Protocol-Specific Preconditions**
A function modifies a storage variable and then reads the now-modified value for a different calculation in the same function, or exits via an early return before updating a dependent state variable. Array management uses a swap-and-pop pattern where the index is sourced from a struct field that is zeroed before it is read. Public functions can be called by anyone (including front-runners) to modify shared state that admin functions depend on being at a specific prior value.

**Detection Heuristics**
Search for patterns where a storage variable is set to zero or a new value, then immediately read back for a different computation in the same function. Identify functions with `return` statements that occur before state variable updates (profit, balance, counter). Look for array swap-and-pop patterns where the index is sourced from a struct field that was already modified. Check for public functions that modify shared state and can be called to front-run admin operations.

**False Positives**
The zeroed or modified value is intentionally the correct input for subsequent logic. The early return path is a genuine terminal state requiring no further updates. A front-runnable function has access controls that prevent adversarial invocation.

**Notable Historical Findings**
Malt Protocol's Repository._removeContract zeroed the `currentContract.index` field before reading it to perform the array swap-and-pop, so the swap always targeted index 0 rather than the intended position, corrupting the contract registry. In the same protocol, `sellMalt()` returned early when the dust threshold was hit, before the `totalProfit += profit` line was reached, causing the cumulative profit tracker to undercount. RewardThrottle.populateFromPreviousThrottle was callable by any address and modified `activeEpoch`, which governance relied on being at a specific value when migrating throttle state, enabling a front-run attack that could corrupt the migration. StabilizerNode.stabilize() failed to update `lastTracking` when conditions were not met, causing unnecessary stabilization incentive payouts on subsequent calls.

**Remediation Notes**
Read all storage values that will be needed before modifying them in the same function scope. Update all cumulative and profit-tracking state variables before any conditional early return. Restrict functions that modify shared state used by governance or admin operations to privileged roles. Use local variable copies to capture values prior to mutation when the pre-mutation value is needed later in the same function.

## reference/solidity/protocols/rwa-lending.md

# RWA Lending Protocol Security Patterns

> Applies to: lending protocols backed by real world assets, undercollateralized RWA loans, RWA credit markets, Maple-style, TrueFi-style, Goldfinch-style, institutional on-chain credit

## Protocol Context

RWA lending protocols extend on-chain credit backed by off-chain collateral, typically to institutional borrowers under legal agreements that the smart contract enforces through credit lines, pools, and fixed repayment schedules. Unlike overcollateralized DeFi lending, these protocols operate with undercollateralization by design and rely on credit assessment, legal recourse, and pool diversification rather than liquidation bots to manage default risk. The smart contract layer is responsible for correctly tracking credit principal and accrued interest, enforcing withdrawal queue ordering, and maintaining accurate accounting across pool participants who may have different seniority.

The attack surface is concentrated in credit queue data structures that maintain ordered state across multiple borrows and repayments, payment routing logic that must correctly distinguish interest from principal, and push-payment patterns in credit close flows where a malicious lender contract can permanently block settlement. Unbounded iteration over credit arrays, rounding errors in time-weighted debt decay, and ETH/ERC-20 handling asymmetries in repayment functions are the most common sources of high-severity findings in audits of this protocol class.

## Bug Classes

---

### Credit Line Queue Corruption (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol manages active credit positions using an ordered array where index 0 carries special meaning (first to be repaid, basis for liquidation eligibility, anchor for insolvency checks). Operations that remove or close entries can produce zero-value gaps in the array. Sorting logic that re-inserts positions skips gaps, leaving them in place indefinitely. Modifiers guarding liquidation and insolvency declaration check `credits[ids[0]].principal == 0` to determine whether borrowing is active, producing a false negative when gaps exist and position 0 is a zero stub.

**Detection Heuristics**
Identify all functions that modify the credit queue array: close, repay, borrow, and any administrative override. Verify that each removal path leaves no zero-value gaps and does not decrement the count for a non-existent entry. Confirm that the `whileBorrowing` modifier (or equivalent) checks for any live principal across the full array, not only at index 0. Trace what happens when `close()` is called with an ID that was never created: does the mapping return a zero-struct that passes the caller check, and does the subsequent `_close` decrement an already-zero count?

**False Positives**
Protocols that use a linked list rather than an array, where index-0 assumptions do not apply. Protocols where every entry point validates existence before modifying the queue, making the zero-struct bypass path unreachable. Arrays maintained as a densely packed set where removals always shift elements left.

**Notable Historical Findings**
In Debt DAO's Line of Credit, a borrower could call `close()` with a non-existent credit ID because the function fetched from the mapping without an existence check; the zero-struct's zero lender address bypassed the caller guard (the borrower always matched), and the subsequent `_close` decremented the `count` to an incorrect value. A separate finding showed that calling `declareInsolvent()` after repaying the first credit position caused a revert because the queue sorting left a zero-value gap at index 0, making the `whileBorrowing` modifier falsely conclude no active debt existed and block the insolvency path.

**Remediation Notes**
Add an explicit existence check (e.g., `credit.lender != address(0)`) as the first operation in any function that reads from the credit mapping. Adjust the `whileBorrowing` modifier to scan the full array for non-zero principal rather than relying on a single index. Ensure the queue sorting function fills gaps rather than skipping them.

---

### ETH Handling and Refund Errors (ref: fv-sol-6)

**Protocol-Specific Preconditions**
Contract has payable functions that accept both ETH and ERC-20 tokens using the same function signature. The ETH receipt path uses a less-than comparison (`msg.value < amount`) rather than strict equality, allowing excess ETH to be silently retained. When the ERC-20 path is invoked, any ETH accidentally included with the call is locked without refund. Outgoing ETH payments use `payable.transfer()`, which fails for smart contract recipients whose `receive()` function consumes more than 2,300 gas.

**Detection Heuristics**
Search for payable functions that branch on a token-address sentinel (e.g., `address(0)` or `Denominations.ETH`) to distinguish ETH from ERC-20. On the ETH branch, verify the comparison is `!= amount` rather than `< amount`. On the ERC-20 branch, verify a revert is triggered when `msg.value > 0`. For outgoing ETH transfers, confirm `call{value:}` is used instead of `.transfer()`. Check whether the `sender` parameter in a shared `receiveTokenOrETH` helper is validated against `msg.sender` when ETH is the token, to prevent spoofed-sender crediting.

**False Positives**
Contracts that intentionally accept ETH donations above the required amount and credit the excess to the sender in internal accounting. Contracts with a dedicated `rescueETH` admin function that recovers any locked ETH.

**Notable Historical Findings**
In Debt DAO, the `receiveTokenOrETH` function used `msg.value < amount` on the ETH path; any excess ETH was accepted and permanently locked in the contract. The same function did not reject ETH on the ERC-20 path, so a caller who mistakenly included ETH with a token transfer lost those funds silently. Outgoing ETH sends in `sendOutTokenOrETH` used `payable(receiver).transfer(amount)`, which failed for any receiver that was a smart contract with a non-trivial receive function.

**Remediation Notes**
Use strict equality (`msg.value != amount`) on the ETH receive path. Revert immediately when `msg.value > 0` on the ERC-20 path. Replace all `payable.transfer()` ETH sends with `call{value:}` and check the return value. Validate `msg.sender == sender` when the payment is ETH to prevent sender spoofing through the shared helper.

---

### Frontrunning Unprotected State Transitions (ref: fv-sol-5)

**Protocol-Specific Preconditions**
A function performs a critical state transition against an implicit target read from contract state rather than an explicit identifier supplied by the caller. The target can change between the time the user signs and submits the transaction and the time it is mined. Governance vote functions reference `activeProposal` from state, allowing a proposal swap to redirect votes to an unintended proposal. NFT claim functions do not mark the ticket as claimed before the transfer, enabling owner front-running. Lender-controlled external call data (e.g., swap calldata) is passed through unchecked, allowing value redirection.

**Detection Heuristics**
Identify functions where the subject of the operation is read from a state variable rather than supplied as a parameter. Check governance vote paths for the presence of an explicit proposal ID parameter and a corresponding equality check against the active proposal. Audit claim functions for a mark-before-transfer pattern. Search for functions that accept arbitrary external call data from a non-borrower caller and execute it against third-party contracts.

**False Positives**
Functions callable only by trusted roles where mempool observation is not relevant. Atomic state transitions that cannot be reordered within a single block. Protocols using private mempools or commit-reveal schemes that make front-running economically infeasible.

**Notable Historical Findings**
In Olympus DAO, the `vote()` function read `activeProposal.proposalId` from state without requiring the caller to specify which proposal they intended to vote on; if a new proposal was activated between submission and mining, the user's votes were silently redirected. In Debt DAO, a lender could supply malicious `zeroExTradeData` calldata to `claimAndRepay`, redirecting swap proceeds away from the borrower's collateral repayment. In Wenwin, an NFT ticket owner could front-run a buyer's purchase transaction by calling `claimWinningTickets` first, collecting the reward before the transfer settled.

**Remediation Notes**
Require explicit target identifiers as function parameters and validate them against current state (e.g., `require(proposalId == activeProposal.proposalId)`). Restrict caller-controlled external call data to the borrower or a trusted role. Mark claims as consumed before any external transfer or payment using checks-effects-interactions.

---

### Funds Locked in Edge-Case States (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Reward distribution sends tokens to a staking or recipient contract before any stakers exist, with no mechanism to account for or recover those tokens. A prediction market or binary outcome contract reaches a state where all participants chose the same direction, making `totalWinningAmount == 0` and blocking all claims. A credit line close path sends funds directly to the lender using a push pattern; a lender contract that reverts on token receipt can permanently block the close operation, trapping borrower funds.

**Detection Heuristics**
Identify all reward distribution or prize-sending paths and check behavior when the eligible recipient count or total winning stake is zero. Look for push-payment patterns in credit close and repayment flows where the recipient is a potentially hostile or failing contract. Verify that every protocol state that receives funds has a corresponding exit path that does not depend on all participants behaving correctly.

**False Positives**
Protocols that guarantee at least one staker through an initialization deposit or minimum stake requirement enforced at launch. Protocols with an admin emergency withdrawal function that can bypass normal accounting invariants. Stuck amounts negligible relative to total protocol value.

**Notable Historical Findings**
In Wenwin, `claimRewards` transferred staking rewards to the `stakingRewardRecipient` contract even when it had zero stakers, permanently locking those tokens because the staking contract's reward accounting produced no claimable amounts. In a prediction market protocol, when all users selected the same direction, `totalWinningAmount` evaluated to zero and every user's claim reverted, locking the entire round's prize pool. In Debt DAO, a lender contract that reverted on token receipt (via a hook or malicious `receive`) could block `_close` indefinitely because the send was mandatory and had no fallback.

**Remediation Notes**
Check for zero-recipient or zero-winner edge cases in every distribution function and route unclaimed funds to a treasury address or preserve them for a subsequent period. Replace push-payment patterns in credit close flows with pull-payment accounting (credit internal balances, allow lenders to withdraw separately). For prediction markets, implement a no-winner fallback that returns funds to depositors or a protocol reserve.

---

### Irrevocable Whitelist or Approval (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Protocol maintains a whitelist or approval mapping for auctioneers, tellers, or revenue contracts that can invoke privileged callbacks or receive funds. An `add` function exists but no corresponding `remove` or `revoke` function. A whitelisted entity that becomes compromised retains all its permissions permanently.

**Detection Heuristics**
Enumerate all mappings and arrays used as access control lists. Confirm that every `add`/`register`/`whitelist` function has a symmetric `remove`/`deregister`/`revoke` counterpart. Assess the blast radius of a whitelisted entity being compromised: can it transfer funds, redirect callbacks, or drain the contract? Check whether the only available mitigation is a full contract pause, which would affect all users.

**False Positives**
Whitelisted entities that are immutable, non-upgradeable contracts incapable of being compromised. Protocols with a pause mechanism that effectively neutralizes a compromised entity without requiring removal. Permissionless designs where whitelist removal is intentionally omitted to prevent censorship.

**Notable Historical Findings**
In Bond Protocol, the `BondAggregator.registerAuctioneer` function set `_whitelist[address(auctioneer_)] = true` with no deregistration function anywhere in the codebase; a compromised auctioneer would permanently retain the ability to operate markets and receive proceeds. The same protocol's teller approval mapping (`approvedMarkets[teller_][id_] = true`) similarly lacked any revocation function, leaving any compromised teller with irrevocable market-level permissions.

**Remediation Notes**
Implement a symmetric removal function for every addition function that modifies an access control mapping. Ensure the removal function is callable by the same authorized role as the addition function and emits an event for off-chain monitoring. Document the expected response procedure for a compromised whitelisted entity.

---

### Missing Existence Validation on State Operations (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol stores credits, markets, or revenue contracts in mappings where non-existent keys return zero-value structs. Operations that modify or query these entities do not verify the entity was previously created. The default zero-value struct inadvertently satisfies validation conditions (e.g., `claimFunction == bytes4(0)` is treated as a valid push-payment mode rather than an unregistered contract). Count or index decrements execute successfully on non-existent entities, corrupting accounting.

**Detection Heuristics**
Identify every mapping lookup where the key is derived from user input or an unvalidated external parameter. Check that the retrieved struct is validated for existence before use - the canonical check is a non-zero address field (`credit.lender != address(0)`). Trace count and array modifications: do they proceed on a zero-struct without reverting? Verify whether zero-value fields in the struct could activate code paths that would only be valid for registered entities.

**False Positives**
Mappings iterated exclusively from a known-valid in-memory array, guaranteeing all accessed keys were previously inserted. Zero-struct returns that cause the function to revert before any state change through a downstream check. Code paths behind access control that prevent untrusted callers from triggering the missing validation.

**Notable Historical Findings**
In Debt DAO, calling `close()` with a never-registered credit ID fetched a zero-struct that passed the caller check (borrower always matched the zero-lender condition) and then decremented `count`, corrupting the queue state. A separate finding showed that passing an unregistered revenue contract to `claimRevenue` was treated as a valid push-payment configuration because `claimFunction == bytes4(0)` matched the push-payment sentinel; the zero `ownerSplit` field then sent 100% of any existing contract balance to the protocol treasury rather than the owner.

**Remediation Notes**
Add an explicit existence guard as the first statement in any function that reads from a mapping keyed by user input. Use a non-zero address field (lender, owner, creator) as the existence sentinel rather than relying on downstream logic to catch zero values. Document the canonical sentinel field for each mapping to enforce consistent validation across callers.

---

### Reentrancy via Token Callbacks (ref: fv-sol-1)

**Protocol-Specific Preconditions**
Contract transfers tokens to a user-controlled address (lender, credit recipient) before deleting the associated state record. The transferred token supports transfer callbacks (ERC-777 `tokensReceived`, ERC-1155 `onERC1155Received`, or ETH `receive`). No `nonReentrant` guard is applied. The re-entrant call finds the credit record still intact, allowing it to extract the deposit or principal a second time before the delete executes. Read-only reentrancy via Curve's `get_virtual_price` allows external protocols that read the LP token price to receive a stale value during a `remove_liquidity` callback.

**Detection Heuristics**
Identify functions that send tokens or ETH to user-controlled addresses. Check whether the delete or balance-update of the corresponding state record occurs before or after the transfer. Search for absence of `nonReentrant` on credit close, repayment, and withdrawal functions. For Curve LP token pricing, verify that a reentrancy lock is triggered before reading `get_virtual_price` to prevent read-only reentrancy.

**False Positives**
Token whitelist that explicitly excludes all tokens with transfer hooks and is enforced at protocol level with no upgrade path. State changes that already occur before the external transfer, satisfying the checks-effects-interactions pattern throughout. Reentrancy guards present on all public entry points.

**Notable Historical Findings**
In Debt DAO, `_close` sent deposit plus accrued interest to the lender before deleting the credit record and decrementing the count; an ERC-777 lender could re-enter `_close` during the callback, receiving the payment twice while the credit record remained live. A Sentiment Update #2 finding demonstrated read-only reentrancy against the wstETH-ETH Curve pool: during a `remove_liquidity` call, `get_virtual_price` returned a stale pre-withdrawal value, allowing an attacker to borrow against an inflated LP token price.

**Remediation Notes**
Follow checks-effects-interactions strictly: delete the credit record and update all counters and status flags before making any external transfer. Apply `nonReentrant` to all public and external functions that transfer value. For Curve LP token pricing, trigger the pool's reentrancy lock (e.g., a zero-amount `remove_liquidity` call) before reading `get_virtual_price`.

---

### Rounding Direction Errors (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Protocol performs division in price, debt decay, or reward calculations where the rounding direction has financial consequences for one party. The protocol's specification or whitepaper explicitly states the required rounding direction. The implementation uses a standard `mulDiv` (round-down) function where a round-up would be correct, or applies the same rounding direction inconsistently across related calculations (e.g., public market price vs. internal market price).

**Detection Heuristics**
Identify all `mulDiv`, `div`, and integer division operations in price, reward, and time-decay calculations. Determine who benefits from rounding in each direction (protocol, maker, taker, staker). Compare the implementation's rounding direction to any specification or whitepaper. Look for inconsistency where the same formula rounds differently depending on which code path is executed. Check for unsafe casts that implicitly truncate precision (e.g., `uint256` to `uint16` for ticket prizes).

**False Positives**
Rounding errors bounded to sub-wei amounts per operation where cumulative impact is negligible. Calculations where both the specification and implementation intentionally round in the protocol's favor for safety margins. Division results used in contexts where rounding direction has no observable financial effect.

**Notable Historical Findings**
In Bond Protocol, `_currentMarketPrice` used `mulDiv` (round-down) where the specification required rounding up to protect sellers from receiving less than the quoted price; the resulting underpricing was systematic and compounded across all market activity. The same protocol's debt decay increment used round-down when the specification required round-up, causing debt to decay faster than intended and reducing the protocol's solvency buffer.

**Remediation Notes**
Implement a `mulDivUp` utility (rounding up) alongside `mulDiv` and apply the correct variant per the specification. Audit all price and debt calculations against any published specification for explicit rounding requirements. Treat rounding direction as a protocol invariant to be verified in unit tests with boundary inputs.

---

### Stale or Manipulable Price Data (ref: fv-sol-10)

**Protocol-Specific Preconditions**
Protocol uses Chainlink oracle feeds with inconsistent staleness thresholds: one feed allows three times the observation frequency before reverting while another allows only one, making the effective combined freshness guarantee weaker than either threshold alone. Curve `get_virtual_price` is called without a reentrancy lock, making it vulnerable to read-only reentrancy during `remove_liquidity` callbacks. A keeper-driven heartbeat system keeps prices active, but no staleness check is performed at the point of swap or liquidation; if the keeper stops, the protocol continues using arbitrarily old prices.

**Detection Heuristics**
Enumerate all Chainlink `latestRoundData` calls and check that each includes `updatedAt < block.timestamp - threshold` validation. Compare staleness thresholds across all feeds used in the same calculation - they should be equivalent or the weaker threshold should govern. Look for `get_virtual_price` calls not preceded by a reentrancy guard. Identify heartbeat-dependent protocols and check whether the swap or liquidation path validates that the last heartbeat timestamp is within an acceptable window.

**False Positives**
Staleness windows that differ intentionally because oracle update frequencies genuinely differ and the threshold is calibrated to each feed's actual heartbeat. TWAP oracles with windows long enough to make momentary manipulation economically infeasible. Price feeds used only for non-critical display or informational purposes where staleness has no financial consequence.

**Notable Historical Findings**
In Olympus DAO, the OHM-ETH feed's staleness threshold was three times the observation frequency while the reserve-ETH feed's threshold was one times; an attacker could profit from the asymmetry by timing operations to the window where the reserve feed was stale but still accepted. The same protocol's RBS system kept swap walls active even when the heartbeat had not been called, meaning users and bots could execute swaps against arbitrarily old prices. A Sentiment Update #2 finding showed that Curve's `get_virtual_price` could be manipulated via a re-entrant `remove_liquidity` call, artificially inflating the LP token price used as collateral.

**Remediation Notes**
Apply a uniform staleness threshold to all feeds combined in the same calculation, calibrated to the feed with the slowest update frequency. Add a reentrancy guard before reading `get_virtual_price` from any Curve pool. Require that heartbeat-dependent pricing systems verify heartbeat freshness at the point of each swap or liquidation rather than relying on an external keeper to always be online.

---

### Timestamp and Expiry Rounding Bypass (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol generates token or bond identifiers by hashing (underlying, expiry) where expiry is rounded to the nearest day internally in one code path but accepted as-is in another. A user who calls `deploy()` with a non-rounded expiry creates a token with a different ID than the one produced by `_handlePayout()`, breaking the token's fungibility and redemption path. Separately, boundary timestamp comparisons use strict inequalities (`<` or `>`) where inclusive comparisons (`<=` or `>=`) are required, allowing two mutually exclusive operations (e.g., execute draw and buy ticket) to occur atomically in the same block.

**Detection Heuristics**
Find all paths that create or reference time-indexed tokens and verify that every path applies the same rounding function to the expiry. Compare `deploy()` or public creation functions against internal mint functions for rounding consistency. For time-gated operations, check every boundary comparison for off-by-one errors: `< deadline` vs. `<= deadline` and `> deadline` vs. `>= deadline`. Identify pairs of operations that should be mutually exclusive at epoch boundaries and verify they cannot both execute at the same `block.timestamp`.

**False Positives**
Off-by-one boundary conditions with no practical impact because the two operations cannot be submitted atomically (e.g., they require distinct signers with no time coordination). Rounding differences that are cosmetic and do not affect token ID generation or financial calculations. Cooldown periods that prevent the boundary condition from being exploitable even if it is reachable.

**Notable Historical Findings**
In Bond Protocol's Fixed Term Teller, `deploy()` did not round the expiry to the nearest day while `_handlePayout()` did; tokens created through `deploy()` with a mid-day expiry had different IDs than tokens minted through normal purchase flows, making them non-redeemable through the standard redemption path. A separate finding showed that `deploy()` accepted expiries in the past without reverting, allowing creation of immediately redeemable tokens at any price. In Wenwin, the `executeDraw` boundary used `<` instead of `<=`, allowing it to execute at the exact same block timestamp that `beforeTicketRegistrationDeadline` still admitted ticket purchases, creating a race condition where a user could buy a ticket and immediately claim the jackpot in the same block.

**Remediation Notes**
Apply expiry rounding consistently in every function that generates time-indexed identifiers. Validate that expiries are strictly in the future after rounding is applied. Use inclusive boundary comparisons at epoch and deadline boundaries to prevent same-block co-execution of mutually exclusive operations.

---

### Unbounded Loop Denial of Service (ref: fv-sol-9)

**Protocol-Specific Preconditions**
Protocol maintains a market counter, prediction array, or position list that grows unboundedly over the protocol's lifetime. A function iterates over the full collection from index 0 to the current counter without a pagination mechanism. The function is on the critical path for claims, withdrawals, or liquidations that must succeed for users to recover funds. Gas cost grows linearly with the collection size and will eventually exceed the block gas limit.

**Detection Heuristics**
Identify all loops where the iteration bound is a state variable that can grow monotonically without a ceiling enforced at insertion time. Check whether the iterated function is a view function (DoS is limited to off-chain reads) or a state-changing function on the critical path (DoS blocks user funds). Estimate gas cost at realistic scale: a collection of 10,000 entries with a per-element cost of 5,000 gas exceeds 50M gas, above most chain block limits. Check for double iteration patterns (collect count, then fill array) that double the gas cost.

**False Positives**
Arrays with a hard-coded maximum size enforced at insertion, where the gas cost at the cap fits within the block gas limit. View-only functions where DoS only affects off-chain tooling and does not block on-chain fund recovery. Protocols with a finite, well-bounded lifetime where the collection cannot grow beyond safe limits.

**Notable Historical Findings**
In Bond Protocol, `BondAggregator.liveMarketsBy` iterated over all markets ever created twice (once to count, once to fill the result array); as market count grew, the function would revert on-chain due to block gas limits. The same protocol's `findMarketFor` function would revert in certain conditions due to unbounded market array traversal. In a prediction market protocol, `claimReward` iterated the full predictions array for a given round; once enough predictions accumulated in a single round, all claim transactions for that round failed permanently.

**Remediation Notes**
Add `start` and `stop` index parameters to any function that iterates over a growing collection, and enforce `stop <= collectionSize` at call time. Prefer off-chain indexing for read-heavy queries, exposing only paginated on-chain access. Cap insertions per round or market at a size provably safe at the block gas limit. For claim paths that could block fund access, redesign to allow per-user O(1) claims rather than full-array iteration.

---

### Unsafe Arithmetic in Unchecked Blocks (ref: fv-sol-3)

**Protocol-Specific Preconditions**
Contract wraps repayment or debt accounting logic in a Solidity `unchecked` block for gas savings. Within the block, a subtraction occurs where the right-hand operand (payment amount, principal payment) could exceed the left-hand operand (accrued interest, recorded principal). No explicit bounds check precedes the subtraction. The resulting underflow silently wraps to a very large value that is stored in the credit's principal or debt fields, converting a normal repayment into an apparent massive debt that triggers immediate liquidation.

**Detection Heuristics**
Search for all `unchecked { }` blocks in the codebase. Within each, identify every subtraction and verify that a preceding `require` or conditional prevents the right operand from exceeding the left. Trace the source of each operand: user-supplied amounts and values derived from external calls are highest risk. Assess the downstream impact of an underflowed value being stored in debt, balance, or prize accounting.

**False Positives**
`unchecked` blocks used exclusively for loop counter increments (`++i`) where overflow is impossible in practice. Subtractions guarded by a prior conditional that makes the subtraction safe (e.g., `if (amount <= credit.interestAccrued) { unchecked { credit.interestAccrued -= amount; } }`). Arithmetic proved safe by invariants that are enforced at every prior entry point.

**Notable Historical Findings**
In Debt DAO, the `repay` function's `unchecked` block subtracted `principalPayment` from `credit.principal` without verifying that `principalPayment <= credit.principal`; a borrower who overpaid (amount exceeding total owed) produced an underflow that set `credit.principal` to a value near `type(uint256).max`, placing the position immediately into liquidation. In Wenwin, a `uint256` to `uint16` unsafe cast in lottery prize calculations silently truncated large prize values, causing winners to receive far less than their correct payout.

**Remediation Notes**
Add an explicit upper-bound check before every subtraction within an `unchecked` block, even when the operands appear logically constrained. Use OpenZeppelin `SafeCast` for all explicit downcasts. Reserve `unchecked` for arithmetic that has been formally proved safe - document the invariant that makes each `unchecked` operation safe in a code comment adjacent to the block.

## reference/solidity/protocols/rwa-tokenization.md

# Real World Asset Tokenization Security Patterns

> Applies to: tokenized real world assets, tokenized treasuries, tokenized real estate, tokenized securities, permissioned ERC-20 tokens, KYC-gated tokens, Centrifuge-style, Ondo-style, off-chain asset backing

## Protocol Context

Real world asset tokenization protocols bridge off-chain assets - treasuries, real estate, private credit - onto a blockchain as permissioned tokens backed by off-chain legal structures. The security model diverges from pure DeFi protocols in that it depends on correct enforcement of KYC/AML restrictions, oracle-reported NAV values, and administrative key management for the custodial bridge. Token transfer restrictions must hold across every balance-changing operation including mint, burn, transfer, ERC4626 deposit/redeem, and cross-chain bridge operations; a single unchecked code path bypasses the compliance layer entirely.

The off-chain backing introduces oracle trust assumptions that differ from price-feed oracles: NAV values are reported by authorized administrators rather than decentralized feeds, creating a privileged role that can misrepresent asset value. Smart contract risk is therefore dominated by access control correctness, restriction bypass through approval-based token transfers, and the interaction of permissioned token semantics with standard DeFi primitives that were not designed with transfer restrictions in mind.

## Bug Classes

---

### Access Control Bypass (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Privileged functions - deposit, claim, mint, propose, execute - use a user-supplied address parameter (receiver, beneficiary, collateral holder) for validation rather than `msg.sender`. Alternative public entry points exist that internally invoke access-controlled logic without re-applying the guard. Role-based modifiers are missing on at least one critical state-changing function. Protocols with KYC-gated tokens often validate the token recipient instead of the caller, creating a systematic bypass surface.

**Detection Heuristics**
Enumerate all external and public functions that modify balances, loans, reward state, or governance. Check that `msg.sender` is compared directly to the authorized party - not to a function-argument address. Trace all internal functions that touch restricted logic and verify every entry point applies the same guard. Look for `depositReward`-style functions callable with zero amounts that still reset `periodFinish` or `rewardRate` without any role check.

**False Positives**
Functions intentionally permissionless by design (e.g., anyone may trigger liquidation of an undercollateralized position). Cases where receiver-address validation is sufficient because only the holder economically benefits. Functions protected by a proxy-layer access control that does not appear in the implementation contract.

**Notable Historical Findings**
In Astaria, `commitToLien` validated the collateral holder's address as the receiver rather than verifying the caller, allowing any party to open a loan against another user's NFT collateral without consent. Separately, a missing `onlyOwner` check on `depositReward` in Zivoe allowed anyone to call the function with a zero reward amount, extending `periodFinish` and diluting the reward rate for existing stakers at no cost. In Ondo Finance, `KYCRegistry` was susceptible to signature replay, and `setPendingRedemptionBalance` could cause a user's cash token to be silently lost through an unchecked state transition.

**Remediation Notes**
Validate `msg.sender` directly against the owner, approved operator, or authorized role - never a caller-supplied address. On reward distribution functions, enforce a role guard and a non-zero reward amount check together. Apply modifiers consistently to every overload and internal entry point, not only to the canonical external function. For KYC-gated protocols, decouple identity verification from the operation's authorization check.

---

### Blacklist and Pause Mechanism DoS (ref: fv-sol-9)

**Protocol-Specific Preconditions**
Protocol integrates with USDC, USDT, or a permissioned RWA token whose issuer maintains a blocklist or pause switch. A critical operation - liquidation, withdrawal settlement, reward claim - iterates over user addresses and must push tokens to each one. A single blacklisted or sanctioned address in the loop causes the entire transaction to revert. Protocols built for regulated markets are disproportionately affected because compliance-driven blocking is expected behavior, not an edge case.

**Detection Heuristics**
Identify every loop that transfers tokens to addresses derived from user-supplied or protocol-maintained lists. Confirm whether the token in scope has a pause or blacklist function at the contract or issuer level. Check whether liquidation and auction settlement paths have try/catch wrappers or skip-and-escrow logic. Look for admin-controlled operations that require all users to have exited before the admin can proceed (e.g., `withdrawExcessRewards` guarded by `totalUsersDeposited == 0`).

**False Positives**
Tokens guaranteed to lack blocklist or pause mechanisms (WETH, DAI). Loops that wrap each transfer in try/catch and credit to an escrow mapping on failure. Protocols with admin override paths that can force-complete operations regardless of individual transfer results.

**Notable Historical Findings**
In Opyn Crab Netting, the `netAtPrice` function iterated a withdrawal queue and pushed USDC to each address; a single USDC-blacklisted user permanently froze the netting and withdraw auction. In the Shiny protocol, a paused or blacklisted RWA NFT contract caused `liquidate` to revert because the burn call was mandatory and had no fallback. In Derby, blacklisting a DeFi protocol within the yield router silently lowered vault allocations rather than triggering a safe fallback, while an emergency blacklist operation could itself revert under certain conditions.

**Remediation Notes**
Replace push-payment patterns with pull-payment (credit-then-claim) for any function that must iterate over user addresses. Decouple liquidation finality from the success of external token transfers by routing proceeds to an escrow contract on failure. Where possible, design permissioned-token interactions to be retryable after a user is removed from a blocklist rather than treating the blocked state as permanent.

---

### Cross-Chain Bridge Vulnerabilities (ref: no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
Protocol bridges tokenized assets or messages across chains using Optimism-style withdrawal flows, Axelar's interchain gateway, or a custom bridge. Gas buffer calculations for withdrawal finalization do not account for all intermediate opcodes between the check and the external call. Cross-chain message receivers validate only that `msg.sender` is the bridge contract, without verifying the origin-chain sender address. Token decimal representations differ between chains and are not normalized during bridging. Failed cross-chain operations have no replay mechanism, permanently stranding funds.

**Detection Heuristics**
Audit the gas buffer constant in withdrawal finalization: count every storage access and external call between the `gasleft()` check and the actual forwarded call, then verify the buffer exceeds this overhead by at least 10,000 gas. In `xReceive`-style handlers, confirm both `msg.sender == bridge` and `_originSender == authorizedSenders[_origin]` are checked. Compare token decimals between each supported chain pair. Verify that finalization functions do not mark the withdrawal hash as complete before the call succeeds.

**False Positives**
Gas buffers large enough to cover all intermediate operations without developer action. Origin sender verified against a contract-level whitelist mapping. Protocols deployed only on chains where the bridged token shares the same decimal precision. Bridge deployments where failed messages can be replayed via the canonical messenger.

**Notable Historical Findings**
Multiple Optimism audits identified that `finalizeWithdrawalTransaction` consumed approximately 5,000 gas between its gas check and the forwarded call, allowing an attacker to supply exactly enough gas to pass the check while the actual call received less than the declared `gasLimit`, permanently locking funds with no replay path. In Axelar's interchain token service, bridge requests to chains where the token was not yet deployed caused a DoS without automatic recovery. Derby's cross-chain provider used an incorrect `chainId` comparison and also allowed an attacker to spoof cross-chain vault rebalancing messages because the origin sender was not authenticated.

**Remediation Notes**
Perform all state mutations before the gas check; place the check immediately before the external call with a buffer that accounts for the full measured opcode cost. Authenticate both the bridge contract (`msg.sender`) and the origin-chain sender address in every cross-chain message receiver. Normalize token amounts to the destination chain's decimal precision before forwarding. Allow finalization to be retried on failure by setting the finalization flag only after a successful call.

---

### Denial of Service via Unbounded Operations (ref: fv-sol-9)

**Protocol-Specific Preconditions**
Contract maintains user-controlled arrays - delegation lists, deposit queues, withdrawal queues - that grow without a meaningful economic cost gate. Iteration over these arrays occurs within a single transaction during settlement, reward distribution, or epoch processing. An attacker can inflate the array at negligible cost (dust deposits, 1-wei delegations), causing legitimate operations to exhaust the block gas limit. Soft caps (e.g., `MAX_DELEGATES = 1024`) are too high to prevent griefing when the minimum value per entry is 1 wei.

**Detection Heuristics**
Find all loops iterating over storage arrays and check whether the array length is bounded by a hard economic constraint. Verify that cancelled, zero-value, or processed entries are pruned and not iterated over in perpetuity. Calculate the gas cost at the array's theoretical maximum size and compare to the block gas limit. For delegation patterns, compute the minimum cost to fill the array to its cap and compare to the expected damage.

**False Positives**
Arrays backed by a minimum stake large enough to make filling the cap economically irrational. Paginated processing where partial progress is committed to storage and resumed across transactions. Admin-only insertion where the griefing vector requires the attacker to control a privileged role.

**Notable Historical Findings**
In Alchemix's veALCX, an attacker could delegate tokens from up to 1,024 positions to a single address for near-zero cost, making any on-chain operation that iterated the delegate list prohibitively expensive. Opyn Crab Netting was vulnerable to the same pattern in both deposit and withdrawal queues: an attacker queuing thousands of tiny deposits then cancelling them still forced the protocol to iterate every cancelled entry on the next processing call. FactoryDAO's `withdrawExcessRewards` became permanently unexecutable when an attacker queued enough small deposits to push the iteration gas above the block limit.

**Remediation Notes**
Enforce a minimum economic value per array entry that makes griefing costlier than the damage caused. Implement paginated processing functions that accept start and stop indices and commit progress between calls. Clean up or compact arrays as entries are processed rather than relying on skip-empty logic. Set maximum array sizes low enough that the gas cost at the cap fits comfortably within the block gas limit.

---

### First Depositor Share Inflation Attack (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Vault or staking pool uses share-based accounting (ERC-4626 or equivalent). No minimum initial deposit, no dead shares mechanism, and no virtual offset is applied to `totalAssets()` or `totalSupply()`. A first depositor can obtain shares with a 1-wei deposit and then donate underlying tokens directly to the vault contract to inflate the exchange rate before any second depositor arrives. Share calculations use integer division that rounds down, causing a second depositor's share count to round to zero when the donation is large relative to their deposit.

**Detection Heuristics**
Check if the share calculation is `(assets * totalSupply) / totalAssets()` with no virtual offset and no revert-on-zero-shares guard. Verify whether `totalAssets()` accounts for direct token donations or only tracks internally recorded deposits. Look for absence of dead shares minted to `address(0)` or a burn address at vault initialization. For reward multiplier schemes, check whether `POINTS_MULTIPLIER` scaled by a 1-share supply can cause arithmetic overflow in correction accounting.

**False Positives**
Vaults using the OpenZeppelin ERC-4626 virtual offset (`_decimalsOffset`). Vaults that mint dead shares equal to a `MINIMUM_LIQUIDITY` constant on the first deposit. Vaults where `totalAssets()` is purely internal accounting, unaffected by direct token transfers.

**Notable Historical Findings**
In Rubicon's compound fork, a first depositor minted 1 share for 1 wei and donated enough underlying tokens to the vault to make every subsequent depositor's share calculation round to zero, effectively stealing all subsequent deposits. Merit Circle's staking pool used a large `POINTS_MULTIPLIER` constant; with a 1-share total supply, a normal deposit amount caused `_correctPoints` to compute a value that overflowed `int256`, making the contract permanently unusable. Ondo Finance and Astaria both reported variants where the first vault deposit established an exchange rate that over-penalized subsequent depositors through rounding.

**Remediation Notes**
Apply a virtual shares and virtual assets offset (OpenZeppelin's `_decimalsOffset`) or lock a `MINIMUM_LIQUIDITY` amount to a dead address on the first deposit. Revert explicitly when a deposit would produce zero shares. Do not allow `totalAssets()` to reflect tokens transferred directly to the contract address outside the deposit function.

---

### Frontrunning and MEV Exploitation (ref: fv-sol-8)

**Protocol-Specific Preconditions**
Reward distribution, exchange rate updates, or epoch-boundary bribe settlements are observable in the public mempool before confirmation. No snapshot-based accounting, minimum staking duration, or commit-reveal prevents a party from depositing immediately before a favorable event and withdrawing immediately after. Governance functions reference the currently active proposal implicitly, so a proposal swap between submission and mining redirects votes. Swap operations compute slippage bounds from the same pool being manipulated.

**Detection Heuristics**
Identify reward `distribute()` functions where the reward amount is readable from pending mempool transactions and no snapshot guards deposits made after the last epoch boundary. Check exchange-rate-updating functions (e.g., `repayBorrow` or `checkpoint`) for patterns where a deposit immediately before and a redemption immediately after extract the rate delta as risk-free profit. Audit epoch-boundary vote and bribe mechanics for the ability to reset votes after earning bribe credit without contributing to the new epoch. Look for governance vote functions that read the active proposal from state rather than accepting an explicit proposal ID parameter.

**False Positives**
Protocols using ERC-20 snapshot extensions where reward eligibility is determined at a snapshot taken before the distribution transaction. Protocols operating on chains with private ordering or where MEV extraction requires infrastructure unavailable on that network. Swap functions where the minimum output is supplied by the caller via calldata from an off-chain quote.

**Notable Historical Findings**
In Alchemix's veALCX, attackers could front-run `distribute()` by depositing into a gauge right before new emissions arrived and back-running by withdrawing, capturing a full epoch's rewards for zero lock-up time. The same protocol's bribe mechanism allowed a voter to reset votes at the epoch boundary after establishing bribe eligibility in the previous epoch, claiming bribes for a period they did not contribute to. In Union Finance, the `repayBorrow` path increased `totalRedeemable`, which raised the `exchangeRateStored`, enabling a sandwich where an attacker minted UTokens at the old rate and redeemed them at the new rate for a risk-free spread.

**Remediation Notes**
Use snapshot-based reward accounting where eligibility is determined at the previous epoch boundary and fresh deposits during the current epoch are ineligible. Enforce a minimum staking duration before reward claims. Require governance vote functions to accept an explicit proposal ID parameter and validate it against the active proposal. Derive slippage bounds from off-chain sources and pass them as calldata parameters rather than computing them on-chain in the same transaction as the swap.

---

### Funds Permanently Locked or Frozen (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol holds user funds subject to conditional withdrawal logic (epoch finalization, auction settlement, cross-chain message delivery). The withdrawal path depends on at least one external call that can permanently fail (pausable token, blacklisted address, dead contract). State transitions can become stuck when their preconditions depend on external actions that may never complete (e.g., outstanding liens, unresolved auctions). No admin emergency recovery path exists. Token merge and burn operations destroy a position without first extracting all accrued value.

**Detection Heuristics**
Trace every path where user funds enter the contract and verify a corresponding exit path for all failure scenarios including zero-bid auctions, paused tokens, and cross-chain delivery failures. Check epoch processing functions for hard `require` conditions that depend on external state (e.g., `liensOpenForEpoch == 0`). Verify that cross-chain finalization marks the hash as complete only after a successful call, not before. For any merge or burn operation, confirm all pending rewards and claimable value are extracted atomically before the position is destroyed.

**False Positives**
Protocols with explicit admin emergency withdrawal functions covering all stuck-fund scenarios. Stuck amounts bounded to sub-wei dust by design. Protocols that explicitly and transparently forfeit unclaimed funds after a documented grace period.

**Notable Historical Findings**
Astaria's `processEpoch` required `liensOpenForEpoch == 0` before advancing, so a single expired lien that could not be liquidated (e.g., due to a paused NFT contract) halted all withdrawal requests for an epoch indefinitely. Alchemix's veALCX destroyed unclaimed ALCX rewards permanently when merging two positions because neither a pre-merge claim step nor a post-merge recovery path existed. Zivoe's `depositReward` with a zero-amount call erroneously locked reward tokens inside the contract with no retrieval mechanism.

**Remediation Notes**
Implement a force-close or admin-bypass path for every protocol state that can block epoch processing. Mark cross-chain withdrawal hashes as complete only after the external call succeeds and allow retry on failure. Make merge and burn operations atomic with a reward claim. Provide an explicit admin emergency withdrawal function that can operate independently of normal accounting invariants.

---

### Liquidation Mechanism Flaws (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Lending or lien-based protocol computes debt for the liquidation trigger using a different formula (without discount) than the internal debt update function uses (with discount). Liquidation does not atomically mark the position as liquidated, allowing re-entry or repeated calls. The auction settlement path is separate from the liquidation trigger, and the no-bid case leaves lien accounting in a corrupt state. External dependencies (NFT burn, oracle read, Seaport order validation) can silently fail or revert within the liquidation path.

**Detection Heuristics**
Compare the debt value passed to the liquidation function with the value that will be used in the internal `updateDebt` modifier - verify they both include or both exclude the same discounts and accrued interest. Confirm that the liquidation function sets an `isLiquidated` flag before creating an auction. Check the no-bid auction settlement path for uncleaned lien data, uncorrected public vault accounting, and unupdated slope/yIntercept values. Verify that any call to an external contract within the liquidation path is wrapped in try/catch or that its failure cannot permanently block the position.

**False Positives**
Protocols where the discount profile is always zero (`NoDiscountProfile`) and no other profiles are deployed. Protocols intentionally supporting partial liquidations where multiple calls to the same position are expected. External call failures caught and handled gracefully by the protocol's existing architecture.

**Notable Historical Findings**
In Mochi, `triggerLiquidation` passed the raw debt without discount to the vault's liquidation function while the vault's internal update applied a discount; the difference caused an underflow whenever the discount was non-zero, making liquidation completely non-functional. Astaria produced multiple related findings: `liquidate` could be called repeatedly on the same expired lien creating duplicate Seaport auctions; when an auction ended with no bids, `liquidatorNFTClaim` failed to clean lien data and update the public vault's accounting, leaving phantom liens and incorrect slope values on the books.

**Remediation Notes**
Use a single canonical debt calculation function with consistent discount and interest-accrual logic for all external and internal callers. Set the `isLiquidated` flag before creating any external auction. Implement a unified settlement function for the no-bid case that clears all lien state and corrects public vault accounting. Wrap external calls in the liquidation path in try/catch and provide an admin recovery path for stuck positions.

---

### Non-Standard ERC-20 Token Handling (ref: fv-sol-6)

**Protocol-Specific Preconditions**
Protocol accepts user-specified token addresses or maintains a whitelist that could include fee-on-transfer tokens, rebasing tokens, ERC-777 tokens with transfer hooks, or USDT-style tokens that require allowance to be zeroed before being set. The contract uses the transferred `amount` parameter directly in accounting without measuring the actual balance change. USDT's `approve` reverts when called with a non-zero current allowance and a non-zero new allowance.

**Detection Heuristics**
Search for `transferFrom` calls where `amount` is used in accounting without a before/after `balanceOf` check. Look for `approve` calls not preceded by a zero-approval reset when the token could be USDT. Check for reentrancy guards on functions that transfer tokens to user-controlled addresses and could be re-entered via ERC-777 hooks. Verify that zero-value transfers do not revert for all tokens in scope. For rebasing tokens, assess whether internal accounting diverges from actual balances over time.

**False Positives**
Protocols explicitly restricted to a whitelist of known standard tokens (WETH, canonical stablecoins) with no upgrade path that could introduce non-standard behavior. Protocols already using the balance-before/after measurement pattern. `safeTransfer` usage from OpenZeppelin (handles non-returning tokens but does not handle fee-on-transfer - this distinction matters).

**Notable Historical Findings**
In Axelar's interchain token service, fee-on-transfer tokens produced accounting discrepancies because the protocol recorded the transferred amount rather than the received amount, allowing cumulative drain of other users' balances. In Astaria, USDT approval calls without prior zero-reset caused Seaport auction settlements to revert for USDT vaults. Axelar's flow limit logic for ERC-777 tokens was broken because the callback path allowed re-entry that bypassed the limit counter update.

**Remediation Notes**
Use the balance-before/after pattern (`balanceAfter - balanceBefore`) for all tokens admitted by a user-specified or extensible whitelist. Reset allowance to zero before granting a new non-zero allowance. Apply `nonReentrant` to all functions that transfer tokens to user-controlled addresses. Document explicitly which token types are supported and enforce this at the whitelist registration step.

---

### Oracle and Price Manipulation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
Protocol prices collateral using AMM spot reserves, LP token `getRate()`, or Uniswap `slot0()` without a TWAP or Chainlink cross-check. LP token pricing applies a formula designed for Curve stable pools to Balancer weighted pools or vice versa, producing systematic overvaluation. Flash loans allow an attacker to temporarily distort pool ratios in the same transaction as the protocol's price read. Chainlink staleness checks (`updatedAt` validation) are absent or use inconsistent thresholds across different price feeds in the same calculation.

**Detection Heuristics**
Enumerate all `getReserves()`, `getRate()`, `slot0()`, and `baseAmount()` calls and trace whether their output is used in collateral valuation, liquidation thresholds, or value transfers. Confirm the LP pricing formula matches the pool type: Curve virtual price for stable pools, fair-value geometric mean formula for weighted pools. Look for absence of `updatedAt` staleness checks on Chainlink feeds and inconsistency in staleness thresholds between feeds combined in the same calculation. Check if the price read and the collateral-using operation can occur in the same transaction, enabling flash loan manipulation.

**False Positives**
Chainlink oracles with proper staleness checks used as the primary and sole price source. TWAP with a window long enough (at minimum 30 minutes) to make flash loan manipulation economically infeasible. LP prices derived from a manipulation-resistant virtual price (Curve `get_virtual_price` with a reentrancy lock).

**Notable Historical Findings**
In Blueberry Update #3, the `WeightedBPTOracle` applied Curve's `minPrice * getRate()` formula to Balancer weighted pools, overvaluing LP tokens by roughly 12% and enabling protocol insolvency through over-leveraged positions. Spartan Protocol's `realise` function calculated synth value directly from AMM spot reserves; a flash loan could skew the pool ratio enough to extract protocol value through a single atomic transaction. Zivoe's `OCL_ZVE.forwardYield` read directly from manipulable Uniswap V2 pool reserves for yield routing decisions.

**Remediation Notes**
Use Chainlink as the primary price source with staleness validation and a maximum deviation circuit breaker against on-chain prices. For LP tokens, implement the fair-value pricing formula appropriate to the pool type rather than reusing a formula from a different pool architecture. Add a reentrancy lock before reading Curve `get_virtual_price` to prevent read-only reentrancy manipulation. Never use the same transaction's pool state as both the manipulation vector and the price input.

---

### Precision Loss and Rounding Errors (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Contract mixes token amounts with different decimal precisions (6, 8, 18) in the same arithmetic expression. Division is performed before multiplication, producing a zero intermediate result for amounts smaller than the divisor. WAD-scaled values (1e18) are passed to `mulWadDown` or similar functions alongside token amounts denominated in a non-18-decimal token. Unsafe downcasting from `uint256` truncates high bits for values that exceed the target type's range. Reward-per-share calculations using a large precision multiplier (`type(uint128).max`) overflow when multiplied by normal deposit amounts.

**Detection Heuristics**
Search for expressions where a division result is subsequently multiplied: the intermediate value may round to zero for small inputs. Verify that every `mulWadDown` / `divWadDown` call operates on values where both operands are in WAD scale; non-18-decimal token amounts must be normalized first. Flag all explicit casts to narrower integer types (`uint48`, `uint96`, `uint128`) and verify bounds proofs. In reward distribution contracts, calculate the maximum value that `pointsPerShare * shares` can reach and compare to `type(int256).max`.

**False Positives**
Precision loss bounded to sub-wei amounts per user by design. Division-before-multiplication that is intentional for gas optimization with provably bounded inputs where the intermediate cannot be zero. Downcasts that are safe because the value is provably within range by a preceding check.

**Notable Historical Findings**
Opyn Crab Netting's `withdrawAuction` performed `(withdraw.amount * 1e18 / crabToWithdraw) * usdcReceived / 1e18`; when `withdraw.amount` was smaller than `crabToWithdraw` the first intermediate rounded to zero, producing a zero USDC payout for the user. Astaria's `claim()` function used `10**ERC20(asset()).decimals() - s.withdrawRatio` where USDC has 6 decimals but `withdrawRatio` is WAD-scaled, causing consistent underflow for any non-18-decimal vault asset. Alchemix reported multiple HIGH-severity findings where `getClaimableFlux` miscalculated flux rewards due to a double application of multipliers and incorrect use of WAD scaling, collectively preventing a significant fraction of users from claiming correct reward amounts.

**Remediation Notes**
Always multiply before dividing when computing share-of-total ratios. Normalize token amounts to a common precision before performing WAD arithmetic. Use OpenZeppelin `SafeCast` for all explicit downcasts. For reward multiplier schemes, bound `POINTS_MULTIPLIER` to a value that cannot overflow `int256` when multiplied by the maximum expected user balance.

---

### Reward and Yield Distribution Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Distribution function is called before the state update that feeds it (e.g., `distribute()` runs before `updatePeriod()` sends new emissions), causing zero-balance distributions. `checkpoint()` reads `balanceOf(address(this))` and treats the entire balance as new revenue, re-counting unclaimed amounts from prior checkpoints. Merge, burn, or transfer operations destroy a veToken or staking position without first claiming accrued rewards for that position. Reward-per-token calculations do not handle the `totalSupply == 0` case, causing rewards deposited during zero-staker periods to be permanently lost or incorrectly allocated.

**Detection Heuristics**
For any `distribute()` or `notifyRewardAmount()` call, confirm that upstream emission logic (minter, yield aggregator) has already settled the new reward amount before distribution uses it. In `checkpoint()` or `revenueHandler` patterns, verify that only newly arrived tokens since the last checkpoint are counted as new revenue. For every merge, burn, or transfer of staked positions, verify all pending rewards are claimed atomically in the same transaction. Check `rewardPerToken()` for a zero-supply guard that correctly defers rather than discards rewards.

**False Positives**
Reward loss bounded to sub-wei rounding. Ordering dependency enforced by an external keeper that always executes the correct sequence in a single multicall. Protocol explicitly and transparently forfeits rewards deposited during zero-staker periods.

**Notable Historical Findings**
Alchemix's veALCX is the densest historical source for this bug class: distribute was called before `updatePeriod` causing zero-emission periods, unclaimed revenue was re-counted on each checkpoint causing protocol insolvency, killed gauges continued accumulating and extracting from the minter, users who merged positions lost all pending ALCX rewards because no pre-merge claim was enforced, and the `checkpointTotalSupply` function could checkpoint before a timestamp was complete, producing incorrect historical supply data. Derby's vault incorrectly shared reward pools between stakers and game players and allowed players to call rebalance before rewards had been pushed to the game contract.

**Remediation Notes**
Always trigger upstream emission settlement before reading the reward balance in `distribute`. Measure newly received rewards as the delta between the current balance and a stored `lastBalance` variable, not as the raw current balance. Make merge and burn operations atomic with a `_claim` call for the source position. Implement a zero-supply guard in `rewardPerToken` that preserves rewards for the first staker rather than silently discarding them.

---

### Missing Slippage Protection in Token Swaps (ref: fv-sol-8)

**Protocol-Specific Preconditions**
Contract executes on-chain token swaps during reward claims, rebalances, or vault checkpoints where the `amountOutMinimum` is either zero, hardcoded to the input amount, or derived from a quoter call in the same transaction against the same pool being swapped. No off-chain quote or Chainlink price floor is used to establish a minimum acceptable output. The swap function is callable by external users or triggered automatically without user-supplied slippage bounds.

**Detection Heuristics**
Audit every external DEX router call (`exactInput`, `exactInputSingle`, `exchange`, `swap`) for the minimum output parameter. Check whether the parameter is zero, equals the input amount (wrong assumption for non-pegged pairs), or is computed on-chain using `IQuoter` against the pool that will be immediately swapped. Look for `deadline: block.timestamp` and `deadline: type(uint256).max` which provide no meaningful protection. Identify reward claim, rebalance, and yield forwarding flows that trigger swaps without exposing a caller-supplied minimum.

**False Positives**
Swaps occurring within a flash loan callback that atomically validates the final output against an invariant. Off-chain keepers that pass externally computed `minAmountOut` values as calldata parameters to internal-only functions. Stablecoin-to-stablecoin swaps with negligible price deviation relative to fees. Dust-level swap amounts where sandwich attacks are economically unprofitable.

**Notable Historical Findings**
Derby's vault executed Uniswap swaps with `amountOutMinimum = 0` during rebalancing, reported twice across two separate audit rounds. Alchemix's `RevenueHandler` performed token swaps with an incorrect minimum output calculated assuming a 1:1 token price; the function also used an on-chain quoter for the same pool being swapped, making the minimum output trivially bypassable in the same transaction. Blueberry Update #3's Aura spell exited a pool during position closure without slippage protection, exposing the full withdrawal to sandwich attacks.

**Remediation Notes**
Require a caller-supplied `minAmountOut` parameter on all swap-executing functions and validate it is non-zero. Use an off-chain quote or a Chainlink oracle as the price floor rather than an on-chain quoter from the same pool. Set deadlines to a meaningful timestamp (e.g., `block.timestamp + DEADLINE_BUFFER`) supplied by the caller. For protocol-internal automated swaps, route through a trusted aggregator with on-chain price validation.

---

### Unsafe External Calls and Unchecked Return Values (ref: fv-sol-6)

**Protocol-Specific Preconditions**
Contract calls `transfer()` or `transferFrom()` on tokens that return `false` on failure rather than reverting. State is updated before the transfer, or the return value is ignored, causing accounting to reflect a transfer that did not occur. ETH is sent via `payable.transfer()`, which forwards only 2,300 gas and fails for smart contract recipients with non-trivial `receive()` logic (Gnosis Safe, multisig wallets). Low-level `call` succeeds against a target with no code, silently discarding the fee.

**Detection Heuristics**
Grep for `transfer(` and `transferFrom(` calls not immediately wrapped in `require` or an `if (!success)` check. Verify SafeERC20 (`safeTransfer`, `safeTransferFrom`) is used for all ERC-20 interactions. Search for `payable(x).transfer(` patterns and replace with `call{value:}`. In fee distribution paths, verify that the target has code before assuming a `call` succeeded.

**False Positives**
Tokens that always revert on failure (standard OpenZeppelin ERC-20). Protocols that already use SafeERC20 throughout. ETH recipients verified to be EOAs or contracts with known gas-efficient `receive()` functions.

**Notable Historical Findings**
In Spartan Protocol, `iBEP20.transfer` return values were consistently not checked, allowing silent failures across multiple withdrawal and distribution functions. In Escher, an NFT sale contract used `payable.transfer()` for ETH refunds; smart contract buyers whose `receive()` function exceeded 2,300 gas were permanently unable to receive their refund. Rubicon Router used `transfer()` for ETH sends in its router, failing for any caller that was a smart contract wallet.

**Remediation Notes**
Use OpenZeppelin SafeERC20 for all ERC-20 transfers. Replace `payable(x).transfer(amount)` with a low-level `call{value: amount}("")` that checks the return value and handles failure explicitly (restore state or emit a retriable event). Verify the code size of fee recipient addresses if they are set dynamically.

## reference/solidity/protocols/services.md

# DeFi Services Security Patterns

> Applies to: protocol utility services, fee routers, meta-aggregators, keeper networks, automation bots, reward distributors, merkle airdrop distributors

## Protocol Context

DeFi service contracts act as intermediaries-routing trades, bridging chains, distributing rewards, and automating keeper operations-across a wide range of underlying protocols. Unlike single-purpose AMMs or lending pools, service contracts are characterized by broad token and protocol integration surfaces, frequent cross-chain message passing, and reward accounting that must remain correct across arbitrary user behavior and rebalancing events. The combination of untrusted token inputs, multi-step state updates, and cross-chain asynchrony produces a vulnerability surface that rewards systematic, path-by-path analysis rather than single-function review.

---

### Access Control and Privilege Escalation (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Proxy implementation contracts are not initialized on deployment, allowing any caller to claim ownership and execute `selfdestruct` via delegatecall
- Admin and owner roles are designed as independent checks but one role controls the appointment of the other, undermining separation
- Keeper or operator roles accept unconstrained numerical parameters (reward proportions, fee rates) that can be set to 100% to redirect all funds

**Detection Heuristics**
- Verify that `initialize()` on implementation contracts is protected by `_disableInitializers()` in the constructor
- Check role separation: can one role nominate or dismiss another role without independent governance approval?
- Confirm bounded ranges on all keeper-controllable parameters (reward proportion, slippage tolerance, fee basis points)
- Review whether `renounceOwnership` or equivalent admin removal leaves critical protocol functions permanently inaccessible

**False Positives**
- Admin override is an intentional emergency design with documented safety properties
- Initialization is called atomically within a factory `create` function, eliminating the front-running window
- Access control is enforced by a multi-sig with a known quorum requirement

**Notable Historical Findings**
Biconomy's `SmartAccount` implementation contract was not initialized at deployment, allowing an attacker to call `initialize` on the implementation directly, become its owner, and destroy it via a `selfdestruct` delegatecall-bricking every proxy sharing that implementation. SeaDrop's `onlyOwnerOrAdmin` modifier allowed either the owner or admin to overwrite the other's drop configuration, and because the owner chose the admin at construction time, the supposed independence of the two roles was illusory. Taurus Protocol's keeper received an unchecked `_rewardProportion` parameter, enabling a malicious or compromised keeper to set it to 10000 basis points and direct the entire reward pool to themselves.

**Remediation Notes**
- Call `_disableInitializers()` unconditionally in every upgradeable implementation's constructor
- Require admin appointments to go through a governance vote or timelock independent of the current owner
- Validate all keeper parameters against explicit maximum bounds enforced at the contract level, not just off-chain

---

### Cross-Chain Bridge and Message Validation (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Cross-chain message receivers do not verify that `msg.sender` is the trusted bridge relayer or that the origin sender matches the expected source chain contract
- `mirrorConnector` or `remoteConnector` variables are permitted to be `address(0)`, silently discarding messages
- Gas limit estimates for destination chain execution are hardcoded or computed incorrectly, causing destination execution to revert and strand tokens
- Bridge-specific chain IDs (Wormhole, LayerZero) differ from EVM `chainId` values and are confused in routing logic

**Detection Heuristics**
- Verify every cross-chain receiver checks both `msg.sender == trustedBridge` and `originSender == trustedRemoteSenders[originChainId]`
- Search for `mirrorConnector == address(0)` reachability; messages sent to the zero address are silently lost
- Audit destination gas estimation logic for dynamic payload-size adjustment, especially on L2s with different gas models
- Confirm a recovery or retry path exists when destination execution fails; tokens must not be permanently stranded in the bridge contract

**False Positives**
- The bridge protocol verifies sender at the transport layer, making application-layer checks redundant by design
- A dedicated retry/recovery mechanism handles all failed destination executions
- The protocol targets a single, well-tested chain pair with validated gas parameters

**Notable Historical Findings**
Connext contained multiple connector contracts where `mirrorConnector` was never validated for zero-address, causing `processMessage` to silently discard all messages routed through those connectors. LI.FI's `GenericBridgeFacet` accepted user-supplied destination call data with insufficient validation, allowing an attacker to craft calls that triggered `transferFrom` on approved tokens held in the Executor contract. Holograph's `LayerZeroModule` miscalculated destination gas by a significant factor, causing cross-chain NFT operations to fail on arrival and permanently lock assets when no recovery path was provided.

**Remediation Notes**
- Require `connector != address(0)` before dispatching any cross-chain message
- Implement a `failedMessages` mapping with a retry or refund path for any destination execution that reverts
- Use bridge-specific chain ID registries and validate them independently from EVM `block.chainid`

---

### Fee-on-Transfer Token Incompatibility (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Service contracts route or aggregate arbitrary user-supplied ERC-20 tokens without restricting to a known-safe whitelist
- Internal accounting increments a balance mapping by the declared transfer amount immediately after `transferFrom`, without measuring actual receipt
- Callback-based token intake patterns (e.g., `timeswapMintCallback`) verify exact amounts arrived, causing hard reverts for any fee-bearing token

**Detection Heuristics**
- Find all `transferFrom(sender, address(this), amount)` calls followed by `balances[user] += amount` or equivalent; flag the lack of before/after balance measurement
- Identify `require(token.balanceOf(address(this)) >= before + exactAmount)` patterns that break for fee-bearing tokens
- Check protocol documentation: if it claims to support "any ERC-20," treat this as a fee-on-transfer vulnerability indicator
- Look for rebasing token interactions where `balanceOf` can change between operations without an explicit transfer

**False Positives**
- Protocol explicitly whitelists non-fee tokens and reverts on unsupported inputs
- Protocol wraps rebasing tokens into non-rebasing equivalents (e.g., wstETH) at ingress
- Balance-difference pattern is already implemented throughout

**Notable Historical Findings**
Trader Joe's liquidity contract allowed a user to transfer tokens to themselves, which triggered the fee-on-transfer deduction while crediting the full nominal amount, net-inflating their own balance by the fee amount on every self-transfer. Beanstalk's internal balance system credited users the full declared amount from `LibTransfer`, but the protocol's actual token balance was systematically short by the accumulated transfer fees across all deposits, eventually causing insolvency in the affected token pools. Connext explicitly acknowledged fee-on-transfer tokens as unsupported but did not enforce this restriction at the smart contract level, leaving the swap path open to economically harmful inputs.

**Remediation Notes**
- Measure received amount as `token.balanceOf(address(this)) - balanceBefore` for every token intake
- Optionally, add a strict mode: `require(actualReceived == amount, "Fee-on-transfer not supported")` at service contract ingress
- Document and enforce the supported token list via an allowlist contract rather than relying on documentation alone

---

### First Depositor Vault Share Inflation (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Share-based vault uses `shares = assets * totalSupply / totalAssets` where `totalAssets` is derived from `balanceOf(address(this))`
- A first depositor can mint one share for one wei, then inflate `balanceOf` by donating tokens, causing all subsequent depositors to receive zero shares
- No minimum initial deposit, dead share mechanism, or virtual offset is enforced at initialization

**Detection Heuristics**
- Identify vault contracts minting shares proportional to `deposit * totalSupply / balanceOf(address(this))`
- Verify first-deposit path cannot create fewer than a configurable minimum share count
- Confirm `balanceOf` cannot be inflated by direct token transfers that bypass the vault's internal accounting
- Check whether zero-shares output is handled; if `shares == 0` is not rejected, victim deposits are silently absorbed

**False Positives**
- Virtual shares offset (e.g., OpenZeppelin ERC4626 `_decimalsOffset`) is implemented
- Dead shares are burned on first deposit, preventing manipulation of the exchange rate
- Vault uses an internal balance tracker that ignores direct token donations

**Notable Historical Findings**
Surge Finance's first depositor could deposit one wei, receive one share, then donate enough tokens to inflate the vault's asset balance such that the next depositor's large deposit minted zero shares-the entire deposit being absorbed by the one-share holder. Liquid Collective's share minting was vulnerable to front-running: an attacker donating one wei to the contract before the first legitimate deposit caused the legitimate depositor to receive zero shares, effectively stealing the entire deposit. Caviar's AMM pool initialization had a related variant where the first depositor could set an extreme price ratio with a negligible liquidity commitment.

**Remediation Notes**
- Burn a configurable amount of dead shares to `address(0xdead)` on first deposit to anchor the exchange rate
- Track internal asset balances separately from `balanceOf(address(this))` so donations do not affect share pricing
- Enforce a minimum initial deposit that makes economic manipulation unprofitable

---

### Flash Loan Price Manipulation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Collateral valuation uses `balanceOf`, `totalAssets / totalSupply`, or AMM `getReserves` within the same transaction context as the collateral check
- ERC-4626 vault share price is read spot for collateral or liquidation decisions
- Checkpoint-based voting uses current balance, allowing flash-borrowed tokens to inflate vote weight within a single block

**Detection Heuristics**
- Confirm no price or collateral calculation reads `balanceOf` or `getReserves` in the same transaction context without a multi-block averaging mechanism
- Verify flash-loan guard patterns: `require(lastDepositBlock[msg.sender] < block.number)` before any borrow or vote action
- Check ERC-4626 oracle implementations for spot-price reads on `totalAssets / totalSupply`
- Audit Balancer vault integrations for read-only reentrancy windows during join/exit callbacks where spot reserves are stale

**False Positives**
- Chainlink or other off-chain oracle is used exclusively and is immune to same-block manipulation
- TWAP window of 30+ minutes is used for all price reads
- Flash-loan guard prevents same-block deposit-and-action sequences
- Economic manipulation cost exceeds potential gain due to deep liquidity

**Notable Historical Findings**
Telcoin allowed staking and unstaking in the same block with no guard, enabling an attacker to flash-borrow TEL, stake a large amount to inflate snapshot weight, collect inflated rewards, and repay the flash loan in a single transaction. Sentiment's ERC4626Oracle read `totalAssets / totalSupply` spot for collateral valuation; a flash loan donation to the underlying vault could inflate this ratio and allow over-borrowing. Carapace Protocol's protection-seller withdrawal mechanism could be bypassed by a flash-loan-assisted Sybil on withdrawal requests, allowing manipulation of the leverage factor governing how much capital could be withdrawn in a single epoch.

**Remediation Notes**
- Require at least one block between deposit and any privileged action (borrow, vote, redemption)
- Use a 30-minute Uniswap V3 TWAP via `OracleLibrary.consult` rather than spot reserves for all collateral pricing
- For ERC-4626 vaults used as collateral, compute share price using a TWAP snapshot of `totalAssets / totalSupply` rather than the current block value

---

### Front-Running and Sandwich Attacks (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- Swap or meta-aggregation functions lack `minAmountOut` or `deadline` parameters, enabling MEV sandwich extraction
- `approve()` pattern allows a spender to spend the old allowance before a reduction transaction lands
- Keeper automation functions executing on-chain swaps do not enforce slippage protection, allowing sequencer or block-builder manipulation
- Blacklist addition functions are observable in the mempool, giving targets time to move funds before the restriction takes effect

**Detection Heuristics**
- Confirm every swap entry point accepts and enforces both `minAmountOut` and a `deadline` timestamp
- Search for `token.approve(spender, amount)` patterns and replace with `increaseAllowance`/`decreaseAllowance` or `forceApprove` + reset
- Check keeper-triggered swaps for hardcoded zero slippage tolerance or missing slippage parameters
- Identify blacklist or role-revocation functions and assess whether the target can front-run to move assets or delegate authority

**False Positives**
- Slippage protection is enforced by the calling context (e.g., user-facing wrapper always sets `minAmountOut`)
- Commit-reveal scheme prevents value extraction from pending transactions
- Flashbots or private mempool makes front-running economically infeasible for the deployment chain

**Notable Historical Findings**
Connext's `SponsorVault` used a spot DEX price to compute reimbursements, enabling a sandwicher to inflate the price between the sponsorship calculation and the actual swap execution. Tigris Trade allowed riskless trades by exploiting a delay check that could be bypassed: users observed pending price-sensitive transactions and submitted their own trades with precisely calibrated timing to capture the spread risk-free. Liquid Collective's `approve()` function directly overwrote allowances, enabling the classic front-run: a spender who observed a reduction transaction could spend the full original allowance before the reduction landed and then spend again under the new allowance.

**Remediation Notes**
- Add `uint256 minAmountOut` and `uint256 deadline` to every swap function signature and enforce both
- Use `SafeERC20.forceApprove(spender, 0)` followed by `forceApprove(spender, amount)` for all approval sequences
- For keeper swaps, accept a caller-provided slippage bound and revert if the swap output falls below it

---

### Oracle Stale Price and Manipulation (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- `latestAnswer()` (deprecated Chainlink function) is used without round metadata
- `latestRoundData()` is called but `answeredInRound >= roundId` and `block.timestamp - updatedAt < STALENESS` checks are omitted
- Chainlink `minAnswer`/`maxAnswer` circuit breaker bounds are cached at construction time as immutables; if Chainlink updates the aggregator, the cached bounds become stale
- L2 deployments on Arbitrum or Optimism do not check the Sequencer Uptime Feed before trusting price data

**Detection Heuristics**
- Search for `latestAnswer()` usage; it must be replaced with `latestRoundData()` with full validation
- For every `latestRoundData()` call, verify all five return values: `roundId`, `price > 0`, `updatedAt > 0`, `block.timestamp - updatedAt < heartbeat`, `answeredInRound >= roundId`
- Check if aggregator min/max bounds are stored as immutables versus fetched dynamically from the current aggregator address
- On L2 deployments, confirm the Sequencer Uptime Feed is consulted and a grace period is enforced before trusting price data

**False Positives**
- Oracle is used only for non-critical display purposes with no downstream financial effects
- Protocol explicitly pauses all operations when oracle returns a stale price, preventing any financial action on bad data
- Multiple independent oracle sources are aggregated and a stale reading from one is overridden by others

**Notable Historical Findings**
Float Capital's entire market malfunctioned when a gap in Chainlink's update frequency caused `latestAnswer()` to return a value outside the expected range without the contract detecting it as stale, triggering cascading incorrect position valuations. Sentiment's WSTETH-ETH Curve LP token oracle relied on a spot Curve price that was manipulated via a flash loan, causing incorrect liquidations and undercollateralized borrowing simultaneously. Morpho cached a P2P rate snapshot that could diverge significantly from the live on-chain rate when the underlying Compound or Aave protocol updated its indexes between Morpho's own updates.

**Remediation Notes**
- Validate all five return values of `latestRoundData()` on every call; create a shared internal validation function to avoid copy-paste omissions
- Fetch `aggregator.minAnswer()` and `aggregator.maxAnswer()` dynamically rather than caching them as immutable constructor values
- For L2 deployments, add a required `sequencerUptimeFeed` check with a configurable grace period (minimum 3600 seconds)

---

### Reentrancy (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Service contracts interact with Balancer vault join/exit callbacks, creating read-only reentrancy windows where the contract's internal reserve state is temporarily inconsistent
- ERC-777 tokens with `tokensReceived` hooks are accepted as deposit or collateral assets
- Reward distribution functions send ETH or call external protocol contracts before marking user positions as processed
- Reentrancy guard is applied to one entry point but another entry point shares the same state without a guard

**Detection Heuristics**
- Identify all external calls: `call`, `send`, `transfer`, `safeTransfer`, `safeTransferFrom`, and any integration with Balancer, ERC-777, or ERC-1155
- Check for state variable writes after external calls in every function and callgraph
- For read-only reentrancy: verify that view functions reading reserve or balance state are not callable from external protocols during a Balancer join/exit callback
- Confirm `nonReentrant` covers all entry points that access the same shared state, not just individual functions

**False Positives**
- External call target is a trusted, immutable contract known to have no callbacks
- Strict CEI is followed throughout and all state is finalized before any external call
- `nonReentrant` covers all entry points that share the affected state
- Token is a standard ERC-20 with no transfer hooks

**Notable Historical Findings**
Notional Finance's `redeemNative` function performed an external transfer before updating the internal redemption state, allowing repeated reentrancy that permanently froze fund access and caused systematic misaccounting. Stakehouse Protocol had multiple reentrancy paths in its reward distribution functions: `_distributeETHRewardsToUserForToken` sent ETH before marking the user's share as distributed, while `withdrawETH` decremented `idleETH` before burning the receipt token, in opposite order of what was needed. Cron Finance was vulnerable to Balancer read-only reentrancy: an external pricing function read virtual reserves during a Balancer join callback when the pool's state was temporarily inconsistent, producing manipulable price reads.

**Remediation Notes**
- Apply `nonReentrant` to every function that modifies shared accounting state
- Add a `_reentrancyGuardEntered()` check to view functions used as price oracles to prevent read-only reentrancy
- Strictly follow CEI: finalize all internal state (balance decrements, supply updates, claim flags) before any external call

---

### Reward Distribution Accounting Errors (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Cached reward variables are set but never reset to zero after a claim, allowing infinite re-claiming
- Unstake logic deletes the total pool's share count instead of the user's individual share count
- Reward token removal makes all unclaimed balances irretrievable without a grace period for existing stakers
- Reward accumulator (`rewardPerToken`) is not updated atomically before each stake/unstake operation

**Detection Heuristics**
- Verify reward accumulator is updated as the first operation in every `stake`, `unstake`, `deposit`, and `withdraw` function
- Check `cachedUserRewards` or equivalent variables: are they reset to zero after a successful claim?
- Trace `unstake` logic to confirm it decrements `userShares[user]`, not `totalShares` or `poolShares`
- Audit reward token removal paths for unclaimed balance accessibility after removal

**False Positives**
- Cached rewards are intentionally persistent as part of a vesting schedule
- Reward token removal is preceded by a mandatory governance-controlled grace period
- "Total shares" deletion is intentional in an epoch-based reset system

**Notable Historical Findings**
OlympusDAO's `cachedUserRewards` was incremented on withdrawal but never reset after a claim call, allowing users to claim the same accumulated rewards indefinitely until the contract was drained. Stakehouse Protocol's unstake function read `_rewardPoolShares[poolId][cycleId]` (the total for the pool) and deleted the entire mapping entry, zeroing out every other user's share in that cycle in a single operation. Neo Tokyo's staking contract updated a pool's total points without adjusting existing stake positions' debt offsets, causing all currently staked positions to immediately over-claim on their next interaction.

**Remediation Notes**
- Zero `cachedUserRewards` atomically within the claim function, not in a separate cleanup step
- Explicitly use `userShares[user]` as the subtrahend in unstake logic, never the pool-wide accumulator
- Before removing a reward token, snapshot all unclaimed balances and maintain claimability for a minimum grace period (e.g., 30 days)

---

### Rounding and Precision Loss (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Service contract aggregates or routes between tokens with heterogeneous decimals (USDC at 6, WETH at 18), and a single `1e18` divisor is applied to both
- Division precedes multiplication in share or rate calculations, truncating intermediate results
- Reward-per-token accumulator uses insufficient precision for small reward rates over large staked amounts, rounding to zero per update

**Detection Heuristics**
- Search for division operations followed by multiplication in the same expression
- Identify hardcoded `1e18` divisors applied to token amounts without checking whether the token has 18 decimals
- Check reward accumulator update frequency: does `reward / totalStaked` round to zero for the expected reward rates?
- Look for `priceFeed.decimals()` return values that are used in scaling calculations and verify the math handles all realistic decimal values

**False Positives**
- Precision loss is bounded to sub-cent dust values with no amplification vector
- Protocol explicitly rounds in the protocol's favor and the per-user loss is negligible
- `mulDiv` or equivalent full-precision arithmetic is used throughout

**Notable Historical Findings**
Surge Finance's `userCollateralRatioMantissa` calculation used token pairs with different decimals and a single fixed-precision divisor, producing ratios that were systematically incorrect for pairs involving USDC or WBTC-causing liquidations to trigger at wrong thresholds. Taurus Protocol assumed 18-decimal collateral throughout its pricing and leverage calculations, making the protocol entirely non-functional for tokens like WBTC, USDC, or USDT. Liquid Collective's operator reward shares suffered from compounding division errors due to multiple sequential divisions rather than a single `mulDiv` operation, causing measurable under-distribution to node operators over time.

**Remediation Notes**
- Always multiply before dividing: `(amount * PRECISION) / price` rather than `(amount / price) * PRECISION`
- Normalize all token amounts to a common 18-decimal representation at ingress using `amount * 10**(18 - tokenDecimals)`
- Use `1e36` precision for reward-per-token accumulators to prevent rounding to zero on small emission rates

---

### Signature and Message Replay Attacks (ref: fv-sol-4-c4, fv-sol-4-c10, fv-sol-4-c11)

**Protocol-Specific Preconditions**
- EIP-4337 UserOperation hash does not include `chainId`, making the same user operation valid on any EVM chain
- EIP-712 domain separator is computed once in the constructor with `block.chainid` as an immutable; after a hard fork, the cached separator becomes valid on both chains
- Merkle proof-based minting or airdrop claiming lacks per-address claim tracking, allowing proofs to be reused in the same epoch

**Detection Heuristics**
- Verify all `ecrecover` / `ECDSA.recover` call sites include `block.chainid` or a domain separator computed dynamically
- Search for `immutable DOMAIN_SEPARATOR` computed in the constructor; this breaks after hard forks
- For Merkle airdrop contracts, confirm a `claimed[address]` or `claimedBitmap` prevents proof reuse
- Validate that `ecrecover` return value is checked against `address(0)` before use

**False Positives**
- Protocol deploys to a single chain with no fork risk and the domain separator is documented as chain-specific
- Per-address mint caps effectively prevent economically significant replay even without explicit digest tracking
- Domain separator is recomputed dynamically using `block.chainid` on every call

**Notable Historical Findings**
Biconomy's paymaster hash omitted `chainId`, allowing a UserOperation signed for one chain to be replayed on any other EVM chain the paymaster was deployed to. Golom discovered that its EIP-712 domain separator was fixed at construction time with the deployment chain's ID; after an ETH hard fork, the stored separator remained valid on both the original and forked chain, enabling cross-chain replay of any signed order. SeaDrop's `mintSigned` function did not track used signatures, allowing a valid signed mint allocation to be submitted repeatedly until the per-wallet cap was reached, but the cap itself could be bypassed by using separate wallets.

**Remediation Notes**
- Compute the EIP-712 domain separator dynamically in a view function using `block.chainid` rather than storing it as an immutable
- Track all used signature digests in a `mapping(bytes32 => bool) public usedSignatures` regardless of nonce-based replay protection
- For UserOperation-style hashes, follow EIP-4337 exactly by including `chainId` in the packed encoding

---

### State Desynchronization and Missing State Updates (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Asset recovery functions (`bringUnusedETHBack`, `rescueTokens`) move ETH or tokens into the contract without updating the corresponding internal tracking variable (`idleETH`, `totalAssets`)
- Liquidations executed on external lending protocols seize collateral without the service contract updating its internal position records
- Reward checkpoint logic overwrites `claimed` to the current maximum value without first distributing the pending amount to the user

**Detection Heuristics**
- For every function that changes the contract's real token or ETH balance, verify a corresponding internal variable is incremented or decremented in the same transaction
- Search for `delete` or direct assignment to balance/position tracking variables where `+=` / `-=` should be used
- Check all deposit/withdraw hooks for completeness: do they update reward state, checkpoint values, and balance trackers atomically?
- Audit config update functions for failing to account for already-accrued values before resetting the config variable

**False Positives**
- The "missing update" variable has no downstream financial effects within the protocol
- State is reconciled in a subsequent mandatory call that always precedes the next exploitable action
- The function is admin-only and manual reconciliation is an accepted operational procedure

**Notable Historical Findings**
Stakehouse Protocol's `bringUnusedETHBackIntoGiantPool` transferred ETH from staking vaults back to the giant pool but never incremented `idleETH`, causing subsequent deposit/withdrawal operations to use a stale (lower) idle balance and systematically underpay users. OlympusDAO's withdrawal logic set `userRewardDebts` to zero before computing the debt difference, ensuring the full accumulator value was credited rather than only the delta-a single off-by-one in the order of operations that allowed unlimited reward extraction. Morpho's state desynchronized from Aave's when an Aave-level liquidation seized a user's collateral without Morpho's internal accounting being notified, leaving phantom collateral recorded in Morpho's books.

**Remediation Notes**
- Measure balance change as `after - before` on every asset recovery operation and add the delta to the internal tracker
- In reward checkpoint logic, always distribute pending rewards to the user before overwriting the `claimed` checkpoint
- For protocols that integrate external lending, implement a reconciliation function that reads the external protocol's current state and syncs internal records

---

### Unsafe ERC20 Approval Patterns (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Service contract calls `token.approve(router, amount)` without first resetting to zero; USDT and similar tokens revert on non-zero to non-zero approval
- Router or bridge address in the approval is user-controlled, allowing an attacker to approve their own contract and drain the service contract's token balance
- Max approvals (`type(uint256).max`) are granted to upgradeable routers that may change behavior after the approval is set

**Detection Heuristics**
- Search for `IERC20.approve(spender, nonZeroAmount)` without a preceding `approve(spender, 0)` or equivalent `forceApprove`
- Verify the approved spender is a hardcoded, immutable, audited address-not a user-supplied or governance-updatable parameter
- Check for `type(uint256).max` approvals to external contracts; confirm the contract is immutable or the approval is revoked after use
- Identify patterns where the same approval is granted in a loop without revocation between iterations

**False Positives**
- The token is known to not require zero-reset (standard OpenZeppelin ERC-20)
- Allowance is always fully consumed in the same transaction, leaving no residual
- Spender is a hardcoded, immutable, well-audited address

**Notable Historical Findings**
UXD Protocol's `PerpDepository.rebalance` approved `PerpDepository` for user token spending with no validation of who triggered it, allowing any user who had previously approved the depository to have their entire balance drained by a third-party caller. LI.FI's facets approved arbitrary user-supplied addresses for ERC-20 tokens held by the diamond proxy, enabling token theft by routing through a malicious "facet" address. Notional's approval sequence failed to reset to zero before setting a new allowance, causing all swaps to revert permanently on USDT-style tokens that enforce the zero-reset requirement.

**Remediation Notes**
- Use `SafeERC20.forceApprove(spender, amount)` which handles the zero-reset automatically
- Never accept a spender address as a user-supplied parameter; hardcode or restrict to a registry of audited contracts
- Revoke approvals (`forceApprove(spender, 0)`) immediately after single-use operations rather than relying on exact consumption

### validateUserOp Signature Replay via Missing nonce or chainId (ref: pashov-21)

**Protocol-Specific Preconditions**
- The smart account's `validateUserOp` constructs the signature digest manually rather than delegating to `entryPoint.getUserOpHash(userOp)`
- The manually constructed digest omits `userOp.nonce` or `block.chainid` or both
- The same signed user operation can be replayed on the same chain (if nonce is omitted) or on any other EVM chain where the same account contract is deployed (if chainId is omitted)

**Detection Heuristics**
- Find all `validateUserOp` implementations. Check whether signature verification uses `entryPoint.getUserOpHash(userOp)` as the digest or builds a custom hash
- If a custom hash is built, confirm it includes `userOp.nonce` and `block.chainid` explicitly
- Test cross-chain replay: sign a user operation on testnet and attempt replay on mainnet using the same account address
- Check whether the domain separator or any wrapper hash includes `address(this)` to bind signatures to the specific account contract instance

**False Positives**
- Signature digest is derived exclusively from `entryPoint.getUserOpHash(userOp)`, which the EntryPoint constructs to include sender, nonce, and chainId
- A custom digest explicitly includes `userOp.nonce`, `block.chainid`, and `address(this)` with documented deviations from the standard hash format
- The account is deployed only on a single chain with no cross-chain functionality

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Use `entryPoint.getUserOpHash(userOp)` as the canonical hash for signature verification in `validateUserOp`. This function includes the nonce, chain ID, and sender address in its encoding, covering all replay vectors. If a custom hash is required for protocol-specific reasons, include `abi.encode(userOp.nonce, block.chainid, address(this))` in the digest and test cross-chain and same-chain replay scenarios explicitly.

---

### Banned Opcode in Validation Phase Causing Simulation-Execution Divergence (ref: pashov-100)

**Protocol-Specific Preconditions**
- The `validateUserOp` or `validatePaymasterUserOp` function reads environment-dependent values including `block.timestamp`, `block.number`, `block.coinbase`, `block.prevrandao`, or `block.basefee`
- ERC-7562 prohibits these opcodes in the validation phase because their values during bundler simulation differ from their values at execution time
- Validation logic that passes during simulation may fail during on-chain execution if the environment value changes between the two, or the bundler may reject the user operation entirely before it reaches the chain

**Detection Heuristics**
- Search for `block.timestamp`, `block.number`, `block.coinbase`, `block.prevrandao`, and `block.basefee` references inside `validateUserOp` and `validatePaymasterUserOp` function bodies
- Check whether signature expiry or permit deadline validation uses `block.timestamp` in the validation phase rather than deferring to the execution phase
- Verify whether the entity is staked under ERC-7562's reputation system, which relaxes some opcode restrictions for staked entities
- Confirm that any time-based validity checks are performed in `execute` or `executeBatch`, not in `validateUserOp`

**False Positives**
- All uses of banned opcodes are confined to the execution phase (`execute`, `executeBatch`) and not the validation phase
- The entity (paymaster or account factory) is staked under the ERC-7562 reputation system with sufficient stake, which permits relaxed opcode access under staked entity rules
- The contract is not an ERC-4337 account and the validation/execution distinction does not apply

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Move all environment-dependent checks (deadlines, block number comparisons, fee checks) from `validateUserOp` and `validatePaymasterUserOp` into the execution phase. If a time-bound validity check is necessary at the validation stage, implement it using a user-provided timestamp parameter included in the signed payload rather than reading `block.timestamp` directly.

---

### Paymaster Gas Penalty Undercalculation Draining Deposit (ref: pashov-108)

**Protocol-Specific Preconditions**
- The paymaster prefund calculation does not account for the 10% penalty charged by the EntryPoint on unused execution gas (`postOpUnusedGasPenalty`)
- User operations specify a large `executionGasLimit` relative to actual execution gas consumption
- The paymaster's deposit is drained at a rate proportional to the gap between the requested gas limit and actual consumption, making operations unprofitable over time

**Detection Heuristics**
- Locate the prefund calculation in `validatePaymasterUserOp` and check whether it includes a term for `postOpUnusedGasPenalty` (approximately 10% of `executionGasLimit - actualGasUsed`)
- Compute the worst-case penalty: if `executionGasLimit` is large and actual usage is small, calculate how much deposit is lost per operation beyond direct execution cost
- Verify whether the paymaster's on-chain deposit is monitored and topped up at a rate that accounts for penalty-inclusive drain
- Check whether there is a maximum `executionGasLimit` that the paymaster accepts to bound worst-case penalty exposure

**False Positives**
- The prefund formula explicitly adds the unused-gas penalty: `requiredPrefund += executionGasLimit * PENALTY_BPS / BASIS_POINTS` or equivalent
- The paymaster applies conservative overestimation in its prefund calculation that covers worst-case penalty at any execution gas limit it accepts
- The paymaster enforces a maximum accepted `executionGasLimit` that bounds the penalty to an acceptable level

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Update the prefund calculation to explicitly include the unused-gas penalty: add `executionGasLimit * PENALTY_BPS / BASIS_POINTS` (where `PENALTY_BPS` is the EntryPoint's configured penalty rate) to the required prefund. Enforce a maximum acceptable `executionGasLimit` in validation to bound worst-case deposit drain. Monitor the paymaster's EntryPoint deposit and set top-up thresholds that account for penalty-inclusive expenditure.

---

### Paymaster ERC-20 Payment Deferred to postOp Without Pre-Validation (ref: pashov-122)

**Protocol-Specific Preconditions**
- The paymaster sponsors user operations by collecting ERC-20 payment from the user but defers the actual token transfer to the `postOp` phase via `safeTransferFrom` rather than locking tokens during `validatePaymasterUserOp`
- Between validation and execution, the user can revoke the ERC-20 allowance granted to the paymaster
- The paymaster's EntryPoint deposit is debited for the operation cost even when `postOp` fails to collect the ERC-20 payment, resulting in a net loss per such operation

**Detection Heuristics**
- Find `validatePaymasterUserOp` and check whether it transfers or escrows any tokens, or merely records the payment intent
- Locate the `postOp` handler and check whether `safeTransferFrom` is the primary payment collection mechanism
- Assess whether a user can front-run `postOp` by calling `token.approve(paymaster, 0)` between validation and execution to revert the transfer
- Verify whether a failed `postOp` causes the EntryPoint to still debit the paymaster's deposit

**False Positives**
- Tokens are locked or transferred from the user's account during `validatePaymasterUserOp`, making the allowance irrevocable by the time `postOp` executes
- The paymaster uses an ERC-20 permit (EIP-2612) where the signed approval is consumed atomically and cannot be revoked between validation and execution
- `postOp` is used only for refunding excess payment, not for the primary collection; the primary payment occurs during validation

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Transfer or lock ERC-20 payment tokens during `validatePaymasterUserOp` rather than deferring to `postOp`. One approach is to call `token.transferFrom(user, address(this), maxCost)` during validation and refund any excess in `postOp`. Alternatively, use an EIP-2612 signed permit included in the `paymasterAndData` field and consume it atomically during validation, eliminating the window for allowance revocation.

---

### validateUserOp Missing EntryPoint Caller Restriction (ref: pashov-150)

**Protocol-Specific Preconditions**
- `validateUserOp` is declared `public` or `external` with no `require(msg.sender == address(_entryPoint))` guard or equivalent `onlyEntryPoint` modifier
- An attacker can call `validateUserOp` directly with a crafted `UserOperation`, causing signature validation to execute in an untrusted context and potentially advancing nonces or triggering state changes intended to occur only under EntryPoint control
- The same issue may affect `execute` and `executeBatch`, which should be callable only by the EntryPoint after validation

**Detection Heuristics**
- Confirm `validateUserOp` has an `onlyEntryPoint` modifier or an inline `require(msg.sender == address(_entryPoint))` as its first statement
- Check `execute` and `executeBatch` for the same restriction; these functions execute arbitrary calldata and must be equally protected
- Verify the `_entryPoint` address is set correctly in the constructor or initializer and is immutable
- Test direct calls to `validateUserOp` from an unauthorized address; the call should revert with an access control error

**False Positives**
- `validateUserOp` carries an `onlyEntryPoint` modifier that validates `msg.sender` against the stored EntryPoint address
- `execute` and `executeBatch` are equally restricted to the EntryPoint
- The function is declared `internal` and can only be reached through the EntryPoint's call path

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Add `require(msg.sender == address(_entryPoint), "only EntryPoint")` or an equivalent modifier as the first statement of `validateUserOp`, `execute`, and `executeBatch`. Use OpenZeppelin's `BaseAccount` or `SimpleAccount` as a reference implementation, which applies the `onlyEntryPoint` modifier consistently across all protected functions.

---

### Counterfactual Wallet Address Takeover via Incomplete CREATE2 Salt (ref: pashov-163)

**Protocol-Specific Preconditions**
- The account factory's `createAccount` function deploys smart accounts using CREATE2 but derives the salt from an incomplete set of initialization parameters, omitting the owner address or other security-critical fields
- An attacker can call `createAccount` with a different owner while producing the same CREATE2 salt, deploying a wallet they control at the address a legitimate user intended as their counterfactual account
- The legitimate user may have pre-funded this counterfactual address, configured it as a beneficiary in other contracts, or signed user operations targeting it

**Detection Heuristics**
- Locate the CREATE2 salt derivation in the factory's `createAccount` function. Verify the salt incorporates all initialization parameters that determine account ownership, including the owner address
- Check whether an attacker can produce the same salt as a legitimate user by supplying different parameters
- Verify that the factory reverts or returns the existing address without redeployment if the account already exists
- Confirm that the initializer is called atomically within the `createAccount` call, not in a subsequent transaction

**False Positives**
- The CREATE2 salt is derived as `keccak256(abi.encodePacked(owner, salt))` or equivalent, binding the deployed address to the owner
- The factory includes a check to prevent overwriting an existing account at the target address
- The initializer is called atomically in the deployment transaction, so no window exists between deployment and initialization

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Derive the CREATE2 salt as `keccak256(abi.encodePacked(owner, userSalt))` where `owner` is a required parameter that fully determines the deployed account's access control. Call the initializer atomically within the `createAccount` deployment by passing the encoded initializer calldata to the proxy constructor. For existing factories, verify the salt derivation in the source code against the deployed bytecode to confirm no parameters are omitted.

---

## reference/solidity/protocols/staking.md

# Staking Protocol Security Patterns

> Applies to: staking pools, liquid staking, validator pools, ETH staking, staking derivatives, Lido-style, RocketPool-style, restaking protocols, EigenLayer-style

## Protocol Context

Staking protocols occupy a uniquely high-trust position: user capital is locked into validator key infrastructure, exposing it to consensus-layer risks including slashing that originate entirely outside the smart contract layer. Share price is a direct function of validator performance and beacon chain reward timing, meaning off-chain oracle reports introduce manipulation surfaces not present in purely on-chain yield protocols. Withdrawal queue mechanics create time-asymmetric liquidity risk where the contract's liability (user claims) can diverge materially from its liquid assets for weeks or months, and accounting that fails to model this gap correctly systematically misprices shares.

---

## Bug Classes

### Reentrancy (ref: fv-sol-1)

**Protocol-Specific Preconditions**
- Contracts send ETH or interact with ERC721/ERC1155/ERC777 tokens during deposit, withdrawal, or reward claim flows, creating callback windows.
- Read-only reentrancy is especially relevant for protocols that read share prices or reserve balances from external AMMs (Balancer, Curve) while those pools are mid-execution.
- Staking contracts that wrap NFT receipt tokens or use ERC721 for position tracking trigger `onERC721Received` callbacks before state is finalized.

**Detection Heuristics**
1. Identify every external call in non-view functions: `call`, `transfer`, `send`, `safeTransfer`, `safeTransferFrom`, NFT `mint`/`burn`.
2. Verify state writes (balances, totals, share supply, reward debt) occur before any external call, not after.
3. Look for token standards with callbacks: ERC777 `tokensReceived`, ERC721 `onERC721Received`, ERC1155 `onERC1155Received`.
4. For read-only reentrancy: check if view functions or pricing logic read from Balancer, Curve, or Compound pools that may have inconsistent state during a join/exit callback.
5. Check cross-function reentrancy: re-entering a different function on the same contract may exploit shared mutable state even when the direct function has a `nonReentrant` guard.
6. Confirm `nonReentrant` modifier is applied to all functions sharing mutable state, not only the obvious withdraw path.

**False Positives**
- External calls to immutable, non-callback contracts such as standard ERC20 tokens without ERC777 extensions.
- Functions where the only external call is at the very end after all state updates (correct CEI).
- State updates that are idempotent or revert on re-entry regardless of guard presence.

**Notable Historical Findings**
Several staking protocols using ERC777 or ERC721 receipt tokens suffered complete balance drainage because reward distribution or deposit finalization happened after the token mint or transfer hook fired. In one Stakehouse Protocol audit, `GiantMevAndFeesPool.withdrawETH` was drained via reentrancy because idle ETH accounting was decremented before the transfer but balance checks occurred after. Sandclock's deposit function allowed a reentrant call to extract more value than the original withdrawal because the balance update occurred after the ETH transfer. Balancer read-only reentrancy was separately identified in a Cron Finance audit where pool reserve reads inside a callback window returned manipulated values, leading to incorrect pricing.

**Remediation Notes**
Apply the checks-effects-interactions pattern uniformly and add `nonReentrant` to all functions that share state with potential reentrancy entry points. For read-only reentrancy via Balancer, call `balancerVault.ensureNotInVaultContext()` before reading pool state in any view or pricing function. For liquid staking protocols reading from external price sources during oracle callbacks, snapshot the value before initiating any external call.

---

### Precision Loss, Rounding, and Decimal Mismatch (ref: fv-sol-2)

**Protocol-Specific Preconditions**
- Share-to-asset conversion uses integer division where rounding direction determines whether the protocol or user bears the truncation loss.
- Reward accumulators multiply small per-second rates by large totals, and premature division of intermediate values causes silent truncation.
- Protocols that accept multiple collateral or reward tokens mix 6-decimal and 18-decimal assets in the same arithmetic without normalization.
- Fee calculations using `amount * FEE_BPS / 10000` allow zero-fee transactions when `amount < 10000 / FEE_BPS`.

**Detection Heuristics**
1. Search for division (`/`) appearing before multiplication (`*`) in the same calculation chain; this is the canonical precision-loss pattern.
2. Verify that `previewDeposit` rounds down (fewer shares issued) and `previewWithdraw` rounds up (more shares burned), both in favor of the vault.
3. Check that `mulDiv` or equivalent precision-preserving libraries are used for share price and reward rate calculations.
4. Scan for hardcoded `1e18` or `10**18` divisors used with tokens that are not 18 decimals.
5. Check `balanceOf` aggregation across multiple tokens without decimal normalization.
6. Look for `totalReward / numOperators` patterns in distribution loops that silently discard the remainder.

**False Positives**
- Rounding within 1 wei that is documented and bounded.
- Protocols using `FullMath.mulDiv` or Solmate's `FixedPointMathLib` throughout.
- Single-token protocols with a known fixed decimal count.

**Notable Historical Findings**
In a Liquid Collective audit, operator reward shares suffered rounding errors because multiple sequential divisions were applied to the same intermediate result instead of a single combined division. yAxis vault's `balance()` function summed raw USDC and WETH balances without decimal normalization, producing nonsensical totals that were then used for share issuance. A Napier audit uncovered that a rounding error in ERC4626 exchange rate calculations, combined with a donation attack, allowed an attacker to steal victim funds entirely. GoGoPool's `recreateMinipool` contained a compounded precision error that caused reassigned AVAX amounts to diverge from what node operators expected.

**Remediation Notes**
Multiply before dividing for all multi-step calculations. Normalize all token amounts to a common 18-decimal precision before any cross-token arithmetic, then convert back at the final transfer step. Use `mulDivUp` for fee calculations and share redemption to ensure the protocol never rounds in the user's favor on exit. Track and redistribute integer division remainders explicitly when distributing to multiple operators or stakers.

---

### Access Control and Authorization Bypass (ref: fv-sol-4)

**Protocol-Specific Preconditions**
- Privileged functions controlling reward speed, validator key sets, fee parameters, or pool configuration lack access modifiers or have them only on one of several entry paths.
- Permissionless vault or minipool creation grants the deployer elevated access on a shared staking contract the deployer does not own.
- Callback functions (`onTokenTransfer`, `notifyRewardAmount`) bypass role restrictions that apply to direct calls.
- Node operator registration or delegation is open to arbitrary addresses before a validation step takes effect.

**Detection Heuristics**
1. Enumerate all external and public functions; verify each state-modifying one has an appropriate access control modifier or explicit `msg.sender` check.
2. Trace internal functions to all callers, including permissionless paths, to confirm restricted logic cannot be reached without authorization.
3. Verify callback functions check `msg.sender` against the expected protocol address before processing any state change.
4. Confirm role assignment functions are themselves protected and cannot be called to self-assign elevated roles.
5. Check for single-step ownership transfer without a two-step `pendingOwner` acceptance pattern.
6. Verify that `tx.origin` is never used for authorization in place of `msg.sender`.

**False Positives**
- Intentionally permissionless functions such as public liquidation or reward notification with adequate downstream validation.
- Access control enforced by an upstream proxy or factory not visible in the local scope.
- Functions that only affect the caller's own state with no cross-user impact.

**Notable Historical Findings**
In a Popcorn staking audit, a missing access control check on `changeRewardSpeed` allowed any attacker to deplete reward token balances by setting an extreme distribution rate. GoGoPool's node operator minipool creation had a hijacking vector where an attacker could recreate a minipool assigned to a victim's address by exploiting the state transition sequence. Ethena Labs audits identified that the `SOFT_RESTRICTED_STAKER_ROLE` could be bypassed via token approvals or by routing transfers through a secondary account, undermining the compliance purpose of the role. Multiple Stakehouse Protocol findings showed that giant pool vault authenticity checks were weak, allowing an attacker to drain pools by presenting a fabricated vault address.

**Remediation Notes**
Apply `onlyRole` or equivalent modifiers to every function that modifies reward parameters, validator key configuration, fee settings, or protocol state. Implement two-step ownership transfer for all admin roles in staking contracts. For liquid staking, separately protect `notifyRewardAmount` and `addRewardToken` entry points rather than sharing a single internal function that bypasses caller validation.

---

### Logic Errors and Stale State (ref: fv-sol-5)

**Protocol-Specific Preconditions**
- Buyout, transfer, or payee-change operations modify one accounting variable but leave interdependent variables (slope, y-intercept, reward debt) with stale values.
- Minipool or validator slot recycling reuses storage structs without resetting time-tracking fields (`rewardsStartTime`, `avaxAssigned`).
- Loops compute updated state (`newStack`) but subsequent iterations continue reading the pre-update original, causing only the last iteration's result to be applied.
- Multiple functions write to the same storage slot without enforcing mutual exclusion or consistent ordering.

**Detection Heuristics**
1. For every state-changing operation, enumerate all related storage variables and confirm each is updated atomically.
2. Inspect loops that compute a new state value and verify subsequent iterations use the updated value, not the original.
3. Check all transfer and ownership-change functions for dependent mappings that must also be updated: payee, reward debt, approvals, delegation records.
4. Verify that cancellation or liquidation functions zero out all time-tracking and amount fields on recycled structs.
5. Look for `delete` or zero-assignment usage after operations that should invalidate existing state.
6. In upgradeable contracts, verify storage layout is consistent across versions and no slot collisions exist.

**False Positives**
- Lazy evaluation patterns where stale state is intentionally corrected on next access.
- Variables that are always overwritten before being read again.
- State that a separate reconciliation function correctly handles.

**Notable Historical Findings**
Multiple Astaria protocol findings exposed that `setPayee` did not update the vault's y-intercept or slope, allowing a vault owner to account for lien interest that was actually flowing to a different address, eventually enabling fund extraction. The `makePayment` function in the same protocol used the original stack in a loop instead of the progressively updated `newStack`, meaning only the final payment iteration was reflected in the state hash. GoGoPool's `cancelMinipool` omitted resetting `rewardsStartTime`, which then persisted through a recreate cycle causing the node operator reward window to be calculated from an incorrect epoch. In Liquid Collective, `Oracle.removeMember` using an array-swap removal allowed the swapped member to vote a second time in the same epoch because their per-epoch action was tracked by index rather than address.

**Remediation Notes**
Always pass the updated intermediate state as the input to subsequent iterations in loops that transform sequences. When recycling validator or minipool structs, zero all time-tracking, amount, and status fields before reinitializing. For protocols with vault slope and y-intercept accounting tied to lien or position payees, update all dependent vault parameters whenever the payee changes. Track per-epoch oracle actions by member address, not array index.

---

### Unchecked Return Values and Unsafe External Calls (ref: fv-sol-6)

**Protocol-Specific Preconditions**
- Low-level `call` or `transfer` return values are ignored, allowing silent failures where user funds are debited but the transfer never succeeds.
- Non-standard tokens (USDT, tokens without return values on `transfer`) are used without `SafeERC20` wrappers.
- Flash loan callbacks or proposal execution engines accept arbitrary `target` and `data` without validating the target or function selector.
- Authorized external contracts (tellers, adapters) cannot be revoked once added, leaving compromised contracts permanently in the permission set.

**Detection Heuristics**
1. Verify all `call`, `transfer`, and `send` return values are checked; prefer `SafeERC20` throughout.
2. Search for `token.transfer` or `token.transferFrom` without `safe` wrappers in any protocol that does not exclusively use tokens with guaranteed revert-on-failure behavior.
3. In proposal execution or keepers, check that `target` addresses are validated against an allowlist and that arbitrary `data` cannot construct unauthorized `approve` or `transfer` calls.
4. Look for authorization grants with no corresponding revocation mechanism.
5. Verify flash loan callbacks validate `msg.sender` is the expected lending pool before executing any state change.

**False Positives**
- Targets restricted to a hardcoded whitelist of trusted immutable contracts.
- Callback validation handled by an upstream router or wrapper not visible in the audited scope.
- Protocols that only use tokens with guaranteed revert-on-failure behavior throughout the entire codebase.

**Notable Historical Findings**
A Sturdy Finance audit found that `_withdrawFromYieldPool` contained a success check after a `return` statement, making the check dead code and allowing failed ETH transfers to pass silently. Bond Protocol audits identified that authorized tellers could not be removed from the callback registry after being set, meaning a compromised teller retained permanent access. In Liquid Collective, Solmate's `safeTransfer` was used with addresses that could be non-contract addresses, as Solmate's implementation does not check code size, allowing transfers to EOAs to appear successful when they should fail.

**Remediation Notes**
Use OpenZeppelin's `SafeERC20` for all token interactions, including `safeApprove(spender, 0)` followed by `safeApprove(spender, newAmount)` for USDT compatibility. Implement revocation for all role or adapter grants. For proposal or keeper execution engines in staking governance, maintain an explicit whitelist of allowed targets and function selectors.

---

### Slippage and MEV (ref: fv-sol-8)

**Protocol-Specific Preconditions**
- Rebalancing, reinvestment, or reward compounding functions pass `minAmountOut = 0` or `minAmountOut = inputAmount` (wrong for cross-token swaps) to AMM calls.
- Deadline parameters are set to `block.timestamp`, providing no protection against transaction ordering or delayed inclusion.
- Oracle update transactions are predictable and can be sandwiched: attacker front-runs the update to open a position at the stale price, then back-runs to close at the updated price.
- An arbitrary `account` parameter in rebalance functions allows an attacker to drain funds from any address that has granted approval to the protocol.

**Detection Heuristics**
1. Search all AMM swap calls (`swapExactTokensForTokens`, `exactInputSingle`, `exchange`, etc.) for `minAmountOut` or equivalent parameter.
2. Flag any case where `minAmountOut == 0`, `minAmountOut == inputAmount`, or `deadline == block.timestamp`.
3. Identify functions that accept a user-supplied `account` or `from` address as a fund source for swaps or deposits.
4. Check whether oracle report submissions are publicly visible in the mempool and whether they trigger state changes exploitable by front-running.
5. Look for reward harvesting or reinvestment functions callable by anyone with no slippage floor.

**False Positives**
- Swaps executed through private mempools or MEV-protected relayers.
- `block.timestamp` deadline used only for immediate atomic settlement where no pending queue exists.
- Slippage validated by an upstream coordinator function before the swap call.

**Notable Historical Findings**
A UXD Protocol audit found that `rebalance` and `rebalanceLite` accepted an arbitrary `account` parameter for sourcing quote tokens, allowing an attacker to drain any address that had approved the contract. Olympus Update audits identified that oracle sandwich attacks were profitable because the vault's rebalance logic depended on freshly updated prices that were predictably front-run. In Notional Update, the single-side redemption slippage mechanism was structurally broken, providing no actual price protection. Multiple Liquid Collective findings showed that oracle report submissions were front-runnable by other oracle members who could observe pending reports and submit competing or interfering transactions.

**Remediation Notes**
Derive `minAmountOut` from a Chainlink or TWAP oracle with an explicit acceptable slippage percentage, not from the input amount. Require user-supplied deadlines with a reasonable future timestamp rather than `block.timestamp`. For oracle-dependent rebalancing, add a timelock or commit-reveal scheme to prevent profitable sandwiching. Never accept an `account` or `from` address as a parameter for any function that transfers funds on behalf of that address without explicit on-chain authorization from that address in the same transaction.

---

### Denial of Service and Unbounded Loops (ref: fv-sol-9)

**Protocol-Specific Preconditions**
- Reward claim or distribution functions iterate over arrays that grow with each user deposit, validator key, or prediction entry, with no pagination or maximum bound.
- Critical operations (liquidation, unstaking, withdrawal) depend on external token burns or transfers that can be blocked by token pause or blacklist.
- Deterministic `CREATE2` deployment salts allow an attacker to pre-deploy a contract to the expected address, preventing the protocol's deployment from succeeding.
- Dust donations to the pool create a non-zero balance before any shares exist, causing division by zero or share calculation reversion on the first legitimate deposit.

**Detection Heuristics**
1. Search for unbounded `for` or `while` loops over user-controlled arrays (predictions, positions, delegations, validator keys).
2. Identify critical-path external calls (withdraw, liquidate, settle) to tokens with pause or blacklist capability (USDC, USDT, certain NFTs).
3. Check `CREATE2` deployment functions for user-controlled or predictable salts without a `msg.sender` component.
4. Look for `balanceOf(address(this))` or `address(this).balance` used as denominator in share calculations where external donations are possible.
5. Verify that enumerable limits (e.g., `MAX_DELEGATES = 1024`) cannot be cheaply exhausted by a low-cost griefing attack.
6. Check that missing a time-windowed keeper call does not permanently brick a state machine (epoch progression, cycle sync).

**False Positives**
- Loops bounded by a compile-time constant that is not user-controllable.
- External calls to tokens without pause or blacklist features.
- Functions with `try/catch` that handle individual failures without propagating them to the entire batch.

**Notable Historical Findings**
Liquid Collective's `_getNextValidatorsFromActiveOperators` function could be permanently DoSed if any single operator had a funded-equals-stopped count mismatch, blocking all staking operations for the entire protocol. GoGoPool identified that a division by zero could block `RewardsPool.startRewardCycle` if all multisig wallets were disabled simultaneously. Velodrome Finance contained a `MAX_DELEGATES` exhaustion attack where an attacker could delegate 1024 tiny positions to any target address, preventing legitimate delegators from adding further delegations. A MCP prediction market audit found that `claimReward()` iterated over all user predictions with no pagination, allowing an attacker to DoS the function by creating enough predictions to exceed the block gas limit.

**Remediation Notes**
Replace unbounded iteration with paginated functions accepting `startIndex` and `count` parameters, or use a per-user accumulated reward tracker that avoids iteration entirely. For operations that depend on pausable or blacklistable tokens, implement a pull-payment pattern so that a blacklisted recipient does not block operations for all other users. For `CREATE2` deployments, include `msg.sender` in the salt.

---

### Oracle Manipulation and Flash Loan Attacks (ref: fv-sol-10)

**Protocol-Specific Preconditions**
- Share price, collateral value, or reward rate is computed from spot AMM reserves sampled at a single point in time without a TWAP.
- Governance voting power is derived from current token balances rather than historical snapshots, enabling flash-loan governance attacks.
- Impermanent loss protection or yield calculations use current pool state, allowing an attacker to manipulate reserves within the same transaction to maximize payouts.
- LP token pricing is calculated from current reserve product rather than a manipulation-resistant formula.

**Detection Heuristics**
1. Identify all pricing calls and verify they use TWAP, Chainlink, or another manipulation-resistant source rather than spot AMM balances.
2. Check governance voting power derivation: any mechanism using `balanceOf` at the current block rather than a prior checkpoint is flash-loan-vulnerable.
3. Look for reward rate or IL protection calculations that read current pool reserves without verification they are not mid-manipulation.
4. Verify oracle data freshness: Chainlink feeds should be checked for staleness and the sequencer uptime flag on L2s.
5. Confirm that price feeds validate round completion (`answeredInRound >= roundId`) and timestamp recency.

**False Positives**
- TWAP oracles with a sufficiently long window that makes within-block manipulation uneconomical.
- Governance using ERC20Votes snapshot-based checkpoints with proposal creation at a prior block.
- Flash loan fees that make the attack unprofitable after accounting for gas and borrow cost.

**Notable Historical Findings**
A Behodler protocol audit identified that the LP pricing formula for `purchasePyroFlan` used current reserve product, making it directly manipulable by flash-borrowing one of the reserve assets to skew the ratio and purchase at an artificially low price. Olympus Update confirmed that an adversary could sandwich oracle update transactions by observing the pending report and opening positions just before it landed. Sentiment's ERC4626 oracle was identified as vulnerable to price manipulation because `convertToAssets` reads current vault state, which can be inflated within a single transaction via donation.

**Remediation Notes**
Use Chainlink price feeds with staleness checks for all collateral and reward token pricing in staking protocols. For protocols that reference AMM reserves for any pricing or reward calculation, implement a minimum TWAP window of at least 30 minutes. For governance, require votes to be based on checkpoints from a block prior to proposal creation to prevent flash-loan participation.

---

### Withdrawal Queue and Multi-step Unstaking Issues (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- Withdrawal logic has a dead-code success check: a `return` statement appears before `require(sent)`, making the failure condition unreachable.
- Voting power or reward debt decrements during unstaking apply to `msg.sender` instead of the NFT or position owner, diverging accounting when an approved operator performs the unstake.
- Multi-step withdrawal processes (request, wait, claim) do not validate that the claimer is the same address that initiated the request.
- Withdrawal amount calculations do not account for accrued protocol fees or slippage deductions, resulting in the protocol paying out more than it received.
- Lido or Rocket Pool withdrawal queue limitations can brick a downstream protocol's unstaking path when the queue is at capacity.

**Detection Heuristics**
1. Trace the complete withdrawal path from user action through share burn to token transfer; confirm no `return` or `revert` appears before the success check.
2. In any function with a position owner / caller distinction, verify that accounting decrements (shares, voting power, reward debt) apply to the owner, not `msg.sender`.
3. Check that multi-step withdrawal state (request records) cannot be claimed by a different address than the one that initiated.
4. Verify that withdrawal fee or slippage deductions are computed and applied before the transfer, not afterward.
5. For protocols built on Lido or Rocket Pool, check whether their external withdrawal queue backlog can delay or block protocol-level unstaking indefinitely.

**False Positives**
- Single-step withdrawals where `msg.sender == owner` is always true by design.
- Return-before-check patterns that are gated by upstream guards making them unreachable.
- Protocols where withdrawal fees are intentionally zero.

**Notable Historical Findings**
A Sturdy Finance vault audit found that the ETH transfer success check came after a `return` statement, making it dead code; failed withdrawals were silently treated as successful. FrankenDAO's `_unstake` decremented voting power from `msg.sender` rather than the token owner, allowing approved operators to corrupt the voting accounting of unrelated addresses. Notional Leveraged Vaults' integration with Lido's withdrawal queue was found to brick the unstaking process in an edge case where Lido's queue limit was reached, leaving user funds permanently inaccessible until queue capacity was restored. A Stakehouse Protocol audit found that unstaking did not update the `sETHUserClaimForKnot` mapping, leaving residual claims that could be exploited by earlier stakers against new depositors.

**Remediation Notes**
Place all `require` success checks before any `return` statement and after all state changes. When supporting an operator or approval delegation pattern for unstaking, explicitly pass the position owner address (not `msg.sender`) to all accounting decrements. For protocols that rely on external liquid staking withdrawal queues (Lido, Rocket Pool, Frax), implement a fallback or emergency exit path that does not depend on queue availability.

---

### Vault Share Inflation and First-Depositor Attack (ref: fv-sol-2-c6)

**Protocol-Specific Preconditions**
- The vault follows an ERC4626-style `shares = assets * totalSupply / totalAssets` formula with no virtual offset or dead-share initialization.
- An attacker can be the first depositor (depositing 1 wei) and then donate a large amount directly to the vault contract to inflate `totalAssets` before any other user deposits.
- The vault's `totalAssets()` reads `balanceOf(address(this))` rather than an internal accounting variable, making it sensitive to direct donations.
- No minimum initial deposit requirement or dead-share burn to `address(0)` exists in the constructor.

**Detection Heuristics**
1. Check if `convertToShares` uses the formula `assets * supply / totalAssets` with supply sourced from `totalSupply()` and assets from `balanceOf(address(this))`.
2. Confirm whether the constructor or initializer mints dead shares or enforces a minimum initial deposit.
3. Test whether an attacker can deposit 1 wei, then donate a large amount, and cause the next depositor to receive 0 shares due to rounding.
4. Verify the vault reverts on zero-share mints; note this alone is insufficient if the attacker can front-run before the `require(shares > 0)` check is reached by the victim.
5. Check for `_decimalsOffset()` overrides (OpenZeppelin virtual offset pattern) as evidence of first-depositor protection.

**False Positives**
- Vaults using OpenZeppelin's `_decimalsOffset()` virtual shares mechanism.
- Vaults that mint dead shares to `address(0xdead)` during initialization.
- Vaults where `totalAssets()` uses internal accounting rather than `balanceOf`.
- Permissioned vaults where only a trusted address can be the first depositor.

**Notable Historical Findings**
GoGoPool's ggAVAX vault suffered the classic first-depositor share inflation: an attacker could deposit 1 wei to receive 1 share, then donate AVAX to inflate the exchange rate, making subsequent depositors receive zero shares or a negligible number. The same pattern appeared in Redacted Cartel's AutoPxGmx and AutoPxGlp vaults, where share price manipulation was used to steal underlying assets from existing depositors. In Napier Finance, a combination of rounding error and exchange rate manipulation was sufficient for an attacker to steal victim funds, rated as a high severity finding. Liquid Collective identified that a donate-before-deposit sequence could cause new depositors to receive zero shares due to the interplay between `idleETH` and the share issuance formula.

**Remediation Notes**
Initialize all new staking vaults with dead shares minted to `address(0xdead)` or use OpenZeppelin's virtual offset pattern (`_decimalsOffset() = 3` or higher). Alternatively, track `totalAssets` via an internal variable updated only on deposit and withdrawal, never from `balanceOf(address(this))`. Enforce a minimum initial deposit threshold large enough that the cost of the inflation attack exceeds any realistic profit.

---

### Reward Distribution Flaws (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
- Reward distribution uses a `rewardPerToken` accumulator that is not updated on every stake or unstake, allowing stale accrual.
- `notifyRewardAmount` or `depositFees` functions can be front-run: an attacker stakes immediately before the reward notification and unstakes immediately after to extract a disproportionate share.
- A cycle-based `syncRewards` function must be called manually at epoch boundaries; late calls cause rewards to be credited to a different set of depositors than intended.
- Reward speed or rate configuration is permissionless or has gaps in access control, allowing unauthorized manipulation.
- Multiple reward tokens are tracked but share accounting state, causing cross-token reward accounting errors.

**Detection Heuristics**
1. Check whether the `rewardPerToken` accumulator is updated on every deposit and withdrawal, not only on explicit reward notification calls.
2. Identify `depositFees` or `notifyRewardAmount` functions that do not require a lockup before rewards become claimable.
3. Verify whether `syncRewards` or epoch-transition functions must be called manually and what happens to rewards if the call is delayed.
4. Check if reward speed configuration functions are properly access-controlled.
5. Confirm that zero `totalSupply` is handled in reward calculations to prevent division by zero.
6. For multi-token reward systems, verify each token's accumulator is tracked independently.

**False Positives**
- Protocols with enforced lockup periods longer than one block preventing same-block stake/unstake.
- Systems using time-weighted average staking balance that makes instantaneous front-running unprofitable.
- Reward rates so low that front-running is economically infeasible after gas costs.

**Notable Historical Findings**
Velodrome Finance contained a suite of reward accounting bugs including front-runnable bribe distributions, incorrect epoch boundary calculations that caused rewards to be measured from the wrong checkpoint, and gauge kills that locked previously claimable distributions permanently. In a Beraji Ko audit, stakers could lose earned aSugar tokens because the reward claim depended on a spot price at claim time rather than at accrual time, enabling sandwich attacks on the claim transaction. GoGoPool found that node operators were slashed for the full validation duration even though rewards were distributed on a 14-day cycle, causing systematic over-penalization. Liquid Collective's `Oracle.removeMember` array-swap approach allowed the swapped member to vote twice in the same epoch, directly corrupting the oracle consensus that drives staking reward reporting.

**Remediation Notes**
Update the `rewardPerToken` accumulator synchronously on every deposit, withdrawal, and transfer operation. Use a per-user `rewardDebt` checkpoint pattern that snapshots the accumulator at the time of balance change. For cycle-based protocols, automate `syncRewards` or make it permissionless with a grace period but ensure late calls do not retroactively disadvantage depositors who were active during the missed window. Lock reward claims for at minimum one block after a stake event to prevent flash-stake extraction.

---

### ERC4626 Vault Non-Compliance (ref: fv-sol-2-c6)

**Protocol-Specific Preconditions**
- `maxDeposit`, `maxMint`, `maxWithdraw`, or `maxRedeem` return non-zero values when the vault is paused, causing integrating protocols to attempt operations that will revert.
- `previewDeposit` or `previewWithdraw` results do not match actual execution, causing integrators that rely on preview functions for slippage checks to receive incorrect amounts.
- `withdraw` and `redeem` burn shares from `msg.sender` rather than `owner`, breaking the ERC4626 delegated withdrawal pattern.
- Rounding direction is incorrect: deposit/mint functions should round against the depositor, and withdraw/redeem should round against the withdrawer.

**Detection Heuristics**
1. Test that all `max*` functions return 0 when the vault is paused.
2. Verify `previewDeposit` rounds down (depositor receives fewer shares) and `previewWithdraw` rounds up (more shares burned per asset withdrawn).
3. Confirm `withdraw` and `redeem` correctly burn from `owner`, check `msg.sender`'s allowance when `msg.sender != owner`, and transfer to `receiver`.
4. Check that `totalAssets()` is not manipulable via direct token donations.
5. Verify router peripheral contracts do not make redundant `approve` calls that cause ERC4626 flows to revert.

**False Positives**
- Documented intentional deviations from ERC4626 with downstream handling.
- Vaults implementing a superset of ERC4626 with explicit additional safety checks.
- Rounding differences bounded to 1 wei that are mathematically acceptable.

**Notable Historical Findings**
GoGoPool's TokenggAVAX vault returned incorrect values from `maxDeposit` and `maxMint` when the contract was paused, causing external protocols that used these functions for deposit validation to proceed and then revert. Multiple Astaria findings showed that `redeemFutureEpoch` transferred shares from `msg.sender` instead of `owner`, breaking delegated redemption. Redacted Cartel's AutoPxGmx vault's share price was manipulable due to a `totalAssets()` calculation that read from `balanceOf(address(this))`, enabling the classic donation inflation attack. A Popcorn audit found that the vault was drainable because its ERC4626 implementation was non-compliant in a way that allowed the exchange rate to be walked to an extreme value.

**Remediation Notes**
Implement `maxDeposit` and `maxMint` with an explicit `if (paused()) return 0` guard. Always burn from `owner` in `withdraw` and `redeem`, and check `allowance[owner][msg.sender]` when caller differs from owner. Use internal accounting for `totalAssets()` rather than `balanceOf(address(this))` to eliminate donation manipulation surfaces.

---

### Token Integration Issues (ref: fv-sol-2-c7, fv-sol-6-c10)

**Protocol-Specific Preconditions**
- The protocol accepts arbitrary ERC20 tokens and records deposit amounts from the `transferFrom` parameter rather than a before/after balance difference, causing over-accounting for fee-on-transfer tokens.
- `safeApprove` is called with a non-zero amount on a token (USDT, USDC) that has a residual non-zero allowance, causing reversion.
- Rebasing tokens (stETH, aTokens) increase in balance between deposit and withdrawal, but the protocol tracks balances at the time of deposit, leading to an effective loss of the accrued rebase.
- Non-standard tokens that return `false` on transfer failure instead of reverting are used without `SafeERC20` wrappers.

**Detection Heuristics**
1. Check for `transferFrom(sender, this, amount)` immediately followed by `balances[user] += amount` without a before/after balance difference.
2. Search for `safeApprove(spender, nonZeroAmount)` calls that may execute when a residual allowance exists.
3. Identify whether rebasing tokens are in scope; if so, verify balance snapshots are taken at the time of withdrawal, not deposit.
4. Confirm `safeTransfer` / `safeTransferFrom` from OpenZeppelin or Solmate is used universally.
5. Look for exact equality checks (`==`) on post-transfer balances, which always fail for fee-on-transfer tokens.

**False Positives**
- Protocols that explicitly document and enforce support only for non-fee, non-rebasing tokens with known fixed decimals.
- Before/after balance diff pattern used consistently throughout the codebase.
- Contracts that only interact with a single known safe token (e.g., WETH only).

**Notable Historical Findings**
Sublime Finance's strategy integration broke when Aave's aToken was used as collateral because the rebasing balance growth was not accounted for in the deposit tracking, causing systematic undervaluation. Multiple Redacted Cartel findings showed that fee-on-transfer token interactions in GMX vault deposits caused an inflated internal balance relative to actual holdings, which later caused withdrawals to fail or drain the pool. Liquid Collective's Solmate-based transfer wrappers passed silently for non-contract addresses because Solmate's implementation does not check code size, bypassing the intent of the safe transfer abstraction. In Gauntlet's protocol, `safeApprove` with a non-zero residual allowance caused deposit configuration to revert for USDT-like tokens, breaking protocol initialization.

**Remediation Notes**
Use before/after balance difference (`balanceAfter - balanceBefore`) for all deposit accounting to be fee-on-transfer and rebase-safe. Always reset allowance to 0 before calling `safeApprove` with a new amount. Use OpenZeppelin's `SafeERC20` library universally rather than Solmate's transfer helpers when supporting arbitrary tokens, as OpenZeppelin checks code size.

---

### Governance and Voting Manipulation (ref: fv-sol-5-c6)

**Protocol-Specific Preconditions**
- Voting checkpoints are written per-transfer; multiple transfers in the same block create separate checkpoints with the same timestamp, and binary search returns the wrong one.
- Staking time bonus for governance weight uses `unlockTime - block.timestamp` with no maximum cap, allowing `type(uint256).max` to produce unbounded voting weight.
- Proposal creation has no minimum holding or voting power threshold, allowing a zero-vote proposal to be submitted and potentially executed.
- Delegation mechanics allow an adversary to force-delegate to a target and exhaust its `MAX_DELEGATES` limit, blocking the target from receiving legitimate delegations.

**Detection Heuristics**
1. Check if voting checkpoints correctly handle multiple updates within the same block timestamp (consolidate rather than append).
2. Verify that staking bonus multipliers applied to unlock time have a hard maximum cap enforced on-chain.
3. Confirm proposal creation requires a minimum token balance or voting power, not just a non-zero balance.
4. Look for delegation limits that can be cheaply exhausted: if creating a delegation costs only gas, a griefing attack is feasible.
5. Verify that `getPastVotes` binary search returns the correct checkpoint for the queried timestamp when multiple updates occur in one block.

**False Positives**
- Checkpoint conflicts within a block that are impossible due to protocol-level transaction ordering guarantees.
- Proposals requiring multi-sig approval before execution regardless of vote count.
- Delegation limits high enough that exhaustion is economically impractical.

**Notable Historical Findings**
FrankenDAO's unbounded `_unlockTime` parameter allowed an attacker to pass `type(uint256).max` and receive an astronomically large staking bonus, dominating governance entirely. Nouns Builder had two distinct voting bugs: multiple checkpoints in the same block caused binary search to return incorrect historical vote counts, and `ERC721Votes` self-delegation doubled voting power by counting the same balance twice. Olympus DAO allowed any address to pass a governance proposal before any VOTES tokens were minted, enabling a single attacker to pass an arbitrary proposal with zero opposition. Velodrome Finance found that bribes and fee emissions could be gamed by voters who desynchronized the bribe-payment timing from the emissions period, collecting bribes without triggering the corresponding gauge emissions.

**Remediation Notes**
Consolidate voting checkpoints within the same block timestamp into a single entry rather than appending a new one. Enforce a hard on-chain maximum for staking unlock time (`block.timestamp + MAX_STAKE_DURATION`). Require a minimum voting power for proposal creation enforced at the contract level. Track per-epoch oracle or governance actions by address rather than array index to prevent double-action via array-swap removal.

---

### Signature Replay and Validation Gaps (ref: fv-sol-4-c4, fv-sol-4-c10, fv-sol-4-c11)

**Protocol-Specific Preconditions**
- `ecrecover` is called directly without checking for an `address(0)` return, which occurs for any invalid signature input; if `address(0)` holds a role or is an initialized mapping key, the check passes.
- Nonces are absent or not incremented after use, allowing the same signature to be replayed indefinitely.
- EIP-712 domain separators omit `chainId` or the contract address, making signatures valid across chains or redeployments.
- Cross-chain deployments share the same signature domain, enabling replay on any chain where the contract is deployed.

**Detection Heuristics**
1. Check all `ecrecover` calls for explicit `require(recovered != address(0))` or equivalent.
2. Prefer OpenZeppelin's `ECDSA.recover`, which reverts on `address(0)`.
3. Verify that signature schemes include a nonce incremented after use.
4. Confirm EIP-712 domain separator includes both `block.chainid` and `address(this)`.
5. Check whether `permit` or authorization signatures have expiration timestamps.
6. Verify that signature inputs use `abi.encode` rather than `abi.encodePacked` for dynamic types to prevent hash collision.

**False Positives**
- Systems where `address(0)` can never hold a valid role or authorization by construction.
- Nonce management handled by a trusted upstream contract not in the audited scope.
- Protocols on a single chain with immutable contracts and no plans for cross-chain deployment.

**Notable Historical Findings**
GoGoPool found that minipool creation signatures lacked sufficient validation, allowing an attacker to hijack another node operator's minipool and cause loss of staked funds. Ondo Finance's KYCRegistry was found to be vulnerable to signature replay: the same KYC approval signature could be submitted multiple times because no per-signature nonce was consumed. Hats Protocol had multiple findings where `address(0)` could effectively own a hat through signature manipulation, causing the safe's signature validation to accept phony signatures in critical multisig operations. Stakehouse Protocol's `deployLPToken` used a cross-chain replayable signature domain, enabling replay attacks across any EVM-compatible chain where the contract was also deployed.

**Remediation Notes**
Use OpenZeppelin's `ECDSA` library for all signature recovery. Include `block.chainid`, `address(this)`, and a per-signer nonce in all EIP-712 domain separators and message hashes. Increment or mark nonces as consumed atomically within the same transaction that validates the signature. For staking protocols operating across multiple chains, verify that operator key registration or delegation signatures are chain-specific.

### Staking Reward Front-Run by New Depositor (ref: pashov-144)

**Protocol-Specific Preconditions**
- Reward distribution uses a `rewardPerToken` accumulator pattern (Synthetix-style) where `rewardPerTokenStored` is updated by dividing pending rewards by total supply
- The stake deposit function increments `_balances[user]` before calling `updateReward(user)` or the equivalent checkpoint function
- A new depositor's balance is recorded at the pre-update `rewardPerTokenStored` value, meaning the user is credited for rewards that accrued before they staked

**Detection Heuristics**
1. Locate the stake deposit function. Check whether `_balances[user] += amount` appears before or after the `updateReward(user)` call.
2. Verify that `rewardPerTokenPaid[user]` is set to the current `rewardPerTokenStored` value after the checkpoint update, not before.
3. Simulate a deposit immediately before a large reward notification: confirm the depositor does not receive a share of rewards that accrued before their deposit.
4. Check for the same ordering issue in delegation or restaking functions where a balance change precedes a reward checkpoint.
5. Verify that `notifyRewardAmount` or reward distribution cannot be called in the same transaction as a deposit to create a front-run opportunity.

**False Positives**
- `updateReward(account)` is called as the first statement of the stake function, before any balance mutation, via a modifier or inline call
- `rewardPerTokenPaid[user]` is set to `rewardPerTokenStored` atomically at the start of every deposit, making the user's baseline always current before their balance increases
- The reward accumulator design does not use a per-user paid checkpoint and instead uses a different mechanism that is not susceptible to this ordering issue

**Notable Historical Findings**
No specific historical incidents cited in source.

**Remediation Notes**
Apply an `updateReward(account)` modifier or function as the unconditional first step in every stake, withdraw, and getReward function. The modifier must read `rewardPerToken()`, store it in `rewardPerTokenPaid[account]`, and compute the user's pending rewards before any balance change occurs. OpenZeppelin's `StakingRewards` reference implementation places `updateReward` as a modifier to ensure ordering is enforced syntactically.

---

## reference/solidity/protocols/synthetics.md

# Synthetic Asset Protocol Security Patterns

> Applies to: synthetic assets, synths, mirror assets, Synthetix-style, collateral-backed synthetic price tracking, debt pool mechanics, synthetic minting and burning

## Protocol Context

Synthetic asset protocols allow users to mint token representations of external price feeds against pooled collateral, creating debt positions denominated in the value of the synthetic rather than the underlying. The debt pool model - where all minters share proportional exposure to the total synthetic supply - means that individual position accounting errors and oracle inaccuracies aggregate across the entire system rather than being isolated to a single user. Price feed manipulation for any collateral or synthetic asset has a multiplied impact because it affects both minting eligibility and debt pool valuation simultaneously.

The architectural dependency on oracle accuracy is more severe than in lending protocols because synthetics have no direct backing asset to recover in a liquidation; the only backstop is the collateral ratio and the ability to burn synthetic tokens at the correct price. This makes rounding errors in debt calculations, incorrect fee accrual in the global debt ledger, and access control gaps on synthetic minting all critical-severity issues. Auction mechanisms used for liquidation must be resistant to griefing that would prevent the protocol from closing undercollateralized positions before collateral value drops further.

## Bug Classes

---

### Access Control and Privilege Escalation (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Functions that modify collateral parameters, oracle addresses, or synthetic minting permissions lack proper access control modifiers. Admin or owner roles have unconstrained privileges allowing immediate fund drainage or oracle replacement without timelocks. Approval or allowance mechanisms in synthetic vault flows can be exploited by unauthorized callers who observe on-chain approvals. NFT-gated collateral withdrawal functions check that the NFT is held but not that `msg.sender` is the rightful depositor. Centralized owner keys can add malicious strategies or adapters that return fabricated data.

**Detection Heuristics**
- Identify all `external` and `public` functions that modify balances, collateral ratios, oracle addresses, or minting permissions and verify they carry appropriate access control modifiers.
- Check for missing ownership verification in collateral NFT withdrawal functions - the function should confirm `msg.sender == depositor[tokenId]`.
- Assess admin powers: can the owner directly drain collateral, swap oracle contracts, or add arbitrary strategy contracts without a timelock?
- Check if approval or allowance is validated before transfers executed on behalf of other users in synthetic minting or redemption flows.
- Review whether critical admin operations require a timelock, multi-sig, or DAO governance delay.

**False Positives**
- The admin is a DAO-controlled timelock with sufficient delay and the risk is publicly documented.
- The function is intentionally permissionless by design, such as a public liquidation call.
- Access control is enforced at a router or proxy layer rather than the implementation contract.
- The function reads state or emits events without modifying balances or critical parameters.

**Notable Historical Findings**
Isomorph allowed any user to withdraw another user's Velo Deposit NFT after the depositor had granted approval to the vault contract, because the withdrawal function verified NFT existence but not that `msg.sender` was the original depositor. Taurus had a finding where a malicious admin could add a strategy contract that reported fabricated collateral valuations, enabling the admin to drain all user collateral. Reality Cards had a `sponsor` function with no access control modifier that allowed any caller to transfer tokens from the treasury to themselves. Velodrome Finance had a finding where a compromised owner could immediately drain the `VotingEscrow` contract of all VELO tokens without any timelock constraint.

**Remediation Notes**
Verify `msg.sender` against the registered owner or approved operator before any collateral withdrawal or position modification. Introduce timelocks for all admin operations that affect oracle addresses, strategy allowlists, collateral parameters, or treasury operations. Emit events with a delay before executing sensitive admin changes to allow monitoring and response. Separate read and write privileges so view functions and non-critical parameter updates do not require the same key as fund-movement functions.

---

### Access Bypass and Rate Limit Circumvention (ref: fv-sol-4)

**Protocol-Specific Preconditions**
Protocol enforces withdrawal caps or cooldown periods per address, but restrictions are tied to the address rather than to the underlying collateral position. Transferring synthetic tokens or collateral receipts to a second address resets or avoids the per-address limit. Cooldown periods enforced at the `withdrawalRequest[msg.sender]` level do not survive token transfers, so a new holder can withdraw immediately. Self-liquidation via alternative code paths such as `closeAll` can bypass invariants enforced on the standard `closePosition` path.

**Detection Heuristics**
- Check if per-address withdrawal limits can be bypassed by splitting across multiple addresses (Sybil attack).
- Verify that token transfers propagate associated cooldown or restriction state to the recipient.
- Look for alternative code paths (liquidation, emergency close, batch close) that skip standard invariant modifiers.
- Check if time-based restrictions survive token transfers or persist only on the requesting address.
- Identify rate limits that do not account for multicall or batch patterns within a single transaction.

**False Positives**
- The rate limit is global rather than per-address, making Sybil bypass irrelevant.
- The alternative bypass path has equivalent restrictions applied via different modifiers.
- The restriction is primarily anti-spam rather than a security-critical invariant.
- Bypassing the limit requires coordinating independent accounts with genuinely separate collateral.

**Notable Historical Findings**
prePO had a `userWithdrawLimitPerPeriod` check that could be bypassed by distributing collateral tokens across multiple addresses, each capable of withdrawing up to the limit independently, effectively multiplying the withdrawal rate by the number of addresses. A separate prePO finding showed the withdrawal delay could be circumvented by transferring collateral tokens before the delay expired, leaving the new holder free to withdraw immediately. Perennial Finance allowed a user to self-liquidate using `closeAll`, which called `_closeMake` and `_closeTake` without the `takerInvariant` modifier that the standard `closePosition` path enforced, enabling positions to be closed in a state that would otherwise be rejected. Inverse Finance's oracle had a two-day low price feature that could be gamed by borrowers to time their repayments at artificially favorable prices.

**Remediation Notes**
Enforce restrictions on the position or collateral receipt rather than only the requesting address. Hook `_beforeTokenTransfer` to block transfers during active withdrawal windows or to transfer the cooldown state to the recipient. Apply identical invariant checks on all code paths that close or modify positions, including liquidation, emergency, and batch variants. Where self-liquidation is permitted, confirm it does not exempt the user from invariants designed to protect protocol solvency.

---

### Auction Mechanism Flaws (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Protocol uses Dutch auctions for liquidations or collateral sales where the price decays over time and can fall below the protocol's break-even threshold, creating bad debt. Auction settlement and new auction creation are coupled in a single transaction using try-catch, making the creation step vulnerable to gas manipulation via EIP-150. First liquidity provider in a stable AMM-style auction pool can set imbalanced initial reserves that make the invariant value near zero, blocking all subsequent swaps.

**Detection Heuristics**
- Check if auction settlement triggers new auction creation in the same transaction with a bare `catch` block that pauses the protocol on any error including out-of-gas.
- Verify auction price decay formulas enforce a minimum floor price that covers the protocol's liquidation break-even.
- Look for first-depositor advantages in AMM-style auction pools where the initial invariant value can be set near zero.
- Check if auction timing parameters are dependent on external state that an attacker can manipulate.
- Verify that auction reserve formulas account for all accumulated debt and fees before pricing collateral.

**False Positives**
- The protocol has an insurance fund or admin restart mechanism that covers bad debt from below-floor auctions.
- The gas manipulation attack cost exceeds the value of forcing the protocol into a paused state.
- First-depositor concern is mitigated by a bootstrapping phase with restricted access.
- The auction is designed to intentionally reach zero (Dutch auction to completion is the intended behavior).

**Notable Historical Findings**
Nouns Builder's `_createAuction` used a bare `catch` block that called `_pause()` on any error from `token.mint()`, meaning an attacker could restrict the forwarded gas via EIP-150's 63/64 rule so the mint call ran out of gas while leaving enough gas for `_pause()` to succeed, permanently halting auctions. Ajna's liquidation auction price decay formula could fall below the collateral's break-even value before any bidder participated, leaving the protocol with bad debt on every under-bid liquidation. Velodrome Finance's first liquidity provider for stable pairs could deposit imbalanced reserves so the `x^3*y + y^3*x` invariant value was effectively zero, causing all subsequent swaps in that pool to revert. A separate Nouns Builder audit found a precision error in `_computeTotalRewards` that could permanently brick auction reward computation.

**Remediation Notes**
In try-catch auction creation, inspect the specific error selector in the catch block and only pause on expected error types (e.g., `NO_METADATA`); treat unrecognized errors including out-of-gas as reverts rather than valid pause triggers. Enforce a minimum auction price equal to or above the protocol's liquidation break-even threshold to prevent bad debt creation. Require a minimum invariant value for stable pair initial deposits to prevent zero-k pool manipulation.

---

### Denial of Service and Griefing (ref: fv-sol-9)

**Protocol-Specific Preconditions**
Contract iterates over arrays or counters that grow without bound over the protocol lifetime, eventually exceeding block gas limits. External calls to plugins, adapters, or market contracts within loops allow a single broken integration to block the entire deposit or withdrawal flow. Try-catch blocks with bare catch handlers can be manipulated via EIP-150 gas restriction to trigger the catch branch's state changes without the intended operation completing. Dust token deposits or refunds can cause arithmetic to revert in functions shared by all users.

**Detection Heuristics**
- Identify loops over arrays or sequential counters that grow without a hard cap and verify gas usage stays within safe bounds.
- Check for `try/catch` blocks where the catch branch writes state (pause, revert mode, counter increment) rather than simply emitting an event.
- Look for batch operations where a single item's failure reverts the entire batch rather than being skipped or queued.
- Search for functions where an attacker can force a dust send that triggers an arithmetic underflow or revert for all subsequent callers.
- Identify external call dependencies on third-party contracts that can be paused or broken, where no fallback or skip logic exists.

**False Positives**
- The array has a hard cap that keeps gas usage within safe block limits for the foreseeable protocol lifetime.
- The protocol has an admin function to skip or remove problematic queue entries.
- The denial of service is temporary and self-resolving (for example, a Chainlink oracle that resumes after downtime).
- The gas cost of mounting the attack exceeds the value that can be extracted or the damage caused.

**Notable Historical Findings**
Bond Protocol's `BondAggregator.liveMarketsBy` iterated over an unbounded `marketCounter` twice in a single view function, a pattern that would eventually hit block gas limits as the number of markets grew. Union Finance had multiple findings involving unbounded iteration: the `getFrozenInfo` vouches array could exceed gas limits, the priority withdrawal sequence grew infinitely, and a single broken money market adapter caused all deposits and withdrawals to fail. Velodrome Finance's `depositManaged` could be permanently blocked by delegating tokens to `MAX_DELEGATES = 1024`, after which any further delegation caused the function to run out of gas. VTVL had a permanent freeze vulnerability caused by an arithmetic overflow in `_baseVestedAmount` that blocked all vesting operations for affected recipients once the overflow condition was reached.

**Remediation Notes**
Paginate all iterations over protocol-level arrays with explicit start and end parameters. Remove or skip broken adapters and plugins atomically and revoke their approvals on removal. In try-catch patterns that produce state changes on failure, inspect the specific error type and treat unexpected errors (including out-of-gas from the 63/64 gas limitation) as hard reverts rather than handled failures. Enforce hard caps on delegate counts and other per-address arrays that feed into unbounded loops.

---

### First Depositor Vault Share Manipulation (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Synthetic vault or collateral pool uses `totalAssets / totalSupply` share pricing without an initial anchor. The first depositor mints shares at a 1:1 ratio, then donates tokens directly to the vault contract to inflate `totalAssets`, making the share price expensive enough that subsequent depositors receive zero shares due to integer division rounding, losing their entire deposit. ERC-4626 vaults used for synthetic collateral management that lack `_decimalsOffset()` are directly vulnerable to this pattern.

**Detection Heuristics**
- Check if the vault mints dead shares or enforces a minimum initial deposit at initialization.
- Verify `convertToShares` handles `totalSupply == 0` with a minimum ratio that prevents the attack.
- Look for ERC-4626 vaults where `previewDeposit` can return zero shares for non-zero assets.
- Check if tokens can be donated directly to the vault contract and reflected in `totalAssets()` without minting shares.
- Verify vault initialization sequences lock minimum liquidity before opening to external deposits.

**False Positives**
- The vault mints dead shares or requires a minimum initial deposit that makes the donation attack economically infeasible.
- The vault applies OpenZeppelin's virtual share offset via `_decimalsOffset()`.
- Direct token donations are not reflected in `totalAssets()` because assets are tracked via internal accounting rather than balance reads.
- The vault only accepts deposits from a trusted router contract that controls first deposit behavior.

**Notable Historical Findings**
Mycelium's tracker vault allowed an attacker to manipulate `pricePerShare` by depositing 1 wei, then donating tokens to inflate the share price so future depositors received zero shares and the attacker could redeem at a profit. Perennial Finance's `BalancedVault` had a similar early depositor exchange rate manipulation finding. Sense Finance's public vault finding showed the initial depositor could set the price-per-share value to a level that caused future depositors to lose funds. Timeswap's first liquidity provider received disproportionate short tokens due to increased duration in the initial period, providing a first-mover extraction advantage.

**Remediation Notes**
Mint a fixed quantity of dead shares (e.g., `1000`) to `address(1)` on the first deposit and deduct them from the depositor's share allocation. Alternatively, apply a `_decimalsOffset()` of at least 6 in ERC-4626 implementations, which requires an attacker to donate `1e6` times the victim's deposit to reduce them to zero shares. Where internal asset accounting is used instead of raw balance reads, confirm the accounting path prevents donation inflation.

---

### Frontrunning and MEV Exploitation (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**
Two-step operations where an approval or authorization transaction is separate from the protected action allow frontrunners to intercept the asset between steps. Reward claims, yield harvests, and accumulated fees can be frontrun by the current owner before a pending transfer completes. Deployment and initialization sequences that set access controls in a transaction separate from contract creation create a window for unauthorized minting or configuration. Liquidation transactions visible in the mempool reveal profitable positions allowing competing liquidators to race.

**Detection Heuristics**
- Identify two-step operations where approval or setup and execution appear in separate transactions with no commitment binding them together.
- Check if reward claims, yield harvests, or fee collections can be triggered by the current token holder before a sale or transfer completes.
- Look for deployment or initialization sequences where access controls, hooks, or price parameters are set after contract creation in a separate transaction.
- Search for liquidation functions whose parameters are fully visible in the mempool and lack any MEV protection.
- Verify that parameter changes such as fees, prices, or oracle sources cannot be sandwiched for profit.

**False Positives**
- Operations use a commit-reveal scheme or are submitted through a private mempool.
- The frontrunning profit is smaller than the gas cost of the attack.
- The protocol operates on an L2 with a centralized sequencer that prevents traditional mempool frontrunning.
- The two-step process has a timelock or authentication that prevents unauthorized interception between steps.

**Notable Historical Findings**
Ajna's CryptoPunks deposit flow required the user to first offer the punk for sale to the pool address at zero price in one transaction, then call `depositPunk` in a second transaction, creating a window where any observer could buy the punk for zero and take the user's position. Wenwin's lottery allowed the seller of a winning ticket to frontrun the buyer's purchase by claiming the reward between the sale agreement and the transfer settlement, leaving the buyer with a worthless ticket. prePO deployed `PrePOMarket` without setting the `mintHook` in the constructor, leaving a window between deployment and hook assignment where anyone could mint unrestricted Long and Short tokens. Abracadabra Money's `create()` factory was vulnerable to reorg attacks where the deployed market address could be predicted and front-deployed with different parameters.

**Remediation Notes**
Combine approval and action into a single atomic transaction wherever possible. For NFT-gated protocols that cannot atomically transfer and act, use a dedicated wrapper or intermediary that performs both steps atomically. Auto-claim rewards during `_beforeTokenTransfer` so that pending rewards are settled before ownership changes. Set all access controls and hooks in the constructor rather than in a post-deployment initialization transaction. For liquidation systems on public chains, consider using commit-reveal or batch auctions to reduce MEV extraction.

---

### Reward Distribution and Staking Flaws (ref: fv-sol-5)

**Protocol-Specific Preconditions**
Reward accumulation divides by `totalStaked` or `totalSupply` which can be zero, causing rewards emitted during empty periods to be permanently lost. Epoch boundary calculations use off-by-one errors that read the next epoch's checkpoint instead of the current epoch's final state. Reward multipliers are based on the duration since `lastUpdated` which is reset by re-borrowing or re-staking, allowing users to game the multiplier without providing genuine economic value. Users can claim rewards from future epochs that have not yet been finalized.

**Detection Heuristics**
- Check `rewardPerToken()` behavior when `totalSupply == 0`: rewards should pause accumulation or be held, not emitted without distribution.
- Look for epoch or period boundary calculations with potential off-by-one errors, specifically checking whether `_currTs + DURATION` reads into the current or next epoch's data.
- Verify reward multipliers cannot be reset by cycling positions: re-staking, re-borrowing, or re-depositing should not provide a fresh multiplier.
- Check if users can call claim functions for future epochs that have not yet been finalized.
- Look for reward accumulation that uses stale `lastUpdated` timestamps based on actions the user can trigger at will.

**False Positives**
- Protocol intentionally burns unclaimed rewards as part of its documented tokenomics model.
- Lost rewards during zero-supply periods are automatically redistributed in the next active period.
- Multiplier gaming requires locking capital at a cost that exceeds the incremental reward benefit.
- Epoch boundary differences result in negligible reward discrepancies below the protocol's minimum unit.

**Notable Historical Findings**
Velodrome Finance had a high-severity finding where `RewardDistributor` cached `totalSupply` at the start of epoch calculations, causing reward amounts to be computed incorrectly for all stakers whenever supply changed during the period. Union Finance had multiple staking reward findings: stakers could gather maximal multipliers regardless of whether borrowers were overdue by exploiting a stale frozen calculation, rewards were lost as `updateLocked` only processed the first active vouch array before stopping, and a staker could maximize UNION reward issuance by cycling deposits without providing real credit. Ajna's rewards manager did not delete old bucket snapshot info on unstaking, allowing users to claim rewards for future epochs that had not yet been finalized. Abracadabra Money's `LockingMultiRewards` had permanent yield loss due to precision loss in per-token reward accounting.

**Remediation Notes**
Gate reward accumulation behind a `totalSupply > 0` check; hold accumulated rewards in a separate variable during zero-supply periods and release them when stakers return. Use independent per-epoch snapshots rather than shared running totals that can be desynchronized. Invalidate multiplier state on any position reset action such as re-stake or re-borrow. Prevent reward claims for epochs that have not yet been finalized by checking that the epoch's end timestamp has passed and the snapshot is complete.

---

### Rounding and Precision Loss (ref: fv-sol-2)

**Protocol-Specific Preconditions**
Contract performs division before multiplication in reward or price calculations, causing intermediate truncation. Synthetic minting, debt issuance, and collateral valuation use inconsistent rounding directions across paired functions. Small input amounts produce zero shares due to floor division, and repeated small transactions can extract value by paying zero fees. Liquidation arithmetic truncates collateral seizure, leaving dust bad debt that accumulates over time and degrades pool health.

**Detection Heuristics**
- Search for division operations followed by multiplication in the same computation path, particularly in reward rate and collateral ratio calculations.
- Check if `mulDiv` vs `mulDivUp` rounding direction is consistent across paired operations: deposits round down shares, withdrawals round up assets.
- Look for intermediate calculations that could round to zero for small but economically valid inputs.
- Verify that fee calculations cannot be avoided by splitting large operations into many small ones where each rounds fees to zero.
- Identify mismatched precision between Chainlink price feeds (8 decimals) and token amounts (18 decimals) in collateral valuation.

**False Positives**
- Precision loss is bounded to 1 wei per operation and accumulation is bounded by protocol constraints.
- The protocol explicitly documents and accepts a specific rounding direction with a stated rationale.
- Rounding consistently favors the protocol over the user, which is the safe default.
- Inputs are constrained to minimum sizes where precision loss is negligible.

**Notable Historical Findings**
Bond Protocol had multiple rounding issues: market price used `mulDivUp` in one calculation path but `mulDiv` in a related internal function, causing inconsistent pricing that borrowers could exploit for better terms. Ajna's liquidation arithmetic truncated the seized collateral amount when the borrower's collateral was fractional, allowing `take` to proceed with the full debt but less collateral, worsening the protocol's position. Abracadabra Money's `MagicLP` had a high-severity finding where a rounding error in the invariant calculation could be amplified by an attacker to break the `I` invariant, enabling malicious arbitrage. Fractional's migration function had severe precision loss when `_newFractionSupply` was set to a very small value, causing users to lose entire fractions to rounding.

**Remediation Notes**
Always multiply before dividing to preserve precision at intermediate steps. Use consistent rounding direction across paired functions: `previewDeposit` rounds down shares, `previewWithdraw` rounds up assets, `convertToShares` rounds down, `convertToAssets` rounds down. Enforce minimum transaction sizes that guarantee at least 1 unit of fee is collected. In liquidation paths, round seized collateral up (more collateral per debt unit) to ensure the protocol's position improves after every liquidation.

---

### Token Decimal Assumptions (ref: fv-sol-3)

**Protocol-Specific Preconditions**
Protocol hardcodes 18 decimals or `WAD` (1e18) in collateral valuation, synthetic issuance, and debt calculations while interacting with tokens of 6 decimals (USDC), 8 decimals (WBTC), or 8-decimal Chainlink price feeds. Fixed-point math libraries assume matching precision across all token pairs in LP pricing functions. Minimum amount thresholds are expressed as absolute values that represent wildly different economic quantities depending on the token's decimal count.

**Detection Heuristics**
- Search for hardcoded `1e18`, `10**18`, or `WAD` in arithmetic involving external token amounts without a prior decimal normalization step.
- Check if `decimals()` is called and applied for normalization in every price and value calculation.
- Verify that Chainlink price feed decimals (typically 8) are explicitly accounted for when combining with token amounts in collateral ratio calculations.
- Look for fixed minimum amounts or thresholds that do not scale with token decimals.
- Identify LP token pricing functions that assume both underlying tokens have equal decimal precision.

**False Positives**
- Protocol explicitly supports only 18-decimal tokens and enforces this at token registration with a `require(decimals() == 18)` check.
- Decimal normalization is handled by an oracle wrapper or adapter layer that standardizes all values before they reach the protocol.
- The token whitelist only includes tokens with matching decimal counts for the specific use case.
- The hardcoded value is mathematically correct for the specific token pair being used in that function.

**Notable Historical Findings**
Taurus assumed all collateral tokens had 18 decimals in its core collateral valuation function, causing a high-severity undercollateralization bug when USDC or WBTC were used as collateral because the value calculation was off by factors of `1e12` or `1e10` respectively. Isomorph's `DepositReceipt` contracts broke when WBTC LP positions were used because the contract used WAD for both tokens in the LP pair value calculation, severely mispricing the 8-decimal token. Sense Finance's LP oracle needed to either enforce 18 decimals for the underlying token or use decimal-flexible fixed-point math; both paths produced incorrect valuations for non-18-decimal tokens. Inverse Finance's oracle assumed Chainlink feed decimals would always be at most 18, failing when a feed returned 20 decimals.

**Remediation Notes**
Read `IERC20Metadata(token).decimals()` at the point of every price and collateral calculation and normalize to a common base precision (18 decimals) before arithmetic. Read the Chainlink aggregator's `decimals()` method explicitly and incorporate it into every price feed read. Replace hardcoded `WAD` in LP pricing with `10 ** token.decimals()` per token. Enforce decimal assumptions at token registration time with an explicit check that reverts if the token does not meet the protocol's supported decimal range.

---

### Unsafe ERC-20 Token Handling (ref: fv-sol-6)

**Protocol-Specific Preconditions**
Protocol uses bare `transfer()` or `transferFrom()` instead of `safeTransfer()` and `safeTransferFrom()`, failing silently when the token returns `false` on failure (USDT on non-Ethereum chains, BNB, OMG). Fee-on-transfer tokens are credited at the nominal transfer amount rather than the measured received amount. ETH is forwarded using `.send()` or `.transfer()` which impose a 2300 gas stipend that fails for contract recipients with non-trivial `receive` logic. ERC-777 tokens accepted as synthetic collateral trigger `tokensReceived` hooks that can reenter state-modifying functions.

**Detection Heuristics**
- Search for `transfer()` and `transferFrom()` on ERC-20 tokens without wrapping in `safeTransfer` or checking the boolean return value.
- Identify tokens in the supported asset list that are known to return `false` on failure rather than reverting.
- Check if `amount` is used directly for accounting after transfers without a balance-before/balance-after measurement.
- Look for `.send()` or `.transfer()` for ETH delivery instead of `.call{value: amount}("")`.
- Check for `approve()` calls on tokens that require setting allowance to zero before a new value (USDT on Ethereum).

**False Positives**
- Protocol only supports a specific token known to revert on failure such as DAI or WETH.
- OpenZeppelin or Solmate `SafeERC20` is already used consistently across all transfer paths.
- The token whitelist explicitly excludes fee-on-transfer and rebasing tokens with enforced documentation.
- The ETH recipient is always an EOA or a known contract that does not exceed the gas stipend.

**Notable Historical Findings**
Inverse Finance's repayment flow used bare `transfer` without checking the return value, allowing a failed transfer to proceed silently and leave the borrower's debt unchanged while the protocol believed the repayment had occurred. Blur Exchange had a Yul-level `call` whose return value was not checked, enabling fund loss when the call failed. OpenQ had multiple unsafe ERC-20 issues including a high-severity finding where bounties could be broken by funding them with malicious ERC-20 tokens that implemented destructive transfer hooks. Union Finance's `AssetManager.withdraw` did not return false on failure, causing the asset manager's retry logic to treat failed withdrawals as successful and proceed without funds.

**Remediation Notes**
Use OpenZeppelin `SafeERC20` or Solmate's `safeTransfer` for all ERC-20 token interactions without exception. Measure actual received amounts using balance-before/balance-after for any token that may have transfer fees. Use low-level `.call{value: amount}("")` for all ETH transfers and check the boolean success return. For tokens that require zero-allowance before approval (USDT), use the pattern `safeApprove(0); safeApprove(amount)` or use `safeIncreaseAllowance`.

---

### Unsafe Type Casting and Integer Overflow (ref: fv-sol-3)

**Protocol-Specific Preconditions**
Contract explicitly casts values to narrower integer types (`uint16`, `uint96`, `uint128`) without range checking, silently truncating reward values or staking amounts. User-supplied expiration timestamps accept `type(uint256).max` as input, which cannot be safely stored in smaller timestamp types used by other protocol components. Solidity 0.8 overflow protection is bypassed by `unchecked` blocks in hot paths where adversarial inputs are possible.

**Detection Heuristics**
- Search for explicit narrowing casts: `uint16(x)`, `uint96(x)`, `uint128(x)`, `int128(x)` - verify the value is range-checked before casting.
- Check if `unchecked` blocks contain arithmetic that could overflow or underflow with adversarial inputs that reach that code path.
- Look for sentinel values like `type(uint256).max` for expiration or amount fields that could cause unexpected behavior when passed to functions using smaller integer types.
- Verify that timestamp values stored in smaller types (uint32, uint40, uint48) will not overflow within the realistic operational lifetime of the protocol.
- Identify where `SafeCast` is used and verify it is applied consistently wherever downcasting occurs throughout the codebase.

**False Positives**
- The input is validated to be within the target type's range before the cast is performed.
- OpenZeppelin `SafeCast` is used at all narrowing cast sites, which reverts on overflow.
- The `unchecked` block contains operations that are mathematically proven safe by invariants maintained elsewhere.
- The timestamp or amount range is naturally bounded by other protocol constraints that prevent overflow.

**Notable Historical Findings**
Wenwin's reward packing function cast each prize value to `uint16` by dividing by a divisor and truncating without checking if the divided value exceeded `65535`, causing high prizes to silently wrap to small values that misdistributed winnings. OpenQ had a high-severity finding where a user could deposit with `_expiration = type(uint256).max`, locking their deposit permanently because the maximum value was accepted without a cap check. VTVL had a permanent vesting freeze caused by an overflow in `_baseVestedAmount` that was reachable under certain vesting schedule configurations, preventing any vesting operations for affected recipients. Velodrome Finance's `RewardsDistributor` had an unsafe cast from a large `uint256` to a smaller type that produced an underflow, corrupting `veForAt` balance calculations.

**Remediation Notes**
Use OpenZeppelin `SafeCast` for all narrowing casts rather than relying on implicit or explicit truncation. Enforce maximum bounds on user-supplied expiration timestamps using `require(expiration <= block.timestamp + MAX_DURATION)`. Replace `unchecked` arithmetic in reward and vesting calculations unless the safety proof is explicit and documented inline. Store timestamps in types sized appropriately for the protocol's expected operational lifetime with margin.

---

### Unchecked External Calls and Untrusted Contracts (ref: fv-sol-6)

**Protocol-Specific Preconditions**
Protocol integrates with external adapters, plugins, or oracle contracts that can be upgraded, paused, or become malicious. External call return values are not checked, allowing silent failures to corrupt accounting state. Protocol accepts arbitrary contract addresses as adapter or oracle parameters without verification against a vetted registry. `delegatecall` is used against user-supplied or upgradeable targets that can modify vault storage layout.

**Detection Heuristics**
- Search for external calls where the return value is not stored or checked against expected success states.
- Identify `delegatecall` to addresses that are not hardcoded or verified against a whitelist or registry.
- Look for plugin or adapter loops where a single external call failure reverts the entire operation rather than being isolated.
- Check if adapter or oracle addresses can be changed by admin to arbitrary values without a timelock or registry check.
- Verify that removed adapters have their token approvals revoked to prevent continued access.
- Look for uncached `decimals()` calls on untrusted ERC-20 tokens whose return value can change between calls.

**False Positives**
- The external contract is immutable, audited, and its address is hardcoded in the implementation.
- External call failure is the intended behavior, such as an optional post-hook that the protocol can function without.
- The admin controlling adapter addresses is a timelock with sufficient delay for monitoring and intervention.
- The `delegatecall` target is restricted to a verified implementation registry with immutable entries.

**Notable Historical Findings**
Mycelium's vault had a finding where a single broken or paused plugin caused all `deposit()` and `withdraw()` operations to fail for all users because the plugin call was in a loop without any error isolation. Sense Finance's adapter interaction allowed calls to transient or unverified external contracts, and the `GClaimManager` was missing reentrancy guards around external claims. Union Finance's `AssetManager` would revert all `deposit`, `withdraw`, and `rebalance` operations when any one of its money market adapters failed, and adapters that were removed kept their token approvals active. Blur Exchange's low-level Yul `call` did not check the return value, silently failing transfers and producing incorrect state.

**Remediation Notes**
Check all external call return values and revert or handle the error explicitly. Wrap individual plugin and adapter calls in try-catch and continue to the next available integration rather than reverting the entire operation. Require that all adapter addresses are validated against an approved registry before interaction. Revoke token approvals when removing or replacing adapters. Cache `decimals()` return values from untrusted tokens at the time of token registration rather than calling them on every operation.

## reference/solidity/protocols/yield.md

# Yield Protocol Security Patterns

> Applies to: yield farming, yield aggregators, strategy vaults, auto-compounders, liquidity mining, ERC-4626 vaults, Yearn-style, Convex-style, reward distribution protocols

## Protocol Context

Yield protocols are distinguished by their dependency on multiple external protocol integrations simultaneously - a single vault may interact with Curve, Aave, Convex, and Uniswap in a single transaction, meaning any mismatch in assumptions about external state (exchange rates, borrow indexes, token decimals) compounds into accounting errors. Reward accounting is uniquely complex because users enter and exit positions asynchronously, requiring per-user checkpointing of global reward accumulators before any balance-changing operation; failure to do so enables retroactive reward manipulation. The share-price model used by ERC-4626-style vaults introduces a class of donation-based inflation attacks where a first depositor can manipulate the share-to-asset exchange rate to steal subsequent depositors' funds, a problem endemic to the category and absent from most other protocol types.

---

## Bug Classes

### First Depositor Share Inflation Attack (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**

- Vault uses shares-based accounting where share price = `totalAssets / totalSupply`
- `totalSupply` can reach zero (no dead shares minted at deployment)
- `totalAssets()` reflects direct token balance (donations affect share price)
- No virtual offset (`_decimalsOffset`) or minimum deposit enforcement on first deposit

**Detection Heuristics**

- Identify vaults where `totalSupply == 0` is handled with a `1:1` branch rather than a virtual offset
- Confirm that `totalAssets()` includes `token.balanceOf(address(this))` without exclusion of donated amounts
- Check that `deposit()` reverts when `shares == 0` would result
- Look for absence of `_decimalsOffset()` override in OpenZeppelin ERC4626 subclasses
- Confirm no dead shares are minted to `address(0xdead)` or equivalent in the constructor or first deposit

**False Positives**

- Vaults using OpenZeppelin ERC4626 with a non-zero `_decimalsOffset()` return are protected by design
- Protocols that mint dead shares (e.g., `1000 shares to address(0)`) on first deposit
- Vaults that enforce a minimum deposit threshold high enough to make the attack economically infeasible
- Protocols tracking assets separately from raw token balance (donated tokens do not affect `totalAssets`)

**Notable Historical Findings**

Napier, GoGoPool, BadgerDAO, Sense, and Rubicon all suffered variants of this attack. In each case, an attacker deposited a nominal amount (1 wei), then directly donated a large token balance to the vault to inflate the exchange rate before a victim's deposit was processed. The victim's deposit rounded to zero shares while the attacker redeemed the inflated single share for nearly all vault assets. Redacted Cartel's AutoPxGmx and AutoPxGlp vaults were drained via the same mechanism. The pattern appears in protocols that forked vault code without auditing the first-deposit path.

**Remediation Notes**

Use OpenZeppelin ERC4626's virtual offset pattern (`_decimalsOffset()` returning 3-8) which adds `10**decimalsOffset` virtual shares and 1 virtual asset to all conversion calculations, making inflation attacks require impractically large donations. As an alternative, mint dead shares (`1000 * 10**decimals`) to `address(0xdead)` on the first deposit and require a minimum initial deposit. Always ensure `deposit()` reverts on zero shares computed.

---

### Reward Distribution and Accounting Errors (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**

- Protocol distributes rewards using `rewardPerTokenStored` accumulator pattern (Synthetix-derived)
- User reward state (`userRewardPerTokenPaid`) is not checkpointed before balance-modifying operations
- Reward calculation applies boost multipliers that reference current state retroactively
- Functions that unstake, re-stake, delegate, or modify lock duration do not call `updateReward` first
- `totalSupply` or user balance can reach zero mid-cycle causing division-by-zero or reward loss

**Detection Heuristics**

- Trace every function that calls `_mint`, `_burn`, `transfer`, or modifies `balanceOf` and verify it calls `updateReward(account)` first
- Look for boost or lock-duration setters that modify multipliers applied inside `earned()` without a prior checkpoint
- Check unstake logic for operations that subtract total pool shares rather than user shares
- Verify that reward token configuration changes (adding or replacing a reward token) force a full epoch flush before taking effect
- Check the first-claim path when `totalSupply == 0` to confirm `rewardPerToken()` returns early without division

**False Positives**

- Protocols using time-weighted average balances (TWAB) checkpointing where historical snapshots make retroactive manipulation impossible
- Systems where rewards are pushed (distributed pro-rata at a point in time) rather than pulled (accumulated continuously)
- Contracts where the `updateReward` modifier is applied at the inherited ERC20 `_beforeTokenTransfer` hook, covering all paths

**Notable Historical Findings**

Blueberry lost reward accounting across multiple findings in a single audit - users depositing extra funds into ICHI positions lost all accrued ICHI rewards because the position update did not checkpoint rewards first. GoGoPool's slashing logic operated on full slash duration regardless of actual accrual period. Sense Finance had a compounding error where the `pounder` reward was excluded from xPYT auto-compound calculations. Velodrome Finance contained at least six reward accounting findings in a single review: incorrect epoch boundary calculations, totalSupply caching in the reward distributor, undistributed rewards not rolling over, and bribe/fee emissions gameable by just-in-time voters.

**Remediation Notes**

Apply the Synthetix `updateReward(address account)` modifier unconditionally to every function that changes `balanceOf`, `totalSupply`, lock duration, or boost multiplier. The modifier must update `rewardPerTokenStored`, `lastUpdateTime`, and the caller's `rewards[account]` and `userRewardPerTokenPaid[account]` before the state change executes. For protocols with multiple reward tokens, apply the modifier for all active reward tokens. Never clear `rewards[account]` unless a non-zero transfer to the user has been confirmed.

---

### Stale Cached State Desynchronization (no fv-sol equivalent - candidate for new entry)

**Protocol-Specific Preconditions**

- Protocol caches values from external lending protocols (borrow indexes, exchange rates, cumulative prices)
- Cached values are not refreshed before use in liquidation, withdrawal, or interest calculations
- Time elapses between cache updates, allowing values to diverge from live protocol state
- Aggregator protocol tracks position state independently of the underlying protocol it wraps (e.g., Morpho over Aave)

**Detection Heuristics**

- Search for `lastPoolIndexes`, `lastBorrowPoolIndex`, `lastExchangeRate`, or analogous cached fields used directly in health factor or share price calculations
- Identify state transition functions that advance a timestamp (`domainStart`, `lastUpdate`) without first settling outstanding interest or accrued rewards
- Verify that `accrueInterest()` or equivalent is called before any liquidation authorization check
- Check that rebasing token balances are read via `balanceOf()` at call time, not from a stored snapshot
- Confirm TVL or totalAssets values are refreshed before deposit/withdrawal share calculations

**False Positives**

- Protocols where the external system guarantees atomic updates (e.g., a same-block oracle)
- Cached values used only as non-critical metadata (event emission, UI hints) with no impact on fund accounting
- Systems that deliberately use snapshot values for fairness (e.g., TWAP-based pricing)

**Notable Historical Findings**

Morpho contained multiple stale-index findings: Compound's `borrowIndex` was read from Morpho's internal cache rather than from the live cToken, causing health factor and liquidation threshold calculations to understate actual debt. Liquidating a Morpho-Aave position advanced Morpho's internal state without propagating the matching update to Aave, leaving the two systems desynchronized. Mellow Protocol's AaveVault did not update TVL on deposit or withdrawal, so share prices were computed against a stale total. Timeless Finance's `claimYieldAndEnter` accumulated yield against a cached value that did not advance between calls.

**Remediation Notes**

Always call the upstream protocol's `accrueInterest()` or equivalent before reading any derived state (borrow balance, health factor, collateral value). Treat any value obtained from an external protocol as immediately stale and re-fetch it at the point of use. For protocols that cache TVL or totalAssets, invalidate the cache on every deposit, withdrawal, and harvest.

---

### Incorrect State Updates (fv-sol-5)

**Protocol-Specific Preconditions**

- Protocol maintains redundant counters or derived state alongside canonical accounting (e.g., `minipoolCount`, `totalLend`, vote power totals)
- Error, cancellation, or liquidation paths do not update all state that the happy path updates
- Admin parameter changes (fee rates, boost multipliers, thresholds) affect in-flight reward calculations retroactively
- NFT-based position systems do not zero ownership mappings on burn

**Detection Heuristics**

- For every function that increments a counter or accumulator, verify the corresponding decrement exists in every code path that reverses the operation (cancellation, error, emergency exit)
- Check liquidation and slashing handlers for omitted `totalLend`, `totalSupply`, or `minipoolCount` adjustments
- Verify reward parameter setters call `updateReward` before modifying multipliers
- Audit NFT burn functions for dangling `ownerOf` mappings exploitable via index reuse
- Compare state variable sets touched by `create` vs. `cancel`/`error` paths

**False Positives**

- Lazy evaluation patterns where counters are intentionally re-derived on read
- Systems where a missing decrement is bounded to dust and non-exploitable

**Notable Historical Findings**

GoGoPool's `recordStakingError` failed to decrement `minipoolCount`, permanently inflating the count used in reward distribution. Blueberry's `withdrawLend` caused an accounting error in `totalLend` that cascaded into incorrect interest rate calculations. FrankenDAO's `unstake` removed votes using current power rather than the original staked power, enabling vote inflation or permanent power loss depending on whether multipliers increased or decreased after staking. CLOBER's order cancellation did not zero the `ownerOf` mapping, allowing future NFTs minted at the same order index to be stolen.

**Remediation Notes**

For every state variable updated in a forward path, audit all reverse paths (cancel, error, liquidation, emergency). Snapshot the values that need to be reversed at the time of the forward operation (e.g., store `originalVotingPower[tokenId]` at stake time) rather than attempting to recompute them later from potentially changed parameters. Delete all mappings keyed by an ID when that ID is invalidated.

---

### Rounding and Precision Loss (fv-sol-2)

**Protocol-Specific Preconditions**

- Share-to-asset conversions use integer division without explicit rounding direction
- Rounding direction favors the user on withdrawal (round down burns fewer shares) or favors the protocol on deposit (round up gives fewer shares)
- Low-decimal tokens (USDC 6, WBTC 8) interact with 18-decimal reward rates, causing truncation to zero
- Reward accumulator math performs division before multiplication

**Detection Heuristics**

- Check ERC-4626 conversion functions: `previewWithdraw` and `previewRedeem` must round up (shares burned), `previewDeposit` and `previewMint` must round down (assets taken)
- Search for `a * b / c` where the multiplication result can be smaller than `c`
- Look for reward per token calculations involving low-decimal reward tokens divided by large 18-decimal `totalSupply`
- Verify that `rewards[account]` is not cleared when the computed payout rounds to zero
- In reward distribution loops, confirm that the remainder (undistributed dust) is handled

**False Positives**

- Protocols using `Math.mulDiv` with explicit `Math.Rounding.Ceil` or `Math.Rounding.Floor` from OpenZeppelin
- Systems where token amounts are large enough that rounding loss is economically irrelevant
- Fixed-point math libraries (PRBMath, ABDKMath) that handle precision internally

**Notable Historical Findings**

Napier's exchange rate manipulation finding combined rounding errors with first-depositor donation to amplify losses. Surge had two separate findings where `userCollateralRatioMantissa` calculations produced different results depending on operation order. Locke Finance's reward accumulator truncated to zero for small stakers and then cleared their accrued state, permanently losing their rewards. Rubicon's market `buy()` function allowed zero-cost purchases for low-decimal tokens when the spend calculation rounded to zero.

**Remediation Notes**

Use `Math.mulDiv(a, b, c, Math.Rounding.Ceil)` for any conversion that should round against the user (withdrawals, mints that take more assets). Use `Math.Rounding.Floor` for conversions that should round in the protocol's favor (deposits, redeems that give fewer assets). Never clear accrued reward state unless the transfer amount is confirmed non-zero. Add `+ 1` virtual asset and virtual share offsets to share conversion formulas as a combined inflation and rounding fix.

---

### Token Decimal Mismatch (fv-sol-2)

**Protocol-Specific Preconditions**

- Vault or strategy supports multiple tokens with different decimal precisions (USDC=6, WBTC=8, DAI=18)
- Balance aggregation mixes normalized (18-decimal) and raw token amounts in the same expression
- Price oracle returns values in a different decimal basis than token amounts
- LP token valuation code assumes both constituent tokens have the same decimals

**Detection Heuristics**

- Look for arithmetic that sums `balanceOf()` results from tokens with different `decimals()` values without normalization
- Check oracle price arithmetic: confirm the decimal basis of the returned price is accounted for when multiplying by token amounts
- Verify LP valuation functions account for per-token decimal offsets, not a single `POOL_PRECISION`
- Identify any controller or adapter that returns a balance in its own internal denomination that callers assume is in 18 decimals
- Check reward token decimal conversion: ICHI v1 (9 decimals) to v2 (18 decimals) type conversions require multiplying, not dividing

**False Positives**

- Protocols that enforce a strict 18-decimal whitelist and revert on token registration for non-conforming tokens
- Systems that normalize all amounts to a shared internal precision at the boundary and operate uniformly internally

**Notable Historical Findings**

yAxis Vault's `balance()` and `withdraw()` mixed normalized and raw amounts from a controller that returned USDC in 6-decimal basis alongside 18-decimal normalized balances, causing withdrawal amounts to be off by `1e12`. Blueberry's ICHI v2 farming calculation divided by `1e9` when it should have multiplied, delivering `1e18` fewer tokens to users. Notional's Curve vault under-valued or over-valued LP pool tokens for any constituent token with fewer than 18 decimals. Sense Finance's LP oracle required explicit 18-decimal enforcement.

**Remediation Notes**

Normalize all external token amounts to 18 decimals at the earliest point of entry using `amount * 10**(18 - token.decimals())`. Never mix normalized and raw amounts in the same accumulator. When aggregating across multiple tokens in a strategy, normalize each independently. For oracle-derived prices, confirm the returned value's precision matches the expected precision before use in any multiplication.

---

### Slippage, Sandwich, and Frontrunning Attacks (fv-sol-8)

**Protocol-Specific Preconditions**

- Protocol executes token swaps during harvest, compound, or rebalance operations
- `amountOutMinimum` or `min_dy` is set to zero or derived on-chain from the same pool state being manipulated
- Swap transactions are submitted to the public mempool
- `deadline` is set to `block.timestamp`, providing no protection against delayed inclusion

**Detection Heuristics**

- Search for `exactInputSingle`, `exchange`, `swap`, `exchange_underlying` calls with `amountOutMinimum: 0` or `min_dy: 0`
- Check if slippage is computed via an on-chain quoter call in the same transaction as the swap
- Look for keeper/compound functions that execute swaps without a caller-supplied `minAmountOut`
- Verify Balancer/Curve pool deposit/withdrawal functions pass per-token minimum amounts
- Check if `deadline: block.timestamp` is used (equivalent to no deadline)

**False Positives**

- Keeper functions with off-chain computed oracle-derived slippage bounds passed as parameters
- Swaps executing through private mempool relays (still risky but not frontrunnable via public mempool)
- Rebalancing where the swap amount is provably dust-level

**Notable Historical Findings**

Derby Finance had two separate HIGH findings for vault swaps executing with zero slippage protection. Redacted Cartel's `AutoPxGmx.compound` was callable by anyone with no slippage, enabling sandwich attacks on compounding operations. Notional Update had multiple slippage findings including a case where `minTokenAmounts_` was structurally ineffective due to configuration changes. Olympus Update's oracle-update sandwiching allowed an adversary to profit by depositing just before a favorable oracle update and withdrawing before an unfavorable one.

**Remediation Notes**

For user-facing functions, require caller-supplied `minAmountOut` and `deadline` parameters. For automated keeper functions, derive slippage bounds from a manipulation-resistant oracle (Chainlink TWAP) and apply a configurable `MAX_SLIPPAGE_BPS` tolerance. Never compute slippage from the same pool state that will execute the swap. Set `deadline` to `block.timestamp + N` for a meaningful N (e.g., 300 seconds) and revert on expiry.

---

### Reentrancy Vulnerabilities (fv-sol-1)

**Protocol-Specific Preconditions**

- Vault or pool accepts hookable tokens (ERC-777, ERC-721 with `onERC721Received`, ERC-1155)
- State updates (share minting, balance writes, flag flips) occur after external `transfer` or `transferFrom` calls
- No `nonReentrant` modifier on functions that combine external calls with state updates
- Guard patterns (pre/post execution hash checks) use state that can shift during execution

**Detection Heuristics**

- Identify external calls that precede state updates - the canonical violation of Checks-Effects-Interactions
- Check if hookable token standards are used or accepted as deposit tokens
- Look for `balanceOf(address(this))` pre/post measurement patterns without reentrancy guards
- Verify `collectFees` or multi-recipient transfer functions have `nonReentrant`
- Check that guard pre/post execution checks cannot be bypassed by incremented module counts or module additions during the guarded transaction

**False Positives**

- Contracts that interact exclusively with non-hookable tokens (standard ERC-20 with no callback)
- Functions protected by OpenZeppelin `ReentrancyGuard`'s `nonReentrant` modifier
- Code following strict CEI (all state updates complete before any external call)

**Notable Historical Findings**

Buffer Finance's `resolveQueuedTrades` transferred fee refunds before marking trades as non-queued, enabling ERC-777 re-entry to steal funds. Rubicon's `BathToken._deposit` allowed share inflation via re-entry from hookable tokens. Hats Protocol had two separate reentrancy paths enabling signers to add unauthorized safe modules by abusing re-entry during `checkAfterExecution`. Paladin's `MultiMerkleDistributor` could send or withdraw tokens multiple times through an inadvertent re-entry path.

**Remediation Notes**

Follow Checks-Effects-Interactions strictly: write all state changes before making any external call. Add `nonReentrant` to all vault entry points (`deposit`, `withdraw`, `redeem`, `mint`) and fee collection functions. When accepting ERC-777 tokens, recognize that `transferFrom` triggers `tokensToSend` on the sender before the transfer completes, enabling re-entry into any function that has not yet updated state.

---

### Access Control Bypass (fv-sol-4)

**Protocol-Specific Preconditions**

- An access-controlled function calls an internal helper that is also reachable via a separate unprotected path
- Permissionless functions (e.g., `notifyRewardAmount`) internally invoke privilege-requiring helpers (e.g., `_addRewardToken`)
- Cross-chain message handlers validate message content but not the caller (bridge) address
- Public approval or token-transfer functions on the contract itself have no caller restriction

**Detection Heuristics**

- For each internal helper that performs a privileged action, enumerate all external call sites and check each for access control
- Check `notifyRewardAmount` and similar permissionless functions for internal calls to `_addRewardToken` or equivalent
- Verify cross-chain receiver functions check both `msg.sender == trustedBridge` and the source-chain sender
- Search for `approve(token, address)` or similar functions callable by any address on contract-held tokens
- Check that `mintYieldFee`, `rebalance`, and similar protocol-maintenance functions have caller restrictions

**False Positives**

- Intentionally permissionless operations (liquidations, harvests, arbitrage) where any caller is acceptable and outcomes are bounded
- Functions with secondary validation (token whitelist checks) that prevent unauthorized parameter injection even without access control on the outer function

**Notable Historical Findings**

Alchemix's Bribe contract allowed anyone to add arbitrary reward tokens by calling `notifyRewardAmount`, which bypassed the gauge-only restriction on `addRewardToken` by calling `_addRewardToken` directly. PoolTogether's `Vault.mintYieldFee` was callable by any address to mint vault shares to any recipient. Derby Finance's cross-chain rebalance function authenticated the message content but not the bridge address, allowing crafted messages from any caller. Napier Finance had a permissionless path converting users' unclaimed yield without consent.

**Remediation Notes**

Apply access control at the internal helper level or refactor so that permissionless functions only operate on pre-approved tokens, not the token-addition path. For cross-chain receivers, validate both `msg.sender == trustedBridge` and the source-chain address via the bridge's authenticated message metadata. Never expose `approve` or token-management functions as public without access control.

---

### Denial of Service and Griefing (fv-sol-9)

**Protocol-Specific Preconditions**

- Protocol contains loops iterating over arrays that grow without a bounded maximum
- Attacker can cheaply inflate the array (dust delegations, dust NFTs, dust validator registrations)
- Critical path functions (liquidation, withdrawal, settlement) depend on external calls that can revert
- Protocol has a hard cap (e.g., `MAX_DELEGATES = 1024`) that can be filled by an attacker
- Batch operations do not use try-catch, so one failure blocks the entire batch

**Detection Heuristics**

- Search for unbounded `for` loops over user-controlled arrays in withdrawal, liquidation, or settlement functions
- Check for hard limits on delegations, modules, or validators and verify they cannot be exhausted by an attacker at low cost
- Look for critical functions whose only oracle dependency can revert (paused oracle, zero price)
- Verify that batch operations use try-catch or per-item error handling rather than atomically reverting
- Check if a `require(balance == expectedBalance)` check is vulnerable to dust donation causing permanent failure

**False Positives**

- Arrays bounded by a reasonable admin-controlled constant that is not user-influenceable
- Loops where each element's gas cost is trivially small and the array has an enforced maximum
- Systems with alternative execution paths or admin escape hatches for stuck operations

**Notable Historical Findings**

Velodrome Finance's delegation system allowed an attacker to fill `MAX_DELEGATES = 1024` with dust delegations, blocking the victim from receiving further delegations. Sense Finance's AutoRoller could be permanently bricked by a second AutoRoller deployed on the same adapter. Buffer Finance's `resolveQueuedTrades` atomically reverted if any single invalid trade signature was present, blocking resolution of all valid queued trades. Liquid Collective's vesting schedule was permanently broken by an attacker sending 1 wei to an escrow address, causing the `require(balance == totalAmount)` check to permanently fail.

**Remediation Notes**

Replace unbounded array iterations with paginated processing (`start`, `count` parameters). Use try-catch for batch operations. Replace exact-balance comparisons with internally tracked balance accounting that is immune to direct transfers. Impose minimum stake requirements to raise the cost of dust-based griefing. Ensure liquidation and oracle-dependent functions have fallback mechanisms or cached price circuits for oracle outages.

---

### Unsafe Token Handling (fv-sol-6)

**Protocol-Specific Preconditions**

- Protocol accepts arbitrary ERC-20 tokens including fee-on-transfer tokens
- `transferFrom` return values are not checked (raw calls without `SafeERC20`)
- `safeApprove` is called without first resetting allowance to zero (breaks USDT)
- Fee-on-transfer tokens: received amount is assumed to equal the requested transfer amount
- Solmate's `SafeTransferLib` is used without checking that the token address has code

**Detection Heuristics**

- Search for raw `IERC20(token).transfer(...)` and `IERC20(token).approve(...)` calls not wrapped in SafeERC20
- Look for `deposits[msg.sender] += amount` after `transferFrom` without a `balanceBefore / balanceAfter` measurement
- Check all `approve` calls for USDT-style tokens: confirm `approve(0)` precedes any non-zero approval
- Verify Solmate SafeTransferLib usage includes contract-existence validation on the token address
- Look for `allowance[owner][msg.sender] -= amount` that is missing after withdrawal by approved caller

**False Positives**

- Protocols that use OpenZeppelin SafeERC20 throughout and enforce a token whitelist excluding fee-on-transfer tokens
- Systems where the token address is deployed by the protocol itself and known to conform to standard ERC-20

**Notable Historical Findings**

Morpho's USDT mainnet market entered a broken state because the USDT approval path did not reset to zero first. Spartan Protocol had three ERC-20 handling findings in one audit including unchecked return values and allowance bypass. Beanstalk duplicated fees for fee-on-transfer tokens in `LibTransfer::transferFee`. Sushi's `swapCurve` was incompatible with tokens where `approve()` has no return value. Rubicon's `allowance()` function did not limit `withdraw()`, meaning any amount could be extracted regardless of the approved allowance.

**Remediation Notes**

Use OpenZeppelin's `SafeERC20` for all token interactions without exception. For fee-on-transfer token support, measure `balanceAfter - balanceBefore` to determine actual received amounts. For USDT-style approvals, call `safeApprove(spender, 0)` before setting a new non-zero allowance, or use `forceApprove`. Ensure `transferFrom`-based withdrawal functions decrement the caller's allowance after use.

---

### ERC Standard Non-Compliance (fv-sol-5)

**Protocol-Specific Preconditions**

- Contract claims ERC-4626, ERC-5095, ERC-721, or ERC-1155 compliance
- Function parameter usage deviates from the specification (e.g., `mint` transfers `shares` instead of `assets`)
- Rounding direction in ERC-4626 preview functions is opposite to what the spec requires
- Self-transfer handling in custom ERC-20 or ERC-1155 implementations uses cached balances

**Detection Heuristics**

- Compare `mint(shares, receiver)` implementation: must call `asset.transferFrom(msg.sender, address(this), assets)` using the `assets` amount returned by `previewMint`, not the `shares` argument
- Verify `previewWithdraw` rounds up (caller burns more shares) and `previewDeposit` rounds down
- Check ERC-1155 `_transfer` for the self-transfer case: `_balances[id][from] -= amount` followed by `_balances[id][to] += amount` where `from == to` overwrites the deduction
- Confirm `safeTransferFrom` is used rather than `transferFrom` for ERC-721 transfers to arbitrary addresses
- Verify `supportsInterface` returns correct interface IDs

**False Positives**

- Documented intentional deviations with no composability impact
- Contracts that do not claim the affected ERC standard and are not integrated by standard-assuming code

**Notable Historical Findings**

Tribe Finance's ERC-4626 `mint` transferred `shares` instead of `assets` as the deposit amount, allowing users to deposit fewer tokens than the shares they received. Trader Joe's ERC-1155 `_transfer` allowed self-transfers that doubled the caller's balance via cached pre-transfer values. Multiple ERC-721 staking contracts used `transferFrom` rather than `safeTransferFrom`, permanently locking NFTs when sent to contracts without `onERC721Received`. Sense Finance's AutoRoller had rounding directions inconsistent with ERC-4626 specification.

**Remediation Notes**

Implement ERC-4626 conversions using OpenZeppelin's base with explicit `Math.Rounding` arguments on every conversion. Add `if (from == to) return;` as the first line of any `_transfer` implementation. Use `safeTransferFrom` for all ERC-721 transfers where the recipient is not known to be an EOA. Run ERC-4626 property tests (e.g., Trail of Bits' ERC-4626 property test suite) as part of CI.

---

### Replay and Signature Vulnerabilities (fv-sol-4)

**Protocol-Specific Preconditions**

- Protocol uses off-chain signatures for migration, reward claiming, or meta-transactions
- Signed data omits nonce, chain ID, or contract address
- Merkle proof verifications do not track which leaves have been consumed
- `ecrecover` return value of `address(0)` is not checked (succeeds when `owner == address(0)`)

**Detection Heuristics**

- For every `ecrecover` or `ECDSA.recover` call, verify the signed message includes `chainId`, the contract address, and a nonce
- Check Merkle proof claim functions for a `isRedeemed[leaf]` mapping that is set before the claim is processed
- Verify that `permit`-style functions increment a nonce atomically with verification
- Check that `ecrecover` return value is explicitly compared to `address(0)` before use
- Look for signatures that are verified only by deadline - deadline alone does not prevent replay

**False Positives**

- Implementations using OpenZeppelin's `EIP712` with proper domain separator (includes `chainId` and `address(this)`)
- ERC-2612 `permit` with standard `nonces[owner]++` pattern
- Operations that are naturally idempotent and have no economic impact if replayed

**Notable Historical Findings**

Beanstalk's migration function accepted re-used Merkle proofs and re-used signatures without nonce tracking, allowing the same deposit claim to be replayed until the contract was drained. Rigor Protocol had both untyped data signing (raw `keccak256` without EIP-712 domain separator) and a replay path where a builder could call `Community.escrow` repeatedly with the same signature to reduce debt. Biconomy contained a full suite of EIP-712 failures including cross-chain replay, missing nonce, and `ecrecover` returning `address(0)` for uninitialized owners.

**Remediation Notes**

Use OpenZeppelin's `EIP712` base contract. Include `chainId`, `address(this)`, and an incrementing per-address `nonce` in every signed struct hash. For Merkle-based claims, maintain a `mapping(bytes32 => bool) public isRedeemed` and set it to `true` before crediting. Use `ECDSA.recover` from OpenZeppelin which reverts on invalid signatures rather than returning `address(0)`.

---

### Liquidation Logic Flaws (fv-sol-5)

**Protocol-Specific Preconditions**

- Protocol wraps an external lending protocol and must mirror its liquidation authorization logic
- Health factor calculations use stale borrow indexes cached by the wrapper rather than the live protocol value
- Liquidation path requires withdrawing from an external pool that may have zero liquidity
- Deprecated or LTV=0 assets from the underlying protocol are still counted as collateral in the wrapper

**Detection Heuristics**

- Verify that `accrueInterest()` on the underlying protocol is called before any health factor or liquidation threshold check
- Check if assets with `LTV == 0` in the underlying protocol are excluded from collateral valuation in the wrapper
- Test if liquidation can proceed when the underlying pool has zero liquidity for the collateral asset
- Confirm that pausing one function (e.g., repayments) does not leave liquidations enabled for positions that cannot self-remedy
- Verify that deprecated markets still allow liquidation of existing positions

**False Positives**

- Protocols with direct P2P matching that can liquidate without pool liquidity
- Systems where governance can force an index update before a liquidation campaign

**Notable Historical Findings**

Morpho-Compound used a stale `lastBorrowPoolIndex` cache for debt calculations, understating actual debt and preventing legitimate liquidations. Morpho-Aave's LTV=0 handling diverged from Aave's logic, leaving users with non-collateralizable assets still treated as collateral in Morpho. Liquidating a Morpho-Aave position advanced Morpho's state without a matching Aave update, permanently desyncing the two. Blueberry enabled liquidations while repayments were paused, trapping users who could not cure their positions.

**Remediation Notes**

Mirror the underlying protocol's liquidation authorization logic exactly, including e-mode category checks, oracle sentinel authorization, and LTV=0 handling. Always call the underlying `accrueInterest` before reading any derived state. Provide alternative liquidation paths that do not depend on pool liquidity (e.g., P2P collateral seizure). Gate liquidations to be disabled when repayments are also disabled.

---

### Missing Input Validation (fv-sol-5)

**Protocol-Specific Preconditions**

- Setter functions accept `address(0)`, `address(this)`, or out-of-range numeric values without reversion
- Constructors and initializers do not validate critical address or parameter arguments
- Fee parameters have no upper bound (can be set to 100%+ of user funds)
- Arrays that must maintain uniqueness (validator pubkeys, reward tokens) accept duplicates

**Detection Heuristics**

- Search for `function set*(address _x) external onlyOwner` patterns without `require(_x != address(0))`
- Check fee/rate setters for `require(_fee <= MAX_FEE)` bounds
- Verify `initialize` functions validate all address parameters
- Check array-append operations for duplicate prevention where uniqueness is required
- Confirm reward token registration functions cannot register the same token twice

**False Positives**

- `address(0)` used intentionally as a sentinel (e.g., burning to `address(0)`)
- Functions callable only by a multisig with its own off-chain validation layer
- Bounds enforced structurally by the type (e.g., `uint8` fee percentage that cannot exceed 255)

**Notable Historical Findings**

Liquid Collective's `LibOwnable._setAdmin` accepted `address(0)` as the admin, bricking governance. Velodrome Finance accepted duplicate veNFTs in voting checkpoints, inflating voting balances. Morpho's initializer omitted validation for several critical addresses, causing downstream failures when called with zero addresses. Archimedes Finance accepted positions with zero leverage and did not validate parameter precision, leading to fund loss on edge-case inputs.

**Remediation Notes**

Add `require(_addr != address(0), "zero address")` and `require(_addr != address(this), "self")` to all address setters. Bound all fee and rate parameters against a named constant (`MAX_FEE = 1000` for basis points). In initializers, validate every parameter before writing to state. Use a `mapping(bytes32 => bool) registered` guard to prevent duplicate entries in append operations.

---

### ETH Handling and Refund Issues (fv-sol-5)

**Protocol-Specific Preconditions**

- Contract uses `.transfer()` or `.send()` for ETH forwarding (2300 gas stipend fails for smart contract recipients)
- Payable functions that interact with WETH or ETH-denominated swaps do not refund excess `msg.value`
- WETH deposit/withdraw paths wrap a fixed amount but the caller sent more

**Detection Heuristics**

- Search for `payable(x).transfer(amount)` and `x.send(amount)` calls to arbitrary addresses
- Check all `payable` functions for surplus `msg.value` refund after exact-amount operations
- Verify WETH wrapping operations use `{value: exactAmount}` and refund `msg.value - exactAmount`
- Confirm swap functions that return unused ETH do so via `.call{value: ...}("")` with success check

**False Positives**

- `.transfer()` to known EOA addresses where the 2300 gas stipend is sufficient
- Payable functions that consume exactly `msg.value` by design (e.g., exact-ETH deposits)

**Notable Historical Findings**

Morpho's `repayWithETH` wrapped only the `debtAmount` but kept excess ETH in the contract permanently. Sushi's `wrapNative` unwrapped all native tokens regardless of what was requested. Rubicon's FeeWrapper failed to refund ETH from wrapped calls, with an attacker exploiting the retained balance. Multiple protocols used `.transfer()` to send ETH to contract recipients, causing unexpected failures when those recipients had logic in their `receive` functions.

**Remediation Notes**

Replace all `.transfer()` and `.send()` with `.call{value: amount}("")` and verify the return value. After any ETH-involving swap or WETH operation, compute `address(this).balance - preOperationBalance` and refund the surplus to the caller. Use `msg.value` tracking to ensure every wei sent in is either consumed or returned.

---

### Governance and Voting Flaws (fv-sol-5)

**Protocol-Specific Preconditions**

- Total community voting power is tracked as a redundant sum that must be kept in sync with per-user balances
- Delegation changes do not correctly update the total when re-delegating between two non-self addresses
- Unlock time or stake amount is not bounded, enabling artificial voting power inflation
- Proposals can be created or passed before meaningful token distribution occurs

**Detection Heuristics**

- Verify delegation logic updates total only when transitioning between self-delegation and external-delegation (not on re-delegation between two external delegates)
- Check that `unstake` uses the originally recorded voting power, not the current computed value
- Verify `_unlockTime` has an enforced maximum bound (`stakingSettings.maxStakeBonusTime`)
- Confirm `castVote` checks that the caller has non-zero voting power before accepting the vote
- Check that quorum calculations are not manipulable through delegation

**False Positives**

- Snapshot-based voting where power is frozen at proposal creation time
- DAOs with timelock and guardian veto that can block exploited proposals before execution

**Notable Historical Findings**

FrankenDAO had four HIGH governance findings simultaneously: total community voting power updated incorrectly on delegation, `unstake` applying current power rather than original staked power, unbounded `_unlockTime` enabling infinite voting power via `stakedTimeBonus`, and `_unstake` removing votes from `msg.sender` rather than the actual owner. Alchemix's governance accepted proposals below the proposal threshold through a spam path. Olympus DAO allowed any address to pass a proposal before the first VOTES tokens were minted.

**Remediation Notes**

Store `originalVotingPower[tokenId]` at stake time and use it exclusively during unstake. Enforce `unlockTime - block.timestamp <= maxStakeBonusTime`. Update the total community power only when crossing between self-delegated and externally-delegated states. Require `votingPower[msg.sender] > 0` in `castVote`. Use snapshotted balances for quorum calculations to prevent delegation manipulation.

---

### Upgradeable Contract Storage Gap (fv-sol-7)

**Protocol-Specific Preconditions**

- Contract uses a proxy upgrade pattern (UUPS, Transparent, Beacon)
- Base contracts in the inheritance hierarchy define storage variables without a `__gap` array
- Non-upgradeable versions of `Ownable` or other libraries are mixed into upgradeable contracts

**Detection Heuristics**

- Check each base contract in the inheritance chain for a `uint256[N] private __gap` storage reservation
- Verify that `OwnableUpgradeable` is used rather than `Ownable` in proxy-deployed contracts
- Confirm that `Initializable` is the base for all upgradeable contracts and `initialize` is called rather than a constructor
- Check if EIP-7201 namespaced storage is used as an alternative to gap arrays
- Verify that Diamond/EIP-2535 facets use explicit storage position pointers rather than sequential slots

**False Positives**

- Contracts not deployed behind a proxy
- Protocols that have committed to never adding storage variables to base contracts
- Diamond pattern contracts with storage positioned via explicit assembly slots

**Notable Historical Findings**

Notional's vault had a corruptible upgradeability pattern where base contract storage additions would shift all child contract storage slots. Covalent used the non-upgradeable `Ownable` in an upgradeable contract, leaving the owner as `address(0)` after proxy deployment. Biconomy's `SmartAccount` inherited from non-upgradeable contracts while intending to be upgradeable. Rubicon had missing storage gaps across multiple upgradeable base contracts.

**Remediation Notes**

Add `uint256[50] private __gap;` to every base contract in an upgradeable hierarchy, sized to bring each contract's total storage slot count to a round number. Use `OwnableUpgradeable`, `PausableUpgradeable`, and other OpenZeppelin upgradeable variants consistently. Run OpenZeppelin's `upgrades-core` plugin or Hardhat upgrades plugin storage layout diff checks in CI to catch accidental layout changes before deployment.

## reference/ton

```

```

## reference/ton/fv-ton-1-message-handling

```

```

## reference/ton/fv-ton-1-message-handling/fv-ton-1-cl1-transfer-notification-sender-validation.md

# FV-TON-1-CL1 Transfer Notification Sender Validation

## TLDR

`transfer_notification` (op `0x7362d09c`) handlers that do not verify `sender_address` against the stored Jetton wallet address allow attackers to fake token deposits by sending a crafted message from any contract.

## Detection Heuristics

**Missing sender check in notification handler**
- `recv_internal` branches on `op::transfer_notification` without `throw_unless(error::wrong_jetton_wallet, equal_slices(sender_address, jetton_wallet_address))`
- Handler reads `from_user` or `amount` from the notification body and credits balances without verifying who sent the message
- `jetton_wallet_address` never stored in contract data, making validation impossible

**Trusting payload body instead of sender identity**
- Code extracts a `depositor` or `from_user` field from the payload and uses it as proof of depositor - this field is attacker-controlled
- No call to `calculate_user_jetton_wallet_address()` to derive and compare the expected wallet address

**Multi-Jetton contracts with per-token wallet storage**
- Contract supports several Jetton types but only validates sender for some of them
- Wallet address stored for initialization but not re-checked on every notification

## False Positives

- Contract deliberately accepts notifications from any sender as a relay or aggregator with no balance-crediting logic
- Sender check present but uses a local helper function that wraps `equal_slices` - follow the call chain before concluding the check is absent

## reference/ton/fv-ton-1-message-handling/fv-ton-1-cl2-bounce-message-handling.md

# FV-TON-1-CL2 Bounce Message Handling

## TLDR

Contracts that send messages but lack a bounce handler - or whose handler misparses the bounced body - leave state permanently inconsistent when the sent message fails, because there is no rollback path.

## Detection Heuristics

**No bounce handler**
- `recv_internal` does not check `msg_flags & 1` (the bounced flag) before dispatching
- State (balance credits, debt entries) is committed before `send_raw_message` with no matching recovery opcode
- Jetton `internal_transfer` sent but no handler for `op::internal_transfer` bounce

**Bounce handler present but incorrectly parsed**
- Handler does not skip the 32-bit `0xFFFFFFFF` prefix before extracting the original opcode: `int op = cs~load_uint(32)` without first calling `cs~load_uint(32)` to discard the bounce prefix
- Handler reads the opcode from the wrong position, dispatches to wrong branch, and silently ignores the bounce
- Handler catches the bounce opcode but does not revert the corresponding state change

**State committed before the send**
- `set_data()` called with updated balances/credits before `send_raw_message` - if the message bounces and the handler is missing, the state update is permanent

## False Positives

- Message sent with non-bounceable flag (`store_uint(0x10, 6)`) to a user wallet - such messages never bounce, so a bounce handler is not required
- Contract sends fire-and-forget notifications (logs) where bounce recovery is intentionally not needed and documented as such

## reference/ton/fv-ton-1-message-handling/fv-ton-1-cl3-deserialization-and-end-parse.md

# FV-TON-1-CL3 Deserialization and end_parse

## TLDR

Omitting `end_parse()` after deserializing a message or storage slice silently ignores trailing bytes, masking injected extra data, storage corruption, or format version mismatches.

## Detection Heuristics

**Missing end_parse after message body read**
- `in_msg_body~load_uint(32)` (opcode), followed by field reads, with no final `in_msg_body.end_parse()` or `in_msg_body~end_parse()`
- Handler returns or continues after all expected fields are read without verifying the slice is empty

**Missing end_parse after storage load**
- `get_data().begin_parse()` sequence that loads all fields but does not end with `end_parse()`
- Storage loaded into a slice variable reused across multiple handlers, each reading some fields - last handler does not verify exhaustion

**Bit/ref layout mismatch**
- `store_uint(x, N)` on the sending side but `load_uint(M)` where `M ≠ N` on the receiving side
- References stored in a different order than they are loaded, causing field-shift bugs
- Optional fields (present/absent conditionally) handled inconsistently between writer and reader

## False Positives

- `end_parse()` absent but the slice is provably empty after all reads due to a fixed-length format with no variable-length fields - confirm this holds for all code paths, including future versions
- Forward payload slices intentionally passed through to another contract unmodified, where the downstream contract performs its own validation

## reference/ton/fv-ton-1-message-handling/fv-ton-1-cl4-opcode-and-address-handling.md

# FV-TON-1-CL4 Opcode and Address Handling

## TLDR

Unhandled or silently accepted unknown opcodes, incorrect bounceable/non-bounceable address flags, and missing workchain validation each create distinct attack surfaces in message routing.

## Detection Heuristics

**Missing unknown-opcode rejection**
- `recv_internal` dispatch ends with `if / elseif` branches but no `else { throw(error::unknown_op); }` or equivalent
- Opcode 0 (plain TON transfer with no body) not handled separately - falls through to functional message handling
- Contract accepts and partially processes messages it does not understand, potentially changing state based on partial reads

**Incorrect bounceable/non-bounceable flag**
- Address prefix `store_uint(0x18, 6)` (bounceable) used when sending to undeployed user wallets - message bounces, funds returned unexpectedly
- Address prefix `store_uint(0x10, 6)` (non-bounceable) used when sending to other contracts - losing the safety net of bounce recovery on failure

**Missing workchain validation**
- Incoming `sender_address` stored or compared without calling `force_chain(WORKCHAIN)` or extracting and checking the workchain prefix
- Address passed to child contract deployment without workchain check - contract deployed in wrong workchain
- `equal_slices` comparison between addresses from different workchains silently returns false, bypassing authorization

## False Positives

- Contract deliberately acts as a passthrough or router and intentionally accepts all opcodes, forwarding them downstream
- Workchain is enforced at a higher level (deployer or factory) and all addresses in storage are guaranteed to be in the correct workchain

## reference/ton/fv-ton-1-message-handling/readme.md

---
description: Validate message entry points, sender identity, bounce handling, and serialization correctness in FunC/Tact contracts.
---

# FV-TON-1 Message Handling

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-1-message-handling/<filename>` to read any case file listed below.

#### fv-ton-1-cl1-transfer-notification-sender-validation.md
#### fv-ton-1-cl2-bounce-message-handling.md
#### fv-ton-1-cl3-deserialization-and-end-parse.md
#### fv-ton-1-cl4-opcode-and-address-handling.md

## reference/ton/fv-ton-2-access-control

```

```

## reference/ton/fv-ton-2-access-control/fv-ton-2-cl1-recv-external-accept-ordering.md

# FV-TON-2-CL1 recv_external Accept Message Ordering

## TLDR

Calling `accept_message()` before validating the external message signature and sequence number lets attackers drain the contract's TON balance by flooding it with invalid external messages - each accepted call charges gas from the contract.

## Detection Heuristics

**accept_message before validation**
- `accept_message()` appears as the first or near-first statement in `recv_external`, before any `check_signature()` or `throw_unless(seqno == stored_seqno)` call
- Sequence: `accept_message()` → parse body → validate → use; the safe sequence is: parse → validate → `accept_message()` → execute

**Unconditional acceptance**
- `recv_external` that calls `accept_message()` on every message regardless of content or signature
- No signature variable or public key loaded from storage - validation is entirely absent

**Expensive computation before acceptance gate**
- Even if `accept_message()` is after some code, if that code involves expensive cell operations (large dictionary reads, recursive unpacking) before the validation throw, the gas cost is still charged on failure

## False Positives

- Contract intentionally accepts external messages without signature (e.g., a permissionless trigger) and the only effect is an idempotent state change with no fund movement - document the design intent clearly
- `accept_message()` placed before a computationally trivial check (single integer comparison) where the gas exposure is negligible by design

## reference/ton/fv-ton-2-access-control/fv-ton-2-cl2-replay-protection.md

# FV-TON-2-CL2 Replay Protection

## TLDR

External message handlers without sequence number validation, or with seqno incremented after execution rather than before, allow valid signed messages to be replayed indefinitely - re-executing transfers, withdrawals, or any privileged action.

## Detection Heuristics

**Missing seqno check**
- `recv_external` does not load or compare `seqno` from the message body against the stored value
- No `throw_unless(error::bad_seqno, msg_seqno == stored_seqno)` call
- Sequence number stored in c4 but not loaded via `load_data()` before use

**Seqno incremented after execution**
- Seqno updated and saved via `set_data()` at the end of the handler - if execution throws after the main action but before `set_data()`, the replay window remains open
- Safe pattern: load → validate → `accept_message()` → increment seqno → `set_data()` → execute actions

**Internal message replay**
- Internal messages that trigger privileged one-time actions (e.g., claim, initialize) have no idempotency mechanism - no nonce, no "already processed" flag in a dictionary
- Same signed external message deliverable across restarts or forks if valid_until is not checked

## False Positives

- Contract is a stateless relay that processes every incoming message identically - replay is intentional by design
- Seqno check present in an inlined helper function; follow the call to confirm it executes before `accept_message()`

## reference/ton/fv-ton-2-access-control/fv-ton-2-cl3-admin-authorization.md

# FV-TON-2-CL3 Admin Authorization

## TLDR

Administrative operations (parameter updates, fund withdrawals, pausing, upgrading) that lack a sender address check against the stored admin/owner address allow any contract or wallet to invoke privileged functions.

## Detection Heuristics

**Missing sender validation on privileged opcodes**
- Handler for ops like `op::change_admin`, `op::withdraw`, `op::upgrade`, `op::set_params` has no `throw_unless(error::not_owner, equal_slices(sender_address, admin_address))`
- `admin_address` loaded from storage but the comparison is skipped or commented out
- Authorization check present only on some admin ops but not all - inconsistent coverage

**No admin transfer mechanism or insecure one**
- No `op::change_admin` or `op::transfer_ownership` handler - admin key loss permanently locks privileged functions
- Admin address change in a single step with no pending/confirm pattern - sending to a wrong address is irreversible
- Admin address stored in code cell (c3) instead of data cell (c4), requiring a full code upgrade to change it

**Tact contracts**
- Tact contract uses `Ownable` trait but one or more `receive()` handlers for administrative messages do not call `self.requireOwner()` before executing

## False Positives

- Contract is intentionally permissionless for the audited operation; confirm this is by design and there is no way to escalate to fund extraction
- Admin check implemented via a helper function - verify the helper performs `equal_slices(sender_address, admin_address)` and throws on mismatch

## reference/ton/fv-ton-2-access-control/fv-ton-2-cl4-initialization-security.md

# FV-TON-2-CL4 Initialization Security

## TLDR

Contracts whose initialization function is callable by anyone, or re-callable after first deployment, can have their entire state overwritten - including the admin address - by an attacker who sends the init opcode.

## Detection Heuristics

**Initialization callable by anyone**
- Handler for the initialization opcode does not verify `sender_address` is the deployer or a factory contract
- No check that the message includes the original StateInit (which would tie deployment to initialization)
- Init data (admin address, configuration) accepted from the message body without any authorization gate

**Re-initialization possible**
- No `is_initialized` boolean flag in storage - contract does not record that initialization already occurred
- Second call to the init handler overwrites `admin_address`, `total_supply`, or critical configuration with attacker-supplied values
- `set_data()` in the init handler executable any number of times

**State accessible through standard opcodes post-deployment**
- Contract does not separate initialization from normal operation opcodes - a crafted message with the init opcode triggers re-initialization during live operation

## False Positives

- Contract uses a factory pattern where only the factory can deploy and the factory address is hardcoded or derived via a hash - confirm the factory itself enforces one-time initialization
- Re-initialization intentionally allowed but gated behind admin authorization and limited to non-critical parameters

## reference/ton/fv-ton-2-access-control/readme.md

---
description: Prevent unauthorized access, replay attacks, and insecure contract initialization.
---

# FV-TON-2 Access Control

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-2-access-control/<filename>` to read any case file listed below.

#### fv-ton-2-cl1-recv-external-accept-ordering.md
#### fv-ton-2-cl2-replay-protection.md
#### fv-ton-2-cl3-admin-authorization.md
#### fv-ton-2-cl4-initialization-security.md

## reference/ton/fv-ton-3-arithmetic-errors

```

```

## reference/ton/fv-ton-3-arithmetic-errors/fv-ton-3-cl1-integer-as-boolean.md

# FV-TON-3-CL1 Integer as Boolean

## TLDR

FunC represents true as `-1` (all bits set) and false as `0`. Code that stores `1` for true and uses bitwise NOT (`~`) to invert it produces `-2`, which is truthy - silently executing the branch that was intended for the false/inactive case.

## Detection Heuristics

**Non-canonical boolean storage**
- `int flag = 1;` or `load_uint(1)` returning `0`/`1` used directly in boolean logic with `~`
- `if (~ is_active)` where `is_active` may be `1` - `~1 == -2`, which is truthy, so the inactive branch always executes when `is_active = 1`
- Function returning `1` to indicate success, later called with `~ result` to check for failure

**Pattern requiring canonical normalization**
- `load_uint(1)` returns `0` or `1`; converting to FunC boolean requires `int b = -(cs~load_uint(1));` (negating maps `1 → -1`, `0 → 0`)
- Absence of this normalization before any `~`, `&`, or `|` operation on the loaded value

**Constants defined incorrectly**
- `const int TRUE = 1;` instead of `const int TRUE = -1;`
- Boolean return values from helper functions not annotated or reviewed for canonicality

## False Positives

- Variable is used only in `if (flag)` checks (truthy test) and never with `~` - non-canonical value is safe in this usage
- Value is `load_uint(1)` result used only with `== 0` or `== 1` comparisons, not with bitwise operators

## reference/ton/fv-ton-3-arithmetic-errors/fv-ton-3-cl2-signed-unsigned-confusion.md

# FV-TON-3-CL2 Signed Unsigned Integer Confusion

## TLDR

`load_int()` can return negative values; using it for amounts, balances, or sizes that must be non-negative allows a caller to supply a negative value that bypasses positive-amount checks and causes incorrect arithmetic.

## Detection Heuristics

**load_int used for financial amounts**
- `int amount = in_msg_body~load_int(256);` for a transfer or deposit amount - negative amounts can pass `amount > 0` checks if the comparison is done before the signedness is noticed
- `int size = cs~load_int(32);` used as a loop bound or allocation size

**Missing range validation after load**
- No `throw_unless(error::invalid_amount, amount > 0)` or `throw_unless(error::invalid_amount, amount >= MIN_AMOUNT)` after loading
- No upper bound check - extremely large values can cause downstream overflow

**store_uint width mismatch**
- Value computed as signed 257-bit integer then packed with `store_uint(amount, 64)` - if `amount > 2^63`, it silently truncates
- Storing a 120-bit coins value in a 64-bit field via `store_uint` loses the high bits; `store_coins` uses variable-length encoding and avoids this

## False Positives

- `load_int` used for a field that is genuinely signed by protocol design (e.g., a signed delta) with subsequent sign-aware arithmetic
- Truncation is safe because the protocol enforces an invariant upstream that the value fits in the target width

## reference/ton/fv-ton-3-arithmetic-errors/fv-ton-3-cl3-overflow-and-truncation.md

# FV-TON-3-CL3 Overflow and Truncation

## TLDR

While TVM 257-bit integer arithmetic throws on overflow by default, intermediate calculations involving `store_uint`/`load_uint` with smaller widths silently truncate, and unbounded accumulators can overflow before being packed.

## Detection Heuristics

**store_uint truncation on intermediate results**
- `store_uint(a * b, 64)` where the product of `a` and `b` can exceed 2^64 - the multiplication succeeds on the 257-bit TVM stack but truncates on pack
- Accumulator pattern: `total += amount` across many iterations where `total` is later packed with a fixed bit-width smaller than the accumulated range

**Multiplication before bounds checking**
- `int result = price * qty;` before any validation of `price` or `qty` - if either is user-controlled and large, the product overflows 257 bits (though rare given 257-bit size, it's possible with loop accumulation)
- No early rejection of zero-value or maximum-value inputs before entering arithmetic

**exit code collision**
- Custom `throw()` codes in range 0–127, reserved by TON for system exit codes - confuses error handling and debugging tooling
- Tact contracts using error codes 128–255, reserved by the Tact runtime

## False Positives

- Arithmetic result bounded by a protocol invariant (e.g., total supply cap, max deposit) that makes overflow mathematically impossible - document and verify the invariant
- `store_uint` width matches the type's actual range precisely and the compiler or prior check ensures no value exceeds it

## reference/ton/fv-ton-3-arithmetic-errors/fv-ton-3-cl4-precision-and-rounding.md

# FV-TON-3-CL4 Precision and Rounding

## TLDR

Division performed before multiplication, rounding consistently in the user's favor, and precision loss in reward accumulators are systematic value-extraction vectors in TON DeFi contracts.

## Detection Heuristics

**Division before multiplication**
- `fee = amount / 100 * fee_rate` instead of `fee = amount * fee_rate / 100` - integer division truncates before the multiplication, losing up to `fee_rate - 1` units per call
- `shares = deposit / total_tokens * total_supply` instead of `shares = deposit * total_supply / total_tokens`

**Rounding direction favoring attacker**
- Deposit share calculation rounds UP (user gets more shares than their deposit warrants)
- Withdrawal token calculation rounds UP (user gets more tokens than their shares warrant)
- Both rounding in the user's favor enables round-trip profit: deposit → immediate withdraw → net gain

**Precision loss in reward accumulators**
- `reward_per_token += reward_amount / total_staked` with no precision multiplier - if `reward_amount < total_staked` the accumulator never increments, small stakers receive zero rewards forever
- Absence of a scaling factor (e.g., multiply numerator by `1_000_000_000` before dividing) in the accumulator update

**Division by zero**
- Divisor sourced from storage or message body without `throw_unless(error::zero_divisor, divisor > 0)`
- `total_supply` or `total_staked` can be zero on first deposit or after full withdrawal - pool calculations fail or produce infinity

## False Positives

- Rounding direction is intentional and documented, consistently rounds in protocol's favor (never user's favor for both directions simultaneously)
- Precision multiplier present but implemented as a named constant - verify the constant is applied

## reference/ton/fv-ton-3-arithmetic-errors/readme.md

---
description: Prevent integer errors, boolean logic inversion, precision loss, and rounding exploitation in FunC/Tact arithmetic.
---

# FV-TON-3 Arithmetic Errors

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-3-arithmetic-errors/<filename>` to read any case file listed below.

#### fv-ton-3-cl1-integer-as-boolean.md
#### fv-ton-3-cl2-signed-unsigned-confusion.md
#### fv-ton-3-cl3-overflow-and-truncation.md
#### fv-ton-3-cl4-precision-and-rounding.md

## reference/ton/fv-ton-4-gas-and-storage

```

```

## reference/ton/fv-ton-4-gas-and-storage/fv-ton-4-cl1-forward-ton-amount-validation.md

# FV-TON-4-CL1 Forward TON Amount Validation

## TLDR

A user-controlled `forward_ton_amount` used directly in `send_raw_message` without validating it against `msg_value` lets attackers specify a large forward amount while sending minimal gas - the contract covers the difference from its own balance.

## Detection Heuristics

**Unbounded user-controlled forward amount**
- `int forward_ton_amount = in_msg_body~load_coins();` used directly as the forward amount in an outgoing send without any upper bound check
- No assertion that `msg_value >= tx_fees + forward_ton_amount` before sending
- Contract uses send mode 1 (pay fees from contract balance) with a user-controlled forward amount

**Indirect drain patterns**
- A chain of messages is initiated with user-supplied amounts at each hop - cumulative forward costs drain the contract
- Fixed forward amounts that were safe at deployment but become insufficient as gas costs change, causing the contract to silently subsidize the difference

**Secure patterns to look for (confirm they are present)**
- Fixed constant for `forward_ton_amount` that cannot be overridden by message body
- `throw_unless(error::insufficient_value, msg_value > tx_fee + forward_ton_amount)` before any send
- Send mode 64 (return remaining incoming value) used - automatically limits forward to what was received

## False Positives

- Contract deliberately subsidizes forward fees as a product feature, with an explicit per-call maximum enforced to cap the subsidy
- `msg_value` validation happens in a wrapper function called before the forward amount is used - trace the full call path

## reference/ton/fv-ton-4-gas-and-storage/fv-ton-4-cl2-send-mode-flags.md

# FV-TON-4-CL2 Send Mode Flags

## TLDR

Incorrect `send_raw_message` mode flags can drain the contract's balance (mode 128 without reserve), cause the contract to pay gas for user operations (mode 1 with user-controlled amounts), mask critical send failures (mode +2), or accidentally destroy the contract (mode +32).

## Detection Heuristics

**Mode 128 without prior raw_reserve**
- `send_raw_message(msg, 128)` carries the entire remaining contract balance - if `raw_reserve(MIN_STORAGE, RESERVE_REGULAR)` is not called first, the contract is left with zero balance and freezes
- Mode 128 combined with mode +32 (`send_raw_message(msg, 160)`) intentionally destroys the contract - verify this is not reachable via user-triggered paths

**Mode 1 with user-controlled amounts**
- `send_raw_message(msg, 1)` instructs the VM to deduct send fees from the contract balance rather than the message value - if the message value is user-controlled and can be zero, the contract pays all fees
- Pattern: user sends 0 TON → contract builds message with mode 1 → contract pays the gas

**Mode +2 masking failures**
- `send_raw_message(msg, 2)` or `send_raw_message(msg, 3)` silently ignores send errors - if the send fails (insufficient balance, oversized message), execution continues as if it succeeded, leaving state inconsistent
- Used in non-critical notification paths but applied broadly to all sends

**Missing mode flag**
- `send_raw_message(msg, 0)` (or implicit default) when mode 64 or 128 is semantically required - message sends a fixed amount but remaining value is not forwarded

## False Positives

- Mode 128 with mode +32 is used in a self-destructing cleanup contract where destruction is the intended terminal state, gated behind admin authorization
- Mode +2 used only for optional notification messages where failure is acceptable by protocol design and no state change depends on the send succeeding

## reference/ton/fv-ton-4-gas-and-storage/fv-ton-4-cl3-storage-fee-exhaustion.md

# FV-TON-4-CL3 Storage Fee Exhaustion

## TLDR

TON contracts pay continuous storage fees. Without minimum-balance reserves, operations that reduce the balance to near-zero cause the contract to be frozen - permanently inaccessible until someone sends TON to unfreeze it.

## Detection Heuristics

**No raw_reserve before sends**
- `send_raw_message` calls throughout the contract without a preceding `raw_reserve(MIN_TON_FOR_STORAGE, RESERVE_REGULAR)` that protects the minimum viable balance
- Withdraw or refund operations that compute `amount = my_balance - fees` without subtracting a storage reserve

**Mode 128 sends without reserve**
- Sending with mode 128 (all remaining balance) without first reserving storage fees via `raw_reserve` - balance hits zero, storage phase fails on next block, contract freezes

**Unbounded dictionary growth**
- `udict_set` / `dict_set` accumulating entries on every user interaction without a `MAX_ENTRIES` check - growing state increases storage fees proportionally, eventually making the contract economically unviable
- No cleanup or expiry mechanism for old dictionary entries

**Long-lived contracts without storage budget**
- Contract expected to be live for months or years but no storage fee analysis was performed - initial TON balance insufficient to sustain storage fees over the protocol's lifetime

## False Positives

- Contract is short-lived by design (single-use escrow, one-time action) and the deployer accounts for the total storage cost at deployment
- Storage reserve implemented in a shared helper function called at the start of every handler - verify it is called on all execution paths

## reference/ton/fv-ton-4-gas-and-storage/fv-ton-4-cl4-gas-draining-and-loops.md

# FV-TON-4-CL4 Gas Draining and Unbounded Loops

## TLDR

Spam of invalid external messages drains contract balance via repeated `accept_message()` calls, and unbounded iteration over dictionaries or user-controlled data structures exhausts the transaction gas limit, leaving state inconsistent.

## Detection Heuristics

**External message gas draining**
- `accept_message()` called before signature validation or seqno check - every incoming external message (valid or not) charges gas from the contract balance
- No minimum-balance enforcement before `accept_message()`, allowing the contract to be drained to zero through spam

**Unbounded loop over dictionary**
- `while` or `do ... until` loop iterating over a dictionary without a `MAX_ITERATIONS` limit
- `dict_get_next` / `udict_get_next` in a loop that grows with user input - attacker adds entries to force expensive iteration
- Batch operations (distribute rewards to all holders, update all positions) that process the entire dictionary in one transaction

**Missing dust amount rejection**
- No `throw_unless(error::amount_too_small, amount >= MIN_VIABLE_AMOUNT)` - processing economically insignificant amounts costs more in gas than the operation is worth, enabling griefing via flood of dust transactions

**Recursive cell structures**
- TVM stack overflow from recursive cell unpacking or deeply nested message parsing - cells-within-cells exceeding max depth of 256 or stack depth limit

## False Positives

- Loop is bounded by a hard-coded constant or a protocol invariant that limits the maximum number of dictionary entries
- External message handler has a cheap seqno check as the very first operation before `accept_message()` - gas exposure per invalid message is minimal and bounded

## reference/ton/fv-ton-4-gas-and-storage/readme.md

---
description: Protect against gas draining, incorrect send modes, and storage fee exhaustion that can freeze TON contracts.
---

# FV-TON-4 Gas and Storage

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-4-gas-and-storage/<filename>` to read any case file listed below.

#### fv-ton-4-cl1-forward-ton-amount-validation.md
#### fv-ton-4-cl2-send-mode-flags.md
#### fv-ton-4-cl3-storage-fee-exhaustion.md
#### fv-ton-4-cl4-gas-draining-and-loops.md

## reference/ton/fv-ton-5-async-execution

```

```

## reference/ton/fv-ton-5-async-execution/fv-ton-5-cl1-async-reentrancy.md

# FV-TON-5-CL1 Async Reentrancy

## TLDR

TON's actor model enables reentrancy through message ordering rather than call stacks: a contract sends a message and another message arrives before the callback, reading and modifying state between the send and the response - each as separate committed transactions.

## Detection Heuristics

**No operation-in-progress lock**
- Contract updates state, sends a message, and expects a callback, but no "processing" or "locked" flag prevents other messages from modifying the same state while awaiting the response
- Pattern: user A calls → state updated → message sent to B → user B (or A again) calls before callback arrives → state modified again → callback processes stale expectations

**Callback handler reads pre-send state**
- Callback or bounce handler re-reads storage but applies logic based on values captured before the original send - those values may have changed between the send and the callback
- No re-validation of preconditions (e.g., balance still sufficient, position still open) in the callback path

**Multi-message operation without sequence enforcement**
- Contract initiates a chain A→B→C and acts on A's state assumption, but B or C can be overtaken by other messages targeting A's state

## False Positives

- Contract uses a per-user processing flag stored in a dictionary and only one operation per user can be in flight at a time - verify the flag is set before the send and cleared in both success and bounce paths
- The state being protected cannot be modified by any other message (e.g., it is keyed to the in-flight operation ID) - trace all write paths to confirm mutual exclusion

## reference/ton/fv-ton-5-async-execution/fv-ton-5-cl2-race-conditions-and-ordering.md

# FV-TON-5-CL2 Race Conditions and Message Ordering

## TLDR

Message ordering between different source contracts is not guaranteed on TON. Only messages from the same sender to the same receiver maintain order via logical time - cross-contract flows can interleave unpredictably, causing race conditions on shared mutable state.

## Detection Heuristics

**Shared state modified by independent flows**
- Two users can simultaneously send messages that both read and write the same contract state (e.g., a shared pool balance or auction slot) - whichever arrives second may overwrite the first without seeing its update
- First-come-first-served logic (claim a slot, win an auction) with no locking mechanism

**Cross-contract ordering assumptions**
- Contract expects message from A to arrive before message from B (based on initiation order), but both are from different senders to the same contract - order is validator-dependent
- Setup or configuration message assumed to arrive before operational messages in multi-contract initialization sequences

**Balance-check race**
- Two concurrent withdrawal requests both pass the balance check before either deducts - double-spend pattern via race condition on the same balance field
- No "pending withdrawal" entry or lock to prevent concurrent claims

**raw balance usage**
- `my_balance` or raw contract balance (manipulable by any sender) used for business logic - attacker sends TON directly to inflate balance before triggering a balance-dependent path

## False Positives

- All writes to the shared state go through a single serialized queue or use a lock that is set and checked atomically in the same message handler
- The state is per-user (keyed by sender address) and no two users can affect each other's entries

## reference/ton/fv-ton-5-async-execution/fv-ton-5-cl3-stale-state-and-partial-execution.md

# FV-TON-5-CL3 Stale State and Partial Execution

## TLDR

In multi-message operations, each sent message creates a separate committed transaction. If any message in the chain fails (bounces or runs out of gas), prior state changes are already committed and require explicit rollback via bounce handling - silence means permanent inconsistency.

## Detection Heuristics

**State committed before multi-message chain completes**
- `set_data()` called after the first successful step in a multi-step operation - if subsequent messages in the chain fail and bounce handlers are absent, the state reflects a partial completion
- Example: debit sender → send credit message to receiver → if credit bounces without handler, sender is debited with no credit issued

**Callback reads cached pre-send state**
- Callback or bounce handler uses local variables captured at the time of the original send rather than re-reading from c4 - if another message modified storage between the send and the callback, the handler operates on stale data
- `get_data()` not called at the start of the bounce handler

**throw after send**
- Developer expects a `throw` after `send_raw_message` to cancel the send - in TVM, a throw during the compute phase reverts the action list (messages queued) along with c4, but this only applies within the current transaction; messages dispatched by already-completed transactions are irreversible
- Relying on throw-based rollback in multi-transaction flows

**Dangling storage references**
- `set_data()` called, then the old slice from before `set_data()` is read again - the old slice is stale and does not reflect the saved state

## False Positives

- Every outgoing message in the chain has a corresponding bounce handler that fully reverts the state change associated with that message
- Partial execution is an accepted protocol state (idempotent operation) and the system self-heals on retry

## reference/ton/fv-ton-5-async-execution/readme.md

---
description: Identify vulnerabilities arising from TON's asynchronous actor model - async reentrancy, race conditions, stale state, and message ordering assumptions.
---

# FV-TON-5 Async Execution

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-5-async-execution/<filename>` to read any case file listed below.

#### fv-ton-5-cl1-async-reentrancy.md
#### fv-ton-5-cl2-race-conditions-and-ordering.md
#### fv-ton-5-cl3-stale-state-and-partial-execution.md

## reference/ton/fv-ton-6-contract-lifecycle

```

```

## reference/ton/fv-ton-6-contract-lifecycle/fv-ton-6-cl1-set-code-and-state-migration.md

# FV-TON-6-CL1 set_code and State Migration

## TLDR

`set_code()` takes effect only for subsequent transactions, not the current one. Code upgrades without a matching storage migration leave the new code reading old data in the wrong format - silently corrupting balances, access control, and configuration.

## Detection Heuristics

**Logic after set_code expecting new code**
- Code placed after `set_code()` in the same transaction assumes the new logic is active - the current transaction continues executing the old code through to completion
- Initialization steps intended for the new contract version run under old code semantics

**Missing storage migration**
- `set_code()` without a corresponding `set_data()` that converts the storage layout to the format expected by the new code
- New code adds a field or changes field ordering but old data remains in storage - new code reads fields from wrong offsets, producing incorrect values
- No `version` field in storage to allow the new code to detect format and migrate on first run

**Upgrade without authorization or timelock**
- `set_code()` reachable without admin/owner `equal_slices` check
- No governance requirement or multi-sig for upgrades
- Upgrade executes immediately with no delay for users to review and optionally exit

## False Positives

- Upgrade mechanism intentionally designed as two-step: first transaction sets new code, second transaction (under new code) performs migration - verify the migration step is implemented and cannot be skipped
- Storage layout is identical between old and new code; the only changes are logic - confirm this by comparing all `load_*` / `store_*` sequences in both versions

## reference/ton/fv-ton-6-contract-lifecycle/fv-ton-6-cl2-contract-deployment-stateinit.md

# FV-TON-6-CL2 Contract Deployment and StateInit

## TLDR

Child contracts deployed without StateInit are never created, causing messages to bounce or funds to be lost. Address computation using different code or data than the actual StateInit sends messages to the wrong address - which may be an attacker-controlled contract.

## Detection Heuristics

**Missing StateInit in deploy message**
- Message intended to deploy a child contract (Jetton wallet, sub-account) is missing the `state_init` flag bit or the StateInit cell in the message layout
- `store_uint(1, 1)` + StateInit cell absent from the `begin_cell()` message builder
- Init data cell does not match the format the child contract expects on first execution

**Address computation mismatch**
- `calculate_address()` uses different code or data than what is actually sent in the StateInit - the computed address and the deployed address differ
- `cell_hash(state_init)` not used as the address hash, or workchain ID prepended incorrectly
- Contract stores a "derived" address but derives it differently from how the child contract was actually deployed - `equal_slices` checks against this address never match

**Sending to uninitialized accounts**
- Message sent to an address that may not have a deployed contract, using a bounceable flag - message bounces if the account is uninitialized
- Operational (non-deploy) message sent to a calculated address before confirming the child is deployed

## False Positives

- Child contract deployment is one-time and the address is verified off-chain or via a getter before operational messages are sent - confirm the getter is called and the result validated
- StateInit computation matches what is documented in the protocol spec; verify by tracing `cell_hash` of the sent StateInit against the stored expected address

## reference/ton/fv-ton-6-contract-lifecycle/fv-ton-6-cl3-balance-logic-and-self-destruct.md

# FV-TON-6-CL3 Balance-Based Logic and Self-Destruct

## TLDR

Using the contract's raw TON balance for business decisions is manipulable since anyone can send TON to any contract. Send mode flag +32 accidentally included in non-terminal paths destroys the contract permanently when its balance reaches zero.

## Detection Heuristics

**Balance-based logic**
- `my_balance` or `get_balance()` used as a condition for unlocking features, determining pool size, or computing share prices - an attacker can inflate the balance by sending TON directly
- Protocol accounting uses raw balance instead of an internally tracked `total_deposits` variable
- Fee calculations, liquidation thresholds, or rate calculations derived from the raw contract balance

**Accidental mode +32 reachability**
- `send_raw_message(msg, 160)` (128 + 32) present in a code path reachable by non-admin callers
- Mode +32 used in refund logic or error recovery paths where the intent was to send remaining balance, not destroy the contract
- Contract balance can be reduced to zero through user-triggered operations, activating mode +32 destruction

**Missing global variable initialization**
- FunC handler uses global variables before calling `load_data()` - globals contain zero/default values, bypassing stored admin addresses or configuration
- `load_data()` called conditionally (only in some op branches) - other branches read uninitialized globals

## False Positives

- Mode +32 is used intentionally in an admin-gated shutdown function and is clearly documented as the terminal state for the contract
- Balance is used only as a sanity check upper bound (e.g., cannot withdraw more than balance) alongside a tracked internal accounting variable that is the primary source of truth

## reference/ton/fv-ton-6-contract-lifecycle/readme.md

---
description: Prevent vulnerabilities in contract deployment, code upgrades, StateInit construction, and contract lifecycle management.
---

# FV-TON-6 Contract Lifecycle

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-6-contract-lifecycle/<filename>` to read any case file listed below.

#### fv-ton-6-cl1-set-code-and-state-migration.md
#### fv-ton-6-cl2-contract-deployment-stateinit.md
#### fv-ton-6-cl3-balance-logic-and-self-destruct.md

## reference/ton/fv-ton-7-token-standards

```

```

## reference/ton/fv-ton-7-token-standards/fv-ton-7-cl1-jetton-wallet-validation.md

# FV-TON-7-CL1 Jetton Wallet Validation

## TLDR

Accepting Jetton deposits without verifying the sender is the legitimate wallet (derived from the minter's StateInit) lets attackers send fake `transfer_notification` messages to credit themselves with tokens they never transferred.

## Detection Heuristics

**transfer_notification without wallet address check**
- Handler credits balance based on `amount` in the notification body without verifying `sender_address == expected_jetton_wallet_address`
- `jetton_wallet_address` is not stored during initialization or not retrieved from storage before the check
- Check present but uses `from_user` (a body field the attacker controls) instead of `sender_address` (the actual message sender)

**Wallet address not recomputed**
- Contract does not implement or call `calculate_user_jetton_wallet_address(owner, jetton_minter)` to derive the expected wallet address from the StateInit hash
- Wallet addresses accepted as parameters from user messages without on-chain derivation

**internal_transfer sender not validated**
- Jetton wallet's `internal_transfer` handler does not verify the sender is either the minter or another wallet of the same minter - any contract can call and inflate balances

## False Positives

- Contract is the Jetton minter itself and receives `burn_notification` (not `transfer_notification`) - confirm the opcode being handled
- Jetton wallet address was validated at the contract level via a factory deployment and is stored immutably - confirm the stored address is derived correctly and cannot be updated without authorization

## reference/ton/fv-ton-7-token-standards/fv-ton-7-cl2-supply-invariants-and-burn.md

# FV-TON-7-CL2 Supply Invariants and Burn

## TLDR

`total_supply` must be decremented on every burn. If `burn_notification` is never sent by the wallet, or the minter's handler does not update `total_supply`, the supply is permanently inflated - breaking all calculations that divide by supply.

## Detection Heuristics

**Burn notification not sent**
- Jetton wallet burn handler executes the balance deduction and sends TON to the user but does not send `op::burn_notification` to the minter
- Custom burn path (e.g., admin burn) bypasses the standard TEP-74 flow and omits the notification

**Minter not handling burn_notification**
- Minter contract has no handler for `op::burn_notification` - or the handler exists but does not decrement `total_supply`
- `total_supply -= burn_amount` missing or applied to the wrong variable

**Supply invariant not maintained elsewhere**
- Mint operation increments `total_supply` in some paths but not all (e.g., admin mint vs. user mint have different code paths)
- Rounding differences between per-wallet balances and total supply - systematic rounding causes total to drift from the sum of all wallet balances over time

**Missing TEP-74 getters**
- `get_jetton_data()` or `get_wallet_address()` getter absent or returning incorrect values - breaks integration with wallets, DEXes, and explorers
- Non-standard op codes for `transfer` (not `0xf8a7ea5`) or `burn` (not `0x595f07bc`) - breaks interoperability

## False Positives

- Custom burn mechanism that bypasses notification is authorized admin-only and `total_supply` is updated in the same transaction by the admin's direct call - verify the update is present

## reference/ton/fv-ton-7-token-standards/fv-ton-7-cl3-nft-ownership-and-index.md

# FV-TON-7-CL3 NFT Ownership and Index

## TLDR

NFT item ownership can be overwritten by unauthorized parties if the transfer handler lacks a current-owner check, and the collection index can be manipulated to mint duplicate items or overwrite existing ones if `next_item_index` is not atomically incremented.

## Detection Heuristics

**Ownership transfer without current owner check**
- NFT item's transfer handler does not verify `equal_slices(sender_address, owner_address)` before updating ownership
- Any contract can send a transfer message and become the new owner
- No check that the sender is either the owner or an approved operator

**Index manipulation**
- Minting function accepts a user-supplied index instead of using `next_item_index` - allows minting at arbitrary indices, including existing ones
- `next_item_index` incremented after the mint message is sent rather than before - race condition allows two minters to claim the same index
- No `throw_unless(error::invalid_index, index == next_item_index)` when an index is provided

**Metadata URI manipulation**
- NFT metadata URI can be changed by unauthorized parties - buyers purchase based on displayed metadata that is later swapped
- No admin check on metadata update handler

**TEP-62 non-compliance**
- Standard getters (`get_nft_data`, `get_collection_data`) absent or returning non-standard formats
- Transfer message format deviates from TEP-62 - breaks marketplace integrations

## False Positives

- Index is admin-controlled for a curated collection where the admin manually assigns indices, but minting is restricted to admin only and index reuse is prevented by explicit check

## reference/ton/fv-ton-7-token-standards/fv-ton-7-cl4-token-encoding-and-accounting.md

# FV-TON-7-CL4 Token Encoding and Accounting

## TLDR

Mixing `store_coins`/`load_coins` (variable-length) with `store_uint`/`load_uint` (fixed-width) on the same field corrupts the cell layout. Using raw contract balance instead of internally tracked deposits allows balance manipulation through direct TON transfers.

## Detection Heuristics

**store_coins / load_coins mismatch**
- Field written with `store_coins(amount)` but read with `load_uint(64, ...)` - `store_coins` uses a 4-bit length prefix that `load_uint` does not skip, causing all subsequent fields to be read from incorrect bit offsets
- Reverse: written with `store_uint(amount, 120)` but read with `load_coins()` - `load_coins` reads the length prefix from what is actually data bits

**Zero-amount coins encoding**
- `store_coins(0)` encodes as 4 bits (length prefix = 0) - code that assumes zero amounts take zero space miscounts cell layout
- Conditional serialization where zero amounts are omitted but the reader always expects them, or vice versa

**Raw balance for accounting**
- Vault or pool contract uses `my_balance` to calculate share prices or withdrawal amounts - attacker sends TON directly to inflate `my_balance` and manipulate the calculation
- No internal `total_deposits` tracker; all accounting done against raw balance

**Token decimal mismatch**
- Cross-token operations (e.g., swap, collateral) that do not normalize for different decimal precisions - comparing 1 unit of a 6-decimal token to 1 unit of a 9-decimal token as equal
- No per-token decimal metadata stored or applied during calculations

## False Positives

- `store_uint` / `load_uint` used consistently on both sides for a field that is known to fit within the specified bit width and is not a `coins` type field in the TL-B schema
- Balance-based check used only as an upper-bound safety net while a tracked variable is the authoritative accounting value

## reference/ton/fv-ton-7-token-standards/readme.md

---
description: Audit Jetton (TEP-74) and NFT (TEP-62) implementations for supply invariants, wallet validation, encoding correctness, and standard compliance.
---

# FV-TON-7 Token Standards

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-7-token-standards/<filename>` to read any case file listed below.

#### fv-ton-7-cl1-jetton-wallet-validation.md
#### fv-ton-7-cl2-supply-invariants-and-burn.md
#### fv-ton-7-cl3-nft-ownership-and-index.md
#### fv-ton-7-cl4-token-encoding-and-accounting.md

## reference/ton/fv-ton-8-tact-language

```

```

## reference/ton/fv-ton-8-tact-language/fv-ton-8-cl1-ownership-and-access.md

# FV-TON-8-CL1 Ownership and Access in Tact

## TLDR

Tact's `Ownable` trait provides ownership infrastructure, but authorization is opt-in per handler. Any `receive()` handler that performs privileged state changes without calling `self.requireOwner()` is accessible to any sender.

## Detection Heuristics

**Missing requireOwner on admin handlers**
- Tact contract `receive()` handler for ops like parameter updates, withdrawals, or contract configuration does not call `self.requireOwner()` at the start
- Mixed patterns in the same contract: some admin handlers use `self.requireOwner()`, others do not - inconsistency is the vulnerability

**require() polarity errors**
- `require(condition, "error message")` used where the condition being true should cause rejection - same polarity error as `throw_unless` vs `throw_if` in FunC
- Complex boolean expressions in `require` that invert the intended check

**Ownership not transferred or not two-step**
- No `transfer_ownership()` or equivalent in the Tact contract - admin key loss is unrecoverable
- Single-step ownership transfer with no pending/confirm state - sending to a wrong address is permanent

## False Positives

- Handler is deliberately permissionless and only reads state or emits a notification - no state modification that requires authorization
- `requireOwner()` implemented in a parent trait or base contract that the audited contract inherits - trace the inheritance chain to confirm it is called

## reference/ton/fv-ton-8-tact-language/fv-ton-8-cl2-encoding-and-interoperability.md

# FV-TON-8-CL2 Tact Encoding and Cross-Language Interoperability

## TLDR

Tact structs and messages serialize to cells using Tact's own encoding conventions. When a Tact contract sends messages to a FunC contract (or vice versa), field widths, layout, and op codes must match exactly - silent mismatches cause incorrect parsing with no error.

## Detection Heuristics

**Op code mismatch between Tact and FunC**
- Tact message type's op code (derived from the message type name hash) does not match the FunC constant used by the receiving contract to dispatch
- Custom op code defined with `@opcode` in Tact does not equal the `const op::*` in the FunC counterpart

**Field width and ordering mismatch**
- Tact `Bool` (1 bit) or `Int` (257 bits default) field widths do not match the `load_uint(N)` / `store_uint(N)` widths expected by the FunC side
- Fields serialized in a different order in Tact structs than the FunC parser reads them
- Tact optional fields (`Int?`) add a 1-bit presence flag - FunC reader that does not skip this flag misaligns all subsequent fields

**Error code incompatibility**
- Tact's `require(condition, "message")` throws with a string-derived code; FunC contracts that catch specific numeric exit codes to determine response type will fail to match
- Cross-contract protocol relies on catching specific error codes to decide whether to retry or abort

## False Positives

- Tact and FunC contracts verified to have matching field layouts by a canonical TL-B schema that both implement against - confirm the schema is up to date with both implementations

## reference/ton/fv-ton-8-tact-language/fv-ton-8-cl3-map-and-init-patterns.md

# FV-TON-8-CL3 Tact Map and Init Patterns

## TLDR

Tact `map<K,V>` types are unbounded by default and lack built-in size tracking. The `init()` function can be replayed post-deployment if no initialized guard is present, overwriting all contract state including the owner.

## Detection Heuristics

**Unbounded map growth**
- `map<Address, Int>` or similar type accumulates entries on every user interaction without a maximum size check
- Iteration over the map (`foreach` or similar) in a single transaction without a per-call iteration limit - gas exhaustion when the map grows large enough
- No separate counter variable tracking map size to enforce a cap

**Missing initialized guard in init()**
- Tact `init()` function callable again after deployment because no `is_initialized` field is stored and checked at the start of `init()`
- Re-calling `init()` resets `owner`, `balance`, or other critical state to attacker-supplied values
- Contract deployed via factory but `init()` can still be triggered by sending a message with the corresponding payload

**receive() fallback over-reach**
- Tact's empty `receive()` fallback handler (processes plain TON transfers) contains state-modifying logic - any TON transfer triggers it, including dust deposits from attackers
- Missing `receive()` causes all plain TON transfers to be rejected, breaking expected deposit flows

## False Positives

- Map is used for a bounded set (e.g., a fixed number of supported tokens) and a size check enforces the cap at insertion time
- `init()` is protected by a deployer check: `require(sender() == deployer_address, "already initialized")` where `deployer_address` is set at first and only first execution

## reference/ton/fv-ton-8-tact-language/readme.md

---
description: Identify Tact-specific vulnerability patterns including ownership enforcement, fallback misuse, struct encoding, and cross-language interoperability.
---

# FV-TON-8 Tact Language

## Classifications

Run `cat $SKILL_DIR/reference/ton/fv-ton-8-tact-language/<filename>` to read any case file listed below.

#### fv-ton-8-cl1-ownership-and-access.md
#### fv-ton-8-cl2-encoding-and-interoperability.md
#### fv-ton-8-cl3-map-and-init-patterns.md

## reference/ton/protocols

```

```

## reference/ton/protocols/amm-dex.md

# AMM and DEX Security Patterns (TON)

> Applies to: Dedust-style AMM protocols, STON.fi-style swap contracts, liquidity pool contracts on TON, swap routers, Jetton-based liquidity pools, any FunC or Tact contract implementing constant-product or curve-based invariant math, position managers, concentrated liquidity implementations

## Protocol Context

AMM and DEX contracts on TON operate under the async message model where every user action is a sequence of messages rather than an atomic transaction. This means slippage protection, deadline enforcement, and invariant verification must be explicitly applied at each hop of the message chain, not just at the entry point. Unlike EVM AMMs where a single transaction reverts atomically on slippage violation, a TON swap can span multiple contract calls, and a slippage check at the router level does not prevent the underlying pool from settling at a worse price if the router does not re-verify after the pool responds. Sandwich attacks on TON are constrained by the async model (true atomicity for the attacker is limited) but front-running is possible through mempool observation.

## Bug Classes

---

### Slippage Bypass

**Protocol-Specific Preconditions**

- `min_amount_out` computed from current pool reserves inside the pool contract at swap time rather than supplied by the user in the initiating message body
- No `min_amount_out` field in the swap message at all; protocol accepts any output amount
- Router contract checks slippage against the quoted amount, but the underlying pool is called directly via a message and processes the swap at the actual current reserves, which may have shifted

**Detection Heuristics**

- Find the swap message handler; check whether `min_amount_out` is a field in the message body parsed from the user's initiating message or computed on-chain at the pool
- Trace the message chain for a swap: if the slippage check is at the router and the pool receives a message without a minimum, the pool-level check is absent
- Verify that when slippage is violated (actual output < minimum), the pool sends a bounce-back message returning funds to the user, not a silent failure that credits 0 to the user
- Check whether the minimum is validated against the post-fee output or the pre-fee output; applying it pre-fee allows the fee to push the actual output below the minimum

**False Positives**

- Pool contract receives `min_amount_out` as part of its message payload and enforces it directly before crediting the output
- Router contract includes the user-supplied minimum in the forwarded pool message, not just its own slippage check

---

### Missing Swap Deadline

**Protocol-Specific Preconditions**

- Swap messages have no `valid_until` or `deadline` field in the message body
- TON message queues can delay message processing during network congestion; a user's swap can be held for minutes and executed at a significantly different price
- Governance or maintenance windows shift the effective exchange rate; a pending swap message submitted before the shift executes at the post-shift rate

**Detection Heuristics**

- Check the swap message body layout for a `deadline` or `valid_until` Unix timestamp field
- Find the swap handler's early validation section; verify `throw_unless(error::expired, now() <= deadline)` is called before any state modification
- For protocols using a two-phase swap (request + confirm), verify the deadline is enforced at the confirm phase as well, not only at the request phase
- Check whether the deadline is optional (defaulting to 0 meaning no expiry); a swap with deadline 0 should be rejected or treated as immediately expired

**False Positives**

- All swap messages include a deadline field and the handler rejects messages with `now() > deadline`
- Protocol is a CLMM or batch auction that settles at block-end with a deterministic settlement price; individual message timing does not affect the realized price

---

### AMM Invariant Not Verified After Swap

**Protocol-Specific Preconditions**

- After crediting the output token and debiting the input token from reserves, the protocol does not re-verify the constant product or curve invariant
- Partial fee accounting error causes `k = reserve_a * reserve_b` to decrease after a swap, allowing reserves to be depleted faster than the invariant permits
- LP removal in a single-sided mode does not verify that the remaining reserve ratio is within acceptable bounds

**Detection Heuristics**

- Find the post-swap state update; check whether `new_reserve_a * new_reserve_b >= old_reserve_a * old_reserve_b * (1 - fee_fraction)` is asserted
- For curve AMMs (Stableswap invariant), verify the invariant check uses the correct formula for the asset mix after each operation
- Check LP add and remove operations for invariant verification; single-sided additions or removals that skip the invariant check can drain one side of the pool
- Verify that fee accumulation does not double-count: fee should increase the effective k value, not reduce it

**False Positives**

- Invariant assertion present as a `throw_unless` immediately after reserve updates in both swap and LP operations
- Invariant stored and compared against the pre-operation value with a rounding tolerance that accounts for integer arithmetic

---

### Front-Running via Message Ordering

**Protocol-Specific Preconditions**

- TON mempool is observable before messages are included in a block; large swaps visible in the mempool can be front-run by a faster message from the same or a competing account
- TON validators can reorder messages within a block to extract MEV from large AMM swaps
- Protocol does not use a commit-reveal or delayed execution scheme for large trades

**Detection Heuristics**

- Assess whether any single swap is large enough relative to pool liquidity that front-running is economically viable given TON gas costs
- Check whether the protocol has any MEV protection such as a private relay, per-block randomized ordering, or a maximum allowed price impact parameter
- Verify that the slippage parameter enforced by the protocol is tight enough that front-running the swap beyond the tolerance is unprofitable

**False Positives**

- All swaps subject to a user-controlled slippage bound that is tight enough to make front-running economically unviable
- Protocol uses a batch auction settlement model where all orders in an epoch are settled at a single clearing price

## reference/ton/protocols/bridge-governance.md

# Bridge and Governance Security Patterns (TON)

> Applies to: cross-chain bridge contracts on TON, TON-to-EVM bridges, Jetton bridge contracts, DAO governance contracts, multisig-controlled protocols, proposal-and-vote governance, timelock controllers, admin key management, any FunC or Tact contract processing messages from external chains or governing protocol parameter changes

## Protocol Context

Bridge contracts on TON must enforce message uniqueness across the async message model: the same cross-chain transfer message can potentially be delivered multiple times due to network retries or relay failures, and without an on-chain deduplication dictionary, each delivery mints tokens. TON governance contracts face the standard flash governance attack from EVM but with the added complexity that vote-weight snapshots must be anchored to a specific seqno or timestamp before voting opens, since TON Jetton balances change continuously via messages. Governance execution on TON is also async, meaning the timelock countdown should begin after the message is processed, not when it is sent.

## Bug Classes

---

### Bridge Message Replay

**Protocol-Specific Preconditions**

- Bridge claim handler does not maintain a dictionary of processed message hashes or nonces
- The same cross-chain transfer proof can be submitted multiple times; each submission mints tokens on the TON side without checking whether it was already processed
- Bridge relay can submit duplicate messages during network partitions or bridge software restarts; no deduplication at the protocol level

**Detection Heuristics**

- Find the bridge claim or mint handler; check for `throw_unless(error::already_processed, ~ dict_get(processed_messages, msg_hash))` before minting
- Verify that after processing, the message hash is stored in the dictionary: `processed_messages = dict_set(processed_messages, msg_hash, true)`
- Check whether the processed messages dictionary has a bounded size or is pruned; an unbounded dictionary grows indefinitely and increases storage fees; verify this is acceptable or that a TTL-based pruning is implemented
- Verify that the message hash is computed from all fields that uniquely identify the transfer (source chain, source address, destination address, amount, nonce) and not just a subset

**False Positives**

- Message hash checked against processed dictionary before minting and stored after minting in the same handler
- Bridge uses a committee attestation with threshold signatures; each valid proof is unique by construction and cannot be replayed

---

### Bridge Supply Invariant Violation

**Protocol-Specific Preconditions**

- Total minted supply on the TON side can exceed the locked supply on the source chain due to decimal conversion rounding, replay, or a missing supply cap
- No `total_minted` counter maintained and compared against the authorized `total_locked` value reported by the bridge authority
- Decimal conversion between the source chain's token decimals and TON Jetton decimals rounds up on mint and down on burn, allowing a rounding profit per round-trip

**Detection Heuristics**

- Check whether the bridge contract maintains a `total_minted` counter and verifies `total_minted <= authorized_locked_amount` before each mint
- Verify the decimal conversion math for round-trip correctness: minting and burning the same amount should leave both sides unchanged; test with edge-case amounts
- Check the supply cap check path: if the bridge authority reports a new locked amount, verify the report is authenticated and cannot be inflated by the reporter
- Verify that the bridge contract's Jetton minting authority is restricted to the bridge contract itself and cannot be invoked by any other address

**False Positives**

- Total minted is tracked and capped to the authorized locked amount at every mint instruction
- Decimal conversion uses floor on mint (conservative) and ceiling on burn (also conservative for the protocol); round-trip never produces more than the original amount

---

### Governance Flash Vote

**Protocol-Specific Preconditions**

- Vote weight read from the voter's current Jetton balance at voting time rather than from a historical snapshot taken before the voting period opened
- Attacker can purchase tokens in a message preceding the vote, vote with inflated weight, and sell tokens in a subsequent message - all in the same block or short window
- No minimum holding period required before a holder is eligible to vote

**Detection Heuristics**

- Check the vote handler for how it reads `voter_weight`: is it queried from the Jetton wallet's current balance or from a snapshot stored at vote initialization?
- Verify that the voting period initialization stores a snapshot seqno or timestamp and the vote handler computes weight from balances at that seqno, not current balances
- Check whether token transfers are blocked during an active vote; if not, balance at vote time may differ from balance at vote submission time
- Calculate the maximum weight an attacker could acquire with a flash loan and whether that exceeds the quorum threshold

**False Positives**

- Vote weight computed from a historical snapshot: balance as of the block before voting opened, stored in the proposal or read from a checkpoint contract
- Token transfers during active vote period do not affect votes already cast; new tokens acquired after vote submission cannot change the recorded weight

---

### Proposal Execution Without Timelock

**Protocol-Specific Preconditions**

- Governance proposals execute immediately upon reaching the vote threshold without a delay period for users to review and exit
- `timelock_delay` field in the governance contract is zero or not enforced
- Proposal execution is triggered by the passing-vote message itself rather than by a separate execution message sent after the timelock expires

**Detection Heuristics**

- Find the proposal execution path; check whether `throw_unless(error::too_early, now() >= proposal.passed_at + TIMELOCK_DELAY)` is enforced before execution
- Verify that the timelock delay start is set to the time the proposal passes, not the time it was submitted; a proposal submitted with a future execution time that skips the timelock is equivalent to no timelock
- Check whether there is a cancellation mechanism during the timelock window that allows governance to halt execution if a malicious proposal is detected after passing
- Verify that `TIMELOCK_DELAY` is a governable parameter and that reducing it requires a proposal subject to its own timelock

**False Positives**

- Execution requires a separate `op::execute_proposal` message sent after `now() >= passed_at + timelock_delay`; passing the vote alone does not execute
- Timelock delay is a contract constant that cannot be changed without a program upgrade; its value is hardcoded and reviewed at each upgrade

## reference/ton/protocols/lending.md

# Lending and Vault Security Patterns (TON)

> Applies to: TON lending protocols, collateralized borrowing contracts, vault share protocols, Jetton-based money markets, stablecoin CDPs, over-collateralized loan contracts, flash loan providers, any FunC or Tact contract that issues shares against deposited Jetton or TON collateral

## Protocol Context

Lending and vault protocols on TON face the same fundamental share-accounting and liquidation vulnerabilities as EVM counterparts but with important TON-specific complications. The async message model means liquidation is not atomic: a liquidator sends a message, the lending contract processes it, and the collateral release is a separate outbound message. Between these hops, the collateral value can change and the liquidation can become unprofitable or under-collateralized. Vault share inflation via direct Jetton transfers is trivially executable on TON because any address can send Jetton transfer notifications to any contract; the contract must not derive its total_assets from the raw Jetton balance.

## Bug Classes

---

### Vault Share Inflation

**Protocol-Specific Preconditions**

- Vault computes `total_assets` from its Jetton balance (read from the Jetton wallet via `op::get_wallet_data` or inferred from received transfer notifications) rather than from a tracked internal counter
- First depositor inflates the share price by sending a direct Jetton transfer to the vault's wallet address (not through the deposit op-code), which increases the vault's Jetton balance without updating `total_supply`
- Next depositor's share calculation: `shares = deposit * total_supply / total_assets` yields 0 due to the inflated denominator, and the deposit is absorbed by the attacker as excess assets per share

**Detection Heuristics**

- Find the `total_assets` (or equivalent) computation in the vault contract; check whether it reads a tracked internal variable or uses the raw Jetton balance
- Check the first deposit case: if `total_supply == 0`, what happens? Verify a minimum shares amount is minted to a dead address at initialization to anchor the share price
- Verify whether the virtual shares pattern is applied: `shares = (deposit + VIRTUAL) * (total_supply + VIRTUAL) / (total_assets + VIRTUAL)`; if not, first-depositor inflation is possible
- Test: deploy vault, deposit 1 unit (get 1 share), donate 1e9 units directly to Jetton wallet, attempt 1e9 deposit; verify the second depositor receives > 0 shares

**False Positives**

- Vault tracks deposited assets in a persistent variable updated only through the deposit op-code handler; direct Jetton transfers are ignored or rejected
- Virtual shares pattern applied with an offset large enough to make the inflation attack economically unattractive

---

### Liquidation Not Atomic in Async Model

**Protocol-Specific Preconditions**

- Liquidation involves two or more message hops: (1) liquidator sends `op::liquidate` to lending contract, (2) lending contract sends `op::transfer` of collateral to liquidator
- Between message (1) and message (2) processing, the collateral's oracle price can change
- No reserve for worst-case liquidation bonus is locked at message (1) receipt; the bonus is computed from the oracle price available at message (2) processing time

**Detection Heuristics**

- Trace the liquidation message sequence; check whether the collateral amount is determined at the first message or at a later hop
- Verify that the protocol checks the borrower's collateral availability at message receipt time and locks the exact collateral amount before sending the outbound release message
- Check whether the liquidation bonus is computed from a price snapshot taken at `op::liquidate` receipt or from the current oracle price at collateral release time
- Verify that if the collateral value drops between the liquidation request and the release, the liquidation does not result in the protocol being undercollateralized

**False Positives**

- Collateral is locked (marked as in-use) at `op::liquidate` receipt and the locked amount is sent regardless of subsequent price changes
- Protocol uses a conservative price haircut at liquidation request time that is sufficient to cover price volatility during the message processing window

---

### Insufficient Liquidation Incentive for Dust Positions

**Protocol-Specific Preconditions**

- Liquidation bonus is a percentage of the collateral value; small positions yield a bonus smaller than the TON gas cost to execute the liquidation
- No minimum position size enforced; positions can be opened at any size
- Dust positions accumulate as bad debt because no external liquidator is economically motivated to close them

**Detection Heuristics**

- Calculate the minimum collateral value at which the liquidation bonus exceeds the expected TON gas cost for the full liquidation message chain
- Check whether the lending contract enforces a `min_collateral_value` at position open time and at partial repayment time
- Verify the protocol has a mechanism to close dust positions, such as a privileged admin function or a protocol-sponsored dust sweeper
- Count the number of messages in the liquidation flow; each additional hop increases the gas cost and the minimum economically viable position size

**False Positives**

- Minimum position size is enforced such that the liquidation bonus at the minimum position size always exceeds the maximum expected gas cost for the liquidation flow
- Protocol has a documented dust handling mechanism and the minimum position size is reviewed against current TON gas prices

---

### Bad Debt Not Socialized

**Protocol-Specific Preconditions**

- When a liquidation leaves the protocol with more debt than collateral (bad debt), no insurance fund or socialization mechanism absorbs the residual
- Bad debt is silently accumulated in the protocol's accounting, creating a growing deficit between tracked liabilities and actual assets
- Eventual withdrawal run: when total withdrawals exceed total deposits minus accumulated bad debt, the protocol becomes unable to honor remaining withdrawals

**Detection Heuristics**

- Check the liquidation handler for a case where `debt_value > collateral_value`; what happens to the residual debt?
- Look for an insurance fund or bad debt tracker variable; verify it is funded and checked when bad debt occurs
- Verify that `total_liabilities` (what the protocol owes depositors) and `total_assets` (what the protocol holds) stay in sync after every operation including partial liquidations
- Check whether the protocol ever writes off bad debt by reducing depositor claims proportionally (socialization) or absorbing it from a reserve

**False Positives**

- Insurance fund covers bad debt up to a documented maximum; protocol documentation acknowledges and accepts the residual risk above this threshold
- Socialization mechanism explicitly reduces depositor shares proportionally when bad debt exceeds the insurance fund, with governance approval required

## reference/ton/protocols/oracle.md

# Oracle Integration Security Patterns (TON)

> Applies to: TON lending collateral pricing, TON derivatives and perpetuals, synthetic asset minting, margin trading contracts, any FunC or Tact contract consuming external price data on TON, Pyth on TON integrations, Redstone on TON, off-chain oracle with on-chain delivery

## Protocol Context

Oracle integrations on TON face a structural challenge unique to the actor model: price updates arrive as asynchronous inbound messages rather than being pulled synchronously from an on-chain contract. This means a price update message and a subsequent action message (borrow, liquidate, swap) can be reordered or batched in ways that create a window where stale prices are applied. Unlike EVM where a single transaction reads the current oracle state atomically, TON contracts must store the last received price in their persistent data and explicitly validate its recency on every use. An additional attack vector is the fake oracle sender: any contract can send a message to a TON smart contract, so the price update handler must verify the sender address matches the registered oracle.

## Bug Classes

---

### Stale Price Acceptance

**Protocol-Specific Preconditions**

- Contract stores `last_price` and `last_update_time` in persistent data; a price-consuming instruction does not compare `now() - last_update_time` against a maximum allowed staleness
- Oracle messages delayed by network congestion arrive minutes after being published; no on-chain check detects the gap
- Oracle contract itself goes silent due to a backend outage; last stored price is arbitrarily old with no circuit breaker

**Detection Heuristics**

- Find all reads of stored oracle price data; check each for a staleness guard: `throw_unless(error::stale_price, now() - last_update_time <= MAX_STALENESS_SECONDS)`
- Verify `last_update_time` is stored alongside the price in a dedicated persistent variable and is updated atomically with the price value in the same oracle message handler
- Check whether `MAX_STALENESS_SECONDS` is a hardcoded constant or a configurable parameter; if hardcoded, verify its value is appropriate for the protocol's liquidation time horizon
- Look for any path that reads the price without first checking freshness, such as an internal helper function called from multiple places where only some callers validate staleness

**False Positives**

- Every price consumption site includes a staleness guard with an appropriate threshold
- Contract uses a circuit breaker that halts all price-dependent operations if the oracle has not updated within the acceptable window

---

### Missing Confidence Validation

**Protocol-Specific Preconditions**

- Pyth on TON publishes both a price midpoint and a confidence interval in each price update message; contract stores only the midpoint
- During volatile market conditions, the confidence interval widens significantly, making the midpoint unreliable for collateral valuations or liquidation thresholds
- No maximum confidence ratio is enforced; protocol acts on prices with extreme uncertainty

**Detection Heuristics**

- Check whether the oracle message handler stores the confidence field alongside the price; if not, confidence validation is impossible
- Verify the price-consuming instruction checks `throw_unless(error::low_confidence, confidence <= MAX_CONF_RATIO * price / 100)` before using the price
- For lending protocols, verify that a high-confidence price threshold is applied more strictly for liquidation decisions than for regular borrows, given the asymmetry of impact
- Check whether `MAX_CONF_RATIO` is a mutable admin parameter; an immutable constant that cannot be tightened post-deployment is a risk

**False Positives**

- Contract receives both `price` and `confidence` in the oracle message and validates the ratio before storing or using the price
- Protocol uses a price range (`price - confidence, price + confidence`) and applies conservative bounds for each operation type

---

### Fake Oracle Sender

**Protocol-Specific Preconditions**

- TON actor model: any contract can send a message to any other contract; the price update handler does not authenticate the sender
- Oracle address stored in contract data but not compared against `msg_sender` in the price update handler
- Multiple oracle addresses supported but the allowlist check is missing or incomplete

**Detection Heuristics**

- Find the `op::oracle_price_update` (or equivalent op-code) handler in the main receive function; verify the first statement checks `throw_unless(error::unauthorized, equal_slices(sender_address, stored_oracle_address))`
- If multiple oracle sources are supported, verify the sender check iterates the allowlist or compares against all valid oracle addresses
- Check whether the oracle address in persistent data can be updated by a privileged admin instruction; if so, verify the admin check on that instruction
- Look for any handler that processes external price data via `parse_cell` on a message body without verifying the sender

**False Positives**

- Price update op-code handler has `throw_unless` on sender address as the first instruction before any data parsing
- Oracle contract is a deterministic PDA-equivalent (StateInit-derived address) whose address is computed at deployment and hardcoded in the consumer contract

---

### On-Chain Spot Price as Oracle

**Protocol-Specific Preconditions**

- Collateral or swap price derived from live AMM pool reserves (`reserve_a / reserve_b`) in the same transaction chain as the dependent operation
- No TWAP or multi-block averaging; price reflects instantaneous reserves at message processing time
- Multi-hop message chain: swap message arrives, contract reads AMM price, proceeds with collateral valuation - the AMM price was set by the attacker in a prior message in the same transaction chain

**Detection Heuristics**

- Identify all price computation sites; check whether the price is read from a stored oracle variable or computed from pool reserve fields in real time
- For any protocol reading DEX reserves for pricing, verify whether a TWAP accumulator or time-delayed price snapshot is used
- Check message ordering: if the protocol sends a message to the AMM to fetch the price and then acts on the response, verify the response validation enforces freshness independent of when the AMM was manipulated

**False Positives**

- Protocol uses a dedicated off-chain oracle with an independent update feed not connected to any on-chain DEX pool
- TWAP accumulator with a sufficiently long window (multiple minutes) maintained in the AMM contract and used exclusively for pricing in this protocol

## reference/ton/protocols/staking.md

# Staking and Reward Security Patterns (TON)

> Applies to: TON nominator pool contracts, validator delegation protocols, liquid staking on TON, Jetton-based staking reward distributors, TON Whales staking, tsTON, hTON, any FunC or Tact contract distributing rewards proportional to staked balances over time

## Protocol Context

Staking on TON has two distinct layers: native TON validator staking (nominator contracts delegating to validators) and application-layer Jetton staking (user deposits Jettons and earns yield). Both layers share common accumulator-ordering vulnerabilities but the validator layer adds TON-specific risks around commission manipulation and validator key management. Application-layer staking contracts on TON are particularly vulnerable to direct Jetton transfer inflation of reward rates, since any address can send Jetton tokens to any contract without using the staking deposit op-code, potentially diluting or inflating the reward pool.

## Bug Classes

---

### Reward Accumulator Ordering

**Protocol-Specific Preconditions**

- Global `reward_per_token` accumulator updated in the same message handler that modifies a user's staked balance, but after the balance modification
- User's pending reward computed as `(current_reward_per_token - user_reward_per_token_snapshot) * user_balance`; if the snapshot is updated after the balance change, the new balance is used retroactively
- Staking contract processes unstake and reward claim in the same message handler without settling pending rewards first

**Detection Heuristics**

- Trace the execution order in every op-code handler that modifies `user_balance`: verify `settle_pending_rewards(user)` and accumulator update appear before any balance change
- Check the order of storage writes: `set_reward_per_token(new_accumulator)` must precede `set_user_balance(new_balance)` and `set_user_snapshot(new_accumulator)` in the same handler
- Verify with a test scenario: stake, wait for rewards to accrue, stake again in a new message, immediately claim; user should earn rewards only on the first stake amount for the pre-second-stake period
- Check multi-reward-token systems: each token must have an independent accumulator and each must be settled before any balance change

**False Positives**

- Every balance-modifying handler settles rewards as its first operation before any state mutation
- Accumulator is a read-only observation point; rewards are credited to a separate pending balance that is computed at claim time from a snapshot taken at the last balance change

---

### Flash Stake Reward Capture

**Protocol-Specific Preconditions**

- No minimum staking duration; user can stake and unstake in back-to-back messages sent in the same logical transaction chain
- Reward distribution is a discrete snapshot event (all rewards distributed to current stakers at a point in time) rather than continuous accumulation; attacker can stake just before the snapshot
- No warmup period between stake and reward eligibility

**Detection Heuristics**

- Check whether the staking contract enforces a minimum lock period: `throw_unless(error::locked, now() - stake_time >= MIN_LOCK_SECONDS)` before allowing unstake
- Identify whether reward distribution is event-driven or continuous; discrete events are snapshot-able and more susceptible to last-second staking
- For continuous accumulators, verify that a same-block stake and unstake yields zero net reward (the accumulator value at deposit equals the accumulator value at withdrawal)
- Check whether the unstake message can be sent in the same message chain as the stake message, effectively bypassing any block-boundary checks

**False Positives**

- Minimum lock period enforced on-chain, not just by convention; verified by checking the on-chain unstake handler for a time check
- Continuous accumulator correctly records the global index at stake time; same-block unstake yields zero reward by construction

---

### Reward Rate Inflation via Direct Transfer

**Protocol-Specific Preconditions**

- Reward rate derived from the staking contract's Jetton balance rather than from an internal tracked `reward_reserve` variable
- Attacker sends a large Jetton transfer directly to the staking contract's Jetton wallet address without using the `op::add_rewards` op-code
- The extra balance inflates the apparent reward pool, diluting per-staker rewards; or it deflates the reward rate if the contract computes rate as `reward_reserve / total_staked` and the denominator grows

**Detection Heuristics**

- Find the reward rate calculation; check whether it reads the Jetton wallet balance or a tracked internal variable
- Verify the `op::jetton_transfer_notification` handler (or equivalent) for unsolicited transfers: does the contract accept them silently and add them to the reward pool, or does it reject/bounce transfers from unknown senders?
- Check whether the total reward pool is bounded: can an attacker donate tokens to change the reward rate or dilute existing reward obligations?
- For emission-based rewards (fixed tokens per time period), verify the emission logic does not incorporate the contract's live Jetton balance

**False Positives**

- Reward reserve tracked in a persistent variable updated only through the privileged `op::add_rewards` handler; Jetton balance is irrelevant to reward calculations
- Unsolicited Jetton transfers are bounced back or credited to a separate dust account that does not affect the reward pool

---

### Cooldown Griefing via Dust Messages

**Protocol-Specific Preconditions**

- Cooldown period for unstaking can be reset by a new stake or partial unstake from the same or a different address
- Attacker sends a dust stake message to the victim's staking position, triggering a cooldown reset for the victim's pending unstake
- No per-epoch or per-deposit entry cooldown tracking; a single contract-level or user-level cooldown applies to all pending unstakes and is reset by any deposit activity

**Detection Heuristics**

- Check the cooldown reset logic: what events reset `cooldown_start_time` in the user's data? Any new stake? Any external trigger?
- Verify whether a dust stake (minimum TON value stake) is sufficient to trigger a cooldown reset; if so, the attack is economically trivial
- Check whether the cooldown is per-deposit-entry (each unstake request has its own timer) or per-user (a single timer covers all pending unstakes)
- Verify that third parties cannot trigger cooldown resets for another user's staking position

**False Positives**

- Cooldown is tracked per unstake request entry, not per user; a new stake does not affect pending unstake cooldown timers
- Minimum stake amount is large enough to make repeated griefing economically unviable relative to the gas cost

## reference/vyper

```

```

## reference/vyper/fv-vyp-1-reentrancy

```

```

## reference/vyper/fv-vyp-1-reentrancy/fv-vyp-1-c1-classic-reentrancy.md

# FV-VYP-1-C1 Classic Reentrancy

## TLDR

Classic reentrancy occurs when a Vyper contract performs an external call before updating its own state, allowing a malicious callee to re-enter the function and exploit the stale state. Vyper provides the `@nonreentrant` decorator as a built-in guard, but it must be applied explicitly and consistently.

## Detection Heuristics

**State updated after `raw_call`**
- `raw_call` invoked with `value=` parameter before the corresponding balance or state variable is zeroed or decremented
- Pattern: read balance into local variable, call external address, then set storage variable to zero
- ETH send via `raw_call(recipient, b"", value=amount)` where `amount` is derived from a storage variable not yet cleared

**Missing `@nonreentrant` decorator**
- `@external` functions that perform `raw_call`, interface calls, or send ETH lack a `@nonreentrant("lock")` decorator
- Multiple functions share access to the same balance mapping but not all carry the same `@nonreentrant` key

**Interface-based external calls before state writes**
- Calls through a Vyper interface (e.g., `IERC20(token).transfer(...)`) placed before storage mutations
- `self.balances[msg.sender]` or `self.shares[msg.sender]` read but not cleared before the outbound call

## False Positives

- `raw_call` with `revert_on_failure=False` used purely for logging or notification to a trusted internal address where re-entry has no exploitable state
- Functions decorated with `@nonreentrant` using a shared lock key that covers all co-dependent state mutations
- Contracts where all ETH transfers use the checks-effects-interactions pattern strictly: state fully updated before any outbound call

## reference/vyper/fv-vyp-1-reentrancy/fv-vyp-1-c2-cross-function-reentrancy.md

# FV-VYP-1-C2 Cross-Function Reentrancy

## TLDR

Cross-function reentrancy occurs when an external call in one function allows a re-entrant attacker to invoke a different function in the same contract while shared state is still inconsistent. Vyper's `@nonreentrant` decorator prevents this only when the same lock key is applied to every function that reads or writes the shared state.

## Detection Heuristics

**Inconsistent `@nonreentrant` key coverage**
- Function A performs an outbound call and carries `@nonreentrant("lock")`, but function B that mutates the same storage mapping does not
- Two functions operate on the same `HashMap` (e.g., `balances`, `shares`) with different or absent `@nonreentrant` keys
- A `transfer` or `approve`-style function modifies the same state as a `withdraw` function but lacks a matching guard

**Stale state exploitable via sibling function during outbound call**
- `raw_call` or interface call to an untrusted address before `self.balances[msg.sender]` is zeroed, while a sibling `transfer` function still reads that mapping
- Contract sends ETH in function A, and function B allows spending or transferring the same balance without checking the in-flight amount

**Manual mutex patterns with incomplete coverage**
- `locked: HashMap[address, bool]` guard set and cleared within one function but absent from related functions that access the same balances
- Lock variable stored per-user (`locked[msg.sender]`) rather than globally, allowing a different account to be used as re-entry vector

## False Positives

- Contracts where every function touching shared state carries the same `@nonreentrant` key, providing mutual exclusion across all entry points
- Read-only (`@view`) sibling functions that do not modify state and cannot affect the outcome of an in-progress withdrawal
- Trusted internal calls between functions on the same contract instance where no external code executes between the state read and write

## reference/vyper/fv-vyp-1-reentrancy/readme.md

---
description: Prevent reentrancy attacks in Vyper contracts through proper state management.
---

# FV-VYP-1 Reentrancy Attacks

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-1-reentrancy/<filename>` to read any case file listed below.

#### fv-vyp-1-c1-classic-reentrancy.md
#### fv-vyp-1-c2-cross-function-reentrancy.md

## reference/vyper/fv-vyp-10-upgradeability

```

```

## reference/vyper/fv-vyp-10-upgradeability/fv-vyp-10-c1-storage-collision-upgrades.md

# FV-VYP-10-C1 Storage Collision in Upgrades

## TLDR

Vyper assigns storage slots sequentially in declaration order, without the EIP-1967 slot reservation pattern used by many Solidity proxy frameworks. When an upgradeable proxy delegates to a new implementation that reorders or inserts storage variables, previously stored values are reinterpreted under the wrong variable names, corrupting contract state.

## Detection Heuristics

**New storage variables inserted between existing declarations**
- Implementation V2 declares a new state variable between two variables that existed in V1, shifting all subsequent slots by one
- Comparison of V1 and V2 source shows differing declaration order for any subset of variables that were live in V1

**Variable renamed or retyped at the same declaration position**
- A slot previously holding an `address` is now declared as `uint256` or vice versa without a corresponding data migration
- A `HashMap[address, uint256]` replaced by `HashMap[address, bool]` at the same position, silently truncating stored values

**No reserved gap slots in V1**
- V1 implementation contains no `_reserved0`, `_reserved1`, ... placeholder variables to absorb future additions
- All storage declarations in V1 are immediately followed by logic without any gap or explicit storage layout comment

**Proxy pattern using `delegatecall` without layout enforcement**
- Contract uses `raw_call` with `is_delegate_call=True` or an external proxy routes through `delegatecall` to a Vyper implementation
- No off-chain storage layout snapshot (e.g., Ape, Brownie, or `vyper -f layout` output) checked against the previous version

**`__init__` re-executed on upgrade writing over live storage**
- Upgrade flow calls `__init__` on the new implementation through the proxy, overwriting `owner` or other critical variables stored at slot 0

## False Positives

- Contracts that only append new variables at the end of the storage declaration list without inserting between or reordering existing ones
- Non-upgradeable contracts deployed fresh with each version where no prior state persists across deployments
- Proxy implementations where storage is intentionally segregated using a fixed high-entropy slot via inline assembly, verified against the deployed bytecode layout

## reference/vyper/fv-vyp-10-upgradeability/readme.md

---
description: Secure implementation of upgradeable contracts and proxy patterns.
---

# FV-VYP-10 Upgradeability Issues

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-10-upgradeability/<filename>` to read any case file listed below.

#### fv-vyp-10-c1-storage-collision-upgrades.md

## reference/vyper/fv-vyp-2-integer-overflow

```

```

## reference/vyper/fv-vyp-2-integer-overflow/fv-vyp-2-c1-arithmetic-overflow.md

# FV-VYP-2-C1 Arithmetic Overflow

## TLDR

In Vyper 0.3.x and earlier, arithmetic on integer types does not revert on overflow by default in all compilation modes. In Vyper 0.4.x the behavior changed, but code compiled with older compiler versions or with `@pragma optimize` can silently wrap. Overflow in token minting, share accounting, or fee accumulation leads to incorrect balances or supply values.

## Detection Heuristics

**Unchecked additive accumulation on `uint256` storage variables**
- `self.total_supply += amount` with no prior assertion that `self.total_supply + amount <= MAX_SUPPLY` or similar bound
- `self.balances[to] += amount` without verifying the sum does not exceed a declared cap
- Repeated additions in a loop (e.g., reward accumulation) with no overflow guard on the running total

**`convert` narrowing before arithmetic**
- `convert(value, uint128)` applied to a `uint256` before addition or multiplication, silently truncating the high bits before the operation
- `convert(a, int256)` used on an unsigned accumulator then added to a signed value, allowing wrap-through negative

**Multiplication before bounds check**
- `shares * price_per_share` computed without verifying neither operand is large enough to overflow before the multiplication
- `amount * 10**18` with `amount` accepted directly from `msg.value` or calldata without a cap

**Compiler version below 0.3.8 with no explicit safe-math annotations**
- `# @version ^0.2` or `# @version ^0.3.0` in the pragma where overflow checking was not unconditionally enabled
- No `MAX_*` constant and no `assert` bracketing any additive or multiplicative operation on accumulator variables

## False Positives

- Code compiled with Vyper 0.3.8+ where the compiler unconditionally inserts overflow checks for all integer operations in the default (non-`unchecked`) context
- Arithmetic bounded by a `constant` cap asserted before the operation: `assert self.total_supply + amount <= MAX_SUPPLY`
- Values derived from `len()` on a `DynArray` with a declared maximum, making overflow geometrically impossible within the type range

## reference/vyper/fv-vyp-2-integer-overflow/fv-vyp-2-c2-arithmetic-underflow.md

# FV-VYP-2-C2 Arithmetic Underflow

## TLDR

Underflow on unsigned integer types in Vyper wraps to the maximum value of the type when the result would be negative. Unlike Solidity with SafeMath or Vyper 0.3.8+ defaults, older compiler versions and explicit `unchecked` blocks do not revert on underflow, enabling an attacker to inflate balances or bypass balance checks by triggering a wrap.

## Detection Heuristics

**Subtraction on `uint256` without a preceding lower-bound assertion**
- `self.balances[msg.sender] -= amount` with no `assert self.balances[msg.sender] >= amount` before it
- `self.total_supply -= burned` without verifying `burned <= self.total_supply`
- Loop body performs `running_total -= fee` where `fee` is caller-controlled and `running_total` may be smaller than `fee`

**`convert` widening after subtraction**
- Subtraction performed on a narrower type (e.g., `uint128`) and then `convert`-ed to `uint256`, propagating a wrapped value silently

**Subtraction result stored in intermediate local variable before check**
- `result: uint256 = a - b` assigned without assertion, then `result` used in a later condition that assumes non-negative semantics
- A function returns `a - b` directly as a `uint256` return value with no guard

**Compiler pragma older than 0.3.8**
- `# @version ^0.2` or `# @version ^0.3.0` through `^0.3.7` where underflow is not guaranteed to revert

## False Positives

- Vyper 0.3.8+ compiled code in default mode where the compiler inserts underflow checks unconditionally
- Subtraction preceded immediately by `assert a >= b` or an equivalent conditional that reverts on failure
- Subtraction result bounded by design: e.g., decrementing a loop counter initialized from `len(arr)` where the loop structure prevents the counter from going below zero

## reference/vyper/fv-vyp-2-integer-overflow/readme.md

---
description: Handle arithmetic operations safely to prevent overflow and underflow.
---

# FV-VYP-2 Integer Overflow/Underflow

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-2-integer-overflow/<filename>` to read any case file listed below.

#### fv-vyp-2-c1-arithmetic-overflow.md
#### fv-vyp-2-c2-arithmetic-underflow.md

## reference/vyper/fv-vyp-3-access-control

```

```

## reference/vyper/fv-vyp-3-access-control/fv-vyp-3-c1-missing-owner-checks.md

# FV-VYP-3-C1 Missing Owner Checks

## TLDR

Vyper has no built-in access control modifiers analogous to OpenZeppelin's `onlyOwner`. Every privileged function must explicitly assert the caller's identity. Missing or misplaced `assert msg.sender == self.owner` statements leave administrative, emergency, and fund-moving functions callable by any address.

## Detection Heuristics

**State-mutating `@external` functions with no caller assertion**
- Functions that write to `self.owner`, `self.paused`, or any configuration variable contain no `assert msg.sender == ...` at the top
- `raw_call(msg.sender, b"", value=self.balance)` or similar ETH-draining call appears in a function with no access guard
- Functions named `pause`, `unpause`, `set_fee`, `upgrade`, `emergency_withdraw`, or similar administrative verbs lack any access check

**`assert` placed after state-mutating lines**
- An access check appears after a storage write or `raw_call`, meaning the side effect occurs before authorization is validated

**Owner stored in a mutable variable with no transfer guard**
- `self.owner` can be overwritten by a function that only checks `msg.sender == self.owner` but not the zero address or other invariants, allowing the owner to be burned

**`@internal` helper functions that perform privileged operations without caller checks**
- An internal function executes `raw_call` or modifies critical state, and the calling `@external` function has no access guard

## False Positives

- Functions intentionally open to all callers: `deposit`, `bid`, `enter`, or any participation function where unrestricted access is the design intent
- View or pure functions (`@view`, `@pure`) that read state but cannot modify it
- Functions guarded by an alternative mechanism such as a `paused` flag checked before execution, where the separate `pause` function itself is properly access-controlled

## reference/vyper/fv-vyp-3-access-control/fv-vyp-3-c2-role-based-access-control-flaws.md

# FV-VYP-3-C2 Role-Based Access Control Flaws

## TLDR

Vyper has no native role system. Contracts that implement role hierarchies using `HashMap[address, bool]` are prone to privilege escalation when lower-privileged roles can grant themselves or others higher privileges, or when role checks are applied to the wrong operations.

## Detection Heuristics

**Horizontal privilege escalation: role members can add peers**
- A function gated by `assert self.moderators[msg.sender]` also writes `self.moderators[new_address] = True`, allowing any moderator to create additional moderators without admin approval
- `assert self.operators[msg.sender]` used to guard `self.operators[target] = True`, creating an unbounded role-grant loop

**Role assigned to funds or critical operations that should require admin**
- `assert self.moderators[msg.sender]` gates `raw_call(msg.sender, b"", value=amount)` or other fund-moving operations that should require a higher-privilege role
- A role check that is appropriate for read operations is reused verbatim on write or withdrawal operations

**Role revocation not implemented or callable by the role member themselves**
- No function exists to revoke a role, making compromised role holders permanent
- `self.moderators[msg.sender] = False` callable by the role member, allowing self-revocation to evade detection after an exploit

**Default HashMap value exploited as implicit role**
- `HashMap[address, bool]` defaults to `False` for unset keys; a bug in initialization logic that sets a default-valued entry to `True` could grant unexpected access
- Zero address (`empty(address)`) implicitly holds a role because no explicit exclusion is checked

**No separation between role administration and role usage**
- The same check (`assert self.admins[msg.sender]`) guards both the ability to grant roles and the ability to use those roles in sensitive operations, conflating administration and execution

## False Positives

- Role-gated functions where the role exclusively controls non-fund, non-state-critical operations such as metadata updates or display parameters
- Multi-sig or DAO-controlled admin addresses where the role-grant function is protected by an off-chain governance process even if the on-chain check appears weak in isolation
- Explicit role hierarchies where the admin role is separated from the operator role and the grant function verifies `msg.sender == self.admin` rather than any role mapping

## reference/vyper/fv-vyp-3-access-control/readme.md

---
description: Implement proper access control mechanisms in Vyper contracts.
---

# FV-VYP-3 Access Control

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-3-access-control/<filename>` to read any case file listed below.

#### fv-vyp-3-c1-missing-owner-checks.md
#### fv-vyp-3-c2-role-based-access-control-flaws.md

## reference/vyper/fv-vyp-4-external-calls

```

```

## reference/vyper/fv-vyp-4-external-calls/fv-vyp-4-c1-unchecked-external-call-returns.md

# FV-VYP-4-C1 Unchecked External Call Returns

## TLDR

Vyper's `raw_call` returns a `bool` success flag and optional `Bytes` return data. When `revert_on_failure=False` is passed or when the return value is discarded, a failed external call silently continues execution. Unlike Solidity's low-level `.call()`, Vyper callers may not realize that `raw_call` with `revert_on_failure=False` requires explicit success validation.

## Detection Heuristics

**`raw_call` result not captured or not asserted**
- `raw_call(target, data, ...)` called as a statement with no assignment: `raw_call(target, b"", value=amount)` with no `success: bool =` prefix and default `revert_on_failure=True` assumed but not verified
- `success: bool = raw_call(..., revert_on_failure=False)` present but `success` never checked afterward before continuing execution or emitting state changes
- `raw_call` inside a loop where one failing iteration does not halt the loop and remaining iterations proceed with incorrect accounting

**Interface calls to ERC-20 tokens that return `bool`**
- `IERC20(token).transfer(to, amount)` return value not captured, relying on revert behavior that non-standard tokens (e.g., USDT) do not implement
- `IERC20(token).transferFrom(...)` called without checking the returned `bool`, silently failing on tokens that return `False` instead of reverting

**Batch dispatch with no per-call failure handling**
- A `for` loop over `DynArray[address, N]` calling `raw_call` on each entry where a failed call does not revert the entire batch and accounting proceeds as if all succeeded
- Silent failure in one leg of a multi-recipient distribution leaves the contract's internal balance accounting inconsistent with actual ETH transferred

## False Positives

- `raw_call` where `revert_on_failure=True` (the default) is used, causing the call to revert the entire transaction on failure
- Calls to `raw_call` used purely for side-effect notification (e.g., pinging a logging contract) where the contract's own state does not depend on the callee's success
- ERC-20 interfaces where the token is a known, audited implementation that always reverts on failure rather than returning `False`

## reference/vyper/fv-vyp-4-external-calls/fv-vyp-4-c2-gas-griefing-external-calls.md

# FV-VYP-4-C2 Gas Griefing via External Calls

## TLDR

When a Vyper contract forwards all available gas to an untrusted external callee via `raw_call`, a malicious recipient can consume the gas intentionally to cause the transaction to run out of gas, or execute arbitrary expensive logic to grief callers. This is particularly dangerous in batch distribution patterns where one bad actor blocks the entire operation.

## Detection Heuristics

**`raw_call` with no explicit `gas=` parameter forwarding to caller-influenced addresses**
- `raw_call(recipient, b"", value=amount)` with no `gas=` argument, forwarding all remaining gas to an address supplied by or derived from user input
- Loop over a `DynArray` of addresses calling `raw_call` without a gas cap, where any entry could be a contract with an expensive or infinite `__default__` function

**ETH distribution to user-supplied addresses without gas limit**
- `raw_call(self.recipients[i], b"", value=reward)` inside a `for` loop where `self.recipients` is populated by untrusted callers via a public `add_recipient` function
- Reward or refund dispatch to `msg.sender` via `raw_call` with no `gas=2300` or equivalent cap

**`gas=` set to a value derived from user input**
- `raw_call(target, data, gas=user_provided_gas)` where `user_provided_gas` is a function parameter not bounded by a constant maximum

**Loop structure vulnerable to single-recipient griefing**
- A `for` loop calls `raw_call` with no `revert_on_failure=False`, meaning one griefing recipient reverts the entire batch and all other recipients receive nothing
- No per-iteration gas budget check or batch-pause mechanism to resume from a failed index

## False Positives

- `raw_call` to a hardcoded or owner-controlled address where the callee is trusted and cannot be replaced by user input
- Gas cap of `2300` or a similar low constant already applied: `raw_call(recipient, b"", value=amount, gas=2300)`
- Pull-payment pattern where recipients call a separate `claim` function, eliminating the push-to-untrusted-address risk entirely

## reference/vyper/fv-vyp-4-external-calls/readme.md

---
description: Prevent external call vulnerabilities and handle call failures properly.
---

# FV-VYP-4 External Call Safety

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-4-external-calls/<filename>` to read any case file listed below.

#### fv-vyp-4-c1-unchecked-external-call-returns.md
#### fv-vyp-4-c2-gas-griefing-external-calls.md

## reference/vyper/fv-vyp-5-timestamp-dependencies

```

```

## reference/vyper/fv-vyp-5-timestamp-dependencies/fv-vyp-5-c1-block-timestamp-manipulation.md

# FV-VYP-5-C1 Block Timestamp Manipulation

## TLDR

`block.timestamp` in Vyper exposes the same miner-manipulable value as Solidity. Validators (post-Merge) can adjust the timestamp within the protocol's allowed drift to influence time-dependent logic such as auction deadlines, vesting cliffs, or cooldown periods. Short time windows measured in seconds are especially susceptible.

## Detection Heuristics

**Critical state transitions gated on exact `block.timestamp` comparison**
- `assert block.timestamp >= self.unlock_time` or `assert block.timestamp < self.deadline` where the window is seconds-wide and the outcome has financial value
- Auction end or sale close logic using `block.timestamp == self.end_time` (exact equality) rather than a range check
- Cooldown logic computing `block.timestamp - self.last_action[msg.sender] < COOLDOWN` where COOLDOWN is a small constant (under 30 seconds)

**`block.timestamp` used as a seed or entropy source**
- `convert(block.timestamp, bytes32)` passed to `keccak256` as the sole or primary entropy input for randomness
- Winner or outcome selection using `block.timestamp % n` where `n` determines a prize

**Timestamp-based vesting or unlock with validator-influenceable precision**
- `self.vesting_end = block.timestamp + duration` set at deployment where `duration` is measured in seconds and the initial timestamp can be nudged by the block proposer
- Staking reward calculations using `block.timestamp - self.stake_start` where a small timestamp delta meaningfully changes the reward amount

**`block.number` used as a timestamp proxy with inaccurate block-time assumptions**
- Comments or constants assume exactly 12-second block times: `BLOCKS_PER_HOUR: constant(uint256) = 300` when actual block times vary
- Duration in blocks derived from `seconds / 12` hardcoded without accounting for missed slots or Ethereum consensus changes

## False Positives

- Time windows measured in hours or days where the allowed validator drift (a few seconds to ~15 seconds) is economically insignificant relative to the stakes
- `block.timestamp` used only for informational event logging without affecting control flow or fund distribution
- Contracts on chains with deterministic block timestamps (e.g., certain L2s with fixed sequencer timing) where manipulation is not possible within the threat model

## reference/vyper/fv-vyp-5-timestamp-dependencies/readme.md

---
description: Handle timestamp dependencies and block properties securely.
---

# FV-VYP-5 Timestamp Dependencies

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-5-timestamp-dependencies/<filename>` to read any case file listed below.

#### fv-vyp-5-c1-block-timestamp-manipulation.md

## reference/vyper/fv-vyp-6-weak-randomness

```

```

## reference/vyper/fv-vyp-6-weak-randomness/fv-vyp-6-c1-predictable-random-number-generation.md

# FV-VYP-6-C1 Predictable Random Number Generation

## TLDR

Vyper provides no native source of verifiable randomness. Contracts that derive randomness from `block.timestamp`, `blockhash`, `block.prevrandao`, or combinations thereof are vulnerable to manipulation by validators or to pre-computation by any on-chain observer. Vyper's lack of assembly makes some obfuscation techniques from Solidity unavailable, but the fundamental on-chain entropy problem is identical.

## Detection Heuristics

**Randomness derived solely from block variables**
- `block.timestamp % n` used to select a winner, determine an outcome, or assign a trait
- `convert(blockhash(block.number - 1), uint256) % n` used as a random index, where the block hash is known to the validator producing the block
- `block.prevrandao % n` used without any additional off-chain entropy, relying on a value the validator can influence by skipping a block

**`keccak256` of exclusively on-chain inputs**
- `keccak256(concat(convert(block.timestamp, bytes32), convert(msg.sender, bytes32)))` used to generate a random number, where both inputs are observable before transaction inclusion
- Seed updated with `keccak256(convert(self.random_seed, bytes32))` in a loop, providing only pseudorandomness without fresh external entropy

**Lottery or trait assignment in the same transaction as the triggering action**
- Winner drawn or NFT trait assigned in the same `draw_winner` or `mint` call that can be sandwiched or front-run by observing the mempool
- No commit-reveal scheme: participant submits entry and outcome is computed atomically in one transaction

**`blockhash` called with `block.number - 1` or a recent block**
- `blockhash(block.number - 1)` returns the previous block hash, which the current block's validator already knows
- `blockhash` with offsets greater than 256 blocks always returns `0x0`, silently collapsing entropy to a constant

## False Positives

- Contracts that use `block.prevrandao` in contexts where validator manipulation is economically irrational relative to the value at stake and no single validator controls enough stake to reliably bias the output
- Commit-reveal schemes where the reveal transaction uses a user-provided nonce combined with a hash committed in a prior block, preventing front-running of the outcome
- Contracts that integrate Chainlink VRF, Pyth Entropy, or another verifiable off-chain randomness oracle where the on-chain block variables are used only as a secondary, non-decisive input

## reference/vyper/fv-vyp-6-weak-randomness/readme.md

---
description: Secure random number generation and prevent predictable outcomes.
---

# FV-VYP-6 Weak Randomness

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-6-weak-randomness/<filename>` to read any case file listed below.

#### fv-vyp-6-c1-predictable-random-number-generation.md

## reference/vyper/fv-vyp-7-front-running

```

```

## reference/vyper/fv-vyp-7-front-running/fv-vyp-7-c1-transaction-order-dependencies.md

# FV-VYP-7-C1 Transaction Order Dependencies

## TLDR

Front-running in Vyper contracts arises when a pending transaction reveals information (price, amount, action) that an observer can exploit by submitting a competing transaction with a higher gas price before the original is included. Vyper's lack of function overloading means slippage protection and deadline parameters must be explicitly added to every affected function signature.

## Detection Heuristics

**Price or rate read from storage with no caller-specified maximum**
- `assert msg.value >= self.price` with no `max_price: uint256` parameter in the function signature, allowing a price increase sandwiched around the buyer's transaction
- `rate: uint256 = self.exchange_rate` read inside a swap or purchase function with no tolerance parameter, enabling the owner or a bot to change the rate between block submission and inclusion

**Owner-controlled parameter update with no time lock**
- `self.price = new_price` or `self.fee_rate = new_rate` executable by the owner in the same block as a pending user transaction, with no `price_lock_until` or equivalent delay
- No minimum notice period before a parameter change takes effect, allowing atomic front-run of user actions

**Approval front-running on ERC-20-like allowance patterns**
- `self.allowance[owner][spender] = amount` set unconditionally, enabling a spender to observe the pending approval and spend the old allowance before the new one overwrites it
- No `increase_allowance` / `decrease_allowance` pattern; direct `approve`-style setter without a zero-first requirement

**Predictable function selector exploitation**
- A function with a known selector and profitable outcome (e.g., arbitrage, liquidation) callable by anyone, allowing MEV bots to replicate the call with higher gas and claim the profit

**Commit-reveal patterns absent for sensitive submissions**
- Order book entries, bids, or game moves submitted in plaintext in a single transaction without a prior commitment phase, enabling other participants to react before the transaction is confirmed

## False Positives

- Functions that accept a `max_price`, `min_output`, or `deadline` parameter already providing slippage and timing protection to the caller
- Price updates protected by a time lock or multi-sig governance process that prevents atomic front-running
- Contracts operating on private mempools, L2 sequencers with guaranteed ordering, or MEV-protected RPC endpoints where transaction ordering is not manipulable by external parties

## reference/vyper/fv-vyp-7-front-running/readme.md

---
description: Prevent front-running attacks and transaction ordering dependencies.
---

# FV-VYP-7 Front-Running

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-7-front-running/<filename>` to read any case file listed below.

#### fv-vyp-7-c1-transaction-order-dependencies.md

## reference/vyper/fv-vyp-8-division-precision

```

```

## reference/vyper/fv-vyp-8-division-precision/fv-vyp-8-c1-division-by-zero.md

# FV-VYP-8-C1 Division by Zero

## TLDR

Vyper reverts on division by zero for integer types, but the revert is an unhandled exception that may occur at unexpected points in execution, leaving state partially modified if writes preceded the division. Contracts must validate divisors before any division that depends on runtime values.

## Detection Heuristics

**Division by a value derived from storage without a prior zero-check**
- `total_amount / self.total_shares` where `self.total_shares` can be zero if no participants have joined yet
- `reward / len(self.participants)` where `self.participants` is a `DynArray` that may be empty at the time of the call
- `(value * 100) / self.total` where `self.total` starts at zero before the first deposit

**Division by a function parameter without validation**
- `return amount / participants` in a `@view` or `@pure` function where `participants` is a caller-supplied argument with no `assert participants > 0` guard
- `fee = (msg.value * rate) / denominator` where `denominator` is passed by the caller

**State mutations before the division that are not rolled back on revert**
- Storage variable updated (e.g., a counter incremented or a balance modified) before a division operation that may revert, leaving the contract in an inconsistent state if the division panics

**Share or ratio calculations on first-deposit edge cases**
- Initial liquidity deposit to a pool computes `shares = deposited_amount / self.price_per_share` where `self.price_per_share` is initialized to zero or only set after the first deposit
- `convert(a, decimal) / convert(b, decimal)` where `b` as a `decimal` can be `0.0`

## False Positives

- Division by a `constant` value defined at the module level, which the compiler can verify is non-zero at compile time
- Division preceded by `assert denominator > 0` or an equivalent `if denominator == 0: return 0` guard that handles the zero case before the operation
- Division by `len(arr)` where `arr` is a `DynArray` that is always non-empty due to a prior `assert len(arr) > 0` or an invariant maintained by the contract

## reference/vyper/fv-vyp-8-division-precision/fv-vyp-8-c2-precision-loss-division.md

# FV-VYP-8-C2 Precision Loss in Division

## TLDR

Vyper performs integer division with truncation toward zero for `uint256` and `int256` types, and rounds toward negative infinity for `decimal`. Performing division before multiplication, or dividing small numerators by large denominators, silently discards the fractional remainder. This can result in zero fees, under-distributed rewards, or stale exchange rates.

## Detection Heuristics

**Division before multiplication**
- `(amount / denominator) * multiplier` where reversing the order to `(amount * multiplier) / denominator` would preserve precision
- Fee computed as `amount / 10000 * fee_bps` instead of `amount * fee_bps / 10000`, discarding up to `denominator - 1` units per operation

**Small numerator divided by large denominator producing zero**
- `fee = (amount * rate) / PRECISION` where `amount * rate` is smaller than `PRECISION` (e.g., `10**18`), always producing `0`
- Basis-point fee on small deposit amounts: `deposit * 3 / 10000` returns `0` for `deposit < 3334`

**Remainder silently discarded in distribution**
- `reward_per_participant = total_reward / len(self.participants)` with no accounting for `total_reward % len(self.participants)`, causing ETH or tokens to be permanently locked in the contract
- `per_epoch = total / epochs` where `total % epochs != 0` and no remainder accumulator exists

**`decimal` type division rounding not accounted for**
- Vyper `decimal` rounds toward negative infinity; `convert(a, decimal) / convert(b, decimal)` may round down in unexpected directions for negative intermediate values
- Mixed arithmetic between `uint256` and `decimal` via `convert` without awareness that `decimal` has 10 decimal places of precision, not 18

**Share price or exchange rate stored at low precision**
- `self.price_per_share = total_assets / total_shares` stored as `uint256` without a scaling factor, losing all fractional precision as the ratio approaches 1:1
- Accumulated interest computed as `principal * rate / 100` instead of using a higher-precision accumulator scaled by `10**18` or similar

## False Positives

- Division that intentionally floors the result where the remainder is either returned to the caller or accumulated in a separate `dust` variable
- Fixed-point arithmetic that scales the numerator before division (e.g., `amount * 10**18 / denominator`) and scales down the result afterward, correctly preserving precision
- `decimal` type used throughout with full awareness of its 10-decimal-place fixed-point semantics and no implicit conversion from `uint256`

## reference/vyper/fv-vyp-8-division-precision/readme.md

---
description: Handle division operations safely and prevent precision loss.
---

# FV-VYP-8 Division and Precision

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-8-division-precision/<filename>` to read any case file listed below.

#### fv-vyp-8-c1-division-by-zero.md
#### fv-vyp-8-c2-precision-loss-division.md

## reference/vyper/fv-vyp-9-denial-of-service

```

```

## reference/vyper/fv-vyp-9-denial-of-service/fv-vyp-9-c1-unbounded-loops.md

# FV-VYP-9-C1 Unbounded Loops

## TLDR

Vyper requires loop bounds to be statically known at compile time, but a loop over a `DynArray` iterates up to the declared maximum length, which can be reached at runtime. When the array grows through user-controlled appends, the gas cost of any function that iterates the full array grows proportionally, eventually exceeding the block gas limit and permanently bricking the operation.

## Detection Heuristics

**`for` loop over a `DynArray` populated by untrusted callers**
- `for recipient in self.recipients:` where `self.recipients` is a `DynArray[address, N]` that any caller can append to via a public `join` or `register` function
- `for participant in self.participants:` inside `distribute_rewards` or `settle` where `self.participants` grows unboundedly

**Large declared `DynArray` maximum combined with full iteration**
- `DynArray[address, 1000]` or larger used as the type for a list that is fully iterated in a single function call
- The declared maximum (`N`) in `DynArray[T, N]` is large enough that iterating all `N` elements would consume more than the block gas limit at realistic per-iteration cost

**Array compaction via full-scan removal**
- Element removal implemented by iterating the entire array to rebuild it without the target element: `for item in self.items: if item != target: new_list.append(item)`
- No O(1) swap-and-pop pattern used; removal cost grows linearly with array length

**Accumulation inside a loop with no batch-processing mechanism**
- A single function iterates the full list and writes to storage for every element: `self.balances[p] += reward` inside a loop with no `start_index` / `batch_size` pagination
- No `distribution_index` or equivalent cursor to resume a partial iteration in a subsequent transaction

**Loop bounds derived from storage length at call time**
- `for i in range(len(self.list)):` where `len` is evaluated at call time and the list may have grown since deployment

## False Positives

- `DynArray` with a small declared maximum (e.g., `DynArray[address, 10]`) where the worst-case gas cost per call is bounded and well within the block gas limit
- Loop iteration where the declared maximum is enforced by a require in the append function and the per-iteration gas cost is low and audited
- Batch processing functions with explicit `start: uint256` and `end: uint256` parameters that allow callers to iterate a subset of the array across multiple transactions

## reference/vyper/fv-vyp-9-denial-of-service/readme.md

---
description: Prevent denial of service attacks through gas limit and loop vulnerabilities.
---

# FV-VYP-9 Denial of Service

## Classifications

Run `cat $SKILL_DIR/reference/vyper/fv-vyp-9-denial-of-service/<filename>` to read any case file listed below.

#### fv-vyp-9-c1-unbounded-loops.md

## report-template.md

### Final Security Assessment Report

**REPORT STRUCTURE:**

```markdown
# Smart Contract Security Assessment Report

## Executive Summary

### Protocol Overview
**Protocol Purpose:** [What DeFi problem does this protocol solve?]
**Industry Vertical:** [DeFi category: AMM/Lending/Derivatives/etc.]
**User Profile:** [Primary users and their typical interaction patterns]
**Total Value Locked:** [Current or expected TVL]

### Threat Model Summary
**Primary Threats Identified:**
- Economic attackers targeting [specific protocol mechanisms]
- Flash loan exploits affecting [specific functions]
- Governance attacks on [specific protocol parameters]
- Oracle manipulation risks in [specific price feeds]

### Security Posture Assessment
**Overall Risk Level:** [High/Medium/Low]
**Critical Findings:** [Count] requiring immediate attention before mainnet
**Total Findings:** [Count by severity: X Critical, Y High, Z Medium, W Low]

**Key Risk Areas:**
1. [Primary risk area with protocol context]
2. [Secondary risk area with protocol context]  
3. [Additional risk areas...]

## Table of Contents - Findings

### Critical Findings
- [C-1 [Impact] via [Weakness] in [Feature]](#c-1-impact-via-weakness-in-feature) (VALID)
- [C-2 [Impact] via [Weakness] in [Feature]](#c-2-impact-via-weakness-in-feature) (QUESTIONABLE)

### High Findings
- [H-1 [Impact] via [Weakness] in [Feature]](#h-1-impact-via-weakness-in-feature) (VALID)
- [H-2 [Impact] via [Weakness] in [Feature]](#h-2-impact-via-weakness-in-feature) (DISMISSED)

### Medium Findings
- [M-1 [Impact] via [Weakness] in [Feature]](#m-1-impact-via-weakness-in-feature) (VALID)

### Low Findings
- [L-1 [Impact] via [Weakness] in [Feature]](#l-1-impact-via-weakness-in-feature) (QUESTIONABLE)

## Detailed Findings

[Full findings using the enhanced format from Section 4, including triager validation notes]

---

### POC Approach
Follow the proof of concept approach described in the configuration: Only if the repo is already configured with a testing framework, create complete test cases that demonstrate the vulnerability with realistic parameters. Include economic analysis showing attack profitability and exact transaction sequences an attacker would execute.

## solidity-checks.md

# Solidity-Specific Audit Checks

## Protocol Context Lookup

After detecting the protocol type, run `cat $SKILL_DIR/reference/solidity/protocols/<file>` for the matching row. Each file contains protocol-specific preconditions, detection heuristics, and historical exploit patterns cross-referenced to fv-sol-X IDs.

| Detected Protocol Characteristics | Bash Command |
|------------------------------------|--------------|
| AMM, DEX, swap, Uniswap-style, Curve-style, order book, concentrated liquidity | `cat $SKILL_DIR/reference/solidity/protocols/dexes.md` |
| Lending, borrowing, collateral-backed money market, flash loan, Aave-style, Compound-style | `cat $SKILL_DIR/reference/solidity/protocols/lending.md` |
| Bridge, cross-chain, message passing, lock-and-mint, LayerZero, Wormhole | `cat $SKILL_DIR/reference/solidity/protocols/bridges.md` |
| Algorithmic stablecoin, rebase token, seigniorage, endogenous collateral, Luna/UST-style | `cat $SKILL_DIR/reference/solidity/protocols/algo-stables.md` |
| Decentralized stablecoin, exogenous collateral, CDP-backed stable, DAI-style | `cat $SKILL_DIR/reference/solidity/protocols/decentralized-stablecoin.md` |
| Reserve currency, treasury-backed token, Olympus-style, protocol-owned liquidity | `cat $SKILL_DIR/reference/solidity/protocols/reserve-currency.md` |
| Yield farming, yield aggregator, strategy vault, auto-compounder, ERC-4626, Yearn-style | `cat $SKILL_DIR/reference/solidity/protocols/yield.md` |
| Staking pool, liquid staking, validator pool, ETH staking, restaking | `cat $SKILL_DIR/reference/solidity/protocols/staking.md` |
| Derivatives, perpetuals, funding rate, leveraged positions, on-chain perps, GMX-style | `cat $SKILL_DIR/reference/solidity/protocols/derivatives.md` |
| Synthetics, synthetic asset issuance, debt pool, mirror asset, Synthetix-style | `cat $SKILL_DIR/reference/solidity/protocols/synthetics.md` |
| NFT marketplace, order book marketplace, Seaport-style, on-chain NFT auction, NFT lending market | `cat $SKILL_DIR/reference/solidity/protocols/nft-marketplace.md` |
| NFT minting, gaming, play-to-earn, gamefi, NFT staking | `cat $SKILL_DIR/reference/solidity/protocols/nft-gaming.md` |
| Liquidity manager, position manager, Arrakis-style, Gamma-style, concentrated liquidity wrapper, Uniswap v3 position vault | `cat $SKILL_DIR/reference/solidity/protocols/liquidity-manager.md` |
| Governance, DAO, on-chain voting, timelock, treasury management | `cat $SKILL_DIR/reference/solidity/protocols/governance.md` |
| Token launchpad, IDO, token sale, vesting, fair launch | `cat $SKILL_DIR/reference/solidity/protocols/launchpad.md` |
| On-chain insurance, coverage protocol, risk pool, parametric insurance, claims | `cat $SKILL_DIR/reference/solidity/protocols/insurance.md` |
| Index protocol, basket token, index rebalancing, tokenized portfolio, Set Protocol-style | `cat $SKILL_DIR/reference/solidity/protocols/indexes.md` |
| Protocol utility service, fee router, keeper network, meta-aggregator, merkle airdrop | `cat $SKILL_DIR/reference/solidity/protocols/services.md` |
| Privacy protocol, on-chain mixing, zero-knowledge proof, shielded pool, Tornado Cash-style | `cat $SKILL_DIR/reference/solidity/protocols/privacy.md` |
| Real world asset tokenization, tokenized securities, permissioned token, RWA on-chain representation | `cat $SKILL_DIR/reference/solidity/protocols/rwa-tokenization.md` |
| RWA lending, real-world asset-backed lending, credit facility, TrueFi-style, Goldfinch-style | `cat $SKILL_DIR/reference/solidity/protocols/rwa-lending.md` |

---

## Ethereum/Solidity DeFi AMM/DEX Tricks

- Check if external calls use .call() but don't validate return data length for contracts that might self-destruct
- Look for reentrancy guards that protect state but allow view function calls to manipulated external contracts
- Verify if token transfers assume 18 decimals but interact with tokens having different decimal precision
- Search for oracle price feeds that don't validate if Chainlink aggregator rounds are stale or incomplete
- Check if swap calculations use mulDiv but don't handle intermediate overflow in complex pricing formulas
- Look for MEV extraction opportunities in multi-hop swaps or arbitrage paths
- Verify if slippage protection accounts for fee-on-transfer tokens reducing received amounts

## Ethereum/Solidity Lending/Borrowing Tricks

- Check if liquidation logic handles underwater positions correctly during market crashes
- Look for interest rate calculations that can overflow with extremely high utilization rates
- Verify if collateral valuation uses time-weighted average prices to prevent flash loan manipulation
- Search for repayment functions that don't update borrower's debt correctly with compound interest
- Check if flash loan callbacks don't verify the original caller owns the loan amount
- Look for governance proposals that can execute immediately during timelock by manipulating block.timestamp
- Verify if permit functions check deadline but don't prevent replay attacks across forks

## Cross-chain Bridge Tricks

- Check if message verification validates merkle proofs against correct block headers
- Look for relay systems that don't verify message ordering or prevent replay attacks
- Verify if asset locks on source chain require corresponding unlocks/mints on destination
- Search for validator consensus mechanisms that can be manipulated with <33% stake
- Check if time-locked withdrawals can be front-run during dispute periods
- Look for bridge contracts that don't handle failed transactions or stuck assets
- Verify if cross-chain message passing validates sender authenticity

## NFT/Gaming Protocol Tricks

- Check if metadata URIs can be modified by unauthorized parties after minting
- Look for random number generation using predictable sources (block.timestamp, blockhash)
- Verify if royalty calculations handle edge cases (zero prices, maximum royalties)
- Search for batch operations that don't validate individual item permissions
- Check if game state transitions can be front-run or sandwich attacked
- Look for NFT approvals that don't expire or can be exploited across marketplaces
- Verify if play-to-earn mechanisms have anti-sybil protections

## Governance/DAO Tricks

- Check if voting power calculations can be manipulated through flash loans or delegate loops
- Look for proposal execution that doesn't validate proposal state before execution
- Verify if timelock delays can be bypassed through proposal dependencies or emergency functions
- Search for quorum calculations that don't account for total supply changes
- Check if delegation mechanisms prevent vote buying or circular delegation
- Look for treasury access controls that don't require multi-signature approval
- Verify if proposal cancellation can be abused by proposers or governance attacks

## Security Categories

### Access Control & Upgradeability

- Unauthorized access to sensitive functions
- Insecure constructor/init logic
- Upgradeability pattern misuse (e.g. unprotected upgradeTo)

### Fund Management

- Reentrancy vulnerabilities (single-function, cross-function, cross-contract, read-only)
- Incorrect accounting or balance tracking
- Incorrect token transfers or approvals
- Unchecked external call returns

### DeFi Protocol Logic

- Oracle manipulation vulnerabilities
- Flash loan attack vectors
- Slippage and sandwich attack risks
- Price calculation errors
- Fee-on-transfer token handling

### EVM & Solidity Specifics

- Integer overflow/underflow in older Solidity versions (<0.8.0)
- Timestamp dependencies and block manipulation
- Weak randomness sources
- Front-running vulnerabilities in MEV-sensitive logic

### Contract Logic Integrity

- Incorrect state transitions
- Lack of input validation leading to invariant violation
- Division precision errors
- Denial of service through unbounded operations

## Knowledge Base References

For detailed vulnerability patterns, read the relevant README then drill into case files:
- `cat $SKILL_DIR/reference/solidity/fv-sol-1-reentrancy/readme.md` - Reentrancy attack patterns
- `cat $SKILL_DIR/reference/solidity/fv-sol-2-precision-errors/readme.md` - Fixed-point math, ERC4626 rounding, special token accounting
- `cat $SKILL_DIR/reference/solidity/fv-sol-3-arithmetic-errors/readme.md` - Overflow/underflow, assembly pitfalls
- `cat $SKILL_DIR/reference/solidity/fv-sol-4-bad-access-control/readme.md` - Access control, signatures, hash collision
- `cat $SKILL_DIR/reference/solidity/fv-sol-5-logic-errors/readme.md` - Business logic, deployment config, randomness
- `cat $SKILL_DIR/reference/solidity/fv-sol-6-unchecked-returns/readme.md` - External call validation, non-standard tokens
- `cat $SKILL_DIR/reference/solidity/fv-sol-7-proxy-insecurities/readme.md` - Proxy patterns, upgrade lifecycle, diamond
- `cat $SKILL_DIR/reference/solidity/fv-sol-8-slippage/readme.md` - MEV, slippage, oracle front-running
- `cat $SKILL_DIR/reference/solidity/fv-sol-9-unbounded-loops/readme.md` - DoS, gas griefing, blacklistable tokens
- `cat $SKILL_DIR/reference/solidity/fv-sol-10-oracle-manipulation/readme.md` - Oracle attacks, Chainlink validity, L2 sequencer

## ton-checks.md

# TON (FunC/Tact) Specific Audit Checks

## TON/FunC/Tact Audit Tricks

- Check every `recv_internal` handler for `transfer_notification` - verify `sender_address` is compared against a stored, initialized Jetton wallet address, not just the `from_user` field inside the payload body
- Look for `accept_message()` in `recv_external` handlers - confirm it appears AFTER signature verification and seqno check, never before
- Search for `send_raw_message` calls and record the mode flag on each - modes 128 and +32 are high-risk; mode 1 with user-controlled amounts drains contract balance
- Verify every `raw_reserve()` call uses the correct reserve mode and minimum value; contracts without `raw_reserve` before sends are susceptible to storage-fee freezing
- Check all `recv_internal` handlers for a default/else branch - missing unknown-opcode handling or missing `throw(error::unknown_op)` can silently accept malformed messages
- Look for FunC boolean variables: if `-1`/`0` (true/false) is expected but `1`/`0` is used and later inverted with `~`, the logic is inverted - `~1 == -2` is truthy, not falsy
- For any contract that sends messages, trace whether a bounce handler exists and whether it correctly skips the 32-bit `0xFFFFFFFF` prefix before re-parsing the original opcode
- Confirm `end_parse()` is called after every message and storage deserialization - missing `end_parse()` silently ignores trailing bytes that may indicate injection or format mismatch
- Verify all administrative opcodes (`change_admin`, `upgrade`, `withdraw`) check `sender_address` against a stored admin value; absence of `throw_unless(error::not_owner, ...)` is a critical miss
- In multi-step message chains (A→B→C), verify that sufficient TON value propagates through each hop and that bounce handling restores state at every step

## Security Categories

### Message Handling and Sender Validation

- Missing `throw_unless` comparing `sender_address` to stored Jetton wallet address in `transfer_notification`
- Trusting `from_user` in notification payload body as proof of depositor identity
- `recv_internal` handling privileged ops without any sender check
- No `throw(error::unknown_op)` for unrecognized opcodes
- Missing handling for opcode 0 (plain TON transfer) separately from functional messages
- Incorrect bounceable/non-bounceable flag (0x18 vs 0x10 in address prefix bits)
- Missing workchain validation (`force_chain()`) on incoming addresses
- Bit/ref layout mismatch between sender and receiver

### Bounce and Message Lifecycle

- No bounce handler for messages that modify state before sending
- Bounce handler present but not skipping 32-bit `0xFFFFFFFF` prefix before parsing
- State change committed before message send with no rollback on bounce
- Sending to non-existent accounts without StateInit included in message
- Missing insufficient-gas propagation in multi-hop chains

### Authorization and Replay Protection

- `accept_message()` called before signature or seqno check in `recv_external`
- Missing `seqno` check or seqno incremented after execution instead of before
- Admin operations reachable without `equal_slices(sender_address, admin_address)` guard
- Single-step admin transfer with no pending/confirm pattern
- Contract initialization function callable by anyone or re-callable after deployment
- No idempotency or nonce on internal-message operations that should be one-time

### FunC Language Footguns

- Boolean values stored as `1`/`0` instead of `-1`/`0`, then used with `~` operator
- `load_int()` used for amounts or sizes that should never be negative
- Function containing `send_raw_message()`, `set_data()`, `set_code()`, or `raw_reserve()` missing `impure` specifier
- Global variables read before `load_data()` is called, returning zero/default instead of stored values
- `throw_unless` and `throw_if` polarity swapped - inverts the security check
- Custom exit codes in range 0–127 (TON-reserved) causing confusion with system errors

### Gas and Storage Economics

- `forward_ton_amount` read from user message and used directly without bounding against `msg_value`
- `send_raw_message` with mode 128 without prior `raw_reserve()` to protect minimum balance
- `send_raw_message` with mode 1 and user-controlled amounts, making contract pay gas
- Mode +2 (ignore errors) masking critical send failures
- Mode +32 reachable in non-destructive paths, accidentally destroying the contract
- No `raw_reserve(MIN_TON_FOR_STORAGE, RESERVE_REGULAR)` before sending
- `accept_message()` called before cheap validation, enabling gas draining via spam
- Unbounded dict iteration or loop over user-controlled data in a single transaction
- Contract balance reaching zero through operations without minimum reserve enforcement

### TON Actor Model and Asynchronous Execution

- State updated before message send - no "processing" lock, allowing concurrent modification
- Callback handler reads state cached from before the send, not re-read from c4
- Assuming cross-contract message ordering from different senders
- Multi-message operation with no bounce recovery for any of the sent messages
- `my_balance` or raw contract balance used for business logic - manipulable by anyone sending TON directly
- Logical time ordering relied upon for messages from different source contracts

### Contract Lifecycle

- `set_code()` followed by logic that assumes new code is active in the same transaction
- `set_code()` without corresponding `set_data()` migration - new code misinterprets old storage layout
- No version field in storage to detect format mismatch after upgrade
- `set_code()` reachable without admin authorization or without timelock
- `send_raw_message` with mode flag +32 in refund or error paths
- Address computation for child contract using different code or data than actual StateInit
- Missing StateInit in messages that must deploy child contracts
- Method IDs (CRC16 of function name) colliding between get methods

### Token Standards (TEP-74 Jetton, TEP-62 NFT)

- Jetton wallet balance modified by messages without validating sender is the minter
- `total_supply` not decremented in `burn_notification` handler
- `burn_notification` itself never sent after wallet burn
- Jetton getters `get_wallet_address` / `get_jetton_data` missing or non-standard
- NFT `next_item_index` not atomically incremented, allowing duplicate or arbitrary indices
- NFT owner update handler missing `equal_slices(sender, owner_address)` check
- `store_coins` / `load_coins` mixed with `store_uint` / `load_uint` on the same field, corrupting layout
- TEP-74 or TEP-62 op codes not matching specification, breaking ecosystem interoperability

### Tact-Specific Issues

- Tact `Ownable` trait used but `self.requireOwner()` absent from admin-only receive handlers
- `receive()` fallback handler contains business logic that runs on every plain TON transfer
- Tact struct/message serialization not matching FunC message layout in cross-language contracts
- `map<K,V>` used for unbounded collections without size tracking or iteration limits
- Tact `init` function re-callable post-deployment due to missing `is_initialized` guard
- Cross-language error code reliance broken by Tact's string-based `require()` vs numeric `throw_unless()`

### DeFi Protocol Patterns

- Oracle price consumed without `now - last_update > MAX_STALENESS` check
- Oracle address not validated on price update - any contract can submit fake prices
- Share/vault price derivable from raw token balance, enabling first-depositor inflation attack
- Liquidation bonus does not cover gas cost for minimum viable position size
- Slippage parameter derived from on-chain pool state instead of user-supplied value
- Missing `deadline` parameter on swap operations, allowing delayed-execution attacks
- Bridge message replay: no nonce or message-hash deduplication for processed bridge transfers
- Governance vote weight from current balance, not historical snapshot - flash vote possible
- Governance proposal execution without timelock

## Knowledge Base References

For detailed vulnerability patterns, read the relevant README then drill into case files:
- `cat $SKILL_DIR/reference/ton/fv-ton-1-message-handling/readme.md` - Sender validation, bounce, opcodes, serialization
- `cat $SKILL_DIR/reference/ton/fv-ton-2-access-control/readme.md` - Authorization, replay protection, admin patterns
- `cat $SKILL_DIR/reference/ton/fv-ton-3-arithmetic-errors/readme.md` - Integer/boolean errors, precision, rounding
- `cat $SKILL_DIR/reference/ton/fv-ton-4-gas-and-storage/readme.md` - Gas management, send modes, storage fees
- `cat $SKILL_DIR/reference/ton/fv-ton-5-async-execution/readme.md` - TON actor model, async reentrancy, race conditions
- `cat $SKILL_DIR/reference/ton/fv-ton-6-contract-lifecycle/readme.md` - Deployment, upgrades, StateInit, set_code
- `cat $SKILL_DIR/reference/ton/fv-ton-7-token-standards/readme.md` - Jetton (TEP-74), NFT (TEP-62), token accounting
- `cat $SKILL_DIR/reference/ton/fv-ton-8-tact-language/readme.md` - Tact-specific vulnerability patterns

For protocol-type-specific DeFi audit context (preconditions, historical findings, remediation):
- `cat $SKILL_DIR/reference/ton/protocols/oracle.md` - Oracle integration patterns (async delivery, fake sender, staleness)
- `cat $SKILL_DIR/reference/ton/protocols/amm-dex.md` - AMM and DEX patterns (slippage, deadline, invariant, front-running)
- `cat $SKILL_DIR/reference/ton/protocols/lending.md` - Lending and vault patterns (async liquidation, vault inflation, bad debt)
- `cat $SKILL_DIR/reference/ton/protocols/staking.md` - Staking and reward patterns (accumulator ordering, flash stake, cooldown griefing)
- `cat $SKILL_DIR/reference/ton/protocols/bridge-governance.md` - Bridge replay and governance patterns (deduplication, flash vote, timelock)

## triager.md

### Security Expert 3: Customer Validation Expert
**ROLE:** Customer Validation Expert

**ENHANCED TRIAGER MANDATE:**
```markdown
You represent the PROTOCOL TEAM who controls the bounty budget and CANNOT AFFORD to pay for invalid findings.
Your job is to PROTECT THE BUDGET by challenging every finding from Security Experts 1 and 2.
You are FINANCIALLY INCENTIVIZED to reject findings - every dollar saved on false positives is money well spent.
You must be absolutely certain a finding is genuinely exploitable before recommending any bounty payment.

MANDATORY CROSS-REFERENCE VALIDATION:
□ Finding Consistency Check: Compare all findings for logical contradictions or overlapping issues
□ Evidence Chain Validation: Verify each finding's evidence chain (Code Pattern → Vulnerability → Impact → Risk)
□ Contract Location Verification: Confirm all referenced contracts, functions, and line numbers exist and are accurate
□ Attack Path Cross-Check: Ensure attack scenarios don't contradict protocol protections found in other areas
□ Severity Calibration Review: Check if severity levels are consistent across similar finding types
□ Economic Impact Validation: Verify economic attack scenarios are realistic and profitable

BUDGET-PROTECTION VALIDATION:
□ Technical Disproof: Actively test the finding to prove it's NOT exploitable in practice
□ Economic Disproof: Calculate realistic attack costs vs profits to show it's unprofitable
□ Evidence Challenges: Identify flawed assumptions and test alternative scenarios
□ Exploitability Testing: Try to reproduce the attack and document where it fails
□ False Positive Detection: Find protocol protections or mitigations that prevent exploitation
□ Production Reality Check: Test how actual deployment conditions invalidate the finding

Your default stance is BUDGET PROTECTION - only pay bounties for undeniably valid, exploitable vulnerabilities.
```

**ENHANCED TRIAGER VALIDATION FOR EACH FINDING:**

```markdown
### Triager Validation Notes

**Cross-Reference Analysis:**
- Checked finding against all other discoveries for consistency
- Verified no contradictory evidence exists in other analyzed contracts
- Confirmed attack path doesn't conflict with protocol protections found elsewhere
- Validated severity level matches similar findings in this audit

**Economic Feasibility Check:**
- Calculated realistic attack costs (gas fees, capital requirements, time investment)
- Analyzed profit potential vs. risk and complexity
- Evaluated if attack is economically rational for attackers

**Technical Verification:**
- Actively tested the vulnerability by attempting reproduction with provided steps
- Performed technical disproof attempts: [specific tests run to invalidate the finding]
- Verified contract locations and challenged technical feasibility through direct testing
- Calculated realistic economic scenarios to disprove profitability claims

**Evidence Chain Validation:**
[Document the complete evidence chain and validate each link:
- Code Pattern Observed: [Specific smart contract code pattern]
- Vulnerability Type: [How pattern leads to security weakness]
- Attack Vector: [How an attacker would exploit this]
- Business Impact: [Real-world consequences for protocol and users]
- Risk Assessment: [Why this matters to the protocol team]]

**Protocol Context Validation:**
[Specific technical challenges raised against this finding:
- Contract function calls tested and results
- Economic scenarios simulated and actual outcomes
- Integration tests performed and discrepancies found
- External dependency checks and potential mitigating factors]

**Dismissal Assessment:**
- **DISMISSED:** Finding is invalid because [specific technical reasons proving it's not exploitable]
- **QUESTIONABLE:** Technical issue may exist but [specific concerns about practical exploitability/economic viability]
- **RELUCTANTLY VALID:** Finding is technically sound despite [attempts to dismiss - specific validation evidence]

**Economic Recommendation:**
[Harsh economic critique: Why this finding should be deprioritized or dismissed, focusing on unrealistic economic assumptions, impractical attack scenarios, or misunderstanding of protocol economics]
```
- **QUESTIONABLE:** Technical issue may exist but [specific concerns about practical exploitability/impact]
- **RELUCTANTLY VALID:** Finding is technically sound despite [attempts to dismiss - specific validation evidence]

**Technical Recommendation:**
[Harsh technical critique: Why this finding should be deprioritized or dismissed, focusing on technical inaccuracies, impractical scenarios, or misunderstanding of protocol mechanics]

## vyper-checks.md

# Vyper-Specific Audit Checks

## Security Categories

### Access Control & Upgradeability

- Unauthorized access to sensitive functions
- Insecure constructor/init logic
- Upgradeability pattern misuse (e.g. unprotected upgradeTo)

### Fund Management

- Reentrancy vulnerabilities
- Incorrect accounting or balance tracking
- Incorrect token transfers or approvals
- Unchecked external call returns

### Vyper-Specific Issues

- Integer overflow/underflow (pre-0.3.4 versions)
- Timestamp dependencies and block manipulation
- Weak randomness sources
- Front-running vulnerabilities in MEV-sensitive logic
- Division precision errors specific to Vyper's fixed-point arithmetic

### Contract Logic Integrity

- Incorrect state transitions
- Lack of input validation leading to invariant violation
- Division precision errors
- Denial of service through unbounded operations

## Knowledge Base References

For detailed vulnerability patterns, read the relevant README then drill into case files:
- `cat $SKILL_DIR/reference/vyper/fv-vyp-1-reentrancy/readme.md` - Reentrancy attack patterns
- `cat $SKILL_DIR/reference/vyper/fv-vyp-2-integer-overflow/readme.md` - Overflow/underflow issues
- `cat $SKILL_DIR/reference/vyper/fv-vyp-3-access-control/readme.md` - Access control vulnerabilities
- `cat $SKILL_DIR/reference/vyper/fv-vyp-4-external-calls/readme.md` - External call safety
- `cat $SKILL_DIR/reference/vyper/fv-vyp-5-timestamp-dependencies/readme.md` - Timestamp manipulation
- `cat $SKILL_DIR/reference/vyper/fv-vyp-6-weak-randomness/readme.md` - Random number generation
- `cat $SKILL_DIR/reference/vyper/fv-vyp-7-front-running/readme.md` - MEV and front-running
- `cat $SKILL_DIR/reference/vyper/fv-vyp-8-division-precision/readme.md` - Fixed-point math issues
- `cat $SKILL_DIR/reference/vyper/fv-vyp-9-denial-of-service/readme.md` - DoS vulnerabilities
- `cat $SKILL_DIR/reference/vyper/fv-vyp-10-upgradeability/readme.md` - Upgrade pattern security

