tycho-execution 0.406.2

Provides tools for encoding and executing swaps against Tycho router and protocol executors.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
// SPDX-License-Identifier: BUSL-1.1
pragma solidity ^0.8.26;

import {
    ReentrancyGuardTransient
} from "@openzeppelin/contracts/utils/ReentrancyGuardTransient.sol";
import {
    SafeERC20,
    IERC20
} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol";
import {
    IUniswapV2Pair
} from "@uniswap-v2/contracts/interfaces/IUniswapV2Pair.sol";
import {
    IUniswapV3Pool
} from "@uniswap/v3-core/contracts/interfaces/IUniswapV3Pool.sol";
import {IPoolManager} from "@uniswap/v4-core/src/interfaces/IPoolManager.sol";
import {SwapParams} from "@uniswap/v4-core/src/types/PoolOperation.sol";
import {Currency} from "@uniswap/v4-core/src/types/Currency.sol";
import {PoolKey} from "@uniswap/v4-core/src/types/PoolKey.sol";
import {BalanceDelta} from "@uniswap/v4-core/src/types/BalanceDelta.sol";
import {TickMath} from "@uniswap/v4-core/src/libraries/TickMath.sol";
import {IHooks} from "@uniswap/v4-core/src/interfaces/IHooks.sol";
import {IPropAMM} from "@interfaces/IPropAMM.sol";
import {
    CryptoPool as ICurveCryptoPool,
    StablePool as ICurveStablePool
} from "../executors/CurveExecutor.sol";
import {IFluidV1Dex} from "../executors/FluidV1Executor.sol";
import {UniswapV2Math} from "../../lib/UniswapV2Math.sol";

error TychoFallbackRouter__AddressZero();
error TychoFallbackRouter__CallbackTokenMismatch(
    address requested, address expected
);
error TychoFallbackRouter__InvalidCallback();
error TychoFallbackRouter__InvalidSwapLength(uint256 length);
error TychoFallbackRouter__InvalidUniswapV2Fee(uint256 feeBps);
error TychoFallbackRouter__NoOutput();
error TychoFallbackRouter__NotPoolManager();
error TychoFallbackRouter__NotSelf();
error TychoFallbackRouter__UnknownProtocol(uint8 protocol);

