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 prefixedXMRConsolidation). - Out of scope:
MaxUTXOsToSpendis 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.gocounterparts. Nothing inx/,constants/ortest/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 anyXMRConsolidation*key (checked/thorchain/mimir/nodes_alland/thorchain/mimir), so no pre-existing economic votes change meaning. docs/mimir.mdnow 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.TestIsOperationalMimirXMRConsolidationchecks all 11 keys as written, upper-case, lower-case and mixed-case. It also checks negatives:MaxUTXOsToSpend, a truncated prefix (XMRConsolidatio),ConsolidationXMREnabledandBTCConsolidationEnabled. - New
HandlerMimirSuite.TestXMRConsolidationOperationalVotinguses 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 changeMaxUTXOsToSpend. It also checks that a churn purge clears the operational votes but keeps the effective values and the economicMaxUTXOsToSpendvotes. go test -tags=mocknet ./x/thorchain/ ./x/thorchain/keeper/...passes.scripts/lint.shexits 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 ondocs/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
MsgConsolidatehandler. 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.
MaxLockBpsis capped at 10000.MaxInputsis capped at 32 (MaxXMRSpentOutputRefs), and at signing time it is also capped bymin(MaxInputs, MaxUTXOsToSpend).MaxUTXOsToSpendis economic and set to 10 on mainnet.- Only
Enabledgives 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:
- Keep
MaxLockBps(and possiblyCooldownBlocksandMaxInputs) economic. - Add bifrost hard bounds so plurality can only tune within a safe envelope, for example:
- a
MaxLockBpsceiling of about 2500, - a
CooldownBlocksfloor near the XMR lock time (about 200 THORChain blocks), - a
MaxInputsfloor of 2, - a
TargetOutputs/MinUnlockedOutputsfloor.
- a
This MR deliberately keeps all 11 keys in scope.