All audits
FailedAvalancheIndependent

Platypus Solvency Check (2023)

Smart contract security audit by TierZero · 2023-09-15

Independent research. This is an unsolicited security analysis published by TierZero — not a commissioned audit, and no certificate is issued. It reflects our own review of public code and on-chain events.

Findings summary

1
Critical
1
High
2
Medium
1
Low
1
Informational

Independent research — not a commissioned audit. This is a retrospective analysis of a publicly disclosed incident, written from public post-mortems, on-chain data, and prior reporting. TierZero was not involved in the original codebase.

What the protocol did

Platypus Finance was a stableswap AMM on Avalanche, built around single-sided liquidity pools and an overcollateralized stablecoin, USP. Instead of requiring users to supply both sides of a pair, Platypus let liquidity providers deposit a single stablecoin and receive an LP token. Holders of that LP token could then deposit it as collateral into a separate vault contract — referred to in the protocol's own documentation as the Treasure vault — and borrow USP against it, up to a loan-to-value ratio enforced by a solvency check on every borrow, deposit, and withdrawal path.

The design put a lot of weight on one property: at any point where a user could move collateral out or debt could be created, the contract had to prove, from current state, that the account remained solvent. That property held everywhere except one function.

The vulnerability

The bug lived in the vault's emergencyWithdraw path — a break-glass function meant to let users pull collateral out during a paused or degraded protocol state. Emergency paths like this are frequently under-tested because they're not part of the "happy path" a team spends the most time on, and that's exactly what happened here: the solvency check on this path did not correctly validate that withdrawing the requested collateral would leave the account's debt fully backed. In effect, the check verified a condition that was structurally disconnected from the actual post-withdrawal state — closer to confirming the vault had some collateral on its books than confirming this account's remaining collateral still covered this account's debt.

The pattern, simplified to the shape of the bug rather than the literal source:

// Illustrative — simplified to the class of bug, not the literal exploited source.
function emergencyWithdraw(uint256 amount) external {
    // WRONG: checks a stale/aggregate value, not the account's
    // post-withdrawal collateral-to-debt ratio.
    require(isSolvent(msg.sender), "insufficient collateral");

    collateral[msg.sender] -= amount;
    lpToken.transfer(msg.sender, amount);
    // debt[msg.sender] is never re-validated against the
    // NEW collateral balance — the check ran on old state.
}

The bug class here is a broken invariant check: a solvency guard that exists in the code, reads as correct on a quick pass, and still fails to bind the two quantities — collateral and debt, post-transaction — that actually determine solvency. It's the same family of defect we flag repeatedly in EVM lending and CDP-style code, and it's exactly the kind of thing that only surfaces when you trace a check back to the exact state it's evaluating rather than trusting that a function named isSolvent does what its name implies. We cover the same "does the check bind to the right state" question, in a different ecosystem, in our Solana program security audit checklist for trading systems.

How the exploit worked

  1. The attacker took a flash loan of stablecoins on Avalanche to fund the initial position — no capital of their own was at risk.
  2. They deposited into a Platypus pool, received LP tokens, and posted those LP tokens as collateral in the Treasure vault.
  3. They borrowed USP against that collateral, up to the protocol's stated limit.
  4. They then called the vault's emergency withdrawal path. Because the solvency check on that path didn't correctly re-derive the account's collateralization after the withdrawal, it let the attacker pull the underlying collateral back out while the USP debt stayed on the books — uncollateralized.
  5. This sequence was repeated within the same transaction, compounding the effect and minting far more USP than any real collateral in the system supported.
  6. The attacker swapped the resulting USP through Platypus pools and other Avalanche venues for other stablecoins, repaid the flash loan, and walked away with the difference.

Public reporting put the total drained at roughly $8.5 million. A portion was later frozen by Binance after the funds moved through its rails, and some was recovered through direct negotiation with the attacker; a suspect was separately arrested by French authorities in the weeks after the incident. None of that changes the root cause: a single incorrect check on an infrequently exercised code path turned a stablecoin backed by real assets into one partially backed by nothing.

How an audit catches this

This is a case where line-by-line review of the "main" borrow/repay flow would have passed clean — the bug lived in the path nobody stress-tests. What we specifically do to catch it:

  • Treat every privileged or "emergency" function as first-class attack surface, not an afterthought exempted from the same scrutiny as the primary flows.
  • For every function that can change collateral or debt, write an invariant test asserting collateral(user) >= debt(user) evaluated on the resulting state, not a cached or pre-transaction value — and run it specifically against pause/emergency/liquidation branches.
  • Use property-based and invariant fuzzing (Foundry invariant tests or Echidna) to hammer boundary and rarely-called paths with flash-loan-style, multi-call sequences inside one transaction, which is exactly the shape this exploit took.
  • Have a second reviewer independently re-derive the solvency math from the contract's actual state reads, rather than accepting that a function named for what it claims to check actually checks it.

This is core to how we run a smart contract audit: we scope engagements to include every state-mutating entry point, emergency paths included, and we back manual review with invariant testing rather than treating a clean read-through as sufficient. It's also why we push clients toward a standing code review and audit relationship rather than a single pre-launch pass — this exact bug class recurs any time a codebase adds a new withdrawal or liquidation path after the original audit closed. The same discipline — checking that access-control assumptions in a permit-style flow actually hold at execution time rather than at design time — is what we walk through in our piece on Permit2-style gasless approvals and their exposure to sniping bots.

Remediation

  • Recompute solvency from live, per-account state at the point of every withdrawal or debt-affecting call — never from a cached aggregate or a value read before the state change is applied.
  • Apply the identical solvency invariant to every code path that moves collateral, including emergency and pause-mode withdrawals; an "emergency" label is not license to skip the check, only to change how funds are routed.
  • Add a circuit breaker keyed to anomalous single-transaction mint or withdrawal volume, so a multi-call exploit sequence trips a pause before it fully drains the pool.
  • Require invariant and fuzz test coverage on every state-mutating function as a release gate, not just the functions exercised by the integration test suite.
  • Re-audit after any change to vault or debt-accounting logic, even changes framed as "just adding an emergency path" — those are, in practice, the highest-risk additions to a lending or CDP system.

If your protocol has a debt-and-collateral model with more than one way to move funds out, it's worth having someone independently verify that every one of those paths enforces the same solvency invariant — that's the kind of review we do at TierZero.

Need your contracts audited?

Manual review + tooling across TON, Solana and EVM — certificate and full report included.

Get an audit