| 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 |
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.
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.
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.
When userToken != validatorToken:
- 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 fromgas_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.
-
Fallback to two-hop. If the direct pool has insufficient liquidity:
- Read
intermediateToken = TIP20(userToken).quoteToken(). - If
intermediateToken == validatorToken, revert withInsufficientLiquidity. 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)forcompute_amount_out(maxAmount). - Check liquidity in pool
(intermediateToken, validatorToken)forcompute_amount_out(compute_amount_out(maxAmount)). - Reserve liquidity in both pools (transient storage, per T1C+).
- Write
intermediateTokento transient storage.
- Read
-
If two-hop also fails, revert with
InsufficientLiquidityas today.
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.
When a fee swap is needed and intermediateToken is set (non-zero) in transient storage:
- Read
intermediateTokenfrom transient storage. - Execute two chained swaps:
out1 = execute_fee_swap(userToken, intermediateToken, actualSpending) out2 = execute_fee_swap(intermediateToken, validatorToken, out1) - Accumulate
out2as the validator's collected fees.
When intermediateToken is zero (not set), behavior is unchanged from today.
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.
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).
| 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. |
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_swapcall in post-tx (pool reserve read + write)
Total overhead is bounded: ~6 additional storage operations. No loops or unbounded computation.
The following invariants MUST hold for every transaction that triggers a fee swap. They are consensus-critical and MUST be covered by tests.
- Swap fee The AMM will charge
M = 0.9970(30 bps) for each swap (hop). - Fee math (single-hop). When
two_hop_intermediate == 0anduserToken != validatorToken, the validator-credited amount equals exactly:floor(actualSpending * M) - Fee math (two-hop). When
two_hop_intermediate != 0, the validator-credited amount equals exactly:The two divisions MUST NOT be fused into a singlefloor(floor(actualSpending * M) * M)M**2step. - Path selection is deterministic and direct-preferred. Two-hop is used (i.e.
two_hop_intermediateis set) if and only if, at pre-tx time, the direct pool(userToken, validatorToken)had insufficient liquidity forcompute_amount_out(maxAmount)AND both two-hop pools had sufficient liquidity. The direct path is always preferred when available. - Intermediate token well-formedness. Whenever
two_hop_intermediate != 0:two_hop_intermediate != userTokentwo_hop_intermediate != validatorTokentwo_hop_intermediate == TIP20(userToken).quoteToken()as observed at pre-tx time (it MAY differ from the value ofquoteToken()at post-tx time ifcompleteQuoteTokenUpdateran during the tx).
- 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 onactualSpending(sinceactualSpending ≤ maxAmountandcompute_amount_outis monotonic). - Single-hop unchanged. When
userToken == validatorTokenor the direct pool succeeds,two_hop_intermediate == 0and observable behavior (validator credit, pool state, gas) is identical to pre-TIP-1033. - Transient lifetime.
two_hop_intermediateis zero at the start of every transaction (transient storage semantics). No transaction observes a value written by a prior transaction.
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.
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.
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.