Skip to content
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

fix: write instant price to oracle and don't write liquidity data #223

Merged
merged 79 commits into from
Nov 19, 2024

Conversation

xenide
Copy link
Contributor

@xenide xenide commented Mar 4, 2024

Motivation

  • when calculating TWAP, we need the instant price to calculate the accumulator that has elapsed since the last oracle write (which is almost all the time)

  • as discussed within the team, for a given base and quote token, the price feed (pair) to be used will be controlled by governance instead of which pair has more liquidity, so we're doing away with writing liquidity data in the observations

  • Additional math changes: the following problems have always been there but because the way we write tests we don't progress the time, no oracle writing was done so it wasn't caught. Now that we update the instant price at every block, we caught it

    1. StableMath::_computeLiquidityFromAdjustedBalances would overflow sometimes given very large inputs
      • Solution: introduce fullMulDiv for iterative algorithm for 2 arithmetic operations as overflow can happen for large numbers in those specific operations.
    2. StableOracleMath::calcSpotPrice would sometimes fail when normalized reserves are too small (e.g. 1e3) such that when multiplied and wad-ed down, the result is 0
      • Solution: return a default price of 1e18 if either numerator / denominator is 0
        • Risk assessment: if we're using the pair as an oracle probably it will have more liquidity than just 1-2 tokens (in normalized terms). And if there's so few tokens (1e3) we wouldn't be using the pair as a price feed.
      • Other solutions explored:
        • scaling all reserves by 1e18 if any of the reserves are < 1e18. This causes overflows in some test cases
        • checking if only derivativeY is 0, and then setting it to 1. This is insufficient as derivativeX can be zero too and make the function return 0 then cause a problem with the log compression
      • Potential solutions not yet explored:
        • instead of calculating precisely the spot price, we estimate it with simulating a swap with zero fees 0.01% of the pool's liquidity (to simulate a very small input). This should be close enough
        • use another impl to calculate spot price. Though this impl may be susceptible to the same problem we're facing (going to 0 for small inputs)

Solution

xenide added 30 commits March 4, 2024 23:19
src/ReservoirPair.sol Outdated Show resolved Hide resolved
src/ReservoirPair.sol Outdated Show resolved Hide resolved
Comment on lines 236 to 239
// Attacker could repeat this for every second by only paying the swap fees. Thw mitigation factors on our end are that:
// 1. Arbitrage bots could submit txs that get sandwiched between the two malicious txs. How realistic is this if the attacker broadcasts 2 txs simultaneously (or even better, make it atomic via a contract call)?
// - seems quite unlikely to me, cuz by the time the the block is included, the price would not have changed much to the arb bots, unless they are really sophisticated
// 2. The effect of the clamp
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What does this attack achieve? Every time the attacker trades back to normal price they update the accumulator with the correct price. So they're actually just making the oracle more accurate? The instant price only affects for 1 second and if we clamp the rate of change per second that shouldn't matter much at all

Copy link
Contributor Author

@xenide xenide Mar 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nope, because we only update the 1st trade of the timestamp, when they trade / arb back to the normal price in the same block, nothing happens to that oracle reading in the 2nd trade of the block.

In the same block
1st trade - manipulate oracle, inaccurate instant prices written to oracle
2nd trade - arb it back so no one else does it, oracle sample unchanged

Attacker monitors the pair to make sure no other trades take place: that way the instant prices get time to accumulate. If someone comes in to trade, the instant prices get yanked towards fair price. The attacker can repeat the same 2-trade action to mess with the instant prices again, and continue to monitor.

To the outside world monitoring prices, it didn't change much. But sitting in the oracle sample is the manipulated instant prices waiting to be written and accumulated.

This test case demonstrates that perhaps updating instant prices at every trade is necessary.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instant prices don't get written into the oracle but you mean because the oracle price accumulates weight until the next trade it can influence the oracle up the max trade amount?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like cleanest solution is to just write the oracle every swap?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes the cleanest is to write the oracle every swap, and only updating the instant price if it's not the first swap of the block, which is the previous impl with the else branch.

This is the only way to force any manipulators to put their capital at risk (increase the cost) when moving prices, as there is no guarantee they can arb it back in the next block. And if they arb it back in the next block, then the manipulation only lasted for 1 second

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the need for an else if you write the update on every swap, because otherwise it will move the index?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, then we'll have multiple samples per timestamp which creates problems when reading the oracle.

The alternative is to read if lPrevious.timestamp is the current timestamp we're attempting to write, and if so, only overwrite the instant prices. But that's basically an else as well


// a new sample is not written for the first mint
// shortcut to calculate lTimeElapsed > 0 && aReserve0 > 0 && aReserve1 > 0
if (lTimeElapsed * aReserve0 * aReserve1 > 0) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just want to document this for posterity:

I attempted to simplify this condition to only lTimeElapsed > 0, which therefore implies that an oracle sample is created in the very first mint. However, I did not adopt this approach in the end for one edge case. In the edge case of a swap happening after the very first mint (pair creation). the previous timestamp would not be zero, and therefore the clamped price would not be correctly calculated (it'd always be 0 cuz the time elapsed is 0).

So it's back to the original approach of writing only the instant prices for the very first block (regardless of how many things happen in that block), and use the previous oracle observation as a "placeholder" to temporarily store the instant prices for accumulation in the future. This approach is more semantically correct as we're not "creating" a sample (as we don't write a timestamp), but instead just using it as a placeholder.

* chore: import `Buffer.sol` verbatim from balancer
* chore: upgrade solidity version
* fix: set buffer size to 2048
* fix: add unchecked
* fix: use `next()` to get next oracle index
* test: fix expected value
* test: add tests for `Buffer`
* ci: update hashes
* feat: introduce max change per trade
* feat: implementing per trade limited price
* feat: insert assert statement
* test: fix expected emit log
* fix: underflow problem for price reduction case
* test: add tests for different clamp scenarios
* test: rm blank line
* docs: update comment
* fix: update impl to clamp the rate of change
* test: more cases
* test: show how long it takes for a pair to recover from malicious mint
* lint: forge fmt
* test: update case with diff parameters
src/ReservoirPair.sol Show resolved Hide resolved
src/ReservoirPair.sol Outdated Show resolved Hide resolved
src/ReservoirPair.sol Outdated Show resolved Hide resolved
@xenide xenide merged commit 251fc5a into dev Nov 19, 2024
5 of 9 checks passed
@xenide xenide deleted the fix/oracle-struct branch November 19, 2024 07:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants