This document defines the finite state machine (FSM) for governance proposals in vero-core-engine. The FSM prevents invalid state transitions that could lead to bugs such as double-execution, approval after execution, or execution without proper thresholds.
pub enum ProposalState {
Pending = 0, // Awaiting approvals (default at creation)
Approved = 1, // Threshold met; time-lock window active
Executed = 2, // Executed; terminal state
}- When: Immediately after proposal creation via
governance::propose() - Duration: Until
approve()is called enough times to reach threshold - Allowed Operations:
approve()— receives another signer's approval- Query — check approval count and state
- Blocked Operations:
execute()— panics withInvalidStateTransitionapprove()on already-approved signer — panics withAlreadyApproved
- When: When
approve()causesapproved_by.len()to reach or exceedthreshold - Automatic Transition: Triggered in
governance::approve()immediately upon threshold - Events Emitted:
(symbol_short!("GOV"), symbol_short!("approved"))with proposal ID - Duration: From threshold met until time-lock expiry +
execute()call - Allowed Operations:
execute()— executes proposal if time-lock has expired- Query — check time-lock expiry and execution window
- Blocked Operations:
approve()— panics withInvalidStateTransitionexecute()(if timelock active) — panics withTimelockActive
- When:
governance::execute()completes successfully - Transition: Automatic at end of
execute()function - Events Emitted:
(symbol_short!("GOV"), symbol_short!("execute"))with proposal ID - Duration: Permanent (terminal state)
- Allowed Operations:
- Query — check execution history and proposal details
- Blocked Operations:
approve()— panics withInvalidStateTransitionexecute()— panics withInvalidStateTransition
┌─────────────────────────────────────────┐
│ │
▼ │
┌─────────┐ │
│ Pending │ │
└────┬────┘ │
│ │
│ approve() with │
│ approved_by.len() >= threshold │
│ │
▼ │
┌──────────┐ │
│ Approved │ │
└────┬─────┘ │
│ │
│ execute() with │
│ ledger >= unlock_ledger │
│ │
▼ │
┌──────────┐ │
│ Executed │ ════════════════════════════════│
└──────────┘ (invalid transitions)
| Transition | Trigger | Condition | Side Effects |
|---|---|---|---|
| Pending → Approved | approve() called |
approved_by.len() >= threshold AND current state is Pending |
Emit "GOV/approved" event; unlock_ledger already set |
| Approved → Executed | execute() called |
state == Approved AND ledger >= unlock_ledger |
Emit "GOV/execute" event; state becomes terminal |
| Attempted Transition | Trigger | Why Blocked |
|---|---|---|
| Pending → Executed | execute() called |
Skips approval threshold validation |
| Approved → Pending | (no operation) | Backwards transition not allowed |
| Approved → Approved | approve() called |
Cannot approve already-approved proposals |
| Executed → Approved | (no operation) | Backwards transition not allowed |
| Executed → Executed | execute() called |
Double-execution protection |
| Executed → Pending | (no operation) | Backwards transition not allowed |
File: engine-core/src/governance.rs::propose()
pub fn propose(env: &Env, mut proposal: Proposal) -> u64 {
// ... validation ...
proposal.state = ProposalState::Pending; // ← Initialize state
// ... storage ...
}File: engine-core/src/governance.rs::approve()
pub fn approve(env: &Env, signer: &Address, proposal_id: u64) {
// ... auth and signer validation ...
let (mut prop, unlock) = props.get(proposal_id).unwrap_or_else(|| {
panic_with_error!(env, GovError::ProposalNotFound)
});
// GUARD: Only pending proposals can receive approvals
if prop.state != ProposalState::Pending {
panic_with_error!(env, GovError::InvalidStateTransition);
}
if prop.approved_by.contains(signer) {
panic_with_error!(env, GovError::AlreadyApproved);
}
prop.approved_by.push_back(signer.clone());
// AUTO-TRANSITION: When threshold is met
if (prop.approved_by.len() as u32) >= threshold {
prop.state = ProposalState::Approved;
env.events().publish(
(symbol_short!("GOV"), symbol_short!("approved")),
proposal_id,
);
}
props.set(proposal_id, (prop, unlock));
env.storage().instance().set(&KEY_PROPOSALS, &props);
}File: engine-core/src/governance.rs::execute()
pub fn execute(env: &Env, proposal_id: u64) -> Proposal {
let mut props = load_proposals(env);
let (mut prop, unlock) = props.get(proposal_id).unwrap_or_else(|| {
panic_with_error!(env, GovError::ProposalNotFound)
});
// GUARD: Only approved proposals can be executed
if prop.state != ProposalState::Approved {
panic_with_error!(env, GovError::InvalidStateTransition);
}
if env.ledger().sequence() < unlock {
panic_with_error!(env, GovError::TimelockActive);
}
// AUTO-TRANSITION: To Executed
prop.state = ProposalState::Executed;
props.set(proposal_id, (prop.clone(), unlock));
env.storage().instance().set(&KEY_PROPOSALS, &props);
env.events().publish(
(symbol_short!("GOV"), symbol_short!("execute")),
proposal_id,
);
prop
}pub enum GovError {
NotASigner = 1, // Signer not in governance set
AlreadyApproved = 2, // Signer already approved this proposal
ThresholdNotMet = 3, // [DEPRECATED - now checked via state]
TimelockActive = 4, // Proposal not yet executable (timelock)
InvalidStateTransition = 5, // NEW: Guard against invalid state changes
ProposalNotFound = 6, // Proposal ID doesn't exist
}✅ AC1: Valid states only
- Proposals can only exist in one of three states: Pending, Approved, or Executed
- Verified by:
ProposalStateenum definition and state validation guards
✅ AC2: State transitions enforced
- Pending → Approved: Only when threshold met during
approve() - Approved → Executed: Only when timelock expired during
execute() - All other transitions rejected with
InvalidStateTransitionpanic - Verified by: State checks at entry of
approve()andexecute()
✅ AC3: FSM verified
- State transition matrix documented (see above)
- No backwards transitions possible
- No invalid paths through FSM
- Terminal state (Executed) prevents all further transitions
- Verified by: Comprehensive guard clauses in governance module
The FSM validation must be tested with:
-
Happy Path: Pending → Approved → Executed
- Create proposal (state = Pending)
- Receive approvals until threshold (state = Pending)
- Receive final approval (state → Approved)
- Wait for timelock
- Execute (state → Executed)
-
Invalid Transition Tests: Each blocked transition must panic
- Attempting
execute()on Pending proposal - Attempting
approve()on Approved proposal - Attempting
execute()on Executed proposal - Etc.
- Attempting
-
Edge Cases:
- Exactly threshold approvals trigger transition
- Timelock active blocks execution even if approved
- Duplicate approval detection still works
This FSM provides defense in depth against:
- Double-Execution Bugs: State guard prevents executing Executed proposals
- Approval-After-Execution: State guard prevents approving Executed proposals
- Premature Execution: State guard prevents executing Pending proposals
- Time-Lock Bypass: Separate
TimelockActivecheck ensures lock isn't bypassed - Race Conditions: State transitions are atomic within Soroban contract invocation
This FSM can be extended to support additional states such as:
- Cancelled: For vetoed or withdrawn proposals
- Expired: For proposals that hit a maximum age without execution
- Failed: For proposals that failed during execution
Such extensions would maintain FSM validity by defining new allowed transitions.
Document Version: 1.0
Last Updated: 2026-06-19
Status: Implemented and Verified