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: rounding in the protocol's favor #468

Merged
merged 9 commits into from
Apr 4, 2024

Conversation

0xJabberwock
Copy link
Contributor

Description

After thoroughly reviewing the BasePoolMath library in search of rounding inconsistencies, I've found some cases in which multiplications (mulUp / mulDown) or divisions (divUp / divDown) weren't favorable to the protocol.

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Dependency changes
  • Code refactor / cleanup
  • Documentation or wording changes
  • Other

Checklist:

  • The diff is legible and has no extraneous changes
  • Complex code has been commented, including external interfaces
  • Tests have 100% code coverage
  • The base branch is either main, or there's a description of how to merge

Issue Resolution

Closes #395.

@0xJabberwock 0xJabberwock self-assigned this Apr 3, 2024
Copy link

openzeppelin-code bot commented Apr 3, 2024

fix: rounding in the protocol's favour

Generated at commit: 2771ba7a09582fde5475b4eafaad31f6d7bbbd31

🚨 Report Summary

Severity Level Results
Contracts Critical
High
Medium
Low
Note
Total
2
1
0
10
36
49
Dependencies Critical
High
Medium
Low
Note
Total
0
0
0
0
0
0

For more details view the full report in OpenZeppelin Code Inspector

for (uint256 i = 0; i < balances.length; i++) {
for (uint256 i = 0; i < balances.length; ++i) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Prefix increments and decrements (++i; --i) are cheaper than postfix increments and decrements (i++; i--) in terms of gas.

Copy link
Collaborator

Choose a reason for hiding this comment

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

how come no gas snapshots have changed? 👀

Copy link
Collaborator

@wei3erHase wei3erHase Apr 3, 2024

Choose a reason for hiding this comment

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

i think there are a lot of loops around the codebase, let's try that in a new PR and see the gas snapshots improvements (avoid cross-function prs when possible)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Also, there's a 1% threshold on the gas snapshots, so maybe they did change, just less than 1%

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've made further research regarding the gas costs incurred when using prefix or postfix increments or decrements, specifically within for loop declarations. It seems that compiling via_ir = true equals the gas expenditures. For more details, refer to ethereum/solidity#14595.

Comment on lines 147 to 148
if (newBalances[index] > invariantRatio.mulUp(currentBalances[index])) {
uint256 taxableAmount = newBalances[index] - invariantRatio.mulUp(currentBalances[index]);
if (newBalances[index] > invariantRatio.mulDown(currentBalances[index])) {
uint256 taxableAmount = newBalances[index] - invariantRatio.mulDown(currentBalances[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.

The protocol is interested in taxing a larger amount in order to charge more swap fees which, in turn, help in reducing the amount of BPT to bid during addLiquidityUnbalanced.

Comment on lines 268 to 274
// mulUp/divDown maximize the amount of tokens burned for the security reasons
bptAmountIn = totalSupply.mulUp(currentInvariant - invariantWithFeesApplied).divDown(currentInvariant);
// mulUp/divUp maximize the amount of tokens burned for the security reasons
bptAmountIn = totalSupply.mulUp(currentInvariant - invariantWithFeesApplied).divUp(currentInvariant);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The protocol is interested in maximizing the amount of BPT to ask for during removeLiquiditySingleTokenExactOut.

Comment on lines 308 to 312
// Calculate the non-taxable balance proportionate to the BPT burnt.
uint256 nonTaxableBalance = newSupply.mulUp(currentBalances[tokenOutIndex]).divDown(totalSupply);
uint256 nonTaxableBalance = newSupply.mulUp(currentBalances[tokenOutIndex]).divUp(totalSupply);

// Compute the taxable amount: the difference between the non-taxable balance and actual withdrawal.
uint256 taxableAmount = nonTaxableBalance - newBalance;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The protocol is interested in taxing a larger amount in order to charge more swap fees which, in turn, help in reducing the amount of tokenOut to bid during removeLiquiditySingleTokenExactIn.

Copy link
Collaborator

Choose a reason for hiding this comment

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

but isn't this increasing the nonTaxable part?

Choose a reason for hiding this comment

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

Perhaps rename nonTaxableBalance, the tax is calculated from it, which implies the tax is actually a part of nonTaxableBalance.

balanceBeforeTax maybe?

Copy link
Contributor

Choose a reason for hiding this comment

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

Good points. balanceBeforeTax or newProportionalBalance could be better suited. I had the same question as @wei3erHase when we first put this code together.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Should check if there's anywhere else where "taxable" or "nonTaxable" is used confusingly. Should add a comment about the rounding direction after fixing the names.

Comment on lines 198 to 202
// Calculate the taxable amount, which is the difference
// between the actual amount in and the non-taxable balance
uint256 nonTaxableBalance = newSupply.mulUp(currentBalances[tokenInIndex]).divDown(totalSupply);
uint256 nonTaxableBalance = newSupply.mulDown(currentBalances[tokenInIndex]).divDown(totalSupply);

uint256 taxableAmount = (amountIn + currentBalances[tokenInIndex]) - nonTaxableBalance;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The protocol is interested in taxing a larger amount in order to charge more swap fees which, in turn, help in augmenting the amount of tokenIn to ask for during addLiquiditySingleTokenExactOut.

Comment on lines 240 to 252
// solhint-disable-next-line no-inline-assembly
assembly {
let cap := add(numTokens, 1)
for {
let i := 1
} lt(i, cap) {
i := add(i, 1)
} {
let pos := mul(i, 0x20)
mstore(add(newBalances, pos), mload(add(currentBalances, pos)))
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

interesting, would you run ./pkg/vault$ yarn test:forge to see if there are gas savings with this?

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this TODO is inherited from V2 code. I'd leave it out of this PR; it doesn't sound very important.

Sidenote: there's now an mcopy opcode since the last hardfork.
There are other places where we're copying arrays, although it's usually for scaling (we have to do math on every element anyways, so perhaps mcopy doesn't save much). See #308.

Copy link
Collaborator

Choose a reason for hiding this comment

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

In V3 we're avoiding assembly if at all possible (e.g., not with transients quite yet), generally favoring clarity/ease of review/audit over speed.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

interesting, would you run ./pkg/vault$ yarn test:forge to see if there are gas savings with this?

Indeed, there are some gas savings as a result of both gas optimizations introduced. However, I agree that they should be addressed separately in a further PR so as to target single concerns.

Copy link
Contributor

@jubeira jubeira 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, thanks @0xJabberwock!

As @wei3erHase points out, let's please try to keep PRs targeting single concerns whenever possible.
For this one in particular, the pre-increments in the for loops are fine, but let's leave the assembly optimization for another one. The rest looks good!

Comment on lines 240 to 252
// solhint-disable-next-line no-inline-assembly
assembly {
let cap := add(numTokens, 1)
for {
let i := 1
} lt(i, cap) {
i := add(i, 1)
} {
let pos := mul(i, 0x20)
mstore(add(newBalances, pos), mload(add(currentBalances, pos)))
}
}

Copy link
Contributor

Choose a reason for hiding this comment

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

I think this TODO is inherited from V2 code. I'd leave it out of this PR; it doesn't sound very important.

Sidenote: there's now an mcopy opcode since the last hardfork.
There are other places where we're copying arrays, although it's usually for scaling (we have to do math on every element anyways, so perhaps mcopy doesn't save much). See #308.

Comment on lines 308 to 312
// Calculate the non-taxable balance proportionate to the BPT burnt.
uint256 nonTaxableBalance = newSupply.mulUp(currentBalances[tokenOutIndex]).divDown(totalSupply);
uint256 nonTaxableBalance = newSupply.mulUp(currentBalances[tokenOutIndex]).divUp(totalSupply);

// Compute the taxable amount: the difference between the non-taxable balance and actual withdrawal.
uint256 taxableAmount = nonTaxableBalance - newBalance;
Copy link
Contributor

Choose a reason for hiding this comment

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

Good points. balanceBeforeTax or newProportionalBalance could be better suited. I had the same question as @wei3erHase when we first put this code together.

@@ -265,8 +274,8 @@ library BasePoolMath {
swapFeeAmounts = new uint256[](numTokens);
swapFeeAmounts[tokenOutIndex] = fee;

// mulUp/divDown maximize the amount of tokens burned for the security reasons
bptAmountIn = totalSupply.mulUp(currentInvariant - invariantWithFeesApplied).divDown(currentInvariant);
// mulUp/divUp maximize the amount of tokens burned for the security reasons
Copy link
Contributor

@jubeira jubeira Apr 3, 2024

Choose a reason for hiding this comment

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

Suggested change
// mulUp/divUp maximize the amount of tokens burned for the security reasons
// mulUp/divUp maximizes the amount of tokens burned for security reasons

Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we just want to say "to favor the protocol," as elsewhere, instead of "for security reasons"? We are favoring the protocol for security reasons, but maybe we can say that in one place at the top, e.g.: "For security reasons, to help ensure that for all possible "round trip" paths, the caller always receives the same or fewer tokens than supplied, we have chosen the rounding direction to favor the protocol in all cases."

@@ -159,7 +159,7 @@ library BasePoolMath {

// Calculate the amount of BPT to mint. This is done by multiplying the
// total supply with the ratio of the change in invariant.
// mulDown/divDown minize amount of pool tokens to mint.
// mulDown/divDown minimize amount of pool tokens to mint.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// mulDown/divDown minimize amount of pool tokens to mint.
// mulDown/divDown minimizes the amount of pool tokens to mint.

For consistency with other comments. It seems like it should be singular if you read it "mulDown and divDown", but you can think of it as "the combination of mulDown/divDown," or the "mulDown/divDown expression" which would make it minimizes.

@@ -141,11 +141,11 @@ library BasePoolMath {
uint256 invariantRatio = newInvariant.divDown(currentInvariant);

// Loop through each token to apply fees if necessary.
for (uint256 index = 0; index < currentBalances.length; index++) {
for (uint256 index = 0; index < numTokens; ++index) {
// Check if the new balance is greater than the proportional balance.
// If so, calculate the taxable amount.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// If so, calculate the taxable amount.
// If so, calculate the taxable amount, rounding in favor of the protocol.
// We round the invariantRatio term down to subtract less and get a higher `taxableAmount`, which
// charges higher swap fees, reducing the amount of BPT that will be minted.

As elsewhere, I'd comment the reasoning for the rounding direction, so that it can be explicitly reviewed, and we're not always reverse engineering it.

@@ -191,13 +191,13 @@ library BasePoolMath {
uint256 newSupply = exactBptAmountOut + totalSupply;
// Calculate the initial amount of the input token needed for the desired amount of BPT out
// "divUp" leads to a higher "newBalance," which in turn results in a larger "amountIn."
// This leads to receiving more tokens for the same amount of BTP minted.
// This leads to receiving more tokens for the same amount of BPT minted.
uint256 newBalance = computeBalance(currentBalances, tokenInIndex, newSupply.divUp(totalSupply));
uint256 amountIn = newBalance - currentBalances[tokenInIndex];

// Calculate the taxable amount, which is the difference
// between the actual amount in and the non-taxable balance
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// between the actual amount in and the non-taxable balance
// between the actual amount in and the non-taxable balance. Round the `nonTaxableBalance` down to
// favor the protocol by increasing the taxable amount, which charges higher swap fees, ultimately increasing
// the amount of `tokenIn` that will be transferred from the caller.

Comment on lines 240 to 252
// solhint-disable-next-line no-inline-assembly
assembly {
let cap := add(numTokens, 1)
for {
let i := 1
} lt(i, cap) {
i := add(i, 1)
} {
let pos := mul(i, 0x20)
mstore(add(newBalances, pos), mload(add(currentBalances, pos)))
}
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

In V3 we're avoiding assembly if at all possible (e.g., not with transients quite yet), generally favoring clarity/ease of review/audit over speed.

@@ -265,8 +274,8 @@ library BasePoolMath {
swapFeeAmounts = new uint256[](numTokens);
swapFeeAmounts[tokenOutIndex] = fee;

// mulUp/divDown maximize the amount of tokens burned for the security reasons
bptAmountIn = totalSupply.mulUp(currentInvariant - invariantWithFeesApplied).divDown(currentInvariant);
// mulUp/divUp maximize the amount of tokens burned for the security reasons
Copy link
Collaborator

Choose a reason for hiding this comment

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

Do we just want to say "to favor the protocol," as elsewhere, instead of "for security reasons"? We are favoring the protocol for security reasons, but maybe we can say that in one place at the top, e.g.: "For security reasons, to help ensure that for all possible "round trip" paths, the caller always receives the same or fewer tokens than supplied, we have chosen the rounding direction to favor the protocol in all cases."

Comment on lines 308 to 312
// Calculate the non-taxable balance proportionate to the BPT burnt.
uint256 nonTaxableBalance = newSupply.mulUp(currentBalances[tokenOutIndex]).divDown(totalSupply);
uint256 nonTaxableBalance = newSupply.mulUp(currentBalances[tokenOutIndex]).divUp(totalSupply);

// Compute the taxable amount: the difference between the non-taxable balance and actual withdrawal.
uint256 taxableAmount = nonTaxableBalance - newBalance;
Copy link
Collaborator

Choose a reason for hiding this comment

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

Should check if there's anywhere else where "taxable" or "nonTaxable" is used confusingly. Should add a comment about the rounding direction after fixing the names.

Comment on lines +326 to +327
// Compute the taxable amount: the difference between the new proportional and disproportional balances.
uint256 taxableAmount = newBalanceBeforeTax - newBalance;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

During computeRemoveLiquiditySingleTokenExactIn, it turns out that, in order for the taxableAmount to be as large as possible, we would want newBalanceBeforeTax to be rounded up and newBalance to be rounded down.

Nevertheless, newBalance was previously subtracted from currentBalances[tokenOutIndex] so as to compute amountOut; since we'd want the latter to be as low as possible, newBalance had been rounded up initially.

In consequence, a conflict of interests arises regarding the rounding of newBalance.

Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's fine.
Taxable balance is not as important as amounts out to withdraw.

Choose a reason for hiding this comment

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

Might be good to document this rounding conflict and priority in rounding amountOut over taxableAmount so we need not repeat thinking through this again :)

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yes, we should always document the reason for the rounding: see #468 (comment)

Copy link
Contributor

@jubeira jubeira left a comment

Choose a reason for hiding this comment

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

This is looking good IMO, thanks @0xJabberwock !
@EndymionJkb @joaobrunoah would you mind giving it one last look before merging?

/cc @HickupHH3

Comment on lines +326 to +327
// Compute the taxable amount: the difference between the new proportional and disproportional balances.
uint256 taxableAmount = newBalanceBeforeTax - newBalance;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think it's fine.
Taxable balance is not as important as amounts out to withdraw.

Copy link
Collaborator

@EndymionJkb EndymionJkb left a comment

Choose a reason for hiding this comment

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

Looks great. The only thing we might still want to address is the comment in computeRemoveLiquiditySingleTokenExactIn:

"In consequence, a conflict of interests arises regarding the rounding of newBalance."

@0xJabberwock 0xJabberwock changed the title fix: rounding in the protocol's favour fix: rounding in the protocol's favor Apr 4, 2024
@0xJabberwock 0xJabberwock merged commit 1e3e74d into main Apr 4, 2024
10 checks passed
@0xJabberwock 0xJabberwock deleted the fix/base-pool-math-rounding branch April 4, 2024 20:50
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.

Review BasePoolMath rounding
5 participants