Organization
- @SorellaLabs
Engagement Type
Cantina Reviews
Period
-
Repositories
Findings
Medium Risk
1 findings
1 fixed
0 acknowledged
Low Risk
2 findings
0 fixed
2 acknowledged
Medium Risk1 finding
The protocol charges less for exactOut direction swap
Description
When charging protocol fee, the protocol always calculates the fee using the unspecified amount multiplied by
fee_rate_e6
. The problem here is that the percentage of the fee charged should be different when the user's swap direction is different.int128 target_amount = exactIn != params.zeroForOne ? swap_delta.amount0() : swap_delta.amount1(); fee = ((target_amount < 0 ? -target_amount : target_amount) * fee_rate_e6) / 1e6;
Consider a protocol fee of 1%, an swap fee of 2%, and token0:token1 = 1:1.
-
The user uses 1000 token0 to swap token1, first calculates the unspecified amount as 1000 * (1 - 2%) = 980 token1, then calculate the protocol fee as 980 * 10% = 9.8 token1, the user finally gets 970.2 token1.
-
The user uses x token0 to swap 970.2 token1, first calculates the unspecified amount as 970.2 / (1 - 2%) = 990 token0, then calculates the protocol fee as 990 * 1% = 9.9 token0, the user pays 999.9 token0.
Actually when the direction is exactOut, the fee should be
1 / (1 - fee_rate_e6) - 1
instead offee_rate_e6
. Then in case 2 above, the protocol fee should be 990 / (1 - 1%) - 990 = 10 token0, the user pays 1000 token0.Taking the 1% protocol fee as an example, the protocol fee for the exactOut direction should be 1.01%, which would actually cause the protocol to lose 0.01% of the total amount swapped.
Proof of Concept
The following POC shows that 10e18 token0 is swapped for 9.702e18 token1, and only 9.999e18 token0 is needed to swap to 9.702e18 token1
function _createPool(uint16 tickSpacing, uint24 unlockedFee, uint248 startLiquidity) internal returns (PoolKey memory pk) { vm.prank(controller); angstrom.configurePool(asset0, asset1, tickSpacing, 0, unlockedFee, 0.01e6); angstrom.initializePool(asset0, asset1, 0, TickMath.getSqrtPriceAtTick(0)); int24 spacing = int24(uint24(tickSpacing)); pk = poolKey(angstrom, asset0, asset1, spacing); if (startLiquidity > 0) { actor.modifyLiquidity( pk, -1 * spacing, 1 * spacing, int256(uint256(startLiquidity)), bytes32(0) ); } return pk; } function test_poc() public { vm.roll(1000); uint24 unlockedFee = 0.02e6; uint256 swapAmount = 10e18; // ------ PRE SNAPSHOT ------ uint256 snapshotId = vm.snapshot(); uint248 liq = 100_000e21; PoolKey memory pk = _createPool(60, unlockedFee, liq); vm.prank(node); angstrom.execute(""); int128 FeeOut1 = actor.swap(pk, true, -int256(swapAmount), 4295128740).amount1(); // 9701999049204093178 console.log(FeeOut1); vm.revertTo(snapshotId); // ------ ACTUAL CALL ------ pk = _createPool(60, unlockedFee, liq); vm.prank(node); angstrom.execute(""); int128 FeeOut2 = actor.swap(pk, true, int256(9701999049204093178), 4295128740).amount0(); // -9998999990200980011 console.log(FeeOut2); }
Recommendation
Change to
--- a/contracts/src/modules/UnlockHook.sol+++ b/contracts/src/modules/UnlockHook.sol@@ -80,11 +80,11 @@ abstract contract UnlockHook is StoreKeyLib.keyFromAssetsUnchecked(_addr(key.currency0), _addr(key.currency1)); int24 fee_rate_e6 = int24(_unlockedFees[storeKey].protocolUnlockedFee); bool exactIn = params.amountSpecified < 0;-- int128 target_amount =- exactIn != params.zeroForOne ? swap_delta.amount0() : swap_delta.amount1();- fee = ((target_amount < 0 ? -target_amount : target_amount) * fee_rate_e6) / 1e6;-+ {+ int128 target_amount = exactIn != params.zeroForOne ? swap_delta.amount0() : swap_delta.amount1();+ int128 p_target_amount = target_amount < 0 ? -target_amount : target_amount;+ fee = exactIn ? p_target_amount * fee_rate_e6 / 1e6 : p_target_amount * 1e6 / (1e6 - fee_rate_e6) - p_target_amount;+ }
-
Low Risk2 findings
Nodes can avoid sharing fees by using a ToB order and setting save to zero
State
- Acknowledged
Severity
- Severity: Low
Submitted by
cergyk
Description
In Angstrom, bundle execution takes three steps:
- Uniswap v4 swap
- Top of the block orders
- User orders
The fees accumulated through each of the steps (
gasUsedAsset0
orextraFeeAsset0
, or simply excess due to profitable swap) are supposed to be accounted for in thesave
field of the asset:/// @dev Size of a single encoded asset (b20:addr ++ b16:save ++ b16:borrow ++ b16:settle) uint256 internal constant ASSET_CD_BYTES = 68; uint256 internal constant ADDR_OFFSET = 0; uint256 internal constant SAVE_OFFSET = 20; //@audit save field accounts for all fees uint256 internal constant TAKE_OFFSET = 36; uint256 internal constant SETTLE_OFFSET = 52;
Unfortunately, the only constraint checked at the settlement is
bundleDelta[asset] == saving + settle
:Asset asset = assets.getUnchecked(i); address addr = asset.addr(); uint256 saving = asset.save(); uint256 settle = asset.settle(); int256 delta = bundleDeltas.sub(addr, saving + settle); //@audit delta must be zero. bundleDelta[asset] == saving + settle if (delta != 0) { revert BundlDeltaUnresolved(addr); }
With
settle
being the amount to be sent back to UniswapV4 singleton for settling the negative delta.This means that a node can adapt ToB orders to direct the accumulated fees directly to her, and set
save
to be zero for every asset.Concrete example
- Alice sends a user order with a swap of
1000 USDC
for1000 USDT
, accepting10 USDC
asextraFeeAsset0
- Bob the node bundles it with his own ToB order of
0 WETH
for10 USDC
, allowing him to setsave
for USDC to zero.
This way Bob is taking a fee for himself, which should be shared among all validators.
Impact and likelihood
This issue is reported as low severity because Sorella Labs already has mechanisms for slashing nodes that deviate from canonical behavior.
Recommendation
There is no simple way to mitigate this, but a few solutions can be considered:
save
for an asset must be at least the sum of all explicit fees for the asset (gasUsedAsset0
andextraFeeAsset0
)- Force the price of a ToB order to be in range compared to other prices used for pairs in the bundle
When expectedLiquidity == 0 and currentOnly, but amount != 0, the rewards are lost
State
- Acknowledged
Severity
- Severity: Low
Submitted by
cergyk
Description
There is an early return in the
_decodeAndReward
logic:GrowthOutsideUpdater.sol?lines=46,48:
function _decodeAndReward( bool currentOnly, CalldataReader reader, PoolRewards storage poolRewards_, PoolId id, int24 tickSpacing, int24 currentTick ) internal returns (CalldataReader, uint256) { if (currentOnly) { uint128 amount; (reader, amount) = reader.readU128(); uint128 expectedLiquidity; (reader, expectedLiquidity) = reader.readU128(); // we sometimes have to do empty updates for swap encoding // purposes //@audit early return can be triggered when expectedLiquidity == 0, but amount != 0>> if (amount == 0 || expectedLiquidity == 0) {>> return (reader, amount);>> }
Unfortunately, in the case where
expectedLiquidity == 0
butamount != 0
, we can see that the caller still reduces the correspondingbundleDelta
:PoolUpdates.sol?lines=210,213:
(reader, rewardTotal) = _decodeAndReward( variantMap.currentOnly(), reader, poolRewards[id], id, swapCall.tickSpacing, currentTick ); //@audit bundle delta is reduced by the reward amount bundleDeltas.sub(swapCall.asset0, rewardTotal);
If the bundle is otherwise well-formed and able to settle, these rewards become stuck, unclaimable by either LPs or validators.
Recommendation
the There shouldn't be a case where
expectedLiquidity == 0
butamount != 0
:- if (amount == 0 || expectedLiquidity == 0) {+ if (expectedLiquidity == 0 && amount != 0)+ revert RewardsLost(); return (reader, amount); }