ADR: Secondary Fee Token Support
ADR: Secondary Fee Token Support
Status
Draft
Context
Celestia accepts fees only in utia, enforced in app/ante/fee.go. The rest of the fee path (DeductFeeDecorator, x/bank, fee collector, x/distribution) already operates on sdk.Coins.
This ADR proposes the smallest change that admits one hardcoded secondary fee denom. Like BondDenom, the secondary denom is a compile-time constant; it is shipped in a chain upgrade.
Decision
Accept fees in utia, one hardcoded secondary denom, or both. The denom is a compile-time constant in appconsts, selected by social consensus and shipped in an upgrade. Governance controls minimum gas prices in x/minfee; this ADR adds a secondary_min_gas_price field.
Each denom has its own minimum gas price and is validated independently. Mempool priority is local policy: nodes normalize actual gas price against their effective local minimum for that denom. Users choose the fee denom(s); validators receive fees in those denoms.
Out of scope: new modules, new ante decorators, a generalized fee-token whitelist, governance-controlled denom selection, oracles, and token conversion.
Core Invariants
- Every coin in the fee must be an accepted denom (
utiaor the hardcoded secondary denom). - A configured secondary denom must have a positive minimum gas price in
x/minfeeparams. - Each coin is validated against its own denom's minimum gas price.
- Mempool priority = max normalized gas price across all fee coins.
Current Fee Path
user fee coins
-> app/ante.ValidateTxFee
-> SDK DeductFeeDecorator
-> FeeCollector module account
-> x/distribution allocation
The single-denom assumption lives in:
| File | Consensus-critical? |
|---|---|
app/ante/fee.go |
Yes |
app/app.go |
No (query helper) |
app/grpc/gasestimation/gas_estimator.go |
No (gas estimation) |
pkg/user/tx_options.go |
No (tx building) |
pkg/user/tx_client.go |
No (fee defaults) |
Denom Configuration
// pkg/appconsts/global_consts.go
const BondDenom = "utia"
// SecondaryFeeDenom is the accepted secondary fee token.
// Empty string means disabled. Set via social consensus in a chain upgrade.
const SecondaryFeeDenom = "" // set to the bank denom string when ready
When empty, the feature is fully disabled and all behavior is identical to today.
Bridge Token Origin
The secondary fee token is presumably a bridged asset. It arrives on Celestia through either IBC or Hyperlane; the choice of bridge determines the denom string compiled into SecondaryFeeDenom.
IBC (ICS-20)
A standard ICS-20 transfer mints the token as a bank coin with denom ibc/<SHA256(path)>, where path is the port/channel trace (e.g., transfer/channel-0/usdc). This denom is deterministic for a given channel but differs across channels carrying the same underlying asset.
SecondaryFeeDenomis set to the fullibc/...string for the canonical channel.- If the channel is ever closed and a new one opened, the denom changes and a new upgrade is required.
Hyperlane (Warp Routes)
A Hyperlane Warp Route bridges the token via the x/warp module (from hyperlane-cosmos). The warp module holds Minter and Burner permissions on x/bank and mints synthetic tokens with denom hyperlane/<token_id>, where token_id is a hex address identifying the warp route token.
SecondaryFeeDenomis set to the fullhyperlane/...denom string for the relevant synthetic token.- The denom is stable as long as the warp route token ID does not change.
Implications for This ADR
The ante handler is bridge-agnostic: it only sees a x/bank denom string. The bridge choice affects the denom constant and the supply/redemption path, not fee validation logic. Bridging infrastructure is out of scope for this ADR.
Param Model
Extend celestia.minfee.v1.Params by one field:
message Params {
string network_min_gas_price = 1 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "cosmossdk.io/math.LegacyDec",
(gogoproto.nullable) = false
];
string secondary_min_gas_price = 2 [
(cosmos_proto.scalar) = "cosmos.Dec",
(gogoproto.customtype) = "cosmossdk.io/math.LegacyDec",
(gogoproto.nullable) = false
];
}
Governance controls min gas prices via MsgUpdateMinfeeParams; denom selection stays in the upgrade constant.
Param Validation
network_min_gas_price > 0SecondaryFeeDenom == ""→secondary_min_gas_pricemust be0SecondaryFeeDenom != ""→secondary_min_gas_price > 0
No store migration required.
Fee Validation
ValidateTxFee resolves each fee coin's denom to its minimum gas price:
func resolveMinGasPrices(fee sdk.Coins, params minfeetypes.Params) (map[string]math.LegacyDec, error) {
if len(fee) == 0 {
return nil, ErrEmptyFee
}
result := make(map[string]math.LegacyDec, len(fee))
for _, coin := range fee {
switch coin.Denom {
case appconsts.BondDenom:
result[coin.Denom] = params.NetworkMinGasPrice
case appconsts.SecondaryFeeDenom:
if appconsts.SecondaryFeeDenom == "" || !params.SecondaryMinGasPrice.IsPositive() {
return nil, ErrSecondaryFeeDisabled
}
result[coin.Denom] = params.SecondaryMinGasPrice
default:
return nil, ErrUnacceptedFeeDenom
}
}
return result, nil
}
After resolution, for each fee coin:
- Compute
actualGasPrice = coin.Amount / gas. - In
CheckTx, enforce the local validator threshold for that denom. - In
CheckTxandDeliverTx, enforce the network minimum for that denom. - In
CheckTx, compute priority as the max normalized gas price across all fee coins (see Priority). - Return the original
sdk.Coinsso the SDK deducts the submitted denoms.
Priority
Priority uses normalized gas price (actual / effective local minimum) so operators can favor one denom over the other without changing admission rules. The effective local minimum is the node's min-gas-prices for that denom if above the network minimum, otherwise the network minimum from x/minfee. When fees contain multiple coins, the transaction's priority is the maximum normalized gas price across all coins.
// For each fee coin, compute:
// normalizedGasPrice = coin.Amount / (gas * effectiveLocalMinGasPrice)
normalizedDec := math.LegacyNewDecFromInt(coin.Amount).
Quo(effectiveLocalMinGasPrice.MulInt64(int64(gas)))
priorityDec := normalizedDec.MulInt64(priorityScalingFactor)
priorityInt := priorityDec.TruncateInt()
if !priorityInt.IsInt64() {
priority = math.MaxInt64
} else {
priority = priorityInt.Int64()
}
// Take the max priority across all fee coins.
Paying exactly the effective local minimum yields priority 1.0; paying 2x yields 2.0. Example: with min-gas-prices = "0.002utia,0.004token", a tx at 0.004 utia/gas gets priority 2.0 while 0.004 token/gas gets 1.0 — that node favors utia.
Local min-gas-prices
Validators may optionally configure a per-denom threshold:
# app.toml
min-gas-prices = "0.002utia,0.001<secondary-denom>"
If a validator omits the secondary denom, the node falls back to the network minimum from x/minfee. No operator action is required; local config is only needed for stricter thresholds or to express a denom preference via the priority normalization described above.
Fee Collection and Distribution
No changes. The SDK deducts the submitted coin into the fee collector, and x/distribution allocates all fee collector balances via sdk.DecCoins, so rewards naturally accrue in both denoms.
UX and Rollout
Wallet / Tx Builder
Clients query x/minfee, let users choose a denom (or both), and build the fee coins. Gas estimation is unchanged.
Validator / Operator
No action required. Operators who want to favor one denom can set per-denom values in min-gas-prices; raising the local minimum tightens admission and lowers that denom's relative priority.
Governance
Governance does not select the denom (that stays in the upgrade constant). Governance sets secondary_min_gas_price via MsgUpdateMinfeeParams to define the network-wide admission floor.
Rollout Sequence
- Social consensus on the secondary fee denom.
- Update clients, wallets, explorers, and observability.
- Ship the chain upgrade with the denom in
appconsts.SecondaryFeeDenom. - Set
secondary_min_gas_pricevia governance.
Implementation Surface
Consensus-Critical
pkg/appconsts/global_consts.go— addSecondaryFeeDenomproto/celestia/minfee/v1/params.proto— addsecondary_min_gas_pricex/minfee/types/*.pb.go(generated)x/minfee/types/params.go,genesis.goapp/ante/fee.goapp/ante/fee_test.go,get_tx_priority_test.go
Additive (Non-Consensus)
app/app.go— gas-price query helperapp/grpc/gasestimation/gas_estimator.go— gas estimatorpkg/user/tx_options.go,pkg/user/tx_client.go— tx building / fee defaultsapp/errors/insufficient_gas_price.go— error parsing
Consequences
Positive: Minimal state changes. Backward compatible when disabled. Operators can express local preference between accepted fee denoms through existing min-gas-prices configuration.
Negative: Mempool ordering may differ across nodes because priority normalization depends on local operator policy. Rewards become multi-denom. Full UX requires follow-up outside the ante handler.
Neutral: Fee deduction and distribution structurally unchanged.