Skip to content

Latest commit

 

History

History
151 lines (103 loc) · 9.74 KB

File metadata and controls

151 lines (103 loc) · 9.74 KB
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.

  1. 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.
  2. 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.