sherlock-2025-04-burve-m06

[M-06] Incorrect earnings calculation in removeValueSingle() function causes partial user losses

보고서

Summary

removeValueSingle 함수에서 trimBalance를 호출하기 전에 자산을 제거하므로 사용자의 수익이 부분적으로 손실된다. 이는 자산의 remove 함수가 수익 계산 시 값당 수익 변수의 최신 값을 사용하지 않고 오래된 값을 사용하기 때문이다.

Keyword

bug, logic flaw, yield lost

Vulnerability

유저는 removeValueremoveValueSingle를 호출하여 예치금을 출금한다. 이 때 Store.assets().remove()를 호출한다. Store.assets().remove() 함수는 유저의 예치금에서 발생한 이자를 정산한 뒤(유저 레벨의 정산) 유저의 유동성을 제거한다.

Store.assets().remove() 호출이 종료되면 Closure.removeValueSingle()를 호출하여 지난번 정산 시점 이후로부터 새로 발생한 수익을 정산한 뒤(프로토콜 레벨에서의 정산) 클로저의 유동성을 업데이트 한다.

Store.assets().remove()를 먼저 호출하기 때문에 출금자는 이번에 정산한 새로 발생한 수익은 받을 수 없다.

function removeValueSingle(
    address recipient,
    uint16 _closureId,
    uint128 value,
    uint128 bgtValue,
    address token,
    uint128 minReceive
) external nonReentrant returns (uint256 removedBalance) {
    if (value == 0) revert DeMinimisDeposit();
    require(bgtValue <= value, InsufficientValueForBgt(value, bgtValue));
    ClosureId cid = ClosureId.wrap(_closureId);
    Closure storage c = Store.closure(cid); // Validates cid.
    VertexId vid = VertexLib.newId(token); // Validates token.
@>  Store.assets().remove(msg.sender, cid, value, bgtValue); // 출금자의 수익 정산 및 유동성 제거
@>  (uint256 removedNominal, uint256 nominalTax) = c.removeValueSingle( // 새로 발생한 수입 정산 및
        value,
        bgtValue,
        vid
    );
    uint256 realRemoved = AdjustorLib.toReal(token, removedNominal, false);
    Store.vertex(vid).withdraw(cid, realRemoved, false);
    uint256 realTax = FullMath.mulDiv(
        removedBalance,
        nominalTax,
        removedNominal
    );
    c.addEarnings(vid, realTax);
    removedBalance = realRemoved - realTax; // How much the user actually gets.
    require(removedBalance >= minReceive, PastSlippageBounds());
    TransferHelper.safeTransfer(token, recipient, removedBalance);
}

Store.assets().remove() 함수는 유저의 수입을 먼저 정산한 다음 사용자의 유동성을 제거한다. 유저의 수입을 정산하기 위해 Asset.collect를 호출하면 수입을 정산하는데, 발생한 수입을 계산하는 데 사용되는 Store.closure(cid).getCheck() 함수가 오래된 데이터를 리턴한다. epvX128 변수는 해당 클로저의 전체 수입을 의미한다. 아직 최신화되지 않았으므로 오래된 값을 리턴한다.

function remove(
    AssetBook storage self,
    address owner,
    ClosureId cid,
    uint256 value, // Total
    uint256 bgtValue // BGT specific
) internal {
@>  collect(self, owner, cid);  
    ···  
    unchecked {  
        a.value -= value;  
        a.bgtValue -= bgtValue;  
    }  
}
 
function collect(
    AssetBook storage self,
    address recipient,
    ClosureId cid
) internal {
    (
@>      uint256[MAX_TOKENS] storage epvX128,
        uint256 bpvX128,
        uint256[MAX_TOKENS] storage unepbvX128
    ) = Store.closure(cid).getCheck();
    Asset storage a = self.assets[recipient][cid];
    uint256 nonBgtValue = a.value - a.bgtValue;
    for (uint8 i = 0; i < MAX_TOKENS; ++i) {
        // Fees
@>      a.collectedBalances[i] +=
            FullMath.mulX128(
@>              (epvX128[i] - a.earningsPerValueX128Check[i]),
                nonBgtValue,
                false
            ) +
            FullMath.mulX128(
                (unepbvX128[i] - a.unexchangedPerBgtValueX128Check[i]),
                a.bgtValue,
                false
            );
        a.earningsPerValueX128Check[i] = epvX128[i];
        a.unexchangedPerBgtValueX128Check[i] = unepbvX128[i];
    }
    a.bgtBalance += FullMath.mulX128(
        bpvX128 - a.bgtPerValueX128Check,
        a.bgtValue,
        false
    );
    a.bgtPerValueX128Check = bpvX128;
}

Impact

유저가 수익을 원래 받아야 하는 것보다 적게 받는다.

Mitigation

Closure.removeValueSingle()를 먼저 호출한 뒤 Store.assets().remove() 함수를 호출한다. 클로저 수익을 최신화한 뒤 유저 수익을 정산한다.


tags: bughunting, burve, smart contract, solidity, severity medium, logic flaw, yield lost