---
id: TIP-1033
title: Two-Hop FeeAMM Routing
description: Adds a two-hop fallback path through the FeeAMM when there is insufficient liquidity in the direct userToken→validatorToken pool.
authors: Daniel Robinson
status: Approved
related: TIP-1000, FeeAMM, FeeManager
protocolVersion: T5
---

# TIP-1033: Two-Hop FeeAMM Routing

## Abstract

When a user's fee token differs from the validator's fee token, the FeeManager swaps the fee through the FeeAMM at a fixed rate of M = 0.9970 (30 bps). Today, if the direct pool (`userToken → validatorToken`) has insufficient liquidity, the transaction is rejected.

TIP-1033 adds a single fallback path: `userToken → userToken.quoteToken() → validatorToken`. Both hops use the same M rate, so the validator receives `amount × M²` (≈ 0.994009, or ~60 bps total). No general routing or path search is introduced—only one specific two-hop path is checked.

## Motivation

As the number of TIP-20 tokens grows, maintaining deep FeeAMM liquidity for every possible `(userToken, validatorToken)` pair becomes impractical. Most tokens have a `quoteToken` that points to a liquid hub token. By routing through this intermediate token, we can support fee swaps for any `(userToken, validatorToken)` pair where pools `(userToken, userToken.quoteToken())` and `(userToken.quoteToken(), validatorToken)` both have liquidity — no common `quoteToken` ancestor between the two tokens is required.

The design is intentionally minimal: one extra hop, one specific path, no graph search. This keeps the protocol change small and the gas overhead bounded.

---

# Specification

## Overview

The change affects two protocol-level calls on the FeeManager: `collect_fee_pre_tx` and `collect_fee_post_tx`. A new transient storage field stores the intermediate token address when two-hop routing is used.

## Pre-Transaction: `collect_fee_pre_tx`

When `userToken != validatorToken`:

1. **Try direct pool.** Check liquidity in pool `(userToken, validatorToken)`. If sufficient, proceed as today (single hop). Reserve liquidity per T1C+.

> **Note on gas-limit routing.** Because the liquidity check uses `maxAmount` (derived from `gas_limit × effective_gas_price`), a user who sets a high gas limit can cause the direct pool to fail the pre-tx liquidity test even though the actual fee would have fit. This forces the transaction onto the two-hop path, costing the validator an extra ~30 bps. In practice this is low-impact: pools need only modest liquidity to cover typical gas limits (e.g. 30M gas at current prices ≈ ~$0.60 of reserves), so the direct path will almost always succeed unless gas prices are very high or the pool is severely under-provisioned.

2. **Fallback to two-hop.** If the direct pool has insufficient liquidity:
   - Read `intermediateToken = TIP20(userToken).quoteToken()`.
   - If `intermediateToken == validatorToken`, revert with `InsufficientLiquidity`. The single-hop path already failed for this pair, and the two-hop path degenerates to the same pair.
   - Check liquidity in pool `(userToken, intermediateToken)` for `compute_amount_out(maxAmount)`.
   - Check liquidity in pool `(intermediateToken, validatorToken)` for `compute_amount_out(compute_amount_out(maxAmount))`.
   - Reserve liquidity in **both** pools (transient storage, per T1C+).
   - Write `intermediateToken` to transient storage.

3. **If two-hop also fails**, revert with `InsufficientLiquidity` as today.

### Liquidity Reservation

Both pools must be reserved in pre-tx to prevent the transaction's own execution from draining reserves needed for the post-tx swap. This extends the existing `pending_fee_swap_reservation` mechanism to cover two pools instead of one.

## Post-Transaction: `collect_fee_post_tx`

When a fee swap is needed and `intermediateToken` is set (non-zero) in transient storage:

1. Read `intermediateToken` from transient storage.
2. Execute two chained swaps:
   ```
   out1 = execute_fee_swap(userToken, intermediateToken, actualSpending)
   out2 = execute_fee_swap(intermediateToken, validatorToken, out1)
   ```
3. Accumulate `out2` as the validator's collected fees.

When `intermediateToken` is zero (not set), behavior is unchanged from today.

### Fee Math

Each hop applies the standard M = 9970/10000 rate. Two-hop output MUST be computed as two sequential integer divisions:

```
out1 = floor(actualSpending × 9970 / 10000)
out2 = floor(out1 × 9970 / 10000)
```

Implementations MUST NOT combine the two steps into a single multiplication (e.g., `floor(actualSpending × 99400900 / 100000000)`), as the intermediate rounding produces different results. The sequential computation is consensus-critical.

The validator receives ~0.994009× instead of ~0.997× per unit. The extra ~30 bps compensates the second pool's liquidity providers.

## Transient Storage

One new transient storage field on `TipFeeManager`:

