docs (TIP -1054): Fees in non-USD TIP-20 tokens#3859
Conversation
tempoxyz-cyclops-bot
left a comment
There was a problem hiding this comment.
👁️ Cyclops Review
PR #3859 is a doc-only change adding the TIP-1054 Non-USD Fee Tokens draft (tips/tip-1054.md, +343/-0). No executable code is touched, so nothing is consensus-active today, but four spec-level issues would translate directly into exploitable behaviour or under-enforced limits when implemented. Inline comments cover each on the relevant TIP line. The one-block delay, refund monotonicity, validator reservation, and burn-availability invariants all hold as written.
The full consolidated report is at findings/pr-3859/pr-3859-consolidated.md.
Note on a rejected finding: A separate report claiming refundUserToken = maxUserTokenFee - actualUserTokenSpend violates a "refunds round down" invariant was dropped. The spec defines actualUserTokenSpend as the net rounded-up debit and the refund as the escrow residual; forcing an independent floor-rounded refund would over-charge users relative to TIP-1054's own actualUserTokenSpend formula (tips/tip-1054.md:138-145, 256).
Reviewer Callouts
- ⚡ Quote freshness / magnitude / lifecycle policy: Decide an explicit max quote age, a per-block deviation cap, and an absolute cap on
pathUsdPerTokenX18. Specify whether(currentQuote, previousQuote, currentQuoteBlock, previousQuoteBlock)is cleared whenfeeQuoteUpdateris rotated or zeroed (the spec is currently silent). - ⚡ Pin pre-tx quote in transaction-scoped state: Post-tx settlement must use the pre-tx-captured quote. Implementers should store
priceX18/roundIdin transaction context (not re-readpreviousFeeQuote()), so future side-effectful entry points cannot perturb fee accounting mid-transaction. - ⚡ Pool maintenance lacks FX quote/updater invalidations:
TempoPoolUpdates(crates/transaction-pool/src/maintain.rs:38-88, 126-183) has no fields or event branches forFeeQuoteUpdated/FeeQuoteUpdaterSet. Pending FX-fee transactions can become invalid when a quote rolls or an updater is cleared; pool revalidation hooks must be added when implementing. - ⚡
fee_token_cost()unit split: Current pool/balance/key-limit paths (crates/transaction-pool/src/transaction.rs:69-94,tempo_pool.rs:227-238,267-305,1163-1192,best.rs:167-179) treatfee_token_cost()as both USD6 and token units. For FX, balance and key-limit eviction needmaxUserTokenFee; AMM liquidity checks must remain USD6 (validatorFeeOut(maxFeeUsd6)). - ⚡
roundIdsemantics: No monotonicity check onroundId; the same(roundId, price)can be replayed. Document the off-chain contract or enforce monotonicity on push. - ⚡
amountOutUserToken == 0inrebalanceSwapFX: Spec doesn't explicitly forbid zero amounts; mirror the existingrebalance_swapInvalidAmountguard. - ⚡
setUserToken(FX)accepts paused / quote-less tokens: Only nonzero-feeQuoteUpdateris required at registration; document this so user-facing tooling can warn about tokens that will always revert at fee collection.
| Before any FeeAMM operation uses a token quote, FeeAMM MUST call `rollFeeQuote()` for that token and then read `previousQuote`. `rollFeeQuote()` MAY be restricted to FeeAMM. If it is exposed more broadly, it MUST perform only `rollIfEligible`. | ||
|
|
||
| A previous quote is valid only if `previousQuoteBlock != 0`, `previousQuote.roundId != 0`, and `previousQuote.pathUsdPerTokenX18 != 0`. If the previous quote is invalid after `rollFeeQuote()`, the FX token is not fee-enabled for the current block and any operation that requires a valid quote MUST revert. | ||
|
|
There was a problem hiding this comment.
🚨 [SECURITY] FX fee quotes never expire — stale prices can drain direct-pool USD reserves
previousQuote is treated as valid as long as previousQuoteBlock, roundId, and pathUsdPerTokenX18 are nonzero. The interface explicitly omits updatedAt (line 96) and there is no block.number - previousQuoteBlock bound. If an FX token depegs or the updater goes offline, fee settlement and rebalanceSwapFX keep using the inflated old price; users pay too few FX units while FeeAMM still pays validators in USD validator-token reserves, draining LP-provided USD liquidity. The same trust gap also lets a malicious updater push an unbounded pathUsdPerTokenX18 (max uint192) to obtain near-free blockspace or to drain reserve_user_token via rebalanceSwapFX.
Recommended Fix:
Add freshness to quote validity, e.g.
previousQuoteBlock != 0
&& previousQuote.roundId != 0
&& previousQuote.pathUsdPerTokenX18 != 0
&& block.number - previousQuoteBlock <= MAX_FEE_QUOTE_AGE_BLOCKS
Apply the bound before every quote-dependent operation (FX precharge, rebalanceSwapFX, FX mint; post-tx settlement still uses the pre-tx-captured quote). Consider also a per-block deviation cap and an absolute upper bound on pathUsdPerTokenX18, and clear cached quote state when feeQuoteUpdater changes.
| 1. FeeManager asks FeeAMM to call `rollFeeQuote()` for `userToken` and to read and return `userToken.previousFeeQuote()`. | ||
| 2. Fee collection reverts if the resolved FX token is not executable in the current block. | ||
| 3. FeeManager computes `maxUserTokenFee = fxTokenAmountForUsd6(maxFeeUsd6, quote.pathUsdPerTokenX18)`. | ||
| 4. FeeManager transfers `maxUserTokenFee` from the fee payer through the existing precharge path. |
There was a problem hiding this comment.
🚨 [SECURITY] FX token-quantum rounding can debit more value than the signed maxFeeUsd6
The signed gas fields (maxFeeUsd6) remain the only fee bound (line 145, 215-228), but the precharge converts USD6 to FX units with ceilDiv(usd6 * QUOTE_SCALE, priceX18) (line 238). TIP-20 amounts have only 6 decimals and pathUsdPerTokenX18 is capped only by uint192, so one smallest FX unit can be worth far more than maxFeeUsd6. Example: with priceX18 = 100_000e18, one unit ≈ $0.10, so a $0.01 signed max fee rounds up to one whole FX unit. The validator is still paid only validatorFeeOut(maxFeeUsd6); the surplus accumulates as reserve_user_token and is later capturable by LPs / mint+burn. This breaks the invariant that signed gas fields bound the value taken from the fee payer (tips/tip-1054.md:271).
🚨 [SECURITY] Same-tx access-key fee limits are still enforced in USD6 while precharge debits FX units
TIP-1054 keeps maxFeeUsd6 / actualFeeUsd6 (i.e. gas_balance_spending) as the USD6 source of truth but debits the fee payer in FX token units (maxUserTokenFee, actualUserTokenSpend, refundUserToken). The handler's same-tx key-authorization bridge at crates/revm/src/handler.rs:1130-1147 and post-auth decrement at crates/revm/src/handler.rs:1396-1419 already manually checks/decrements gas_balance_spending against the new key's per-token limit, because transfer_fee_pre_tx runs before the key is installed and AccountKeychain::authorize_transfer skips spending checks while transaction_key == ZERO (crates/precompiles/src/account_keychain/mod.rs:1268-1274). For FX tokens this enforces the wrong unit: a $1 max fee at a $0.01/FX quote requires 100 FX of debit, but a root-signed limit of 1 FX passes the precheck and is decremented by only 1.
Recommended Fix:
- Bound the quoted USD value of the rounded token debit, e.g. require
usd6ValueCeil(maxUserTokenFee, priceX18) <= maxFeeUsd6 + MAX_FX_FEE_DUST_USD6, restrict fee-enabled FX tokens to a maxpathUsdPerTokenX18so one smallest unit stays under an acceptable dust threshold, or require the payer/sponsor to sign a token-denominatedmaxUserTokenFee(or slippage). - Thread both an
Usd6FeeAmountand aUserTokenAmountthrough the FeeManager/handler boundary: resolve/roll the quote (or expose a side-effect-free FeeManager helper) before the same-tx precheck, compare the new key'sTokenLimit.limitagainstmaxUserTokenFee, store the token-denominated precharge separately inevm.collected_fee, and refund key limits byrefundUserToken. AMM validator-reserve checks must remain USD6-based; balance and key-limit checks must use the FX amount.
| ```text | ||
| discountedUserReserveUsd6 | ||
| = floor(usd6ValueFloor(U, priceX18) * FEE_AMM_N / FEE_AMM_SCALE) | ||
|
|
There was a problem hiding this comment.
mint undervalues reserve_user_token, inflating LP shares for single-sided minters
Two compounding problems with discountedUserReserveUsd6 = floor(usd6ValueFloor(U, priceX18) * FEE_AMM_N / FEE_AMM_SCALE) used as the denominator at line 194:
- Floor in the denominator. Both
usd6ValueFloorand the outerfloor(... * N / SCALE)round the pool's existing user-token reserve down, shrinking(V + discountedUserReserveUsd6)and minting more LP shares than a conservative valuation would. This violates the protocol-favoring rounding direction. (The existing USD-pool mint atcrates/precompiles/src/tip_fee_manager/amm.rs:271-289uses the same pattern; if accepted for FX, the inherited USD path should be revisited too.) - Reusing the rebalance discount
FEE_AMM_N(~99.85%) inside an LP-deposit valuation. This structurally mis-prices the pool by ~15 bps in the depositor's favor. A flash-loanmint→burnextracts pro-rataUandVat the un-discounted rate, pocketing the spread from existing LPs; even without flash loans every new LP gets a small free transfer from incumbents.
Recommended Fix:
Value the existing user-token reserve conservatively (round up) and without the rebalance spread when sizing LP shares, e.g.
userReserveUsd6 = usd6ValueCeil(U, priceX18)
liquidity = floor(amountValidatorToken * S / (V + userReserveUsd6))
If a discount on U is intentional for FX pools (e.g. to compensate for quote uncertainty), document why and prove that no mint/burn pair can be net-profitable for the minter at any valid quote.
There was a problem hiding this comment.
nit: vars are sometimes named usd6 and sometimes priceX18.
personally i'd use scientific notation-like naming (usdE6 and priceE18), so the decimal scale is explicit. imo X can be confused with uniV3 binary fixed-point notation, where values like X96/X128 are scaled by 2**N
|
directionally looks good, and i think such design could work. my main concerns with this design thought, are around oracle trust and data freshness... as specified, FX direct pools all pricing/oracle risk on LPs, without giving them any protocol-level guarantees. a relatively simple mitigation would be to store quote update time/block and have FeeAMM stop quoting once the price has not been updated for more than a more complex (and opinionated) solution would be to add some kind of rolling average / volatility or max-change check, and stop quoting or reject updates when price changes exceed a threshold. |
|
the new proposed changes partially mitigate my previous concerns; the ceiling + freshness policy is a clean way to bound oracle risk while keeping the mechanism simple. i think we are now in a way better spot than before, however, i would like to have a small dedicated subsection spelling out the trust assumptions LPs are making (what they're protected against, what they're not, etc). Feels like useful hygiene given the oracle/admin surface here. |
@0xrusowsky updated this. |
|
|
||
| ## TIP-20 Changes | ||
|
|
||
| The fee oracle lives in the TIP-20 precompile. This TIP adds storage and entry points to the token rather than defining a separate oracle contract. |
There was a problem hiding this comment.
I was thinking we could have the fee oracle stored in its own precompile rather than on each token, so we don't need to do separate oracle pushes to every token. My expectation would be that most tokens for a given currency would pick some standard shared oracle.
There was a problem hiding this comment.
People might want to charge rent for this, like Chainlink perhaps
|
|
||
| ## TIP-20 Changes | ||
|
|
||
| The fee oracle lives in the TIP-20 precompile. This TIP adds storage and entry points to the token rather than defining a separate oracle contract. |
There was a problem hiding this comment.
People might want to charge rent for this, like Chainlink perhaps
|
|
||
| ### Fee Oracle | ||
|
|
||
| A non-USD TIP-20 token opts into letting users pay fees in that token by setting a `feeQuoteUpdater` and a fee quote policy. Only the token's authorized admin MAY set or clear them. Successful changes MUST emit `FeeQuoteUpdaterSet` or `FeeQuotePolicySet`, and changing either value MUST clear the stored quotes. `feeQuoteUpdater = address(0)` means the token is not configured for FX fees. |
There was a problem hiding this comment.
Should this just be a role instead of a single address?
| ### Fee Quotes | ||
|
|
||
| A fee quote has two fields: | ||
| - `quoteId` is an updater-supplied monotonically increasing identifier for the quote. |
There was a problem hiding this comment.
Does it need to be? If so, we need to introduce some guardrails so you can't just brick a token by passing quoteId = MaxInt
| The first accepted quote is trusted after these checks. After a `latestQuote` exists, each later quote MUST satisfy the token's quote-change limit: | ||
|
|
||
| ```text | ||
| abs(pathUsdPerTokenE18 - latestQuote.pathUsdPerTokenE18) * BPS_SCALE |
There was a problem hiding this comment.
Shouldn't this be comparing with usableQuote? Otherwise I can just push a bunch of updates all in one block and blow past the max change bps
|
|
||
| A fee quote has two fields: | ||
| - `quoteId` is an updater-supplied monotonically increasing identifier for the quote. | ||
| - `pathUsdPerTokenE18` is the value of one whole token in `pathUSD`, scaled by `QUOTE_SCALE`. For example, if one token is worth `2.50 pathUSD`, the updater pushes `pathUsdPerTokenE18 = 2.5e18`. |
There was a problem hiding this comment.
maybe this should just be usdPerTokenE18
TIP-1054 lets users pay transaction fees on Tempo with non-USD TIP-20 tokens.
For a non-USD token to be used for fees the issuer must designate a price oracle for their token which will be used by the feeAMM. This version supports only direct FeeAMM pools from the user's non-USD token to the validator's USD fee token.
Gas accounting does not change. Transactions still use the existing signed gas fields, Tempo still prices gas in attodollars, and validators still receive fees in USD.