Exploit post-mortems
/
Huma Finance V1
Polygon
Validation gap

Huma Finance V1's $101K Loss and How Olympix Would Have Prevented It

Loss
Loss
Exploit date
May 11, 2026

Date: May 11, 2026

Chain: Polygon

Loss: 82,315.57 USDC and 19,074.73 USDC.e, about $101.4K

Class: a credit line reached good standing without ever being approved

On May 11, 2026, at 14:19:25 UTC, one transaction on Polygon drew down three credit lines from three Huma Finance V1 pools and walked away with 82,315.57 USDC and 19,074.73 USDC.e, a total of 101,390.30 in stablecoins. The borrower had never been approved for credit by anyone. Less than four minutes earlier it had asked for a credit line, less than a minute earlier a second contract had asked each pool to refresh that borrower's account, and the refresh alone was enough to turn a pending request into an active line the pools were willing to pay out against.

The pools were part of Huma's original V1 system, a set of BaseCreditPool contracts on Polygon that the team was winding down. Huma said in its disclosure, as reported by crypto.news, that its Solana-based V2, its PayFi deposits and its PST token weren't affected, and that it had paused all remaining V1 contracts. What the attacker took was the stablecoin left sitting in the old pools.

Credit Lines That Needed a Human Yes

Huma V1 was built for credit. A borrower asks a pool for a line by calling requestCredit(), which creates a credit record in the Requested state. An evaluation agent, a service account the pool trusts, reviews the request and calls approveCredit(), which moves the record to Approved and sets its terms. Only then can the borrower call drawdown() and receive pool funds, and after the first drawdown the record sits in GoodStanding while the borrower pays on schedule.

The states are listed in the contract's verified source on Polygon Blockscout, in the BaseStructs library:

enum CreditState {
    Deleted,
    Requested,
    Approved,
    GoodStanding,
    Delayed,
    Defaulted
}

Every pool guarded the approval step properly. approveCredit() calls onlyEAServiceAccount() before it does anything, so no outside address can approve itself. requestCredit() is deliberately open, with a comment in the source that reads "Open access to the borrower", because anyone should be able to ask. The design depends on one rule that the approval step can't enforce on its own: nothing other than an approval should ever move a request forward.

The Refresh That Skipped the Review

refreshAccount() exists to bring a borrower's bill up to date, recalculating what's due and rolling the due date forward when a payment period passes. Anyone can call it for any borrower who isn't in default. The verified BaseCreditPool source shows the whole function:

function refreshAccount(address borrower)
    external
    virtual
    override
    returns (BS.CreditRecord memory cr)
{
    if (_creditRecordMapping[borrower].state != BS.CreditState.Defaulted) {
        if (isDefaultReady(borrower)) return _updateDueInfo(borrower, false, false);
        else return _updateDueInfo(borrower, false, true);
    }
}

The work happens in _updateDueInfo(), which ends by setting the record's state from its payment history. When a period has passed and the borrower hasn't missed any payments, it writes GoodStanding:

if (cr.missedPeriods > 0) {
    if (cr.state != BS.CreditState.Defaulted) cr.state = BS.CreditState.Delayed;
} else cr.state = BS.CreditState.GoodStanding;

That line never asks what state the record was in before. For a borrower who has been drawing down and paying, writing GoodStanding is correct. For a record that's still Requested, it's a promotion straight past the evaluation agent. According to DarkNavy's analysis, the _updateDueInfo path can set GoodStanding without checking that the record was ever Approved, and that's exactly what happened to the attacker's three records.

The last piece is drawdown(). Before paying out, it runs _checkDrawdownEligibility(), which accepts exactly two states:

if (cr.state != BS.CreditState.GoodStanding && cr.state != BS.CreditState.Approved)
    revert Errors.creditLineNotInStateForDrawdown();

A record promoted by a refresh passes that check like any real borrower would. From there the drawdown is limited by the credit limit stored with the request, a number the requester picked for itself, and by how much the pool still held.

Three Transactions in Under Four Minutes

The attacker's wallet, 0x13B4...0DaF, started at 14:15:43 UTC by deploying a helper contract that requested credit from all three pools. DarkNavy reports the CreditInitiated events from that transaction, with each record created in the Requested state and the stored limits set at 10,000,000 USDC on the largest pool, 60,000 USDC.e on the second and 500,000 USDC.e on the third.

