feat(thorchain): make XMR consolidation mimirs operational

Summary

Classify the 11 XMRConsolidation* Mimirs read by bifrost's Monero client (bifrost/pkg/chainclients/monero/client.go) as operational instead of economic. IsOperationalMimir gets one case-insensitive prefix rule, XMRCONSOLIDATION, next to the existing ALLOWNOVAULT- rule. The prefix also covers future consolidation keys.

  • Operational means plurality: the value needs at least OperationalVotesMin (3) matching votes, and node votes are purged every churn while the effective value is kept. Economic means a 2/3 supermajority of active nodes.
  • Keys: Enabled, CheckIntervalBlocks, CooldownBlocks, MinUnlockedOutputs, TargetOutputs, MinUnlockedAtomic, ReserveBps, ChurnLookaheadBlocks, MaxRetries, MaxInputs, MaxLockBps (all prefixed XMRConsolidation).
  • Out of scope: MaxUTXOsToSpend is shared with BTC, LTC, DOGE and the other UTXO chains, so it stays economic. A test covers this.
  • The prefix is used only by these bifrost keys and their config.go counterparts. Nothing in x/, constants/ or test/ uses it.
  • There is no version gate or store migration. This follows the earlier operational reclassifications (AllowNoVault-, DisableTSSSignerFiltering, POLReserveSystemIncomeBps, POL whitelist), which had none either. Mainnet currently has no node votes and no set values for any XMRConsolidation* key (checked /thorchain/mimir/nodes_all and /thorchain/mimir), so no pre-existing economic votes change meaning.
  • docs/mimir.md now documents the key family.

Motivation

Monero outputs are locked for 10 blocks (about 20 minutes) after they are created. Each consolidation run can lock up to MaxLockBps of the vault's spendable XMR, 10% by default, and runs can repeat after a 30-block THORChain cooldown (about 3 minutes). Several runs can therefore overlap inside one Monero lock window and hold back outbound liquidity. While XMR is new, operators need to tune or disable consolidation quickly, without waiting for a 2/3 supermajority.

Tests

  • New KeeperMimirSuite.TestIsOperationalMimirXMRConsolidation checks all 11 keys as written, upper-case, lower-case and mixed-case. It also checks negatives: MaxUTXOsToSpend, a truncated prefix (XMRConsolidatio), ConsolidationXMREnabled and BTCConsolidationEnabled.
  • New HandlerMimirSuite.TestXMRConsolidationOperationalVoting uses 8 active nodes. It checks that 3 votes disable, re-enable and retune (MaxLockBps), with the value flipping on exactly the 3rd vote, and that the same 3 votes do not change MaxUTXOsToSpend. It also checks that a churn purge clears the operational votes but keeps the effective values and the economic MaxUTXOsToSpend votes.
  • go test -tags=mocknet ./x/thorchain/ ./x/thorchain/keeper/... passes.
  • scripts/lint.sh exits 0. golangci-lint v1.63.4 (the version trunk pins) is clean on ./x/thorchain/ and ./x/thorchain/keeper/v1/. prettier and markdownlint are clean on docs/mimir.md, apart from the MD013 line length that every existing line in the file already trips.
  • No regression suite touches these keys, so none were added or changed.

Risk note: worst values under plurality

The threat model is 3 colluding or mistaken nodes.

  • Voting mechanics. 3 matching votes set a value if no other value has more votes. Honest nodes override with 4 or more matching votes. A tie sets nothing, so the current value stands: an attacker who matches the honest vote count freezes the bad value in place. The effective value survives churn even after the votes are purged.
  • Worst case is liveness and fees, not theft. A consolidation is a self-send to the vault's own address, validated on-chain by the MsgConsolidate handler. None of these keys can send funds anywhere else.
  • Bifrost's clamps (loadConsolidationSettings):
    • Any value ≤ 0 falls back to the default, so 0 is not a way to reach an extreme.
    • MaxLockBps is capped at 10000.
    • MaxInputs is capped at 32 (MaxXMRSpentOutputRefs), and at signing time it is also capped by min(MaxInputs, MaxUTXOsToSpend). MaxUTXOsToSpend is economic and set to 10 on mainnet.
    • Only Enabled gives 0 a meaning of its own: it disables.
  • Natural rate limit. No run starts while the vault has an outgoing tx in the mempool.
