Skip to content

Commit f2a06a1

Browse files
authored
Merge pull request #40 from Dopezapha/fix/multisig-governance-archival
fix(multisig): fix #10 by adding instance storage TTL extensions
2 parents 50650fc + 63824ae commit f2a06a1

3 files changed

Lines changed: 148 additions & 0 deletions

File tree

GOVERNANCE_STORAGE.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Governance Storage & TTL Strategy
2+
3+
## Objective
4+
Resolve the instance storage archival issue in the `multisig_governance` contract to prevent in-flight proposal data loss and guarantee finalization.
5+
6+
---
7+
8+
## 1. Storage Class Strategy
9+
The `multisig_governance` contract stores all configuration state (admin address, target contract, version, proposal count) and in-flight proposal data (`PendingTransfer` structure under `KEY_PENDING`) in **Instance Storage**.
10+
11+
In Soroban, instance storage has a specific Time-To-Live (TTL) that is checked and decremented on every ledger close. If instance storage is not accessed or explicitly bumped for a duration exceeding its TTL, it gets archived by the host. Once archived, any contract execution that tries to read or write instance storage (including admin verification via `read_admin`, loading target contract, or voting/approving) will fail.
12+
13+
To prevent this:
14+
- We introduce a centralized helper function `bump_instance_ttl` that calls `env.storage().instance().extend_ttl(...)` using pre-calculated safe bounds.
15+
- This helper is integrated across all state-modifying entrypoints (write paths) and all read-only views (read paths). By executing a TTL extension on every contract invocation, we ensure that as long as there is active interest (either querying or writing), the contract state is kept alive automatically.
16+
17+
---
18+
19+
## 2. TTL Rationale & Parameters
20+
21+
The Soroban SDK functions require two parameters for TTL management:
22+
1. `threshold`: The minimum number of ledgers remaining before a bump is triggered.
23+
2. `bump_to`: The target ledger lifetime to extend to if the threshold is breached.
24+
25+
We define these as:
26+
```rust
27+
const INSTANCE_TTL_THRESHOLD: u32 = 17280; // ~1 day (assuming 5-second ledgers)
28+
const INSTANCE_TTL_BUMP: u32 = 518400; // ~30 days (assuming 5-second ledgers)
29+
```
30+
31+
### Rationale
32+
- **Quorum / Timelock Lifetime**: A proposal must remain pending for a minimum timelock delay of 24 hours (`MIN_TIMELOCK_SECONDS = 86_400`) and expires after a maximum of 7 days (`PROPOSAL_TTL_SECONDS = 604_800`).
33+
- **Safety Window**: To prevent a proposal from being archived while in-flight (waiting for the timelock to expire or gathering approvals), the instance storage TTL must comfortably exceed the maximum potential proposal lifetime (7 days + 1 day delay = 8 days).
34+
- **Threshold (1 Day)**: Setting the threshold to 17,280 ledgers (~1 day) ensures that any interaction within a day of potential expiry triggers the extension.
35+
- **Bump (30 Days)**: Setting the bump to 518,400 ledgers (~30 days) guarantees that the contract instance remains active for a full month on any interaction. This safely accommodates the 7-day proposal TTL + timelock and provides a significant safety margin.
36+
37+
---
38+
39+
## 3. Integration Points
40+
The `bump_instance_ttl` helper is called in the following functions:
41+
- `initialize`: Bumps TTL immediately upon contract creation.
42+
- `version`: Bumps TTL on read.
43+
- `upgrade`: Bumps TTL before upgrading contract code.
44+
- `propose_admin_transfer`: Extends TTL on proposal creation.
45+
- `approve_transfer`: Extends TTL on approval/voting.
46+
- `finalize_admin_transfer`: Extends TTL before finalization.
47+
- `cancel_admin_transfer`: Extends TTL on cancellation.
48+
- `emergency_cancel_proposal`: Extends TTL on emergency actions.
49+
- `expire_proposal`: Extends TTL when marking a proposal as expired.
50+
- All view functions (`get_target`, `get_pending_transfer`, `get_pending`, `has_pending_transfer`, `get_approval_count`, `get_timelock_remaining`) and the private `read_admin` helper.
51+
52+
---
53+
54+
## 4. Verification Test
55+
In `test.rs`, we added the integration test `test_proposal_ttl_extension_keeps_proposal_active_and_finalizable`.
56+
This test simulates the full lifecycle of a multi-sig admin transfer proposal:
57+
1. Propose transfer with a 24-hour timelock delay.
58+
2. Approve from the first signer.
59+
3. Advance the ledger sequence number by `100,000` blocks (~5.8 days) and timestamp by `200,000` seconds. This simulates a long period of inactivity during the voting window.
60+
4. Approve from the second signer (this reads and modifies instance storage successfully, showing it was not archived).
61+
5. Advance beyond the 24-hour timelock.
62+
6. Finalize the proposal successfully.
63+
64+
This test demonstrates that the governance instance storage remains active, finalizeable, and resilient to ledger sequence advancement.

