DEV Community

ohmygod
ohmygod

Posted on

Stablecoin Mint Path Auditing: A 12-Point Security Checklist After the $25M USR Exploit

Hours ago, Resolv Labs' USR stablecoin suffered a $25M exploit. An attacker deposited ~$100K USDC and minted 80 million unbacked USR tokens through a flawed two-step mint process. The root cause? The completeSwap function blindly trusted a _mintAmount parameter without cross-validating it against the actual collateral deposited in requestSwap.

This isn't an isolated pattern. Stablecoin minting bugs have been responsible for some of the largest DeFi exploits in history — from the Wormhole bridge mint ($320M), to Cashio's infinite mint ($52M), to today's USR disaster. Yet most audit checklists treat minting as a simple "check the access control" box.

After analyzing every major stablecoin mint exploit since 2022, here's the systematic checklist I use when auditing mint paths.


The 12-Point Stablecoin Mint Path Checklist

1. Enumerate Every Mint Path

The bug: Protocols often have "the" mint function, plus emergency mints, bridge mints, migration mints, and admin mints scattered across the codebase.

What to check:

  • grep -r "mint\|_mint\|Mint(" contracts/ | grep -v test | grep -v node_modules
  • Search for totalSupply modifications — any function that increases supply is a mint path
  • Check inherited contracts and libraries for hidden mint functions
  • Look for delegatecall to contracts with mint capabilities

USR lesson: The requestSwap + completeSwap two-step process was effectively a hidden mint path that bypassed the validation logic of direct minting.

// DANGEROUS: Multiple mint paths with different validation
function mint(address to, uint256 amount) external onlyMinter {
    require(totalSupply() + amount <= cap, "cap exceeded");
    _mint(to, amount);
}

// This second path might skip the cap check entirely
function completeSwap(bytes32 requestId, uint256 mintAmount) external {
    // Where's the cap check? Where's the collateral validation?
    _mint(requests[requestId].recipient, mintAmount);
}
Enter fullscreen mode Exit fullscreen mode

2. Validate Collateral-to-Mint Ratio On-Chain

The bug: Trusting off-chain services or oracle-provided mint amounts without on-chain verification.

What to check:

  • Is the mint amount calculated on-chain from actual collateral deposited?
  • Can any parameter in the mint path be influenced by external (off-chain) inputs?
  • Is the collateral locked before the mint amount is determined?
  • Are there rounding errors that could be exploited at scale?
// SAFE: Mint amount derived on-chain from actual deposit
function mint(uint256 collateralAmount) external {
    IERC20(collateral).safeTransferFrom(msg.sender, address(this), collateralAmount);
    uint256 mintAmount = (collateralAmount * 1e18) / getCollateralPrice();
    require(mintAmount > 0, "zero mint");
    _mint(msg.sender, mintAmount);
}

// DANGEROUS: Mint amount is a parameter, not derived from deposit
function completeMint(uint256 mintAmount, bytes calldata signature) external {
    require(verifySignature(mintAmount, signature), "bad sig");
    // If the signer is compromised, unlimited minting is possible
    _mint(msg.sender, mintAmount);
}
Enter fullscreen mode Exit fullscreen mode

3. Enforce Global Supply Caps On Every Path

The bug: Supply caps that protect one mint path but not others.

What to check:

  • Does every _mint call check against a global supply cap?
  • Is the cap enforced in the base _mint function itself (not just in wrappers)?
  • Can the cap be changed? By whom? With what timelock?
  • Are there emergency bypass mechanisms that could be exploited?
// BEST PRACTICE: Override _mint to enforce cap universally
function _mint(address to, uint256 amount) internal virtual override {
    require(totalSupply() + amount <= supplyCap, "supply cap exceeded");
    super._mint(to, amount);
}
Enter fullscreen mode Exit fullscreen mode

4. Audit Two-Step Processes for State Desynchronization

The bug: When minting is split into request → complete, the state between steps can be manipulated.

What to check:

  • Can the same request be completed multiple times? (replay)
  • Can request parameters be modified between steps?
  • Is the request tied to a specific block or timestamp that could expire badly?
  • Can a different address complete someone else's request?
  • Is there a maximum time window between request and completion?