| Field | Type | Description |
|-------|------|-------------|
| `two_hop_intermediate` | `Address` | The intermediate token for the current tx's two-hop swap. Zero if single-hop. |

The intermediate token is captured at pre-tx time and read back at post-tx time. If `quoteToken` changes during the transaction (via `completeQuoteTokenUpdate`), it does not matter — the stored address is used regardless, and the reserved pools match what post-tx will swap through. The field is automatically cleared at the end of each transaction (transient storage semantics).

## Edge Cases

| Scenario | Behavior |
|----------|----------|
| `userToken == validatorToken` | No swap needed. Unchanged. |
| Direct pool has sufficient liquidity | Single-hop swap. Unchanged. |
| `userToken.quoteToken() == validatorToken` | Two-hop degenerates to single-hop pair, which already failed. Revert. |
| `userToken.quoteToken() == userToken` | Cannot happen—TIP-20 token graph does not allow self-quoting. |
| Either two-hop pool has insufficient liquidity | Revert with `InsufficientLiquidity`. |
| Transaction drains intermediate pool during execution | Prevented by liquidity reservation in both pools (T1C+). |
| `completeQuoteTokenUpdate` called on fee token during tx | No effect on fee routing. The intermediate token was captured in transient storage at pre-tx time. |

## Gas Overhead

Two-hop routing adds:
- 2 additional storage reads in pre-tx (second pool check + `quoteToken()`)
- 2 transient storage writes in pre-tx (intermediate token + reserve liquidity on the second pool)
- 1 transient storage read in post-tx
- 1 additional `execute_fee_swap` call in post-tx (pool reserve read + write)

Total overhead is bounded: ~6 additional storage operations. No loops or unbounded computation.

## Invariants

The following invariants MUST hold for every transaction that triggers a fee swap. They are consensus-critical and MUST be covered by tests.

1. **Swap fee** The AMM will charge `M = 0.9970` (30 bps) for each swap (hop).
2. **Fee math (single-hop).** When `two_hop_intermediate == 0` and `userToken != validatorToken`, the validator-credited amount equals exactly:
   ```
   floor(actualSpending * M)
   ```
3. **Fee math (two-hop).** When `two_hop_intermediate != 0`, the validator-credited amount equals exactly:
   ```
   floor(floor(actualSpending * M) * M)
   ```
   The two divisions MUST NOT be fused into a single `M**2` step.
4. **Path selection is deterministic and direct-preferred.** Two-hop is used (i.e. `two_hop_intermediate` is set) if and only if, at pre-tx time, the direct pool `(userToken, validatorToken)` had insufficient liquidity for `compute_amount_out(maxAmount)` AND both two-hop pools had sufficient liquidity. The direct path is always preferred when available.
5. **Intermediate token well-formedness.** Whenever `two_hop_intermediate != 0`:
   - `two_hop_intermediate != userToken`
   - `two_hop_intermediate != validatorToken`
   - `two_hop_intermediate == TIP20(userToken).quoteToken()` as observed at pre-tx time (it MAY differ from the value of `quoteToken()` at post-tx time if `completeQuoteTokenUpdate` ran during the tx).
6. **Reservation covers settlement.** If `two_hop_intermediate != 0`, both pools `(userToken, two_hop_intermediate)` and `(two_hop_intermediate, validatorToken)` have reserved liquidity at post-tx time sufficient to execute the chained swap on `actualSpending` (since `actualSpending ≤ maxAmount` and `compute_amount_out` is monotonic).
7. **Single-hop unchanged.** When `userToken == validatorToken` or the direct pool succeeds, `two_hop_intermediate == 0` and observable behavior (validator credit, pool state, gas) is identical to pre-TIP-1033.
8. **Transient lifetime.** `two_hop_intermediate` is zero at the start of every transaction (transient storage semantics). No transaction observes a value written by a prior transaction.

---

# Rationale

### Why only one path?

A general router (LCA walk, BFS, etc.) adds complexity, gas cost, and attack surface for minimal benefit. The `quoteToken` chain provides a natural routing hint: `userToken → userToken.quoteToken() → validatorToken` is arguably the most likely two-step path to be available if the direct pool isn't.

### Why compound the fee instead of flat?

Compounding (`M × M`) lets both hops reuse `execute_fee_swap` at the standard M rate without modification. A flat fee (30 bps of original input to each pool) would require the second pool to operate at a non-standard rate (`9940/9970 ≈ 0.996991`), complicating the swap function and creating an inconsistency in pool reserve ratios that affects rebalancer economics.

### Why store the intermediate token instead of re-deriving it?

Storing the intermediate token address in transient storage at pre-tx time means the routing decision is captured once and used directly in post-tx. This avoids any concern about `quoteToken` changing during transaction execution (via `completeQuoteTokenUpdate`), and ensures the post-tx swap always matches the pools that were reserved in pre-tx.
