-
Notifications
You must be signed in to change notification settings - Fork 3
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: bug in snapshot time in the presence of low rps #220
Conversation
withdrawAt
withdrawAt
time
value during withdraw
Thanks for creating this PR, it indeed seems to be a problem - yet not sure if in Regarding your proposed solution, it doesn't seem to be a correct implementation as, when you normalize the amount returned by For a better understand please see this code example: function testUsdc() public pure returns (uint256, uint256, uint256, uint256) {
// rps ideally for 10 USDC per day
uint256 rps = 0.00011574074074074e18;
uint256 elapsedTime = 1 days;
uint8 decimals = 6;
// 9.999999999999936000
uint256 ongoingDebtNorm = elapsedTime * rps;
// 9.999999
uint256 ongoingDebtDen = denormalizeAmount(ongoingDebtNorm, decimals);
// 9.999999000000000000
uint256 ongoingDebtNormCalculatedInWithdraw = normalizeAmount(ongoingDebtDen, decimals);
// 86399 - it is normal to be less, since we divide: ongoingDebtNormCalculatedInWithdraw / rps
uint256 timeReCalculated = ongoingDebtNormCalculatedInWithdraw / rps;
return (ongoingDebtNorm, ongoingDebtDen, ongoingDebtNormCalculatedInWithdraw, timeReCalculated);
}
The tests failling is normal. If you change the withdraw function as the fuzz tests won't fail anymore: uint40 reconstructedTime = time;
uint40 snapshotTime = _streams[streamId].snapshotTime;
if (!_streams[streamId].isPaused && time > snapshotTime) {
// The normalization process can introduce a small discrepancy in the original `time` value due to loss of
// significant digits during the process. So we reverse the normalization calculation and reconstruct the
// `time` value to be used in `_updateSnapshotTime`.
uint128 normalizedAmount = (time - snapshotTime) * _streams[streamId].ratePerSecond.unwrap();
reconstructedTime =
uint40(_streams[streamId].snapshotTime + normalizedAmount / _streams[streamId].ratePerSecond.unwrap());
} |
Precisely why the bug exists. Just to give you an idea, lets assume the following sequence:
Here is a question for you, given a stream, should function You can find a range |
Consider a wrapped bitcoin with 6 decimals. A sender decides to stream 0.001e6 sBTC (~ $60) a day, which makes When the elapsed time is between 90 and 170 seconds, the withdrawal amount would always be 1 token. So, an attacker waits until the maximum of this range, i.e., 170 seconds, and triggers a withdrawal with The solution is to reverse calculate the time and update that as the snapshot time. In this case, since the net withdrawal would be 1 token, we should update the snapshot time as 90 when the time provided is in the range of 90-170. This is a realistic example. |
I am sorry, but I don't think your example is correct.
as you said: thus, am i missing smth? |
as discussed on Slack, the problem occurs when also, the problem arises when the uint rps = 0.000000115740740740e18;
uint elapsedTime = 2 seconds;
uint8 decimals = 6;
// this will return 0, but the snapshotTime will be update with +2
function testWithdraw() public view returns (uint) {
uint ongoingDebtNorm = elapsedTime * rps;
return denormalizeAmount(ongoingDebtNorm, decimals);
}
my proposed solution is to add these to withdraw function: // Update the snapshot time only if the ongoing debt is greater than 0.
if (ongoingDebt > 0) {
_updateSnapshotTime(streamId, time);
}
|
At 170s, ongoing debt returns 1 but at 180s it would return 2. So theoretical value of ongoing debt is 1.96 (rps * elapsedTime = 0.000000011574e18 * 170) but due to denormalization, it returns 1. You can supply any value of time in the range [90, 170] and it would always return 1. The loss is zero, if the withdraw is called with
The example I provided has a non zero ongoing debt. So the issue can exist with any value and not just zero. |
oh sheesh, got it now, thanks for re-exlaining |
This comment was marked as duplicate.
This comment was marked as duplicate.
ef676b4
to
a33cbde
Compare
97b1728
to
f3e4659
Compare
I found an easier way to replicate this issue locally. Bound I suggest lets reduce the minimum rps bound to 0.0000001e18. Then there is no need to add a new integration test to focus on low RPS with a 6-decimal token as it is now being covered by the fuzz test itself. Added in my latest commit. Now few more tests fail and the goal is to resolve them all. |
time
value during withdraw
yeah, you are right, it is easier this way |
83b0bf8
to
c100652
Compare
@andreivladbrg feel free to give it a review tomorrow and ask as many questions as come to your mind. |
7046fb2
to
2a8fc5e
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it safe to say, that the problem appears only for tokenDecimals < 18
?
If yes, should we optimize the _ongoingDebt
so that corrected time is calculated only in this case?
Have not reviewed fork tests yet.
Leaving this comments for now.
src/SablierFlow.sol
Outdated
uint128 solvencyAmount = Helpers.normalizeAmount(balance - totalDebt, _streams[streamId].tokenDecimals); | ||
/// @dev For a given depletion time t, there could exist a range [t, t+δ] such that the ongoing debt would | ||
/// result in the same value due to denormalization. We are interested into finding the upper bound, t+δ, | ||
/// for the depletion time. This can be discovered by adding 1 to the denormalized balance while calculating |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, why does adding 1 solves this?
i think what would solve this is, adding the MVT (min value transferable) - in case of USDC is 0.000001e6
also, we should add 1 on if the token decimals is not 18
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i think this is why the CI failed: https://github.com/sablier-labs/flow/actions/runs/10705430477/job/29680865435
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
why does adding 1 solves this?
I think its a bad solution to add 1 here. I am still trying to figure it out. Will keep you updated.
adding the MVT (min value transferable) - in case of USDC is 0.000001e6
It should be max value of time. Depletion time is beyond which, uncovered debt will be > 0. And its going to be a range [t1, t1 + δ], and during this range, the uncovered debt will still be zero. So I was trying to return t1 + δ
so that if we warp to t1 + δ + 1
, the uncovered debt will become > 0.
Otherwise if we return t1, at t1 + 1, the uncovered debt may still be zero if its less than t1 + δ.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I fixed it and added it back. have a look know and lmk what you think.
/ flow.getRatePerSecond(streamId).unwrap() + flow.getSnapshotTime(streamId) | ||
); | ||
|
||
uint128 residualDebt = getDenormalizedAmount({ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
hmm, why do we need this since the idea behind corrected time is that it should have the same ongoing debt den?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new total debt = previous total debt at the corrected time (and not at block.timestamp
) - withdrawnAmount.
We had uint128 expectedTotalDebt = totalDebt - withdrawAmount
before but since the actual values can only be calculated using the correctedTime
, the residual debt accounts for that. The point is there is no notion of debt at block.timestamp
anymore. Any debt calculated at current time is basically debt at the corrected time which would be slightly less than the block.timestamp
.
So either we calculate residualDebt
like this or we calculate the previous total debt as sum of snapshot debt and ongoing debt at corrected time.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can refactor the whole code to allow time to be between snapshot time and corrected time. Right now, the check is between snapshot time and current time which no longer applies. What do you think about it?
If you agree, I can create a separate PR to show it and if you also like it we can consider merging it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This makes Flow streams works in discrete similar to tranches. The debt remains constant from the last corrected time until the next corrected time regardless of block.timestamp
.
I have increased number of runs in ci as I think its more appropriate to have higher number of runs in Flow. |
Yes good point. In that case, should we move logic from |
interesting idea, we still use
i wouldn't say "much", it is small difference. the problem i see with it, is that the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
After seeing the code, I can agree with moving the Helpers function directly in the calculation functions
Left some comments:
// Effect: update the snapshot debt. | ||
_updateSnapshotDebt(streamId); | ||
_streams[streamId].snapshotDebt += ongoingDebt; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
what if we add a _snapshotDebtAndTime(streamId)
function that is going to be used in pause
and adjustRPS
:
function _snapshotDebtAndTime(uint256 streamId) internal {
// Calculate the ongoing debt and corrected time.
(uint128 ongoingDebt, uint40 correctedTime) = _ongoingDebtOf(streamId, uint40(block.timestamp));
// Effect: update the snapshot debt.
_streams[streamId].snapshotDebt += ongoingDebt;
// Effect: update the snapshot time.
_streams[streamId].snapshotTime = correctedTime;
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Well, the pause does not update the snapshot time and only the debt. So we cannot do this.
// Compute the snapshot time that will be stored post withdraw. | ||
uint40 expectedSnapshotTime = uint40( | ||
getNormalizedAmount(ongoingDebt, flow.getTokenDecimals(streamId)) / flow.getRatePerSecond(streamId).unwrap() | ||
+ flow.getSnapshotTime(streamId) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we add a mirror function for _ongoingDebt
in Utils to return both the amount and the corrected time?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah we could.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It requires access to getSnapshotTime
, ongoingDebt
, getTokenDecimals
and oldRatePerSecond
which Utils does not have. So how do you propose to mirror it? One way is to pass all these as variables. Is that what you have in your mind?
3e1d5ec
to
936e17a
Compare
936e17a
to
45f858f
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good finding. WDYT about renaming correctedTime
to discreteSnapshotTime
or smth that includes the 'discrete' terminology?
test: lower rps bound in Utils fix: precision loss in time due to denormalization in ongoing debt test: consistencies in assert messages in fork tests testL fix bugs in fork tests refactor: remove redundant internal functions perf: check the corrected time is less than snapshot time test: add an invariant that snapshot debt should always increase if updated chore: fix typos refactor: remove unneeded struct refactor: remove redundant correctedTime checks fix fuzz tests rename originalTime to time fix bug in depletion time docs: polish chore: remove SafeCast from Helpers Co-authored-by: Andrei Vlad Birgaoanu <99738872+andreivladbrg@users.noreply.github.com>
ed7f470
to
964d7f1
Compare
To @PaulRBerg
I am not sure what would be the appropriate name. Naming variables is hard. The way I see it is that it represents the earliest timestamp when the current amount values (calculated using PS: |
Co-authored-by: Paul Razvan Berg <prberg@proton.me>
Good points about the name of the variable .. in this case, let's stick with |
Co-authored-by: Andrei Vlad Birgaoanu <99738872+andreivladbrg@users.noreply.github.com>
I've just found a big problem with the introduction of The issue arises when the To better understand, please see the code Proof of concept
function test_DrainBalance() public {
uint8 decimals = 3;
IERC20 token = createToken(decimals);
uint128 factor = uint128(10 ** (18 - decimals));
deal({ token: address(token), to: users.sender, give: 1_000_000e3 });
resetPrank(users.sender);
token.approve(address(flow), type(uint256).max);
vm.warp(MAY_1_2024);
uint128 rps = 3205.285530987472545e15;
uint128 depAmount = (rps * 1 days) / factor; // deposit for 1 day
uint256 streamId = flow.createAndDeposit(users.sender, users.recipient, ud21x18(rps), token, true, depAmount);
uint40 previousActualSnapshotTime = flow.getSnapshotTime(streamId);
assertEq(previousActualSnapshotTime, MAY_1_2024);
uint128 previousActualBalance = flow.getBalance(streamId);
assertEq(previousActualBalance, depAmount);
// warp just one second
uint40 newTimestamp = MAY_1_2024 + 1 seconds;
vm.warp(newTimestamp);
uint128 previousActualOngoingDebt = flow.ongoingDebtOf(streamId);
uint128 previousExpectedOngoingDebt = (rps * 1 seconds) / factor;
assertEq(previousActualOngoingDebt, previousExpectedOngoingDebt);
// after 1 second the ongoing debt should be basically rps normalized to token decimals
assertEq(previousActualOngoingDebt, 3205); // https://www.wolframalpha.com/input?i=3205285530987472545%2F%2810%5E15%29
// 3205.000000000000000e15 / 3205.285530987472545e15 = 0
// correctedTime = MAY_1_2024 --> snapshotTime remains the same
uint40 expectedCorrectedTime = uint40(previousActualOngoingDebt * factor / rps + MAY_1_2024);
flow.withdrawAt(streamId, users.recipient, newTimestamp);
uint40 actualSnapshotTime = flow.getSnapshotTime(streamId);
assertEq(actualSnapshotTime, expectedCorrectedTime);
assertEq(actualSnapshotTime, previousActualSnapshotTime);
assertEq(actualSnapshotTime, MAY_1_2024);
uint128 actualBalance = flow.getBalance(streamId);
uint128 expectedBalance = previousActualBalance - previousActualOngoingDebt;
assertEq(actualBalance, expectedBalance);
// theorectically, the ongoing debt should be 0, it remains the same
uint128 actualOngoingDebt = flow.ongoingDebtOf(streamId);
assertEq(previousActualOngoingDebt, actualOngoingDebt);
// since we have deposited for 1 day, it means we can withdraw for seconds 1 in one day - 1 second (prev
// withdraw)
for (uint256 i = 0; i < 1 days - 1 seconds; ++i) {
uint128 amountWithdrawn = flow.withdrawAt(streamId, users.recipient, newTimestamp);
assertEq(amountWithdrawn, 3205); // each time 3205
}
// the snapshot time remains the same
actualSnapshotTime = flow.getSnapshotTime(streamId);
assertEq(actualSnapshotTime, MAY_1_2024);
} The reason this was never caught by the invariants is that we warp by at least 2 minutes:
However, I am surprised that it was not caught by fuzz tests, as we do: flow/test/integration/fuzz/withdrawAt.t.sol Lines 222 to 230 in 7f6ae6c
A potential solution might be to return |
I think we should also add an invariant: if the functions (adjustRatePerSecond, pause, withdrawAt ) that updates |
What time? And why cannot it remain constant? If multiple withdraw txs are included in the same block, the |
Please review my finding above. correctedTime cannot be the same with snapshotTime, and if ongoingDebt > 0. If the ongoing amount is zero, it's fine to update snapshotTime with the same value |
Good catch, @andreivladbrg! I have followed up here. |
Good finding @andreivladbrg. Glad you found it before we could merge the PR. I am thinking, as a solution, if we compare the value of |
Agree, and also the following: allow 1 second to pass through |
agree with you, it is easier to understand if we compare the rps with the renormalized amount
i would even bound the time between [0 seconds, x number of days] |
…econd Co-authored-by: Andrei Vlad Birgaoanu <99738872+andreivladbrg@users.noreply.github.com>
I have only included the check in the @andreivladbrg lmk if its good to merge then. And lets continue this in your PR. |
agree, let's merge it, the PR is already spammed, we will address issues in the withdraw one 👍 |
team work |
During withdraw, the normalization process can introduce a small discrepancy in the original
time
value due to loss of significant digits during the process. So we reverse the normalization calculation and reconstruct thetime
value to be used in_updateSnapshotTime
.Failed invariant test: https://github.com/sablier-labs/flow/actions/runs/10618225623/job/29432980263?pr=219
@andreivladbrg note the failing fuzz test (
WithdrawAt_Integration_Fuzz_Test
) in this PR. Ideally, the fuzz test should not fail. The failing fuzz test proves the discrepancy between the the original and the recovered value.Once we agree on this, I will modify the test to account for the recovered time value.