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
totalSupplymodifications — any function that increases supply is a mint path - Check inherited contracts and libraries for hidden mint functions
- Look for
delegatecallto 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);
}
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);
}
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
_mintcall check against a global supply cap? - Is the cap enforced in the base
_mintfunction 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);
}
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);
}
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);
}
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);
}
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");
_;
}
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
The Pattern Behind Every Stablecoin Mint Exploit
Looking at USR, Cashio, Wormhole, and others, the pattern is consistent:
- Multiple mint paths with inconsistent validation
- Trusted intermediaries (off-chain signers, bridges) without on-chain backstops
-
Missing global invariants — no universal supply cap enforced at the
_mintlevel - 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
_mintitself, 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)