feat(dynamic-fee): rework measurement, bracket search, global mode and resets
🔎 Visual review ofc9d58dd2: explainer, review flags and prompts to ask an AI about the change
Visual Review: https://mr-5102.thorchain-mr-reviews.pages.dev/
Why
The live ADR-026 dynamic fee controller is stepping on noise rather than signal. At the median pair the signal clears the noise near ~70 swaps per epoch; a small integration's median is 4. The median epoch-volume CV is 1.54 against a 1 bps step worth ~33% of the fee rate, so most of the measured swing is market-wide activity, not a response to the floor. A ±1 bps step is also too small for the volume response to stand out at all.
This MR reworks how the controller measures and moves:
- it waits for enough flow before deciding, but never longer than ~7 days;
- it measures fee revenue against other swappers' volume;
- it finds the fee with a bracket search before handing off to the ±1 controller;
- it adds a global (per-thorname) mode and resets on any whitelist change.
What
Measurement
1. Liquidity gate with a time cap. A span seals a decision only once it holds L1DynamicFeeMinSwaps swaps (default 30) and L1DynamicFeeMinVolumeUSD of affiliate volume (default $100,000).
- Below the gate, the span rolls into the next epoch, but only until it has run
L1DynamicFeeMaxEpochBlocks(default 100,800 blocks, ~7 days). The next epoch boundary then seals it with whatever it has. - A span with no affiliate swaps at all is sealed empty at the cap, which reads as zero revenue. A fee too high to attract any flow is therefore moved off rather than held. An empty seal does not count as activity, so an idle record is still pruned after 30 epochs.
- A span sealed by the cap below the gate (or empty) is marked
capped: it is too thin to justify a higher fee, so it may lower the fee but never raise it. In the search a capped probe counts as tested but never displaces a lower-fee best; in the ±1 controller a raise becomescapped_holdand a cold start probes down. - Sealing iterates records rather than accumulators, so a record that drew no swaps is still visited.
- A stream counts once, on its first successful sub-swap (
isFirstSuccessfulSubSwap). Counting onCount == 0missed streams whose first attempt failed, which includes most resting limit orders.
2. Signal = fees / other swappers' volume. The controller compares fees_tor / (pool_volume_tor − own_pool_volume_tor): fee revenue against the volume the rest of the market put through the same pools over the same span.
- A pair-scoped
DynamicFeePoolAccumulatorcounts every swap's volume through the pair, affiliate or not. - The thorname's own volume is excluded because, when it is most of a pair's flow, dividing by the whole pool makes the ratio approach the fee rate itself, which always reads higher at a higher fee. In simulation, a 90%-share integrator whose revenue peaks at 7 bps settled near 15.
- An epoch where other flow is under
L1DynamicFeeMinOtherFlowBPS(default 10%) of the pool is recorded but not used as a measurement. - Pool volume is folded every epoch for every enrolled record, swaps or not, so a span's denominator also covers idle epochs before the thorname's first swap. Without that, a fee that delays flow would shrink the denominator and read as a higher signal.
- Spans vary in length, and the ratio is comparable across them because numerator and denominator cover the same span. A per-block revenue rate was considered and rejected: it would let market-wide swings back in.
Movement
3. Bracket search, then ±1. A new record (or a reset one) searches [floor, ceiling] in large jumps rather than ±1 steps.
- It tests its starting bps, then the ceiling, then the midpoint, then keeps halving the larger untested gap beside the best value: 1 → 20 → 10 → … on defaults, ~7–9 tests on
[1, 20]. - A higher fee only wins when it beats the best by more than
L1DynamicFeeDeadbandBPS, so near-ties keep the lower fee. - Each test holds for
L1DynamicFeeProbeEpochssealed spans (default 1). - On convergence the record clears its history and hands off to the existing ±step controller at the best value.
- Records that exist at the upgrade search from their current
dynamic_bps.
4. Ceiling cold start probes down. After convergence the ±step controller cold-starts. A record at the ceiling used to cold-start in place forever: the clamped probe recorded no bps change, so it never got a move to evaluate. It now probes down when it cannot probe up.
Modes and selection
5. Whitelist value selects the mode.
| Value | Mode |
|---|---|
1 |
per-pair (as before) |
2 |
global: one *|* record per thorname, credited by every L1 swap, applied on every L1 pair, measured on the pools it actually traded |
3 / 4 |
per-pair / global monitor: history collected, no search or step, swaps pay the static minimum; queries show phase: "monitor" |
| other | not enrolled |
- Monitor moves off
2; no mainnet thorname holds2today. - In global mode, a multi-leg swap counts its swap and volume once (first leg) and every leg's fees and own volume.
- Global volume is scoped to the pairs the thorname traded, because a spike on an unrelated pair would otherwise read as a revenue collapse.
6. Reset on whitelist change. Any change to a thorname's effective DYNAMICFEE-WHITELIST-* value deletes all its records and accumulators in the mimir handler, immediately: enabling it, switching mode, entering or leaving monitor, or removing it. Its next swap starts a fresh record at the floor and a fresh search. To reset a stuck thorname without changing mode, vote it to 0, then back.
7. Only the largest affiliate counts. In a multi-affiliate memo, only the affiliate with the largest affiliate bps is considered (first wins a tie).
- In state
1/2it is charged its floor and credited. - In
3/4it is credited but the swap pays the static minimum. - Otherwise the swap pays the static minimum and nothing is credited.
- A smaller enrolled affiliate never stands in. The thorname that sets a fee is always the one whose record measures it.
- Trade-off: an integrator can list a non-enrolled affiliate (even its own address) at a higher bps to opt out of a dynamic floor above the static minimum. The curated whitelist is the control. This is flagged in the ADR and in a
ponytail:comment.
Upgrade
Migrate20to21 (consensus version 20 → 21):
- deletes every in-flight thorname accumulator at the upgrade height. An accumulator straddling the upgrade would carry a full epoch of fees but only post-upgrade pool volume.
- sets every record's
span_start_heightto the upgrade height, so the 7-day cap counts from the upgrade.
History written before pool volume was tracked holds raw fee levels and is dropped at the first post-upgrade seal. Records keep their dynamic_bps and start a search from it. New fields read as zero or empty on older rows.
Surface
- New mimirs:
L1DynamicFeeMinSwaps(30),L1DynamicFeeMinVolumeUSD($100,000)L1DynamicFeeMaxEpochBlocks(100,800)L1DynamicFeeMinOtherFlowBPS(1,000)L1DynamicFeeProbeEpochs(1)
- State:
- records:
search_done,probes,span_start_height,pairs - epoch records:
swap_count,pool_volume_tor,own_pool_volume_tor,blocks,capped - probes:
capped - accumulators:
swap_count,pool_volume_tor,own_pool_volume_tor,pairs - new
DynamicFeePoolAccumulatorstore (dynfeepoolacc/); genesis field 51 withomitempty, so other suites' export fixtures do not churn
- records:
- Queries:
phase(search/track/monitor) on both record endpointsprobesper pair on/thorchain/dynamic_l1_fees/{thorname}swap_count,pool_volume_tor,own_pool_volume_tor,blocksandcappedon history entries, andcappedon probesspan_blocksand the volume fields on/thorchain/dynamic_l1_fees_current- a global record's pair is
*|*
- Event
dynamic_l1_fee_update:- new reasons
search_probe,search_convergedandcapped_hold - new attributes
swap_count,pool_volume_tor,blocks,capped fees_before/fees_afterare now normalized signals, not TOR fee levels (names kept for existing consumers)
- new reasons
- Manager interface:
CreditSwapLegreplacesCreditSwap, which remains as the single-leg form on the concrete type.
Known limits
- Protocol-internal swaps that call
swap()directly (POL, affiliate-fee conversion) bypass the swap-completion hook and are excluded from pool volume. Arguably correct for a market-activity denominator; marked with aponytail:comment. - The search assumes revenue is unimodal in bps and discards a losing side for good. The ±1 controller and the reset are the fallbacks.
- A full search takes ~7–9 sealed spans. With the gate, each span is at least one epoch (~1 day) and at most ~7 days.
Testing
-
Unit (
go test -tags=mocknet ./x/thorchain/...): green. Every line this branch changes inx/thorchainis covered except three unreachable guards (a genesis-import panic on a setter that never errors, migration registration, and the sharedLoadManagerIfNecessaryguard). Fault-injection tests wrap the keeper and codec to exercise store and unmarshal failures. Highlights:- the search converging on a unimodal curve (1, 20, 10, 15, 12, 17, 13, 14, 16 → 15);
- a 90%-share integrator converging near its revenue peak;
- holding without outside flow;
- global multi-leg counting and scoped pool volume;
- monitor history-only;
- the largest-affiliate rule across seven memo shapes;
- the mimir-handler reset;
- the ceiling cold start;
- the span cap (roll then seal, empty seal as zero revenue, no activity bump, monitor never sealed empty);
- idle epochs' pool volume carried into the span's denominator;
- capped readings only lowering the fee, in the search and the ±1 controller.
-
Regression: new suite
dynamic-fee/search-modes-reseton a mainnet-depth USDC pool, with an affiliate swap and an equal plain swap every epoch. It covers:- the per-pair search (1, 8, 4, 6, 7 → 8);
- hand-off and the step down from the ceiling;
- the largest-affiliate rule;
- a sub-gate span rolling, being sealed at a 15-block cap with its idle epoch's pool volume, and stepping the fee down rather than up;
- the global switch wiping state, and the global floor charged on a never-traded pair;
- global monitor;
- removal.
All 193 suites pass locally; no other suite's fixtures change.
-
golangci-lint (repo config), markdownlint and prettier: clean.
Docs: ADR-026 (changelog, design, migration, REST), docs/mimir.md, docs/concepts/fees.md, and a note in the affiliate guide to give the enrolled THORName the largest split.