At 14:18:29 UTC, a different address deployed a second contract that called refreshAccount() on each pool for the helper's address. DarkNavy found the three records in GoodStanding after that transaction, with no CreditApproved event anywhere between the request and the drain.

At 14:19:25 UTC, the attacker's wallet called executeCalls() on the helper, which drew down from each pool in turn: 82,315.571143 USDC from 0x3EBc...6FcE, 17,290.75983 USDC.e from 0x9553...00a7 and 1,783.970571 USDC.e from 0xe892...47E5. The helper then swept the stablecoins to the attacker's wallet. There was no flash loan, no swap and no price oracle anywhere in the attack, and DarkNavy notes the DrawdownMade events show equal gross and net amounts, so no fees were taken on the way out.

Huma's own post put the total at about $101.4K across USDC and USDC.e, which matches the on-chain sum of 101,390.30. Co-founder Richard Liu called the exploit "a hard lesson" in comments reported by AMBCrypto.

A Retired System Still Holding Money

Both of the implementations behind the three pools are verified, with the same requestCredit, refreshAccount, _updateDueInfo and drawdown logic according to DarkNavy. The bug didn't depend on anything new, and it didn't need the pools to be busy. It needed a pool with funds still in it and a public path that wrote GoodStanding onto a record the evaluation agent had never seen. Contracts that are being wound down keep every entry point they had while active, and the stablecoin left in them is worth exactly as much as it was when the pools were in daily use.

How Olympix Would Have Traced the Path Around the Evaluation Agent

Olympix reads a contract the way an attacker does, as states, transitions and the entry points that drive them. In BaseCreditPool those are easy to name: the CreditState enum is the state, and requestCredit, approveCredit, refreshAccount and drawdown are the transitions. The design also states its own invariant through the code that guards it. approveCredit sits behind onlyEAServiceAccount, so the rule is that no credit record should reach a state that allows a drawdown unless the evaluation agent approved it first.

Olympix's static analysis would have surfaced the signal here: a public function, reachable by any caller for any borrower, that writes GoodStanding to a credit record without reading the record's previous state, in a contract where the only intended route out of Requested is gated by a service account. That narrows the space of paths from everything the pool can do to the handful that change a record's state without the evaluation agent's involvement.

BugPoCer would then have tried to break the invariant. Counterexample-Guided Invariant Refutation works outward from the system as a whole, searching for a sequence of calls that ends in a state the rule forbids, and in this pool the sequence is short. A fresh address calls requestCredit with a large limit, anyone calls refreshAccount for that address, and the address calls drawdown and receives pool funds with no approval ever recorded. BugPoCer would have returned that sequence as a counterexample with the minimal conditions it needs and a generated Forge test that replays it, which is the May 11 drain produced in development and run against a test copy of the pool.

Because Olympix runs at every merge and before every release, the check is tied to the code rather than to whether a system is new. A deprecated pool's code gets the same treatment as a launch candidate's, and a state machine with one transition that forgets to look at where it's coming from is the kind of finding that stays relevant for as long as the contract holds a balance.

The Check Worth Writing Into Every State Machine

The fix for this class of bug is small. Any function that moves a record into a privileged state should require the specific state it's allowed to move from, and the eligibility check at the point of payment should agree with that rule. Huma's V1 design had the right rule in its approval function and the right list of payable states in drawdown, and one shared helper connected them in a way nobody intended. To see how Olympix maps every transition in your own contracts and hands back a runnable counterexample for each one that breaks, book a technical walkthrough.

On-Chain References

Protocol: Huma Finance V1 BaseCreditPool (Polygon)

Chain: Polygon

Date: May 11, 2026, 14:19:25 UTC

Loss: 82,315.571143 USDC and 19,074.730401 USDC.e

Attacker EOA: 0x13B4...0DaF

Helper contract: 0x44D4...22A3

Refresh contract: 0xEF8A...e1b2

Pool (USDC): 0x3EBc...6FcE

Pool (USDC.e): 0x9553...00a7

Pool (USDC.e): 0xe892...47E5

Implementation: 0x5710...EA1c

Credit request tx: 0x0adf...f5aa

Refresh tx: 0x7126...7e5e

Exploit tx: 0x7b8d...a359

Block: 86725404

See what Olympix finds on your own code

Send us a repo that's already been audited, and we'll run Olympix at the same commit and show you what comes back.