sentri-analyzer-evm 0.3.0

Sentri: EVM smart contract analyzer with static analysis and invariant checking for Ethereum, Polygon, and other EVM chains.
Documentation
//! Detector for unbacked synthetic minting vulnerabilities.
//!
//! This detector identifies when synthetic tokens can be minted without sufficient
//! collateral backing. This pattern was exploited in H56 Echo Protocol ($73M, 2026).

use lazy_static::lazy_static;
use regex::Regex;
use sentri_core::Finding;

lazy_static! {
    /// Regex to match mint functions
    static ref MINT_FUNCTION_REGEX: Regex =
        Regex::new(r"(?i)function\s+(mint|mints)\s*\(").unwrap();

    /// Regex to match conservation checks
    static ref CONSERVATION_CHECK_REGEX: Regex =
        Regex::new(r"(?i)(totalMinted|totalSupply|totalBacking|totalCollateral|require|assert)").unwrap();

    /// Regex to match collateral tracking
    static ref COLLATERAL_REGEX: Regex =
        Regex::new(r"(?i)(collateral|backing|reserve|deposit)").unwrap();
}

/// Detects unbacked synthetic minting vulnerabilities.
pub fn detect_unbacked_synthetic_mint(source: &str, file_path: &str) -> Vec<Finding> {
    let mut findings = Vec::new();

    // Pattern 1: Find mint functions
    for (func_line_num, func_line) in source.lines().enumerate() {
        if !MINT_FUNCTION_REGEX.is_match(func_line) {
            continue;
        }

        let func_name = extract_function_name(func_line);

        // Extract function body (~40 lines)
        let func_start = func_line_num;
        let func_end = (func_line_num + 40).min(source.lines().count());
        let func_body = source
            .lines()
            .skip(func_start)
            .take(func_end - func_start)
            .collect::<Vec<&str>>()
            .join("\n");

        // Pattern 2: Check if there's collateral requirement
        if !checks_backing_requirement(&func_body) {
            let message = format!(
                "Minting function '{}' does not verify collateral backing before minting. \
                 Synthetic tokens must be backed by sufficient collateral. \
                 H56 Echo Protocol ($73M) was exploited when 100M eEGG tokens were minted \
                 without any backing check, allowing attackers to drain collateral. \
                 \
                 Example attack: \
                 1. Deposit 1 ETH as collateral \
                 2. Mint 1,000 synthetic tokens (no backing check) \
                 3. Drain all collateral while keeping synthetic tokens \
                 \
                 Required fix: Verify conservation invariant: \
                 require(totalMinted <= totalCollateral * MAX_MINTING_RATIO, \"Insuffcient backing\");",
                func_name
            );

            findings.push(
                Finding::new(
                    "evm_unbacked_synthetic_mint".to_string(),
                    sentri_core::Severity::Critical,
                    file_path.to_string(),
                    func_line_num + 1,
                    0,
                    message,
                    func_line.trim().to_string(),
                )
                .with_metadata("exploit_id".to_string(), "H56".to_string())
                .with_metadata("exploit_name".to_string(), "Echo Protocol".to_string())
                .with_metadata("loss".to_string(), "$73M".to_string())
                .with_metadata("year".to_string(), "2026".to_string())
                .with_metadata(
                    "vulnerability_type".to_string(),
                    "unbacked_mint".to_string(),
                )
                .with_metadata(
                    "detector".to_string(),
                    "conservation_invariant_check".to_string(),
                )
                .with_source_fragment(func_body),
            );
        }
    }

    // Pattern 3: Check for conservation invariant violations
    // Always check if contract lacks proper conservation checks
    if !has_conservation_check(source) {
        findings.push(
            Finding::new(
                "evm_unbacked_synthetic_mint".to_string(),
                sentri_core::Severity::High,
                file_path.to_string(),
                1,
                0,
                "Contract missing conservation invariant check for synthetic tokens. \
                 Conservation checks ensure totalMinted <= totalCollateral * MAX_RATIO. \
                 Without conservation checks, synthetic tokens can be minted without backing."
                    .to_string(),
                "// Missing: require(totalMinted <= totalCollateral * MAX_RATIO);".to_string(),
            )
            .with_metadata("exploit_id".to_string(), "H56".to_string())
            .with_metadata(
                "vulnerability_type".to_string(),
                "missing_conservation_invariant".to_string(),
            )
            .with_metadata(
                "detector".to_string(),
                "architecture_level_check".to_string(),
            ),
        );
    }

    findings
}