// SAFE: Request is consumed on completion, parameters are immutable
struct MintRequest {
    address depositor;
    uint256 collateralAmount;
    uint256 maxMintAmount;  // Calculated at request time
    uint256 deadline;
    bool completed;
}

function completeMint(bytes32 requestId) external {
    MintRequest storage req = requests[requestId];
    require(!req.completed, "already completed");
    require(block.timestamp <= req.deadline, "expired");
    req.completed = true;  // Mark consumed BEFORE minting

    uint256 mintAmount = calculateMintFromCollateral(req.collateralAmount);
    require(mintAmount <= req.maxMintAmount, "exceeds request");
    _mint(req.depositor, mintAmount);
}
Enter fullscreen mode Exit fullscreen mode

5. Check Off-Chain Signer Trust Boundaries

The bug: Protocols that rely on an off-chain signer to authorize mints create a single point of failure.

What to check:

  • How many signers are required? (1-of-1 is a critical risk)
  • Can the signer authorize unlimited mint amounts?
  • Is there a per-signature mint cap?
  • Are signatures replay-protected (nonce + chain ID + contract address)?
  • What happens if the signer key is compromised?
// SAFER: Multi-sig with per-signature caps
function mintWithAuthorization(
    uint256 amount,
    uint256 nonce,
    bytes[] calldata signatures  // Require M-of-N signatures
) external {
    require(amount <= MAX_SINGLE_MINT, "exceeds single mint cap");
    require(!usedNonces[nonce], "nonce reused");
    usedNonces[nonce] = true;

    require(
        verifyMultiSig(amount, nonce, signatures, REQUIRED_SIGNERS),
        "insufficient signatures"
    );
    _mint(msg.sender, amount);
}
Enter fullscreen mode Exit fullscreen mode

6. Test Collateral Accounting Under Reentrancy

The bug: ERC-721 callbacks, ERC-777 hooks, and ERC-1155 callbacks during collateral transfers can re-enter the mint function.

What to check:

  • Is collateral deposited using tokens with transfer hooks?
  • Does the protocol follow checks-effects-interactions?
  • Are reentrancy guards applied to all mint paths?
  • Can collateral be counted twice through callback manipulation?

7. Verify Oracle Price Feed Integrity

The bug: Stale, manipulable, or misconfigured price feeds that let attackers mint more tokens per unit of collateral.

What to check:

  • Is there a staleness check on oracle data?
  • Can the oracle be manipulated via flash loans?
  • What happens if the oracle returns zero? Does it revert or mint infinite tokens?
  • Are there circuit breakers for extreme price deviations?
// SAFE: Comprehensive oracle validation
function getCollateralPrice() internal view returns (uint256) {
    (, int256 price, , uint256 updatedAt, ) = priceFeed.latestRoundData();
    require(price > 0, "invalid price");
    require(block.timestamp - updatedAt < MAX_STALENESS, "stale price");
    require(
        uint256(price) > lastKnownPrice * 90 / 100 &&
        uint256(price) < lastKnownPrice * 110 / 100,
        "price deviation too high"
    );
    return uint256(price);
}
Enter fullscreen mode Exit fullscreen mode

8. Stress-Test Rate Limiting and Velocity Checks

The bug: Even with correct validation, an attacker can drain pools by minting rapidly.

What to check:

  • Is there a per-block or per-epoch mint limit?
  • Is there a per-address mint limit?
  • Can the rate limit be bypassed by using multiple addresses?
  • Is there a global velocity circuit breaker?
// Rate limiting that actually works
uint256 public mintedThisEpoch;
uint256 public epochStart;
uint256 public constant EPOCH_DURATION = 1 hours;
uint256 public constant EPOCH_MINT_CAP = 1_000_000e18;

modifier rateLimited(uint256 amount) {
    if (block.timestamp > epochStart + EPOCH_DURATION) {
        epochStart = block.timestamp;
        mintedThisEpoch = 0;
    }
    mintedThisEpoch += amount;
    require(mintedThisEpoch <= EPOCH_MINT_CAP, "epoch cap exceeded");
    _;
}
Enter fullscreen mode Exit fullscreen mode

9. Audit Cross-Chain Mint Synchronization

