All writeups
AccountingDeFiAudit

Pear Protocol: A Fee Setter That Erased Every Referrer Payout

June 26, 20267 min read

This one was a team job. I reviewed Pear Protocol V1 alongside tsvetanovv (cvetanovv) under the Shieldify banner — a private engagement, with the report published afterward. tsvetanovv is a great auditor with sharp intuition, the kind of person you want on the other side of a contract when you're both staring at the same fee math trying to decide whether it adds up. A lot of audit work is two people arguing with the code until it confesses, and this finding came out of exactly that.

Pear is an on-chain pairs-trading protocol. The pitch is that you can open a leveraged long and a leveraged short in correlated assets inside a single transaction, say long BTC while short ETH, without juggling two venues and two custody arrangements. It routes mostly through GMX, wraps positions as ERC-721s so they're composable, and runs its fee and discount logic through a central PlatformLogic contract. This was Shieldify's second pass on Pear, so the focus was the newly added functionality rather than the whole stack again.

Fee accounting is where I always slow down on a protocol like this. Trading fees, referee discounts, referrer kickbacks, staker discounts, treasury splits. Every one of those is a number that moves between accounts, and any place a number moves is a place a number can quietly go to the wrong account or vanish. The referral system was the part that caught us, and the bug underneath it was almost embarrassingly small once you saw it.

The engagement

Before any of that, the boring part. We pulled the repo at the review commit, built it, read the scope end to end. PlatformLogic.sol is the heavy file in this codebase, around 570 nSLOC, and it owns the fee structure: referral discounts to pull in new traders, staker discounts to reward people who lock PEAR, and the bookkeeping that decides who eventually gets paid. When two auditors split a file like that, you each form a mental model and then you compare them. The disagreements are where the bugs live.

The function that didn't survive that comparison was _applyPlatformFeeETHGmx, the internal routine that calculates and applies platform fees in ETH for a trade. GmxFactory calls into it, and it's the spot where referee discounts and referrer payouts get worked out.

The finding

Here's the relevant branch. If the user (_referee) was brought in by someone, the contract computes the referee's discount, then the slice the referrer is owed, then records it:

function _applyPlatformFeeETHGmx(
    address adapter,
    address _referee,
    uint256 _grossAmount,
    address _factory
) internal returns (uint256, uint256) {
    // code
    if (referredUsers[_referee] != 0) {
        uint256 _refereeDiscount = calculateFees(_feeAmount, refereeDiscount);
        _feeAmount -= _refereeDiscount;

        // calculate the referrer discount
        // for testing the referralFee is set to 5 bps
        uint256 _referrerWithdrawal = calculateFees(_feeAmount, referrerFee);

        bool success =
            IPearGmxFactoryV2(_factory).feeTransferPlatformLogic(_feeAmount);

        if (!success) {
            revert Errors.PlatformLogic_NotEnoughBalance();
        }

        setPendingReferrerFeeAmount(
            adapter, checkReferredUser(_referee), _referrerWithdrawal
        );

        uint256 _grossAmountAfterFee = _grossAmount - _feeAmountInUsd;
        emit FeesPaid(_referee, _feeAmount, _grossAmountAfterFee);

        // remove the discounted % referee withdrawal
        _feeAmount -= _referrerWithdrawal;
        return (_feeAmount, _referrerWithdrawal);
        // code
    }
}

The line that matters is setPendingReferrerFeeAmount. It's the protocol's promise to the referrer: "you've earned this much, come collect it later." A referrer is supposed to accumulate fees across every trade their referees make, then withdraw the running total. So the only sane way for that setter to behave is to add the new amount on top of whatever's already pending.

It didn't. Here's the setter:

function setPendingReferrerFeeAmount(
    address adapterAddress,
    address referrer,
    uint256 amount
) internal {
    pendingReferrerFeeAmounts[adapterAddress][referrer] = amount;
    emit SetPendingReferrerFeeAmount(adapterAddress, referrer, amount);
}

A plain assignment. =, not +=. Every time a referee traded and triggered this path, the contract didn't credit the referrer their new fee. It threw away everything they'd accrued and replaced it with whatever the latest single trade earned.

How we found it, and why it's easy to miss

This is the sort of bug that reads as correct in isolation. The setter does exactly what its name says, sets a pending amount, and if you only ever look at that function you'll nod and move on. The mistake only shows up when you hold two facts in your head at once: the variable is named pendingReferrerFeeAmounts, plural, a running balance; and the call site fires on every single trade. A running balance written with a bare assignment is a contradiction. The name says accumulate, the code says replace.

That's why having two of us on the file helped. tsvetanovv and I weren't reading the setter, we were reading the lifecycle of a referrer's balance from the trade that creates it to the withdrawal that drains it, and the assignment broke the line between them. You catch this by following the value, not by linting the function.

It also hides behind a comment that all but admits the code is mid-construction, for testing the referralFee is set to 5 bps. Half-finished fee logic is fertile ground. The author was clearly still wiring this up, and the accumulate-versus-overwrite decision is exactly the kind of thing that gets deferred and then forgotten.

Impact

Concretely: a referrer brings in traders, those traders generate fees, and the referrer's pending balance should climb trade after trade. Instead it gets stomped to the value of the most recent trade each time. The second a referee makes a second trade, the fee from the first is gone. Withdraw and you collect a single trade's worth instead of weeks of earnings.

We rated it High. The whole point of a referral program is that it pays out reliably; if it silently deletes what people earned, it's worse than not having one, because users were promised something the contract can't deliver. There's no exotic precondition here either. It's not an attacker draining a pool, it's the protocol quietly losing its own users' money on the normal happy path. Every active referrer is affected, and the more successful they are, the more they lose. That combination, certain to happen and broadly damaging, is what pushes a "small" bug up the severity scale.

The fix

The fix is the one-character fix everyone hopes for and rarely gets cleanly. Add to the existing balance instead of overwriting it:

function setPendingReferrerFeeAmount(
    address adapterAddress,
    address referrer,
    uint256 amount
) internal {
    pendingReferrerFeeAmounts[adapterAddress][referrer] += amount;
    emit SetPendingReferrerFeeAmount(adapterAddress, referrer, amount);
}

That's the substance of what we recommended, accumulate _referrerWithdrawal onto the current value of the mapping rather than replacing it. The Pear team fixed it as proposed.

The lesson

I keep a short mental list of code shapes that earn extra scrutiny, and "bare assignment into a mapping that's supposed to be a balance" is near the top. Anything called pending, accrued, owed, balance, total, or rewards is a running tally by definition, and a running tally written with = is a bug until proven otherwise. The same instinct catches the mirror-image mistake too, the += somewhere that should have reset state and now grows without bound.

The deeper point is that these bugs don't announce themselves at the line they live on. The setter is fine. The call site is fine. The defect only exists in the relationship between them, in the assumption that a thing named like a balance behaves like a balance. You find it by tracing a value through its whole life, which is slower and less satisfying than scanning for obvious red flags, and it's most of why a careful audit takes the hours it does.

It's also a good argument for a second set of eyes. tsvetanovv and I caught this because we were reconciling two readings of the same fee flow, and the assignment was the seam where they didn't line up. If you're shipping fee, reward, or referral logic and want it read that way, by people who follow the money instead of skimming the functions, here's how I work.

Shipping something that needs this checked?

Get an independent smart contract audit before mainnet — manual review, Foundry testing, and fix verification.

Request an Audit