DEV Community

Saravana kumar for Cryip

Posted on • Originally published at cryip.co

How a Missing Input Validation in requestSwap() Let an Attacker Drain $25M from Resolv Labs

Resolv Labs operates a decentralised stablecoin protocol where users deposit collateral to mint USR. The attacker exploited two weaknesses simultaneously. First, the minting logic never verified whether the amount of USR being requested was proportional to the collateral provided. By supplying a wildly inflated targetAmount parameter, they claimed 80 million USR in exchange for a $200K deposit, a 400x overmint. Second, and critically, the SERVICE_ROLE private key that authorises mint completions had been compromised. This meant the attacker did not need to wait for any off-chain oracle or protocol operator to approve the transaction. They called completeSwap() directly, minting unbacked tokens on demand with no external check standing in the way.

To avoid triggering immediate liquidity alarms, the attacker staked the freshly minted tokens into wstUSR, then systematically exited through stablecoin pairs (USDC, USDT) on Curve Finance. The proceeds were finally converted to native ETH. By the time the protocol team could react, the bulk of the stolen funds had already cleared. USR lost its dollar peg, crashing 80% in secondary liquidity pools.

The root cause: two compounding flaws

Flaw 1: No output validation in swap functions
The core bug lived in requestSwap and completeSwap. The protocol accepted a user-supplied targetAmount without ever checking whether it was proportional to amountIn. This is the equivalent of a bank accepting a withdrawal slip without verifying the account balance.
SolidityVulnerable
// No validation: user can request any output amount
function requestSwap(
address tokenIn,
uint256 amountIn,
uint256 targetAmount, // user-controlled, never verified
address tokenOut
) external {
pendingSwaps[msg.sender] = SwapRequest({
amountIn: amountIn,
targetAmount: targetAmount, // stored as-is
tokenOut: tokenOut
});
}

function completeSwap(address user) external onlyServiceRole {
SwapRequest memory req = pendingSwaps[user];
// mints whatever targetAmount says, no sanity check
_mint(user, req.targetAmount);
}
The attacker passed targetAmount = 80,000,000 USR with amountIn = 200,000 USDC. The contract complied without question.
Flaw 2: Single-key SERVICE_ROLE signer
The completeSwap function was gated behind a SERVICE_ROLE modifier, but that role was held by a single private key. Analysts believe this key was compromised, allowing the attacker to trigger completeSwap themselves without waiting for a legitimate off-chain oracle.
SolidityVulnerable
// A single compromised key unlocks unlimited minting
modifier onlyServiceRole() {
require(
hasRole(SERVICE_ROLE, msg.sender),
"Not authorized"
);
_;
}

// If the attacker controls SERVICE_ROLE, they call this directly
function completeSwap(address user) external onlyServiceRole {
_mint(user, pendingSwaps[user].targetAmount); // no limits
}

Attack flow, step by step

Execution timeline

  • 1.Deposit 200,000 USDC as collateral
  • Legitimate entry, raises no flags
  • 2.Call requestSwap with targetAmount = 80,000,000 USR
  • Inflated output param, never validated on-chain
  • 3.Call completeSwap via compromised SERVICE_ROLE key
  • 80M USR minted instantly across two transactions (~50M + ~30M)
  • 4.Stake into wstUSR to bypass liquidity limits
  • Slippage and pool depth constraints avoided
  • 5.Swap through USDC/USDT pools on Curve Finance
  • Value extracted before secondary markets could reprice
  • 6.Convert all proceeds to native ETH and withdraw
  • Approximately 11,400 ETH (~$24M) cleared before the protocol freeze

How developers should fix this

Fix 1: Validate targetAmount against the exchange rate
Every minting function must verify that the requested output is mathematically consistent with the provided input. A maximum slippage tolerance of 1% is a reasonable starting point.
SolidityFixed
function requestSwap(
address tokenIn,
uint256 amountIn,
uint256 targetAmount,
address tokenOut
) external {
// Derive expected output from on-chain oracle or exchange rate
uint256 expectedOutput = getExpectedOutput(tokenIn, amountIn, tokenOut);

// Allow at most 1% above expected, reject anything larger
uint256 maxAllowed = expectedOutput * 101 / 100;
require(
    targetAmount <= maxAllowed,
    "targetAmount exceeds allowable output"
);

pendingSwaps[msg.sender] = SwapRequest({
    amountIn: amountIn,
    targetAmount: targetAmount,
    tokenOut: tokenOut,
    timestamp: block.timestamp
});
Enter fullscreen mode Exit fullscreen mode

}
With this check, the attacker's 80M USR request would have been rejected immediately. The expected output for 200K USDC is roughly 200K USR, making 80M about 400x the allowed ceiling.
Fix 2: Replace the single-key signer with multi-signature
SolidityFixed
// Require 3 of 5 designated signers to authorise any mint
function completeSwap(
address user,
bytes[] calldata signatures
) external {
require(
_verifyMultiSig(signatures, _hashSwapRequest(user)),
"Requires 3 of 5 signers"
);
_mint(user, pendingSwaps[user].targetAmount);
}
Fix 3: Enforce per-transaction and daily mint caps
SolidityFixed
uint256 public constant MAX_MINT_PER_TX = 1_000_000 * 1e18;
uint256 public constant MAX_MINT_PER_DAY = 10_000_000 * 1e18;
mapping(uint256 => uint256) public dailyMinted;

function completeSwap(address user) external onlyServiceRole {
SwapRequest memory req = pendingSwaps[user];

require(req.targetAmount <= MAX_MINT_PER_TX, "Exceeds per-tx cap");

uint256 today = block.timestamp / 1 days;
require(
    dailyMinted[today] + req.targetAmount <= MAX_MINT_PER_DAY,
    "Daily mint cap reached"
);

dailyMinted[today] += req.targetAmount;
_mint(user, req.targetAmount);
Enter fullscreen mode Exit fullscreen mode

}
Fix 4: Add an automatic circuit breaker
SolidityFixed
uint256 public constant ALERT_THRESHOLD = 5_000_000 * 1e18;

function completeSwap(address user)
external onlyServiceRole whenNotPaused
{
SwapRequest memory req = pendingSwaps[user];

if (req.targetAmount > ALERT_THRESHOLD) {
    _pause();
    emit SuspiciousActivityDetected(user, req.targetAmount);
    return; // abort, no mint occurs
}

_mint(user, req.targetAmount);
Enter fullscreen mode Exit fullscreen mode

}

Summary of vulnerabilities and fixes

  • Vulnerability: targetAmount not validated
  • What went wrong: Any output amount accepted regardless of input
  • Correct approach: Validate against on-chain exchange rate with slippage tolerance
  • Vulnerability: Single SERVICE_ROLE key
  • What went wrong: One compromised key enabled unlimited minting
  • Correct approach: Require 3-of-5 multi-sig for all privileged mint operations
  • Vulnerability: No mint limits
  • What went wrong: Unlimited tokens mintable in one transaction
  • Correct approach: Enforce hard caps per transaction and per calendar day
  • Vulnerability: No circuit breaker
  • What went wrong: Protocol continued operating after exploit began
  • Correct approach: Auto-pause when any single mint exceeds an anomaly threshold Core lesson "Never trust user input. Verify it on-chain. An off-chain service validating parameters is not a substitute for on-chain guards. If the smart contract does not check it, the blockchain will not check it for you."

This incident follows a growing pattern of DeFi exploits targeting minting and swap mechanics. As collateral-backed stablecoin protocols proliferate, rigorous on-chain input validation and distributed key management are no longer optional. They are baseline requirements.

Top comments (0)