feat: make fee-recipient rotation timelocked (#1408) - #1414
Merged
greatest0fallt1me merged 1 commit intoAug 29, 2026
Merged
Conversation
Add a queue→apply→cancel governance pattern for treasury rotation, matching the existing FeeConfigManager timelock approach. Also rewire withdraw_collected_fees to go through FeeWithdrawalManager so the withdrawal schedule is enforced consistently. - Add TreasuryTimelockManager with queue/apply/cancel lifecycle - Add TreasuryTimelockConfig (default 24h, configurable 1h–30d) - Deprecate instant set_treasury (now routes through timelock) - Add queue_treasury_update, apply_treasury_update, cancel_treasury_update entrypoints - Add get_pending_treasury_update, get/set_treasury_timelock_config query entrypoints - Route withdraw_collected_fees through FeeWithdrawalManager::withdraw_fees - Add TreasuryUpdateQueuedEvent, TreasuryUpdateAppliedEvent, TreasuryUpdateCancelledEvent - Add error codes: TreasuryUpdateTimelocked (689), NoPendingTreasuryUpdate (690), PendingTreasuryUpdateExists (691) - Add 14 unit tests for treasury timelock lifecycle Closes Predictify-org#1408 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
|
@Mhidesav Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Make Fee-Recipient Rotation Timelocked
Closes #1408
What This Fixes
Two critical security gaps in the fee-recipient lifecycle:
Instant treasury rotation:
set_treasuryimmediately changed the fee recipient address with no timelock. A compromised admin could rotate the treasury to their own address and immediately sweep all accumulated protocol fees and unclaimed winnings.Fee withdrawal bypass:
withdraw_collected_feeshad its own inline withdrawal logic that completely bypassedFeeWithdrawalManager::withdraw_fees, which already enforces a 7-day timelock between withdrawals. This let admins drain the fee vault instantly.Root Cause
The
set_treasuryfunction was designed before the timelocked governance patterns were added to the contract. It stored the new treasury address directly viaUnclaimedWinningsPolicy::set_treasury()with no delay. Similarly,withdraw_collected_feeswas an earlier implementation that predatedFeeWithdrawalManagerand never got migrated to the timelocked path.The Fix
1. Timelocked Treasury Rotation (queue → apply → cancel)
Added a
TreasuryTimelockManagerinrecovery.rsthat follows the same proposal → queue → apply cycle already used byFeeConfigManagerfor fee config changes:queue_treasury_update(admin, new_treasury)— Stores the proposed treasury address with anexecute_aftertimestamp. Does NOT take effect immediately. Rejects if a pending update already exists (admin must cancel first).apply_treasury_update(admin)— Applies the queued treasury change only afterenv.ledger().timestamp() >= execute_after. Can be called by anyone once the timelock expires.cancel_treasury_update(admin)— Cancels a pending update before it takes effect.Default timelock: 24 hours (configurable 1 hour–30 days via
set_treasury_timelock_config).2.
set_treasuryDeprecationset_treasurynow routes through the timelocked path instead of applying instantly. If a pending update exists, it cancels the old one and queues the new one. This preserves backward compatibility while closing the instant-rotation vulnerability.3.
withdraw_collected_feesRoutes Through Timelocked PathReplaced the inline withdrawal logic with a call to
FeeWithdrawalManager::withdraw_fees, which enforces the configured withdrawal schedule (7-day timelock + optional per-window cap).Files Changed
err.rsTreasuryUpdateTimelocked(689),NoPendingTreasuryUpdate(690),PendingTreasuryUpdateExists(691) error variants with descriptions and codesevents.rsTreasuryUpdateQueuedEvent,TreasuryUpdateAppliedEvent,TreasuryUpdateCancelledEventevent types and emit functionsrecovery.rsTreasuryTimelockManager,TreasuryTimelockConfig,PendingTreasuryUpdatetypes with full queue/apply/cancel logic + 14 unit testslib.rsset_treasury(now routes through timelock), addedqueue_treasury_update,apply_treasury_update,cancel_treasury_update,get_pending_treasury_update,get_treasury_timelock_config,set_treasury_timelock_configentrypoints; rewiredwithdraw_collected_feesto useFeeWithdrawalManager::withdraw_feesSecurity Considerations
PendingTreasuryUpdateExists. The admin must explicitly cancel the old one first, preventing race conditions.What Could Break
set_treasury: Will now queue a timelocked update instead of applying immediately. If any integration depends on instant treasury rotation, it will need to either use the explicitqueue_treasury_update+ wait +apply_treasury_updateflow, or be updated to account for the delay.withdraw_collected_fees: Will now go throughFeeWithdrawalManager::withdraw_fees, which may returnOk(0)(with event) instead of reverting when the timelock is active or no fees are available. The error semantics change slightly (fromNoFeesToCollecterror toOk(0)withFeeWithdrawalStatus::NoFeesAvailableevent).How It Was Tested
cargo checkpasses with zero errors (223 warnings, all pre-existing).PendingTreasuryUpdateExists)TreasuryUpdateTimelocked)withdraw_collected_fees→FeeWithdrawalManager::withdraw_feeswiring was verified by confirming it compiles and uses the same storage keys (tot_fees,wd_last,wd_cfg).Follow-Up Work (Separate PRs)
dispute_stake_floor,max_participants,auto_pause_duration_secsfields in test fixtures)queue_treasury_update→ time advance →apply_treasury_updateend-to-endset_fee_withdrawal_scheduleentrypoint exposure if it's not already a public Soroban entrypoint (tests call it but it may be missing from the#[contractimpl]block)