The bug: Multi-chain stablecoins where supply on chain A doesn't reconcile with burns/locks on chain B.

What to check:

  • Are cross-chain mint messages authenticated and non-replayable?
  • Is there a global supply invariant across all chains?
  • Can a bridge message mint on the destination without a corresponding lock on the source?
  • What happens if a bridge message is delayed or reordered?

10. Test Emergency Pause Coverage

The bug: Pause mechanisms that protect some functions but not all mint paths.

What to check:

  • Does pause() block all mint paths?
  • Can new mint paths be added that don't inherit the pause modifier?
  • Who can pause? Is there a guardian with fast-path pause authority?
  • Can the pauser also mint? (role separation)

11. Verify Burn-Mint Symmetry

The bug: Asymmetric mint/burn that allows supply inflation through repeated cycles.

What to check:

  • mint(x) → burn(x) should leave supply unchanged
  • Are there fees that could be exploited in mint/burn cycles?
  • Can partial burns leave dust that accumulates?
  • Is the burn function actually burning, or just transferring to a "dead" address?

12. Check Upgrade Path Integrity

The bug: Proxy upgrades that introduce new mint paths without audit.

What to check:

  • Can the implementation contract be upgraded to add a backdoor mint?
  • Is there a timelock on upgrades?
  • Does the upgrade process require community review?
  • Are storage slots for mint-related state preserved across upgrades?

Automated Detection: Slither Custom Detector

Here's a starting point for a custom Slither detector that flags potential mint path issues:

from slither.detectors.abstract_detector import AbstractDetector, DetectorClassification
from slither.core.declarations import Function

class UnprotectedMintPath(AbstractDetector):
    ARGUMENT = "unprotected-mint"
    HELP = "Mint function without supply cap check"
    IMPACT = DetectorClassification.HIGH
    CONFIDENCE = DetectorClassification.MEDIUM

    WIKI = "https://example.com/unprotected-mint"
    WIKI_TITLE = "Unprotected Mint Path"
    WIKI_DESCRIPTION = "Detects _mint calls without totalSupply cap checks"
    WIKI_RECOMMENDATION = "Add supply cap validation to all mint paths"

    def _detect(self):
        results = []
        for contract in self.compilation_unit.contracts_derived:
            for function in contract.functions:
                if self._has_mint_call(function) and not self._has_supply_check(function):
                    info = [
                        function, " calls _mint without checking supply cap\n"
                    ]
                    results.append(self.generate_result(info))
        return results

    def _has_mint_call(self, function):
        for call in function.internal_calls:
            if hasattr(call, 'name') and '_mint' in call.name:
                return True
        return False

    def _has_supply_check(self, function):
        # Check if totalSupply is read in the function
        for var in function.state_variables_read:
            if 'supply' in var.name.lower() or 'cap' in var.name.lower():
                return True
        return False
Enter fullscreen mode Exit fullscreen mode

The Pattern Behind Every Stablecoin Mint Exploit

Looking at USR, Cashio, Wormhole, and others, the pattern is consistent:

  1. Multiple mint paths with inconsistent validation
  2. Trusted intermediaries (off-chain signers, bridges) without on-chain backstops
  3. Missing global invariants — no universal supply cap enforced at the _mint level
  4. Insufficient rate limiting — even valid mints shouldn't allow 500x leverage in a single transaction

The fix is equally consistent: treat _mint as the most dangerous function in your contract. Every path that reaches it should pass through the same gauntlet of validation — supply caps, collateral verification, rate limits, and pause checks.


Key Takeaways

  • Enumerate before you audit. Find every mint path before you start checking individual functions.
  • Trust the chain, not the signer. On-chain collateral verification beats off-chain signature authorization every time.
  • Universal caps at the base layer. Override _mint itself, don't rely on wrapper functions.
  • Rate limit everything. Even correct mints should be bounded by velocity checks.
  • The USR exploit was preventable with any single item on this checklist.

The $25M USR exploit happened because one validation check was missing in one function. Your audit checklist should be designed so that a single missing check can never be catastrophic.


This checklist is based on analysis of every major stablecoin mint exploit from 2022-2026. For the full USR exploit technical breakdown, see my previous article on the Resolv USR exploit.

Top comments (0)