Skip to content

docs (TIP -1054): Fees in non-USD TIP-20 tokens#3859

Open
malleshpai wants to merge 14 commits into
mainfrom
tip/1054
Open

docs (TIP -1054): Fees in non-USD TIP-20 tokens#3859
malleshpai wants to merge 14 commits into
mainfrom
tip/1054

Conversation

@malleshpai
Copy link
Copy Markdown
Contributor

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.

@malleshpai malleshpai added the cyclops Trigger Cyclops PR audit label May 7, 2026
Copy link
Copy Markdown

@tempoxyz-cyclops-bot tempoxyz-cyclops-bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👁️ 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 when feeQuoteUpdater is 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 / roundId in transaction context (not re-read previousFeeQuote()), 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 for FeeQuoteUpdated / 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) treat fee_token_cost() as both USD6 and token units. For FX, balance and key-limit eviction need maxUserTokenFee; AMM liquidity checks must remain USD6 (validatorFeeOut(maxFeeUsd6)).
  • roundId semantics: No monotonicity check on roundId; the same (roundId, price) can be replayed. Document the off-chain contract or enforce monotonicity on push.
  • amountOutUserToken == 0 in rebalanceSwapFX: Spec doesn't explicitly forbid zero amounts; mirror the existing rebalance_swap InvalidAmount guard.
  • setUserToken(FX) accepts paused / quote-less tokens: Only nonzero-feeQuoteUpdater is required at registration; document this so user-facing tooling can warn about tokens that will always revert at fee collection.

Comment thread tips/tip-1054.md
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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [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.

Comment thread tips/tip-1054.md
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.
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 [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:

  1. 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 max pathUsdPerTokenX18 so one smallest unit stays under an acceptable dust threshold, or require the payer/sponsor to sign a token-denominated maxUserTokenFee (or slippage).
  2. Thread both an Usd6FeeAmount and a UserTokenAmount through 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's TokenLimit.limit against maxUserTokenFee, store the token-denominated precharge separately in evm.collected_fee, and refund key limits by refundUserToken. AMM validator-reserve checks must remain USD6-based; balance and key-limit checks must use the FX amount.

Comment thread tips/tip-1054.md
```text
discountedUserReserveUsd6
= floor(usd6ValueFloor(U, priceX18) * FEE_AMM_N / FEE_AMM_SCALE)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [ISSUE] FX-pool 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:

  1. Floor in the denominator. Both usd6ValueFloor and the outer floor(... * 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 at crates/precompiles/src/tip_fee_manager/amm.rs:271-289 uses the same pattern; if accepted for FX, the inherited USD path should be revisited too.)
  2. 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-loan mintburn extracts pro-rata U and V at 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.

Copy link
Copy Markdown
Contributor

@0xrusowsky 0xrusowsky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@0xrusowsky
Copy link
Copy Markdown
Contributor

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 N blocks. N could either be defined by the protocol or be configurable per non-USD token (by the issuer). This would at least provide clearer liveness/freshness guarantees.

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.

@0xrusowsky
Copy link
Copy Markdown
Contributor

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.

@malleshpai
Copy link
Copy Markdown
Contributor Author

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

@0xrusowsky updated this.

Comment thread tips/tip-1054.md

## 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

People might want to charge rent for this, like Chainlink perhaps

Comment thread tips/tip-1054.md

## 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

People might want to charge rent for this, like Chainlink perhaps

Comment thread tips/tip-1054.md

### 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this just be a role instead of a single address?

Comment thread tips/tip-1054.md
### Fee Quotes

A fee quote has two fields:
- `quoteId` is an updater-supplied monotonically increasing identifier for the quote.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread tips/tip-1054.md
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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Comment thread tips/tip-1054.md

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`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe this should just be usdPerTokenE18

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cyclops Trigger Cyclops PR audit

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants