DEV Community

ohmygod
ohmygod

Posted on

Detecting Off-Chain Trust Assumptions Before They Blow Up: A Tooling Guide After the $25M Resolv Labs Hack

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)'
Enter fullscreen mode Exit fullscreen mode

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 '.')
Enter fullscreen mode Exit fullscreen mode

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\");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Run with aggressive fuzzing:

forge test --match-contract OffChainAssumptionInvariant \
  --fuzz-runs 50000 -vvv
Enter fullscreen mode Exit fullscreen mode

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]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Step 2: For Each Role, Answer

  1. Max damage if key compromised? (USD)
  2. Is damage bounded on-chain?
  3. Can the protocol survive 24h compromise?
  4. On-chain monitoring that triggers alerts?
  5. 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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)