-
Notifications
You must be signed in to change notification settings - Fork 5
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
0xbranded - Positions Can Fail to Close Due to Accounting Failure #86
Comments
Escalate Issue is not a valid dup of #96. Issue suggests and underflow, due to def deduct(x: uint256, y: uint256) -> Val:
if x >= y: return Val({remaining: x - y, deducted: y})
else : return Val({remaining: 0, deducted: x}) |
You've created a valid escalation! To remove the escalation from consideration: Delete your comment. You may delete or edit your escalation comment anytime before the 48-hour escalation window closes. After that, the escalation becomes final. |
this issue is not referring to funding paid exceeding collateral of individual positions.
as described in the note above, this issue refers to the scenario where global funding received exceeds global funding paid
the base/quote collateral was decreased by the higher funding received. But the payer increases it by a lesser amount. So a net decrease in collateral results for the side of the pool getting paid funding. on that side of the pool, now positions could fail to close due to this delta: quote_collateral: [self.MATH.PLUS(fees.funding_paid),
self.MATH.MINUS(pos.collateral)], note that these deltas are used in so there are scenarios where overpaid funding results in insufficient collateral for a position to close, if the overpayment similar to #96 |
If there's no new positions, the collateral will be decreased by maximum the current collateral
Also incorrect, #96 describes a much more sophisticated set of transactions allowing for the state in which underflow occurs. Your statements above are incorrect and would not lead to underflow. |
Funding paid cannot exceed collateral of a position. This was not only acknowledged but formed the basis of this issue. In the event that funding paid for a given position is capped at its collateral value, funding received by the counterparty will not reflect this capped value but can increase unboundedly.
Yes, this is precisely why closing the position can fail. This net change will always be ≤ 0. This issue specifically occurs when the change is negative, and the remaining collateral in the pool is less than the subtracted value (can occur bc The issue described is the same. The recommended mitigation reflects this as well. Apologies if it's still unclear, I'm happy to create a PoC. |
invalid funding_received cannot be greater than funding_paid
let's assume consider add this test to def test_funding_fee_greater_than_collateral(setup, VEL, STX, pools, open, long, close, short, positions):
setup()
open(VEL, STX, True, d(1000), 10, price=d(1), sender=long)#position 1
open(VEL, STX, False, d(10), 10, price=d(1), sender=short)
chain.mine(12000)#funding_paid exceed user collateral
fee = positions.calc_fees(1)
assert fee.funding_paid == 999_000_000 #its not exactly becuase of protocol fee
close(VEL, STX, 1, price=d(1), sender=long)
close(VEL, STX, 2, price=d(1), sender=short)
pool = pools.lookup(1)
assert pool.quote_collateral == 0 |
This calculation does not reflect what is done in the code. As you mention, the calculation is: paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms)
received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) so The numerator is not |
and paid_short_term also is greater than received_short_term becuase received_short_term = paid_short_term / short_collateral |
Here's my understanding of the situation where funding paid would exceed the position's collateral:
Hence, |
That's not quite what this issue was describing.
The point is that it's possible for |
Maybe I'm missing something, but I don't understand where funding received would be larger than funding paid in this case. Could you send the exact link where it would happen, cause I don't see it in the report (or at least doesn't explain it well enough)? |
The problem lies in this logic (WLOG consider longs paying shorts): paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms)
received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) The funding rate paid by longs is multiplied by the total long collateral, and this is divided between all of the short collateral. This is how the funding received by shorts is calculated. However, negative positions will still be counted in this long_collateral as long as they have not been liquidated yet. So they will still contribute to the global funding fee receipts by shorts. This portion of funding_received is incorrect, as the total payment made by longs at the time of closing is capped by the position's collateral. But as shown above, since the global long collateral is used for calculating funding_received, the collateral of these positions should not be included in the calculation. Thus there will be some amount of funding_received which was never accounted for in the total funding_paid of all longs. This will cause insolvency of positions and prevent closing some short positions as less funds were paid by longs than was withdrawn by shorts. While the issue was originally pointing out that the code was not to spec (hence medium), upon reinspection I am requesting consideration for high severity for this issue, since it disrupts a core functionality and potentially causes loss of funds. |
@msheikhattari can you make a numerical POC with exact numbers that would lead to this situation happening (I see there's already a numerical POC from another Watson, but it was about this issue being invalid. Hence, I would like to see a POC for a valid case, since I'm not completely convinced this scenario is indeed possible. |
If no answer is provided, planning to accept the escalation, de-dup and invalidate this issue. |
Just as an example, assume a long has collateral of c1 : Val = self.deduct(c0, fees.funding_paid)
c2 : Val = self.deduct(c1.remaining, fees.borrowing_paid)
# Funding fees prioritized over borrowing fees.
funding_paid : uint256 = c1.deducted However, when determining the funding received by the shorts, the funding is multiplied by the long collateral which continues to accrue with passage of time: paid_long_term : uint256 = self.apply(fs.long_collateral, fs.funding_long * new_terms)
received_short_term : uint256 = self.divide(paid_long_term, fs.short_collateral) For the sake of simplicity,
def calc(id: uint256, long: bool, collateral: uint256, opened_at: uint256) -> SumFees:
period: Period = self.query(id, opened_at)
...
R_f : uint256 = self.multiply(collateral, period.received_long) if long else (
self.multiply(collateral, period.received_short) )
return SumFees({funding_paid: P_f, funding_received: R_f, borrowing_paid: P_b}) So whichever short closes first gets }) if pos.long else Deltas({
base_interest : [],
quote_interest : [self.MATH.MINUS(pos.interest)],
base_transfer : [],
base_reserves : [self.MATH.PLUS(pos.collateral),
self.MATH.MINUS(fees.funding_paid)],
base_collateral : [self.MATH.PLUS(fees.funding_paid),
self.MATH.MINUS(pos.collateral)],
quote_transfer : [self.MATH.PLUS(pnl.payout),
self.MATH.PLUS(fees.funding_received)],
quote_reserves : [self.MATH.MINUS(pnl.payout)],
quote_collateral: [self.MATH.MINUS(fees.funding_received)], # <-
}) Leaving Note that this was a generic example to go through the flow, in practice there will be many more positions and the numbers will not be so clean, but the principle stands. |
Incorrect, the withdraw will not fail due to the following lines of code within funding_received: uint256 = 0 if remaining == 0 else (
# 2) funding_received may add up to more than available collateral, and
# we will pay funding fees out on a first come first serve basis
min(fees.funding_received, avail) ) |
I believe the comment above is correct and that is what I was trying to say before. The code works correctly and there would be no issue. The decision remains, accept the escalation, de-dup and invalidate the issue. |
Result: |
Escalations have been resolved successfully! Escalation status:
|
0xbranded
Medium
Positions Can Fail to Close Due to Accounting Failure
Summary
Due to how funding fee payments are accounted for in the system, closing a position can fail due to insufficient collateral balance. This violates one of the system invariants that
any open position can always be closed
. It can also interfere with liquidations and cause loss of user funds in the event that the position moves unfavorably in the meantime.Vulnerability Detail
Due to the asynchronous nature of how funding fees are paid from one side of a pool to the other, it's possible to end up in a situation where there is not a large enough collateral balance to satisfy the return of collateral to a user's positions. In fact, this is even acknowledged and partially accounted for within
calc_fees
:This funding received is in turn deducted from the collateral of the other side of the pool:
However, as acknowledged above due to negative positions the funding receiving can exceed the paid amount, which can result in 0 collateral tokens remaining in the pool for the other side.
At this stage if a position on the other side of the pool tried to close, it would almost always fail:
since the position's collateral which is subtracted is always greater or equal to the funding fees it paid. Ironically the only positions which can still be withdrawn at this time are those which have underpaid their funding fees, so
fees.funding_paid
added equalspos.collateral
subtracted.Impact
`core.vy includes a specification for one of the invariants for this protocol:
This invariant is broken as there are some edge cases where positions are unable to close (they are not always able to). Thus the current code is not to spec. Additionally, closing positions is a time-sensitive operation, especially related to liquidations, and there is the possibility for a position to move unfavorably during the period in which it cannot be closed.
This can be alleviated by additional depositors on the other side of the pool bringing in additional collateral. Additionally, it's not likely to occur, however it is acknowledged by the team as a possibility due to the handling of negative positions in funding fee payments.
Code Snippet
Tool used
Manual Review
Recommendation
In order to avoid this scenario the funding payment a user is receiving should never exceed the collateral balance of the pool. This accounting mismatch should be accounted for far in advance of this occurring.
Consider modifying the funding payment / receipt structure, either by changing its accounting or making more synchronous payments with receipts.
These approaches can be implemented in a number of ways. For example, when funding fees are underpaid, track the exact quantity of underpayment and apply a pro rata / market adjustment to future receipts of that token funding payment.
The text was updated successfully, but these errors were encountered: