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: dust left in unallocatedOffset despite allocating all LQTY #112

Merged
merged 8 commits into from
Jan 2, 2025

Conversation

danielattilasimon
Copy link
Contributor

This addresses CS-V2Gov-051: Rounding Error on Offset Calculation.

It should be noted that we're not actually eliminating the rounding error — that's not feasible unless we stop mixing together LQTY staked at different timestamps in userState — we're merely feeding it back into unallocatedOffset so that there's no "dust" remaining after a total allocation of one's LQTY.

Closes #111.

Copy link
Contributor

@bingen bingen left a comment

Choose a reason for hiding this comment

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

Looks good!

@@ -606,6 +606,7 @@ contract Governance is MultiDelegateCall, UserProxyFactory, ReentrancyGuard, Own
_requireNoNegatives(_absoluteLQTYVetos);
// If the goal is to remove all votes from an initiative, including in _initiativesToReset is enough
_requireNoNOP(_absoluteLQTYVotes, _absoluteLQTYVetos);
_requireNoSimultaneousVoteAndVeto(_absoluteLQTYVotes, _absoluteLQTYVetos);
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice!

pure
{
for (uint256 i; i < _absoluteLQTYVotes.length; i++) {
require(_absoluteLQTYVotes[i] == 0 || _absoluteLQTYVetos[i] == 0, "Governance: vote-and-veto");
Copy link
Contributor

Choose a reason for hiding this comment

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

Much nicer than the original double negation! ;-)

uint256 offset = userState.unallocatedOffset * lqtyAmount / userState.unallocatedLQTY;

userState.unallocatedLQTY -= lqtyAmount;
userState.unallocatedOffset -= offset;
Copy link
Contributor

Choose a reason for hiding this comment

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

I guess it’s fine (specially because the difference should be almost negligible), but I wonder if we should put a comment mentioning that this is “path dependent” (related to the order of the array).
Let’s put an example with numbers. Let’s say unallocated LQTY is 10, unallocated offset is 5, and we allocate 3 and 7.
Before, we would have:

  • offset[0] = floor(3*5/10) = floor(1.5) = 1
  • offset[1] = floor(7*5/10) = floor(3.5) = 3

So there would be a remainder of 1 for the offset, when it should be zero.

Now, we’ll have:

  • offset[0] = floor(3*5/10) = floor(1.5) = 1
  • offset[1] = floor(7*4/7) = floor(4) = 4
    (as we first reduce unallocated amounts, from 10/5 to 7/4)

So now the final offset is zero as we want, but all the error is “accumulated” in the last initiative.

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, it is path-dependent. I agree it makes sense to document this.

I wouldn't say that all the error is accumulated in the last initiative, more like spread out over all initiatives. Consider an example with more than 2 initiatives. Let's say we have 4 LQTY and an offset of 3, which we want to split up equally between 4 initiatives.

Ideal:

offset[0] = 3*1/4 = 0.75
offset[1] = 3*1/4 = 0.75
offset[2] = 3*1/4 = 0.75
offset[3] = 3*1/4 = 0.75

Before:

offset[0] = floor(3*1/4) = floor(0.75) = 0 // error[0] = 0 - 0.75 = -0.75
offset[1] = floor(3*1/4) = floor(0.75) = 0 // error[1] = 0 - 0.75 = -0.75
offset[2] = floor(3*1/4) = floor(0.75) = 0 // error[2] = 0 - 0.75 = -0.75
offset[3] = floor(3*1/4) = floor(0.75) = 0 // error[3] = 0 - 0.75 = -0.75

Now:

offset[0] = floor(3*1/4) = floor(0.75) = 0 // error[0] = 0 - 0.75 = -0.75
offset[1] = floor(3*1/3) = floor(1.00) = 1 // error[1] = 1 - 0.75 =  0.25
offset[2] = floor(2*1/2) = floor(1.00) = 1 // error[2] = 1 - 0.75 =  0.25
offset[3] = floor(1*1/1) = floor(1.00) = 1 // error[3] = 1 - 0.75 =  0.25

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, right! Which means the issue is even less bad.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am now wondering if this is an improvement at all. Sure, the "average" offset error when allocating all of one's LQTY is zero, but it doesn't change the fact that there are (small) errors in the offsets allocated to the individual initiatives. Previously, the per-initiative error was in the (-1, 0] range, whereas now it's somewhere in (-1, 1).

Is this worth it? It's not like you can continue accumulating "dust" in unallocatedOffset, as we force you to fully deallocate before making any new allocations, which "resets" the accumulated error.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes, good point, but I still think that having unallocatedLQTY zero, but non-zero unallocatedOffset feels a little bit ugly. I wonder if having a positive error in the offset of the initiatives can introduce any new problem.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added assertions and tests to ensure this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added comment about path-dependency.

Copy link
Contributor

@RickGriff RickGriff Jan 1, 2025

Choose a reason for hiding this comment

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

If someone deposits LQTY then immediately allocates it to several initatives, their voting power assigned to each initatitive should be 0, but due to the rounding error, some will get positive and some will get negative voting power, right? (assuming no clamping)

It seems like either users or initatives need to bear the error.

Anyway still clamping lqtyToVotes, right? So in practice we don't get negative voting power. Given that, I'm happy with the solution for clearing the user's dust.

Copy link
Contributor Author

@danielattilasimon danielattilasimon Jan 2, 2025

Choose a reason for hiding this comment

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

When someone has zero unallocated voting power, it implies that their unallocated offset is a multiple of their unallocated LQTY, thus there can be no rounding error when allocating it to any number of initiatives.

Copy link
Contributor

Choose a reason for hiding this comment

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

Nice, that makes sense!

uint256 waitTime;
}

function test_NoDustInUnallocatedOffsetAfterAllocatingAllLQTY(uint256[3] memory _votes, StakingOp[4] memory _stakes)
Copy link
Contributor

Choose a reason for hiding this comment

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

Nice test!

Copy link
Contributor

@bingen bingen left a comment

Choose a reason for hiding this comment

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

This looks really good to me now! Should we merge it then?

// By waiting `initialVotingPower` seconds while having 1 wei LQTY staked,
// we accrue exactly `initialVotingPower`
vm.warp(block.timestamp + initialVotingPower);
governance.depositLQTY(583399417581888701);
Copy link
Contributor

Choose a reason for hiding this comment

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

Where dies this number come from?

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 some random prime. Actually, there's nothing special about it, any number big enough to result in rounding error would work, even round numbers.

// we accrue exactly `initialVotingPower`
vm.warp(block.timestamp + initialVotingPower);

governance.depositLQTY(583399417581888701);
Copy link
Contributor

Choose a reason for hiding this comment

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

Same comment here

Copy link
Contributor

@RickGriff RickGriff left a comment

Choose a reason for hiding this comment

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

Nice solution. Looks good!

@danielattilasimon danielattilasimon merged commit 9efb1d1 into main Jan 2, 2025
3 checks passed
@danielattilasimon danielattilasimon deleted the fix-offset-dust branch January 19, 2025 04:41
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.

CS-V2Gov-051: Rounding Error on Offset Calculation
3 participants