/// @title TychoFallbackRouter
/// @notice Runs a pAMM and, only if it fails, the caller's chosen fallback protocol.
/// @dev Exists because an executor cannot fall back: the Dispatcher transfers a swap's input before
/// it delegatecalls `swap()`, so a reverting pAMM has already been paid and a Uniswap V3 retry,
/// which pays in a callback, cannot be funded. Here the tokens stay in this contract.
///
/// Holds no funds between transactions. A balance that does end up here (Curve rounding dust, a
/// mistaken transfer) is claimable by anyone through `swap` and is considered lost, which is also
/// why a Curve approval is left in place rather than revoked. Native ETH, fee-on-transfer and
/// rebasing tokens are unsupported.
contract TychoFallbackRouter is ReentrancyGuardTransient {
    using SafeERC20 for IERC20;

    /// @notice The protocols a fallback may use.
    enum FallbackProtocol {
        UniswapV2,
        UniswapV3,
        UniswapV4,
        Curve,
        FluidV1
    }

    /// @notice One swap: what goes in, what comes out, and who receives it.
    struct Swap {
        address tokenIn;
        address tokenOut;
        uint256 amountIn;
        address receiver;
    }

    /// @notice The `poolManager.unlock` payload, decoded back in `unlockCallback`.
    struct UniswapV4Swap {
        Swap swap;
        uint24 fee;
        int24 tickSpacing;
        address hook;
        bytes hookData;
    }

    // keccak256("TychoFallbackRouter#CALLBACK_SOURCE")
    bytes32 private constant _CALLBACK_SOURCE_SLOT =
        0xf69ae8e0008b818aeb91c2b052698e485056e760fad9d0aa28144b842debe4f7;
    // keccak256("TychoFallbackRouter#CALLBACK_TOKEN")
    bytes32 private constant _CALLBACK_TOKEN_SLOT =
        0xbb428614797396c24d2ae21e3c7c9a28d69673f64cb7ba6433b600b67ed8541b;
    // keccak256("TychoFallbackRouter#CALLBACK_AMOUNT")
    bytes32 private constant _CALLBACK_AMOUNT_SLOT =
        0xde66fd0ca9c728ba44ca7bab17a304d328bf9cf5d5c72b8bf8ea7cd13765e542;

    IPoolManager public immutable poolManager;
    /// @notice Where `dexCallback` pays a Fluid dex.
    address public immutable fluidLiquidity;

    /// @notice The pAMM failed and `protocol` filled instead. Absence of this event on a filled swap
    /// means the pAMM served it, which is the pAMM fill rate.
    /// @dev The pAMM's revert reason is deliberately not carried: reading it would copy
    /// caller-controlled returndata of any size into this frame.
    event FallbackSwap(
        address indexed pamm,
        address indexed tokenIn,
        address indexed tokenOut,
        uint256 amountIn,
        FallbackProtocol protocol
    );

    constructor(IPoolManager poolManager_, address fluidLiquidity_) {
        if (
            address(poolManager_) == address(0) || fluidLiquidity_ == address(0)
        ) {
            revert TychoFallbackRouter__AddressZero();
        }
        poolManager = poolManager_;
        fluidLiquidity = fluidLiquidity_;
    }

    /// @notice Runs `pamm` and, only if it fails, `fallbackSwap`. A failing fallback reverts the
    /// swap; there is no third attempt.
    /// @dev Permissionless: the caller names every parameter, so a balance sitting in this
    /// contract can be taken by anyone and is considered lost. Push-payment: the caller MUST
    /// transfer `swap_.amountIn` of `swap_.tokenIn` here first. Native ETH, fee-on-transfer and
    /// rebasing tokens are not supported.
    /// `fallbackSwap` names one of Uniswap V2, V3 or V4, Curve, or Fluid V1.
    /// No output is returned: the caller measures its own `swap_.tokenOut` balance diff at
    /// `swap_.receiver`, which is how the Dispatcher verifies every swap.
    function swap(
        Swap calldata swap_,
        address pamm,
        bytes calldata fallbackSwap
    ) external nonReentrant {
        try this.executePropAMM(swap_, pamm) {
            return;
        } catch {}

        FallbackProtocol protocol = _executeFallback(swap_, fallbackSwap);
        // Reentrancy cannot happen: the function is nonReentrant.
        // slither-disable-next-line reentrancy-events
        emit FallbackSwap(
            pamm, swap_.tokenIn, swap_.tokenOut, swap_.amountIn, protocol
        );
    }

    /// @notice Runs the pAMM. External only so `swap` can try/catch it.
    function executePropAMM(Swap calldata swap_, address pamm) external {
        if (msg.sender != address(this)) {
            revert TychoFallbackRouter__NotSelf();
        }
        uint256 balanceBefore = IERC20(swap_.tokenOut).balanceOf(swap_.receiver);

        IERC20(swap_.tokenIn).safeTransfer(pamm, swap_.amountIn);
        // slither-disable-next-line unused-return
        IPropAMM(pamm)
            .swap(
                swap_.tokenIn,
                swap_.tokenOut,
                swap_.amountIn,
                0,
                swap_.receiver,
                block.timestamp
            );

        // Reverts on zero delivered, so a pAMM that fills with nothing still falls through
        // to the fallback.
        if (IERC20(swap_.tokenOut).balanceOf(swap_.receiver) <= balanceBefore) {
            revert TychoFallbackRouter__NoOutput();
        }
    }

    /// @dev Runs the tagged protocol, which pays or forwards to `swap_.receiver`. No output
    /// measurement here: the Dispatcher's balance-diff at the receiver is the single source of
    /// truth.
    function _executeFallback(Swap calldata swap_, bytes calldata fallbackSwap)
        internal
        returns (FallbackProtocol protocol)
    {
        if (fallbackSwap.length == 0) {
            revert TychoFallbackRouter__InvalidSwapLength(fallbackSwap.length);
        }

        uint8 protocolByte = uint8(fallbackSwap[0]);
        if (protocolByte > uint8(type(FallbackProtocol).max)) {
            revert TychoFallbackRouter__UnknownProtocol(protocolByte);
        }
        protocol = FallbackProtocol(protocolByte);
        bytes calldata protocolData = fallbackSwap[1:];

        if (protocol == FallbackProtocol.UniswapV2) {
            _swapUniswapV2(swap_, protocolData);
        } else if (protocol == FallbackProtocol.UniswapV3) {
            _swapUniswapV3(swap_, protocolData);
        } else if (protocol == FallbackProtocol.UniswapV4) {
            _swapUniswapV4(swap_, protocolData);
        } else if (protocol == FallbackProtocol.Curve) {
            _swapCurve(swap_, protocolData);
        } else if (protocol == FallbackProtocol.FluidV1) {
            _swapFluidV1(swap_, protocolData);
        } else {
            revert TychoFallbackRouter__UnknownProtocol(protocolByte);
        }
    }

    /// @dev Uniswap V2's `swap` takes explicit output amounts, so this computes the output from
    /// the reserves.
    function _swapUniswapV2(Swap calldata swap_, bytes calldata data) internal {
        if (data.length != 21) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        IUniswapV2Pair pair = IUniswapV2Pair(address(bytes20(data[0:20])));
        uint256 feeBps = uint8(data[20]);
        if (feeBps > 30) {
            revert TychoFallbackRouter__InvalidUniswapV2Fee(feeBps);
        }

        bool zeroForOne = swap_.tokenIn < swap_.tokenOut;
        // slither-disable-next-line unused-return
        (uint112 reserve0, uint112 reserve1,) = pair.getReserves();
        uint256 calculatedAmount = UniswapV2Math.getAmountOut(
            swap_.amountIn,
            zeroForOne ? reserve0 : reserve1,
            zeroForOne ? reserve1 : reserve0,
            feeBps
        );

        IERC20(swap_.tokenIn).safeTransfer(address(pair), swap_.amountIn);
        if (zeroForOne) {
            pair.swap(0, calculatedAmount, swap_.receiver, "");
        } else {
            pair.swap(calculatedAmount, 0, swap_.receiver, "");
        }
    }

    function _swapUniswapV3(Swap calldata swap_, bytes calldata data) internal {
        if (data.length != 20) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        address pool = address(bytes20(data[0:20]));
        bool zeroForOne = swap_.tokenIn < swap_.tokenOut;

        _setCallbackContext(pool, swap_.tokenIn, swap_.amountIn);
        // slither-disable-next-line unused-return
        IUniswapV3Pool(pool)
            .swap(
                swap_.receiver,
                zeroForOne,
                int256(swap_.amountIn),
                zeroForOne
                    ? TickMath.MIN_SQRT_PRICE + 1
                    : TickMath.MAX_SQRT_PRICE - 1,
                ""
            );
        _clearCallbackContext();
    }

    /// @dev One pool, never a path: the currencies come from the sort order of `tokenIn` and
    /// `tokenOut`. Any hook the caller names is used -- there is no allowlist, so a hook that
    /// takes a fee or refuses the swap is the caller's problem to price into `minAmountOut`.
    function _swapUniswapV4(Swap calldata swap_, bytes calldata data) internal {
        if (data.length < 26) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }

        UniswapV4Swap memory v4Swap = UniswapV4Swap({
            swap: swap_,
            fee: uint24(bytes3(data[0:3])),
            tickSpacing: int24(uint24(bytes3(data[3:6]))),
            hook: address(bytes20(data[6:26])),
            hookData: data[26:]
        });

        // slither-disable-next-line unused-return
        poolManager.unlock(abi.encode(v4Swap));
    }

    /// @dev Curve pays the caller, so this forwards to `receiver`.
    function _swapCurve(Swap calldata swap_, bytes calldata data) internal {
        if (data.length != 23) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        address pool = address(bytes20(data[0:20]));
        uint8 poolType = uint8(data[20]);
        uint256 i = uint8(data[21]);
        uint256 j = uint8(data[22]);

        uint256 balanceBefore = IERC20(swap_.tokenOut).balanceOf(address(this));

        IERC20(swap_.tokenIn).forceApprove(pool, swap_.amountIn);
        if (poolType == 1 || poolType == 10) {
            // stable and stable_ng
            ICurveStablePool(pool)
                .exchange(
                    int128(uint128(i)), int128(uint128(j)), swap_.amountIn, 0
                );
        } else {
            // crypto or llamma
            ICurveCryptoPool(pool).exchange(i, j, swap_.amountIn, 0);
        }

        uint256 received =
            IERC20(swap_.tokenOut).balanceOf(address(this)) - balanceBefore;
        IERC20(swap_.tokenOut).safeTransfer(swap_.receiver, received);
    }

    /// @dev `zero2one` is the dex's token order, not the address sort order, so it cannot be
    /// derived.
    function _swapFluidV1(Swap calldata swap_, bytes calldata data) internal {
        if (data.length != 21) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        address dex = address(bytes20(data[0:20]));
        bool zero2one = uint8(data[20]) > 0;

        _setCallbackContext(dex, swap_.tokenIn, swap_.amountIn);
        // slither-disable-next-line unused-return
        IFluidV1Dex(dex)
            .swapInWithCallback(zero2one, swap_.amountIn, 0, swap_.receiver);
        _clearCallbackContext();
    }

    /// @notice Pays a Uniswap V3 pool.
    /// @dev The pool's deltas are ignored; token and amount come from the callback context.
    function uniswapV3SwapCallback(
        int256, /* amount0Delta */
        int256, /* amount1Delta */
        bytes calldata /* data */
    )
        external
    {
        (address tokenIn, uint256 amountIn) = _consumeCallbackContext();
        IERC20(tokenIn).safeTransfer(msg.sender, amountIn);
    }

    /// @notice Pays the Fluid liquidity layer. The requested token must match the callback
    /// context -- a mismatch means the encoded `zero2one` contradicts the swap -- but the paid
    /// amount comes from the context, never from the dex.
    function dexCallback(
        address token_,
        uint256 /* amount_ */
    )
        external
    {
        (address tokenIn, uint256 amountIn) = _consumeCallbackContext();
        if (token_ != tokenIn) {
            revert TychoFallbackRouter__CallbackTokenMismatch(token_, tokenIn);
        }
        IERC20(tokenIn).safeTransfer(fluidLiquidity, amountIn);
    }

    /// @notice Runs the Uniswap V4 swap inside the PoolManager's unlock: decodes `data`, pays the
    /// encoded `amountIn`, swaps the single pool the encoded protocol data names, and sends the
    /// output to the encoded receiver.
    /// @dev The pool key's currencies come from the sort order of the encoded `tokenIn` and
    /// `tokenOut`, so the protocol data carries no direction.
    function unlockCallback(bytes calldata data)
        external
        returns (bytes memory)
    {
        if (msg.sender != address(poolManager)) {
            revert TychoFallbackRouter__NotPoolManager();
        }
        UniswapV4Swap memory v4Swap = abi.decode(data, (UniswapV4Swap));
        Swap memory swap_ = v4Swap.swap;
        bool zeroForOne = swap_.tokenIn < swap_.tokenOut;

        PoolKey memory key = PoolKey({
            currency0: Currency.wrap(
                zeroForOne ? swap_.tokenIn : swap_.tokenOut
            ),
            currency1: Currency.wrap(
                zeroForOne ? swap_.tokenOut : swap_.tokenIn
            ),
            fee: v4Swap.fee,
            tickSpacing: v4Swap.tickSpacing,
            hooks: IHooks(v4Swap.hook)
        });

        poolManager.sync(Currency.wrap(swap_.tokenIn));
        IERC20(swap_.tokenIn).safeTransfer(address(poolManager), swap_.amountIn);
        // slither-disable-next-line unused-return
        poolManager.settle();

        BalanceDelta delta = poolManager.swap(
            key,
            SwapParams(
                zeroForOne,
                -int256(swap_.amountIn),
                zeroForOne
                    ? TickMath.MIN_SQRT_PRICE + 1
                    : TickMath.MAX_SQRT_PRICE - 1
            ),
            v4Swap.hookData
        );

        int128 amountOut = zeroForOne ? delta.amount1() : delta.amount0();
        // A negative delta (hostile hook) wraps to an amount `take` cannot pay, so it reverts
        // there; a zero delta fails the route-level minAmountOut like any other fallback
        // that pays nothing.
        poolManager.take(
            Currency.wrap(swap_.tokenOut),
            swap_.receiver,
            uint256(uint128(amountOut))
        );
        return "";
    }

    function _clearCallbackContext() internal {
        _setCallbackContext(address(0), address(0), 0);
    }

    function _setCallbackContext(address source, address token, uint256 amount)
        internal
    {
        // slither-disable-next-line assembly
        assembly {
            tstore(_CALLBACK_SOURCE_SLOT, source)
            tstore(_CALLBACK_TOKEN_SLOT, token)
            tstore(_CALLBACK_AMOUNT_SLOT, amount)
        }
    }

    /// @dev Rejects a `msg.sender` that is not the stored source, and clears the context so one
    /// callback cannot pay twice.
    function _consumeCallbackContext()
        internal
        returns (address token, uint256 amount)
    {
        address source;
        // slither-disable-next-line assembly
        assembly {
            source := tload(_CALLBACK_SOURCE_SLOT)
            token := tload(_CALLBACK_TOKEN_SLOT)
            amount := tload(_CALLBACK_AMOUNT_SLOT)
            tstore(_CALLBACK_SOURCE_SLOT, 0)
            tstore(_CALLBACK_TOKEN_SLOT, 0)
            tstore(_CALLBACK_AMOUNT_SLOT, 0)
        }
        // An unset context has source == address(0), which no real sender matches.
        if (msg.sender != source) {
            revert TychoFallbackRouter__InvalidCallback();
        }
    }
}