use lazy_static::lazy_static;
use regex::Regex;
use sentri_core::Finding;
lazy_static! {
static ref MINT_FUNCTION_REGEX: Regex =
Regex::new(r"(?i)function\s+(mint|mints)\s*\(").unwrap();
static ref CONSERVATION_CHECK_REGEX: Regex =
Regex::new(r"(?i)(totalMinted|totalSupply|totalBacking|totalCollateral|require|assert)").unwrap();
static ref COLLATERAL_REGEX: Regex =
Regex::new(r"(?i)(collateral|backing|reserve|deposit)").unwrap();
}
pub fn detect_unbacked_synthetic_mint(source: &str, file_path: &str) -> Vec<Finding> {
let mut findings = Vec::new();
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);
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");
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),
);
}
}
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
}
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()
}
fn checks_backing_requirement(func_body: &str) -> bool {
let func_lower = func_body.to_lowercase();
let backing_checks = vec![
"totalBacking",
"totalCollateral",
"backing",
"collateral",
"require",
"assert",
"totalMinted",
"maxMintRatio",
"checkBackingRatio",
"_validateMint",
"_checkCollateral",
];
let check_count = backing_checks
.iter()
.filter(|check| func_lower.contains(&check.to_lowercase()))
.count();
check_count >= 3
}
fn has_conservation_check(source: &str) -> bool {
let source_lower = source.to_lowercase();
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");
assert!(findings.iter().any(|f| f.message.contains("conservation")));
}
}