Open luzzif opened 2 years ago
Just re-reviewed the contracts after the latest changes. These are some new points:
100 / 0.1 = 1000 USDC
. Let's say the user wants to zap in with 20 USDC. Of these 20 USDC, following the contract's logic, 10 are swapped to ETH (swapping happens at lines 251
and 252
of the zap contract). Following the CPMM math, this 10 USDC
swap gets the user roughly 0.009 ETH
, and so we're ready to add 0.009 ETH
and 10 USDC to the pair that has now changed reserves to 100 + 10 = 110 USDC
and 0.1 - 0.009 = 0.091 ETH
. After our swap the price of ETH in USDC term (which is a representation of the ratio of assets in the pool) is now 110 / 0.091 = 1208.79120879
compared to previously, when it was 1000. The ratio of USDC to ETH that we instead got from our swap to zap in the pool is 10 / 0.009 = 1111.11111111
. We can immediately see there's a discrepancy.addLiquidity
function of the router (called by the zap contract), we can see that the input values are checked against the current reserves of the pair we want to provide liquidity to (to protect the user from providing liquidity at an unfavourable rate, that would result in immediate arbitrage depending on the specific amount) and either amount A or amount B are adjusted accordingly. Let's run a simulation of the check performed by the router plugging in our numbers, and let's see what happens:desiredAmountA * (reserveB / reserveA)
, which is pretty logical (it calculates the ratio of token B to token A at that point and multiplies it by the desired token A to determine the correct token B amount). If we plug in our numbers, we see that the optimal amount B to provide to the pair given the amount A of 10 USDC
is calculated to be 10 * (0.091 / 110) = 0.00827272727273 ETH
, slightly less than what we've swapped to.0.009 - 0.00827272727273 = 0.00072727272727 ETH
locked in the contract forever, as there's no logic to handle this, and the amount0
and amount1
return values from the router's addLiquidity
function are ignored at line 365 of the swap contract.ReentrancyGuard
and Ownable
in the zap contract.245
, I'd probably opt to have only one variable of value amountFrom / 2
to replace amountFromToken0
and amountFromToken1
(you get gas savings in return and at max you can lose 1 wei of value on odd numbers due to integer arithmetic).token0
and token1
functions at line 385
/386
. We could probably save on gas by getting the pair's token0
and token1
as inputs to the function and calculating the pair's address dynamically.removeLiquidity
, add a minimum amount out check, as anything can happen between the user submitting the zap out transaction and it actually going through, and he could find himself with a different propoertion of tokens compared to what he was expecting.removeLiquidity
function on the router returns the values that we got by burning the liquidity tokens, why can't we just return those?_getFactoryPair
function:EXTCODESIZE
is a maximum of 2600 gas while the static call, in this specific context, has a base cost of 2600 + the SLOAD cost in the factory contract, which is 2100).addLiquidity
function in the router supports creating the pair dynamically). If the intention is to avoid accidental pair creation, having a separate zapInNewPair
function could help.zapInFromNativeCurrency
, avoiding putting msg.value
in memory might result in slight gas savings. (moving in memory results in 1 MSTORE
+ 1...n MLOAD
s vs just using the dirt cheap CALLVALUE
opcode).
The goal of this issue is to show any findings/doubts that I had while reviewing Swapr's zap contracts. The points will be expressed in a list below without any particular ordering. I'll make sure to tick the checkboxes as soon as I think the related issue/doubt has been fixed/resolved.
[x] Before going the development route, why didn't we have a deep look at Zapper's Uniswap v2 zap contracts for example? Source code for most features is available here and I'm pretty sure they've been audited (at the bare minimum we can't say they haven't been battle tested).
[ ] I'd personally use importing the specific contracts in a module compared to the whole module. Whole-module imports are fine generally but have their downsides (such as not having control over what actually gets imported, which also leads to issues if 2 modules define same-name entities, even if that entity is not directly used in the importer). I find it also results in a cleaner syntax anyway and makes it clear exactly what needs to be imported at all times.
[x] Instead of using requires with text error messages, use Solidity custom error to reduce gas consumption considerably (parameterless custom errors only take up 4 bytes).
[ ] On line
102
, I'm not sure about the logic here, if it is to account for tokens locked in the contract as protocol fee, I'd heavily suggest working with an internal mapping to keep track of the balance instead of doing an extra external call for 2 reasons:SLOAD
which is 2.1k gas)[x] On line
108
, I'd evaluate adding someunchecked
expressions to save a bit on gas. Particularly on the 10000 division (which cannot over/underflow as far as I'm aware). It can also be applied to line110 since
amountFeeTowill always be less or at the very least equal to
amountReceived`.[ ] Code at line
93
could probably be extracted to its own function since it's also used on line138
. I think this also applies to fee transfers (the only difference being that we need to account for the native currency being sent). Maybe we can send the wrapped variant instead of the native currency, having a unifiable ERC20-based logic for the fee transfers.[ ] On line
247
, I'd opt for an infinite approval to the router. If multiple operations are performed by the user with the same token, it might result in a pretty good net saving of gas.[x] Operation on line
249
can be wrapped inunchecked
.[x] On line
316
, is there a double approval there when called by_zapInFromToken
? I'd remove the approval on one of the 2 places.[x] Got to say I'm a bit confused by what's happening at line
326
. In case the path is 1 element only, shouldn't we just revert?[x] I personally always prefer to define functions as
internal
when they don't need to be public/external. Gas-wise, it should be the same, but it makes it easier to extend functionality when and if needed.[ ] I'm not particularly a fan of all the approvals going on here by using the router. I'd consider using a direct, low level approach and interact directly with the pairs in order to avoid approving this much (gas costs should be reduced quite a bit). This can still be simplified by using Uniswap v2's library.
[ ] So, this I'm a bit unsure of in the sense that in the zap contracts I've checked I haven't seen any dedicated logic and we should be protected by this if setting a "good enough" minimum amount out as in not to incur in exaggerated slippage, but there's an occasion in which a user can lose quite a bit of money if they add liquidity after having performed a swap. You see, if the user performs a swap that moves the price (and token ratio consequently) a lot and then provides liquidity, he might get arbed out of a decent chunk of money. Maybe we should take into account the reserves ratio after the swap to zap in, because right now we do input token / 2 and we swap in order to get both tokens in the pair, but we need to be careful because the ratio of the 2 tokens in the target pair changes (even considerably in certain occasions, particularly if the user is careless with slippage settings).
[ ] Linked to the point above, we should be careful with slippage because the user might be sandwiched on one or more of the performed swaps done along the way to get to the final 2 tokens.