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 (1)

Collapse
 
poushwell profile image
Pavel Ishchin

The part about completeSwap blindly trusting _mintAmount is wild. $100K in, 80 million out, and the function just did what it was told.