/// Extract function name from function declaration
fn extract_function_name(line: &str) -> String {
    if let Some(start) = line.find("function ") {
        let after_function = &line[start + 9..];
        if let Some(end) = after_function.find('(') {
            return after_function[..end].trim().to_string();
        }
    }
    "mint".to_string()
}

/// Check if function verifies collateral backing before minting
fn checks_backing_requirement(func_body: &str) -> bool {
    let func_lower = func_body.to_lowercase();

    // Patterns that indicate backing verification
    let backing_checks = vec![
        "totalBacking",
        "totalCollateral",
        "backing",
        "collateral",
        "require",
        "assert",
        "totalMinted",
        "maxMintRatio",
        "checkBackingRatio",
        "_validateMint",
        "_checkCollateral",
    ];

    // Count how many backing-related checks exist
    let check_count = backing_checks
        .iter()
        .filter(|check| func_lower.contains(&check.to_lowercase()))
        .count();

    // Need at least 3 checks (e.g., require + totalMinted + totalCollateral)
    check_count >= 3
}

/// Check if contract has any conservation checks
fn has_conservation_check(source: &str) -> bool {
    let source_lower = source.to_lowercase();

    // Look for actual conservation check patterns in code, not just keywords in comments
    // NOTE: Removed bare "totalMinted && totalCollateral" check because they appear in
    // comments explaining the LACK of checks (e.g., "No conservation check that totalMinted <= totalCollateral")

    // Check for actual require/assert statements checking conservation
    source_lower.contains("require(totalminted")
        || source_lower.contains("require(total_minted")
        || source_lower.contains("assert(totalminted")
        || source_lower.contains("assert(total_minted")
        || source_lower.contains("require(totalcollateral")
        || source_lower.contains("require(total_collateral")
        || (source_lower.contains("require(")
            && source_lower.contains("minted")
            && source_lower.contains("collateral")
            && !source_lower.contains("no conservation"))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_vulnerable_unbacked_mint_echo() {
        let code = r#"
            contract Echo {
                uint public totalMinted;
                
                function mint(uint amount) external {
                    eggToken.mint(msg.sender, amount);  // No backing check!
                    totalMinted += amount;
                }
                
                function drain() external {
                    // User can drain collateral while keeping minted tokens
                }
            }
        "#;

        let findings = detect_unbacked_synthetic_mint(code, "echo.sol");
        assert!(!findings.is_empty(), "Should detect unbacked mint");
        assert!(findings[0].invariant_id.contains("unbacked_synthetic"));
    }

    #[test]
    fn test_vulnerable_no_conservation_check() {
        let code = r#"
            contract BadSynthetic {
                function mint(uint amount) external {
                    _mint(msg.sender, amount);
                    // No verification of totalSupply <= totalCollateral
                }
            }
        "#;

        let findings = detect_unbacked_synthetic_mint(code, "bad.sol");
        assert!(
            !findings.is_empty(),
            "Should detect missing conservation check"
        );
    }

    #[test]
    fn test_safe_with_backing_check() {
        let code = r#"
            contract SafeSynthetic {
                uint public totalMinted;
                uint public totalCollateral;
                uint constant MAX_RATIO = 50;  // 50% backing ratio
                
                function mint(uint amount) external {
                    require(
                        totalMinted + amount <= (totalCollateral * MAX_RATIO) / 100,
                        "Insufficient backing"
                    );
                    _mint(msg.sender, amount);
                    totalMinted += amount;
                }
            }
        "#;

        let findings = detect_unbacked_synthetic_mint(code, "safe.sol");
        assert!(findings.is_empty(), "Should not flag with backing check");
    }

    #[test]
    fn test_safe_with_collateral_verification() {
        let code = r#"
            contract SafeProtocol {
                function mint(uint amount) external {
                    uint availableCollateral = getAvailableCollateral(msg.sender);
                    require(amount <= availableCollateral, "Not enough collateral");
                    require(totalMinted + amount <= maxSupply, "Max supply exceeded");
                    _checkBackingRatio(msg.sender);
                    _mint(msg.sender, amount);
                }
            }
        "#;

        let findings = detect_unbacked_synthetic_mint(code, "safe.sol");
        assert!(findings.is_empty(), "Should detect multiple backing checks");
    }

    #[test]
    fn test_detects_conservation_invariant_missing() {
        let code = r#"
            contract MissingInvariant {
                function withdraw(uint amount) external {
                    balance[msg.sender] -= amount;
                    msg.sender.transfer(amount);
                    // No conservation check that totalMinted <= totalCollateral
                }
            }
        "#;

        let findings = detect_unbacked_synthetic_mint(code, "missing.sol");
        // Should get at least one finding about conservation
        assert!(findings.iter().any(|f| f.message.contains("conservation")));
    }
}