Platypus Finance: The $8.5 Million Flash Loan That Exposed Stablecoin Logic

In February 2023, Platypus Finance lost $8.5 million to an attacker who found a way to borrow against collateral they immediately withdrew. The flaw wasn’t in access control or input validation. It was in how the protocol tracked the relationship between collateral and debt—and it only became visible when someone constructed exactly the right sequence of function calls.

How Platypus Worked

Platypus was an Avalanche-based stablecoin DEX with a native stablecoin called USP. The flow worked like this:

  1. Users deposited stablecoins (USDC, USDT, DAI) and received LP tokens
  2. LP tokens could be staked in MasterPlatypus for rewards
  3. Staked LP tokens could serve as collateral to mint USP

The protocol checked solvency before allowing USP to be minted:

function _isSolvent(address user) internal view returns (bool) {
    uint256 collateralValue = masterPlatypus.getStakedAmount(user) * lpPrice;
    uint256 debtValue = uspMinted[user];
    return collateralValue >= debtValue;
}

This looks correct in isolation. The problem was what happened after.

The Collateral Was Never Locked

When a user minted USP against their staked LP tokens, the protocol recorded the debt but did nothing to prevent those LP tokens from being withdrawn. The LP tokens remained fully accessible through emergencyWithdraw:

function emergencyWithdraw() external {
    uint staked = masterPlatypus.stakedBalance(msg.sender);
    masterPlatypus.unstake(staked);
    lpToken.transfer(msg.sender, staked);
    // No check: does this leave outstanding USP debt uncollateralized?
}

The solvency check ran at mint time. Once USP was minted, no mechanism prevented the collateral from disappearing. Collateral was counted but never locked.

The Attack

The attacker opened the transaction with a flash loan of 44 million USDC from Aave. Flash loans require repayment within the same transaction—they cost nothing upfront, but everything must unwind before the transaction closes.

The sequence:

  1. Deposit 44M USDC into Platypus pool, receive ~44M LP tokens
  2. Stake LP tokens in MasterPlatypus (collateral value: ~$44M)
  3. Call mintUSP to borrow 41.7M USP against the staked LP tokens
  4. Call emergencyWithdraw to unstake and reclaim the LP tokens
  5. Redeem LP tokens for underlying USDC from the pool
  6. Swap USP for other stablecoins on the open market
  7. Repay the 44M USDC flash loan

At step 4, the attacker had $41.7M of outstanding USP debt and zero collateral. The protocol had no mechanism to prevent step 4—emergencyWithdraw didn’t check whether the caller had active debt.

The attacker walked away with approximately $8.5 million in profit: the difference between the USP they minted (sold for real stablecoins) and the flash loan costs they had to cover.

Why This Pattern Is Hard to Catch

Individual functions in the Platypus codebase looked reasonable:

  • mintUSP had a solvency check
  • emergencyWithdraw was meant for emergency situations and had its own internal logic
  • MasterPlatypus.stakedBalance returned accurate data

The vulnerability only materialized when these functions were composed in a specific order within a single transaction. No individual function call was obviously wrong. The problem was systemic: the protocol’s economic model required that minted stablecoin debt be backed by locked collateral, but the code never enforced the locking.

This is a class of bug that doesn’t appear in unit tests of individual functions. You need tests that exercise multi-step sequences, or formal verification that the invariant “collateral >= debt for all users at all times” holds across all possible function orderings.

The Fix

The correct pattern tracks locked and available collateral separately:

mapping(address => uint256) public lockedCollateral;

function mintUSP(uint256 amount) external {
    uint256 required = amount * COLLATERAL_RATIO / 100;
    uint256 available = (masterPlatypus.stakedBalance(msg.sender) * lpPrice)
                        - lockedCollateral[msg.sender];

    require(available >= required, "Insufficient unlocked collateral");

    lockedCollateral[msg.sender] += required;
    uspMinted[msg.sender] += amount;
}

function emergencyWithdraw() external {
    require(lockedCollateral[msg.sender] == 0, "Repay debt before withdrawing");
    // proceed with withdrawal...
}

function burnUSP(uint256 amount) external {
    uint256 unlock = amount * COLLATERAL_RATIO / 100;
    lockedCollateral[msg.sender] -= unlock;
    uspMinted[msg.sender] -= amount;
    // burn USP...
}

With this structure, minting USP explicitly marks a portion of the collateral as locked. emergencyWithdraw refuses to proceed if any collateral is locked. Burning USP releases the lock. The collateral and debt lifecycle are tied together.

Collateral Logic as an Attack Surface

The Platypus exploit is a member of a family of attacks that exploit the gap between what a protocol’s economic model assumes and what its code actually enforces. Flash loans make this family particularly dangerous: the attacker doesn’t need any capital of their own. They rent $44 million for one block, use it to create a position, exploit the gap between collateral and debt tracking, unwind the position, and return the loan.

Flash loans themselves are not the vulnerability here—they’re just the mechanism that made the attack economical. The same attack would have been possible with enough personal capital, just less accessible to a random attacker.

The design principle this hack illustrates: if your protocol’s solvency depends on collateral remaining staked, the protocol must enforce that collateral cannot be unstaked while debt is outstanding. Checking solvency at the moment of borrowing is necessary but not sufficient. You need the invariant to hold at every moment—not just at the moment you check it.


References