Key Worst value(s) Clamped? Impact
Enabled 0 (or 1 when operators want it off) n/a Off: outputs fragment slowly, back to pre-feature behaviour, and large outbounds may need more inputs than allowed. On: default behaviour. Low.
CheckIntervalBlocks 1 ≤0 → default only Checks every THORChain block. Mostly bounded by cooldown and the mempool check. A huge value effectively disables. Low alone.
CooldownBlocks 1 ≤0 → default only Next run as soon as the previous tx leaves the mempool (about one XMR block), versus about 3 minutes by default. Consolidated outputs stack up under the 10-block lock. Medium in combination.
MinUnlockedOutputs 1 ≤0 → default only Consolidation triggers on almost any vault. Medium in combination.
TargetOutputs 1 ≤0 → default only Runs keep going until 1 unlocked output remains. With MinUnlockedOutputs=1, the vault is swept continuously. Medium in combination.
MinUnlockedAtomic 1 piconero / very large ≤0 → default only Tiny: runs on dust balances. Huge: effectively disables. Low.
ReserveBps very large / 1 ≤0 → default; reserve floor is 0.00005 XMR Large: reserve ≥ spendable, so consolidation never runs (disable). Small: fee headroom is thin, so fee errors trigger retries with a bumped reserve. Low.
ChurnLookaheadBlocks 1 / very large ≤0 → default only 1: consolidates right up to churn, so funds are locked when migration starts and migration is delayed (retried, not lost). Huge: never consolidates. Low–Medium.
MaxRetries very large ≤0 → default only The loop stays bounded because each retry grows the reserve by at least 1.5x until it reaches spendable: about 13 iterations at the default ReserveBps, and under 50 even with a minimal reserve on a large vault. Each iteration is a full TSS keysign, so there is keysign load on repeated fee or mempool errors. Medium (load).
MaxInputs 1 / 32 [1, 32] and ≤ MaxUTXOsToSpend 1 makes each run a 1-in/1-out self-send: it burns a fee and locks the output while reducing nothing. With a short cooldown this is a repeated fee drain. 32 only makes txs larger. Medium in combination.
MaxLockBps 10000 capped at 10000 Up to 100% of spendable minus the reserve, bounded by the input cap, is locked for about 20 minutes per run. Highest impact.

Worst combination: MaxLockBps=10000, CooldownBlocks=1, CheckIntervalBlocks=1, MinUnlockedOutputs=1, TargetOutputs=1. Together these keep most XMR vault liquidity locked almost all the time. XMR outbounds stall (they are delayed, not lost) and every run pays a network fee from the vault. The alternative bad combination, MaxInputs=1 plus a short cooldown, gives a steady fee drain.

For reviewers to decide. The disabling direction (Enabled=0 and the other "effectively disable" values) is the point of this change and is safe. The aggressive direction is what 3 nodes can now reach without a supermajority. Options:

  1. Keep MaxLockBps (and possibly CooldownBlocks and MaxInputs) economic.
  2. Add bifrost hard bounds so plurality can only tune within a safe envelope, for example:
    • a MaxLockBps ceiling of about 2500,
    • a CooldownBlocks floor near the XMR lock time (about 200 THORChain blocks),
    • a MaxInputs floor of 2,
    • a TargetOutputs / MinUnlockedOutputs floor.

This MR deliberately keeps all 11 keys in scope.

🤖 Generated with Claude Code

Merge request reports

Loading
Loading