multisig_governance/src/lib.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ pub struct GovernanceContract;
129129

130130
#[contractimpl]
131131
impl GovernanceContract {
132+
const INSTANCE_TTL_THRESHOLD: u32 = 17280; // ~1 day (5s ledgers)
133+
const INSTANCE_TTL_BUMP: u32 = 518400; // ~30 days (5s ledgers)
134+
135+
fn bump_instance_ttl(env: &Env) {
136+
env.storage()
137+
.instance()
138+
.extend_ttl(Self::INSTANCE_TTL_THRESHOLD, Self::INSTANCE_TTL_BUMP);
139+
}
140+
132141
// ── Initialization ────────────────────────────────────────────────────────
133142

134143
/// Initialize the governance contract.
@@ -143,15 +152,18 @@ impl GovernanceContract {
143152
env.storage().instance().set(&KEY_TARGET, &target_contract);
144153
env.storage().instance().set(&KEY_VERSION, &CURRENT_VERSION);
145154
env.storage().instance().set(&KEY_PROPOSAL_COUNT, &0u32);
155+
Self::bump_instance_ttl(&env);
146156
}
147157

148158
pub fn version(env: Env) -> u32 {
159+
Self::bump_instance_ttl(&env);
149160
env.storage().instance().get(&KEY_VERSION).unwrap_or(0)
150161
}
151162

152163
pub fn upgrade(env: Env, new_wasm_hash: BytesN<32>) {
153164
let admin = Self::read_admin(&env);
154165
admin.require_auth();
166+
Self::bump_instance_ttl(&env);
155167

156168
let old_version = Self::version(env.clone());
157169
let new_version = old_version.saturating_add(1);
@@ -182,6 +194,7 @@ impl GovernanceContract {
182194
) {
183195
let admin = Self::read_admin(&env);
184196
admin.require_auth();
197+
Self::bump_instance_ttl(&env);
185198

186199
if let Some(pending) = env
187200
.storage()
@@ -284,6 +297,7 @@ impl GovernanceContract {
284297
/// Soroban's require_auth guarantees the caller genuinely controls `signer`.
285298
pub fn approve_transfer(env: Env, signer: Address) {
286299
signer.require_auth();
300+
Self::bump_instance_ttl(&env);
287301

288302
let mut pending: PendingTransfer = env
289303
.storage()
@@ -335,6 +349,7 @@ impl GovernanceContract {
335349
/// and must verify the caller is this governance contract address.
336350
pub fn finalize_admin_transfer(env: Env, caller: Address) {
337351
caller.require_auth();
352+
Self::bump_instance_ttl(&env);
338353

339354
let pending: PendingTransfer = env
340355
.storage()
@@ -408,6 +423,7 @@ impl GovernanceContract {
408423
pub fn cancel_admin_transfer(env: Env) {
409424
let admin = Self::read_admin(&env);
410425
admin.require_auth();
426+
Self::bump_instance_ttl(&env);
411427

412428
let mut pending: PendingTransfer = env
413429
.storage()
@@ -444,6 +460,7 @@ impl GovernanceContract {
444460
) {
445461
let admin = Self::read_admin(&env);
446462
admin.require_auth();
463+
Self::bump_instance_ttl(&env);
447464

448465
let mut pending: PendingTransfer = env
449466
.storage()
@@ -486,6 +503,7 @@ impl GovernanceContract {
486503
/// This cleans up stale proposals and allows new ones to be created.
487504
pub fn expire_proposal(env: Env, caller: Address) {
488505
caller.require_auth();
506+
Self::bump_instance_ttl(&env);
489507

490508
let pending: PendingTransfer = env
491509
.storage()
@@ -527,24 +545,28 @@ impl GovernanceContract {
527545
}
528546

529547
pub fn get_target(env: Env) -> Address {
548+
Self::bump_instance_ttl(&env);
530549
env.storage()
531550
.instance()
532551
.get(&KEY_TARGET)
533552
.expect("target contract not set")
534553
}
535554

536555
pub fn get_pending_transfer(env: Env) -> PendingTransfer {
556+
Self::bump_instance_ttl(&env);
537557
env.storage()
538558
.instance()
539559
.get(&KEY_PENDING)
540560
.expect("no pending transfer (4004)")
541561
}
542562

543563
pub fn get_pending(env: Env) -> Option<PendingTransfer> {
564+
Self::bump_instance_ttl(&env);
544565
env.storage().instance().get(&KEY_PENDING)
545566
}
546567

547568
pub fn has_pending_transfer(env: Env) -> bool {
569+
Self::bump_instance_ttl(&env);
548570
if let Some(pending) = env
549571
.storage()
550572
.instance()
@@ -557,6 +579,7 @@ impl GovernanceContract {
557579
}
558580

559581
pub fn get_approval_count(env: Env) -> u32 {
582+
Self::bump_instance_ttl(&env);
560583
let pending: PendingTransfer = env
561584
.storage()
562585
.instance()
@@ -568,6 +591,7 @@ impl GovernanceContract {
568591
/// Returns seconds remaining until the timelock expires.
569592
/// Returns 0 if already elapsed or no pending transfer exists.
570593
pub fn get_timelock_remaining(env: Env) -> u64 {
594+
Self::bump_instance_ttl(&env);
571595
match env
572596
.storage()
573597
.instance()
@@ -601,6 +625,7 @@ impl GovernanceContract {
601625
}
602626

603627
fn read_admin(env: &Env) -> Address {
628+
Self::bump_instance_ttl(env);
604629
env.storage()
605630
.instance()
606631
.get(&KEY_ADMIN)

multisig_governance/src/test.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ impl MockTarget {
3232
.get(&symbol_short!("admin"))
3333
.unwrap()
3434
}
35+
pub fn bump_ttl(env: Env) {
36+
env.storage().instance().extend_ttl(17280, 518400);
37+
}
3538
}
3639

3740
fn setup() -> (Env, GovernanceContractClient<'static>, Address, Address) {
@@ -658,3 +661,59 @@ fn propose_rejects_too_many_signers() {
658661
}
659662
client.propose_admin_transfer(&Address::generate(&env), &addrs, &1, &MIN_TIMELOCK_SECONDS);
660663
}
664+
665+
#[test]
666+
fn test_proposal_ttl_extension_keeps_proposal_active_and_finalizable() {
667+
let (env, client, admin, target) = setup();
668+
let target_client = MockTargetClient::new(&env, &target);
669+
let proposed = Address::generate(&env);
670+
let s1 = Address::generate(&env);
671+
let s2 = Address::generate(&env);
672+
let signers = Vec::from_slice(&env, &[s1.clone(), s2.clone()]);
673+
674+
// Step 1: Set initial ledger info
675+
let mut ledger_info = LedgerInfo {
676+
timestamp: 1000,
677+
protocol_version: 22,
678+
sequence_number: 100,
679+
network_id: Default::default(),
680+
base_reserve: 5_000_000,
681+
min_temp_entry_ttl: 1_000_000,
682+
min_persistent_entry_ttl: 1_000_000,
683+
max_entry_ttl: 10_000_000,
684+
};
685+
env.ledger().set(ledger_info.clone());
686+
687+
// Step 2: Propose transfer (this sets KEY_PENDING and bumps TTL)
688+
client.propose_admin_transfer(&proposed, &signers, &2, &MIN_TIMELOCK_SECONDS);
689+
690+
// Step 3: Approve 1 (this bumps TTL)
691+
client.approve_transfer(&s1);
692+
693+
// Ensure the mock target is also bumped so it isn't archived during the sequence jump
694+
target_client.bump_ttl();
695+
696+
// Step 4: Advance the ledger sequence number and timestamp significantly (e.g. 100,000 blocks and 200,000 seconds).
697+
// This is within the 7-day proposal TTL, but tests that the instance storage remains active.
698+
ledger_info.timestamp += 200_000;
699+
ledger_info.sequence_number += 100_000;
700+
env.ledger().set(ledger_info.clone());
701+
702+
// Step 5: Approve 2 (this bumps TTL and reads/updates proposal)
703+
client.approve_transfer(&s2);
704+
705+
// Bump mock target again to keep it alive
706+
target_client.bump_ttl();
707+
708+
// Step 6: Advance sequence number and timestamp beyond the timelock (24 hours).
709+
// MIN_TIMELOCK_SECONDS is 86400. Let's advance by 90,000 seconds.
710+
ledger_info.timestamp += 90_000;
711+
ledger_info.sequence_number += 18_000;
712+
env.ledger().set(ledger_info.clone());
713+
714+
// Step 7: Finalize should succeed because the proposal was kept alive and finalizable by TTL extensions.
715+
client.finalize_admin_transfer(&admin);
716+
717+
assert_eq!(client.get_current_admin(), proposed);
718+
assert!(!client.has_pending_transfer());
719+
}

0 commit comments

Comments
 (0)