tycho-execution 0.410.0

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
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
// 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 {IUniswapV3StaticQuoter} from "@interfaces/IUniswapV3StaticQuoter.sol";
import {
    ICurveCryptoPool,
    ICurveStablePool,
    isCurveStablePool
} from "@interfaces/ICurvePool.sol";
import {IFluidV1Dex, FluidDexSwapResult} from "@interfaces/IFluidV1Dex.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();
/// @notice Not a failure: carries the amount `simulateUniswapV4` measured, so the swap it ran
/// rolls back.
error TychoFallbackRouter__SimulatedAmountOut(uint256 amountOut);
error TychoFallbackRouter__UnknownProtocol(uint8 protocol);

/// @title TychoFallbackRouter
/// @notice Quotes a pAMM against the caller's chosen fallback protocol and runs whichever quotes
/// more `tokenOut`. A pAMM that wins the quote but fails still falls through to the fallback.
/// @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 Why the fallback ran instead of the pAMM.
    enum FallbackReason {
        // The fallback quoted more `tokenOut` than the pAMM, or the pAMM could not quote at all
        // and its swap was never attempted.
        FallbackQuotedHigher,
        // The pAMM quoted at least as much as the fallback, then reverted or delivered nothing.
        PropAMMFailed
    }

    /// @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 Prices a Uniswap V3 fallback without running it.
    IUniswapV3StaticQuoter public immutable uniswapV3StaticQuoter;

    /// @notice `protocol` filled instead of the pAMM, for `reason`. 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,
        FallbackReason reason
    );

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

    /// @notice Quotes `pamm` and `fallbackSwap`, then runs the fallback if it quotes more
    /// `tokenOut`, otherwise `pamm` and, only if that 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.
    /// A fallback quote that reverts counts as zero, so equal quotes keep the pAMM. A pAMM that
    /// cannot quote skips both the fallback quote and its own swap.
    /// 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 {
        // Low-level so a `pamm` without code, or one returning nothing decodable, quotes zero
        // instead of reverting `swap`. That covers `pamm == address(0)`, so no zero check.
        // slither-disable-next-line low-level-calls,missing-zero-check
        (bool quoted, bytes memory quote) = pamm.call(
            abi.encodeCall(
                IPropAMM.quote, (swap_.tokenIn, swap_.tokenOut, swap_.amountIn)
            )
        );
        uint256 pammAmountOut =
            quoted && quote.length >= 32 ? abi.decode(quote, (uint256)) : 0;

        FallbackReason reason = FallbackReason.FallbackQuotedHigher;
        // A pAMM that cannot quote does not get its swap attempted, so there is nothing for the
        // fallback quote to decide and it is skipped. That saves the whole quote, which for
        // Uniswap V4 is a simulated swap.
        if (pammAmountOut > 0) {
            uint256 fallbackAmountOut = 0;
            try this.quoteFallback(swap_, fallbackSwap) returns (
                uint256 amountOut
            ) {
                fallbackAmountOut = amountOut;
            } catch {}

            if (fallbackAmountOut <= pammAmountOut) {
                try this.executePropAMM(swap_, pamm) {
                    return;
                } catch {}
                reason = FallbackReason.PropAMMFailed;
            }
        }

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

    /// @notice Runs the pAMM. External only so `swap` can try/catch it.
    function executePropAMM(Swap calldata swap_, address pamm) external {
        _requireSelf();
        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();
        }
    }

    /// @notice Quotes the fallback protocol. External only so `swap` can try/catch it: it
    /// reverts with the protocol's own error, or the decoder's, when it cannot quote.
    function quoteFallback(Swap calldata swap_, bytes calldata fallbackSwap)
        external
        returns (uint256 amountOut)
    {
        _requireSelf();
        (FallbackProtocol protocol, bytes calldata protocolData) =
            _decodeFallback(fallbackSwap);

        if (protocol == FallbackProtocol.UniswapV2) {
            (IUniswapV2Pair pair, uint256 feeBps) =
                _decodeUniswapV2(protocolData);
            return _quoteUniswapV2(swap_, pair, feeBps);
        } else if (protocol == FallbackProtocol.UniswapV3) {
            return _quoteUniswapV3(swap_, protocolData);
        } else if (protocol == FallbackProtocol.UniswapV4) {
            // Uniswap V4 has no quote function, so the amount comes off a reverted swap.
            try this.simulateUniswapV4(swap_, protocolData) {
                return 0;
            } catch (bytes memory revertData) {
                return _amountOutFromRevert(
                    revertData, TychoFallbackRouter__SimulatedAmountOut.selector
                );
            }
        } else if (protocol == FallbackProtocol.Curve) {
            return _quoteCurve(swap_, protocolData);
        } else if (protocol == FallbackProtocol.FluidV1) {
            return _quoteFluidV1(swap_, protocolData);
        } else {
            revert TychoFallbackRouter__UnknownProtocol(uint8(protocol));
        }
    }

    /// @notice Runs the Uniswap V4 swap, then reverts `TychoFallbackRouter__SimulatedAmountOut`
    /// with the amount it delivered so the swap rolls back. External only so `quoteFallback` can
    /// try/catch it.
    function simulateUniswapV4(Swap calldata swap_, bytes calldata protocolData)
        external
    {
        _requireSelf();
        uint256 balanceBefore = IERC20(swap_.tokenOut).balanceOf(swap_.receiver);
        _swapUniswapV4(swap_, protocolData);
        revert TychoFallbackRouter__SimulatedAmountOut(IERC20(swap_.tokenOut)
                    .balanceOf(swap_.receiver) - balanceBefore);
    }

    /// @dev Same arguments as `_swapUniswapV3`, priced by the static quoter's own tick walk
    /// rather than by the pool.
    function _quoteUniswapV3(Swap calldata swap_, bytes calldata data)
        internal
        view
        returns (uint256 amountOut)
    {
        address pool = _decodeUniswapV3(data);
        (bool zeroForOne, uint160 sqrtPriceLimit) = _uniswapV3Params(swap_);
        (int256 amount0, int256 amount1) = uniswapV3StaticQuoter.quote(
            pool, zeroForOne, int256(swap_.amountIn), sqrtPriceLimit
        );
        return uint256(-(zeroForOne ? amount1 : amount0));
    }

    /// @dev The direction and price limit `_quoteUniswapV3` and `_swapUniswapV3` share, so the
    /// quote always describes the swap that runs.
    function _uniswapV3Params(Swap calldata swap_)
        internal
        pure
        returns (bool zeroForOne, uint160 sqrtPriceLimit)
    {
        zeroForOne = swap_.tokenIn < swap_.tokenOut;
        sqrtPriceLimit = zeroForOne
            ? TickMath.MIN_SQRT_PRICE + 1
            : TickMath.MAX_SQRT_PRICE - 1;
    }

    function _quoteCurve(Swap calldata swap_, bytes calldata data)
        internal
        view
        returns (uint256 amountOut)
    {
        (address pool, uint8 poolType, uint256 i, uint256 j) =
            _decodeCurve(data);
        if (isCurveStablePool(poolType)) {
            return ICurveStablePool(pool)
                .get_dy(int128(uint128(i)), int128(uint128(j)), swap_.amountIn);
        }
        return ICurveCryptoPool(pool).get_dy(i, j, swap_.amountIn);
    }

    /// @dev Paying `0xdEaD` makes the dex revert `FluidDexSwapResult` with the amount before it
    /// pulls any token.
    function _quoteFluidV1(Swap calldata swap_, bytes calldata data)
        internal
        returns (uint256 amountOut)
    {
        (address dex, bool zero2one) = _decodeFluidV1(data);
        // slither-disable-next-line unused-return
        try IFluidV1Dex(dex)
            .swapIn(zero2one, swap_.amountIn, 0, address(0xdEaD)) {
            return 0;
        } catch (bytes memory revertData) {
            return _amountOutFromRevert(revertData, FluidDexSwapResult.selector);
        }
    }

    /// @dev The `uint256` a `selector(uint256)` revert carries, or zero for any other revert.
    function _amountOutFromRevert(bytes memory revertData, bytes4 selector)
        internal
        pure
        returns (uint256 amountOut)
    {
        if (revertData.length != 36 || bytes4(revertData) != selector) {
            return 0;
        }
        // slither-disable-next-line assembly
        assembly {
            amountOut := mload(add(revertData, 36))
        }
    }

    /// @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)
    {
        bytes calldata protocolData;
        (protocol, protocolData) = _decodeFallback(fallbackSwap);

        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(uint8(protocol));
        }
    }

    function _decodeFallback(bytes calldata fallbackSwap)
        internal
        pure
        returns (FallbackProtocol protocol, bytes calldata protocolData)
    {
        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);
        protocolData = fallbackSwap[1:];
    }

    /// @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 {
        (IUniswapV2Pair pair, uint256 feeBps) = _decodeUniswapV2(data);
        bool zeroForOne = swap_.tokenIn < swap_.tokenOut;
        uint256 calculatedAmount = _quoteUniswapV2(swap_, pair, 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 _quoteUniswapV2(
        Swap calldata swap_,
        IUniswapV2Pair pair,
        uint256 feeBps
    ) internal view returns (uint256 amountOut) {
        bool zeroForOne = swap_.tokenIn < swap_.tokenOut;
        // slither-disable-next-line unused-return
        (uint112 reserve0, uint112 reserve1,) = pair.getReserves();
        return UniswapV2Math.getAmountOut(
            swap_.amountIn,
            zeroForOne ? reserve0 : reserve1,
            zeroForOne ? reserve1 : reserve0,
            feeBps
        );
    }

    function _decodeUniswapV2(bytes calldata data)
        internal
        pure
        returns (IUniswapV2Pair pair, uint256 feeBps)
    {
        if (data.length != 21) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        pair = IUniswapV2Pair(address(bytes20(data[0:20])));
        feeBps = uint8(data[20]);
        if (feeBps > 30) {
            revert TychoFallbackRouter__InvalidUniswapV2Fee(feeBps);
        }
    }

    function _swapUniswapV3(Swap calldata swap_, bytes calldata data) internal {
        address pool = _decodeUniswapV3(data);
        (bool zeroForOne, uint160 sqrtPriceLimit) = _uniswapV3Params(swap_);

        _setCallbackContext(pool, swap_.tokenIn, swap_.amountIn);
        // slither-disable-next-line unused-return
        IUniswapV3Pool(pool)
            .swap(
                swap_.receiver,
                zeroForOne,
                int256(swap_.amountIn),
                sqrtPriceLimit,
                ""
            );
        _clearCallbackContext();
    }

    function _decodeUniswapV3(bytes calldata data)
        internal
        pure
        returns (address pool)
    {
        if (data.length != 20) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        pool = address(bytes20(data[0:20]));
    }

    /// @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 {
        (address pool, uint8 poolType, uint256 i, uint256 j) =
            _decodeCurve(data);

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

        IERC20(swap_.tokenIn).forceApprove(pool, swap_.amountIn);
        if (isCurveStablePool(poolType)) {
            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);
    }

    function _decodeCurve(bytes calldata data)
        internal
        pure
        returns (address pool, uint8 poolType, uint256 i, uint256 j)
    {
        if (data.length != 23) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        pool = address(bytes20(data[0:20]));
        poolType = uint8(data[20]);
        i = uint8(data[21]);
        j = uint8(data[22]);
    }

    /// @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 {
        (address dex, bool zero2one) = _decodeFluidV1(data);

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

    function _decodeFluidV1(bytes calldata data)
        internal
        pure
        returns (address dex, bool zero2one)
    {
        if (data.length != 21) {
            revert TychoFallbackRouter__InvalidSwapLength(data.length);
        }
        dex = address(bytes20(data[0:20]));
        zero2one = uint8(data[20]) > 0;
    }

    /// @notice Pays a Uniswap V3-style pool from the callback context.
    /// @dev Catch-all so it answers to every V3 fork's callback name. Token and amount come from
    /// the context, which `_consumeCallbackContext` ties to the pool `_swapUniswapV3` armed.
    fallback() 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 _requireSelf() internal view {
        if (msg.sender != address(this)) {
            revert TychoFallbackRouter__NotSelf();
        }
    }

    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();
        }
    }
}