The Audit Gap That Cost $25 Million
On March 22, 2026, Resolv Labs lost $25 million when an attacker compromised a SERVICE_ROLE private key and minted 80 million unbacked USR stablecoins. The protocol had passed multiple smart contract audits. The vulnerability wasn't in the Solidity — it was in an off-chain service that enforced minting limits.
This pattern keeps repeating. Smart contract audits verify on-chain logic. Off-chain dependencies — admin keys, oracle feeds, backend validators, rate limiters running on centralized servers — get a brief mention in the audit report's "centralization risks" section and then everyone moves on.
This article presents concrete tooling and techniques for systematically detecting off-chain trust assumptions before deployment.
The Problem: What Auditors Miss
Traditional audit tools (Slither, Mythril, Echidna) analyze contract bytecode and Solidity source. They excel at finding reentrancy, integer overflows, and access control bugs within the contract. They cannot detect:
- Privileged roles that gate critical operations (minting, pausing, upgrading)
- Off-chain rate limits assumed by the protocol design but not enforced on-chain
- Backend service dependencies that, if compromised, break protocol invariants
- Oracle trust assumptions where the contract blindly trusts a single data source
The Resolv hack exploited exactly this gap: the SERVICE_ROLE could mint unlimited tokens because the minting cap was enforced off-chain, not in the smart contract.
Tool 1: Slither's Access Control Detectors (Enhanced)
Slither already has detectors for common access control issues, but you need to go further. Use Slither's printer capabilities to map every privileged function:
# Map all functions with access modifiers
slither . --print modifiers
# List all state-changing functions callable by specific roles
slither . --print require --filter-paths node_modules
# Custom query: find all mint/burn/transfer functions with role checks
slither . --print human-summary | grep -E '(mint|burn|transfer|upgrade|pause)'
But the real power comes from a custom Slither script that maps role → function → impact:
# scripts/map_privileged_roles.py
from slither.slither import Slither
from collections import defaultdict
def analyze_privileged_roles(target):
slither = Slither(target)
role_map = defaultdict(list)
CRITICAL_PATTERNS = {'mint', 'burn', 'transfer', 'upgrade', 'pause',
'setOracle', 'setRate', 'withdraw', 'execute'}
for contract in slither.contracts_derived:
for func in contract.functions:
if not func.is_implemented:
continue
# Check for role-based modifiers
role_modifiers = []
for mod in func.modifiers:
mod_name = mod.name.lower()
if any(k in mod_name for k in ['only', 'role', 'auth', 'admin', 'owner']):
role_modifiers.append(mod.name)
if not role_modifiers:
continue
# Check if function does something critical
func_name = func.name.lower()
is_critical = any(p in func_name for p in CRITICAL_PATTERNS)
# Check for state variable writes
state_writes = [v.name for v in func.state_variables_written]
for role in role_modifiers:
role_map[role].append({
'function': func.canonical_name,
'critical': is_critical,
'state_writes': state_writes,
'visibility': str(func.visibility)
})
# Report
print('\n=== PRIVILEGED ROLE ANALYSIS ===')
for role, funcs in role_map.items():
critical_funcs = [f for f in funcs if f['critical']]
print(f'\nRole: {role}')
print(f' Total functions: {len(funcs)}')
print(f' Critical functions: {len(critical_funcs)}')
for f in critical_funcs:
print(f' ⚠️ {f["function"]}')
print(f' Writes: {f["state_writes"]}')
return role_map
if __name__ == '__main__':
import sys
analyze_privileged_roles(sys.argv[1] if len(sys.argv) > 1 else '.')
What this catches: If Resolv's contracts had been analyzed with this script, the output would have shown SERVICE_ROLE controlling mint() with no on-chain cap — an immediate red flag.
Tool 2: Foundry Invariant Tests for Off-Chain Assumptions
The most powerful technique: write invariant tests that encode the assumptions your off-chain systems are supposed to enforce. If the invariant can be broken without touching the off-chain system, you have a critical vulnerability.
// test/OffChainAssumptions.invariant.t.sol
pragma solidity ^0.8.20;
import \"forge-std/Test.sol\";
contract OffChainAssumptionInvariant is Test {
StablecoinProtocol protocol;
MockCollateral collateral;
// Simulate the SERVICE_ROLE having unlimited power
address serviceRole = makeAddr(\"service\");
function setUp() public {
protocol = new StablecoinProtocol();
collateral = new MockCollateral();
protocol.grantRole(protocol.SERVICE_ROLE(), serviceRole);
}
// INVARIANT: Total minted should never exceed collateral value
function invariant_mintNeverExceedsCollateral() public view {
uint256 totalMinted = protocol.totalSupply();
uint256 totalCollateral = collateral.balanceOf(address(protocol));
assertLe(
totalMinted,
totalCollateral * protocol.collateralRatio() / 1e18,
\"CRITICAL: Minting exceeded collateral\"
);
}
// INVARIANT: No single mint should exceed the documented max
function invariant_singleMintCapped() public view {
uint256 lastMint = protocol.lastMintAmount();
uint256 maxMint = protocol.MAX_MINT_PER_TX();
if (maxMint == 0 || maxMint == type(uint256).max) {
emit log(\"WARNING: No on-chain mint cap found\");
} else {
assertLe(lastMint, maxMint, \"Mint exceeded on-chain cap\");
}
}
}
Run with aggressive fuzzing:
forge test --match-contract OffChainAssumptionInvariant \
--fuzz-runs 50000 -vvv
Key insight: If the fuzzer breaks invariant_mintNeverExceedsCollateral by having the SERVICE_ROLE call mint() directly, you've proven the off-chain cap is a single point of failure.
Tool 3: Semgrep Rules for Trust Boundary Detection
Semgrep can scan Solidity source for patterns that indicate off-chain trust assumptions:
# .semgrep/offchain-trust.yml
rules:
- id: unbounded-privileged-mint
patterns:
- pattern: |
function $FUNC(...) ... $MOD {
...
_mint($TO, $AMOUNT);
...
}
- metavariable-regex:
metavariable: $MOD
regex: (onlyRole|onlyOwner|onlyAdmin|only\\w+)
- pattern-not: |
function $FUNC(...) ... $MOD {
...
require($AMOUNT <= $CAP, ...);
...
_mint($TO, $AMOUNT);
...
}
message: >
Privileged mint function without on-chain amount cap.
If minting limits are enforced off-chain, a key compromise
enables unlimited minting (see: Resolv Labs, $25M loss).
severity: ERROR
languages: [solidity]
- id: missing-onchain-rate-limit
patterns:
- pattern: |
function $FUNC(...) ... $MOD {
...
$TOKEN.transfer($TO, $AMOUNT);
...
}
- metavariable-regex:
metavariable: $MOD
regex: (onlyRole|onlyOwner|onlyAdmin|only\\w+)
- pattern-not: |
function $FUNC(...) ... $MOD {
...
require(block.timestamp >= lastAction + $DELAY, ...);
...
}
message: >
Privileged transfer without on-chain cooldown or rate limit.
severity: WARNING
languages: [solidity]
- id: single-oracle-trust
pattern: |
function $FUNC(...) ... {
...
$PRICE = $ORACLE.getPrice(...);
...
}
message: >
Single oracle dependency without fallback or sanity check.
severity: WARNING
languages: [solidity]
Tool 4: The Trust Boundary Audit Checklist
Beyond automated tooling, every audit should include a manual trust boundary analysis:
Step 1: Map All Privileged Roles
Role → Controlled By → On-chain Enforcement
─────────────────────────────────────────────────────────────
DEFAULT_ADMIN_ROLE → Multisig (3/5) → Timelock (48h)
SERVICE_ROLE → Backend server → ❌ NONE
ORACLE_ROLE → Chainlink + bot → ❌ Heartbeat only
PAUSER_ROLE → Team EOA → ❌ NONE
Step 2: For Each Role, Answer
- Max damage if key compromised? (USD)
- Is damage bounded on-chain?
- Can the protocol survive 24h compromise?
- On-chain monitoring that triggers alerts?
- Can the key be rotated without downtime?
Step 3: Score
| Risk Level | Criteria |
|---|---|
| 🔴 Critical | Unbounded damage + no on-chain enforcement + single key |
| 🟠 High | Large damage + partial on-chain enforcement |
| 🟡 Medium | Bounded damage + timelock + multisig |
| 🟢 Low | Minimal impact + full on-chain enforcement |
Tool 5: Continuous Monitoring with Forta
const { Finding, FindingSeverity, FindingType } = require('forta-agent');
const ROLE_FUNCTIONS = {
'mint(address,uint256)': { maxPerTx: 1000000n * 10n**18n, cooldownSec: 300 },
'burn(address,uint256)': { maxPerTx: 1000000n * 10n**18n, cooldownSec: 300 },
};
const lastCallTimestamp = {};
function handleTransaction(txEvent) {
const findings = [];
for (const [funcSig, limits] of Object.entries(ROLE_FUNCTIONS)) {
const calls = txEvent.filterFunction(funcSig);
for (const call of calls) {
const amount = BigInt(call.args[1].toString());
if (amount > limits.maxPerTx) {
findings.push(Finding.fromObject({
name: 'Anomalous Privileged Operation',
description: `${funcSig} amount ${amount} exceeds threshold`,
alertId: 'OFFCHAIN-TRUST-1',
severity: FindingSeverity.Critical,
type: FindingType.Exploit,
}));
}
}
}
return findings;
}
The Defense-in-Depth Stack
Layer 1: Static Analysis (Slither + Semgrep) → Every commit
Layer 2: Invariant Testing (Foundry) → Every PR, 50k+ fuzz runs
Layer 3: Manual Trust Boundary Audit → Pre-deployment, quarterly
Layer 4: Continuous Monitoring (Forta/Defender) → 24/7 real-time
Layer 5: On-Chain Enforcement → Built from day one
Key Takeaway
Every time your protocol says \"this is enforced by our backend,\" you're one key compromise away from a $25M headline. Enforce it on-chain, or accept the risk explicitly. There is no middle ground.
This article is part of the DeFi Security Tooling series. Follow for weekly deep-dives into audit tools, smart contract vulnerabilities, and security best practices across Solana and EVM ecosystems.
Top comments (0)