diff --git a/contracts/utility_contracts/src/governance.rs b/contracts/utility_contracts/src/governance.rs
new file mode 100644
index 0000000..f867517
--- /dev/null
+++ b/contracts/utility_contracts/src/governance.rs
@@ -0,0 +1,642 @@
+//! # Multi-Sig Governance Enhancements
+//!
+//! Implements Issue #18: Add Proposal Expiry, Quorum, Vote Weighting,
+//! and Nonce Sync Safety to Multi-Sig.
+//!
+//! ## Features
+//!
+//! - **Proposal Expiry**: Votes must be collected within TIMEFRAME
+//! - **Quorum Mechanism**: min_voters must participate regardless of approval
+//! - **Vote Weighting**: Token-based or reputation-based weighted voting
+//! - **Timelock**: Configurable delay after approval threshold is reached
+//! - **Cancel Proposal**: Proposer can retract flawed proposals
+//! - **Proposal Status Query**: Rich status enum for governance transparency
+
+use soroban_sdk::{contract, contractimpl, contracttype, panic_with_error, Address, Env, Symbol};
+
+use crate::{ContractError, DataKey};
+
+// ============================================================
+// Constants
+// ============================================================
+
+/// Default proposal expiry duration (7 days in seconds)
+pub const DEFAULT_PROPOSAL_EXPIRY: u64 = 7 * 24 * 60 * 60;
+
+/// Default timelock duration (48 hours in seconds)
+pub const DEFAULT_TIMELOCK_DURATION: u64 = 48 * 60 * 60;
+
+/// Maximum quorum as absolute number of voters
+pub const MAX_QUORUM_VOTERS: u32 = 100;
+
+// ============================================================
+// Governance Types
+// ============================================================
+
+/// Status of a governance proposal.
+#[contracttype]
+#[derive(Copy, Clone, Debug, Eq, PartialEq)]
+pub enum ProposalStatus {
+ /// Proposal created, voting not yet started
+ Pending = 0,
+ /// Voting is active
+ Active = 1,
+ /// Threshold reached, waiting for timelock
+ Approved = 2,
+ /// Timelock passed, ready for execution
+ Ready = 3,
+ /// Proposal has been executed
+ Executed = 4,
+ /// Proposal has expired
+ Expired = 5,
+ /// Proposal has been cancelled by proposer
+ Cancelled = 6,
+}
+
+/// Weighted vote from a voter.
+#[contracttype]
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct Vote {
+ /// The voter's address
+ pub voter: Address,
+ /// Whether the voter approves (true) or rejects (false)
+ pub approve: bool,
+ /// The weight of this vote (based on token holdings or reputation)
+ pub weight: u64,
+ /// When this vote was cast
+ pub voted_at: u64,
+}
+
+/// Vote weight provider trait.
+/// Implementations can provide token-based, NFT-based, or 1-person-1-vote weighting.
+#[contracttype]
+#[derive(Clone)]
+pub struct VoteWeightProvider {
+ /// Whether token-based weighting is enabled
+ pub use_token_weighting: bool,
+ /// The token address for weighted voting (if applicable)
+ pub token_address: Option
,
+ /// Whether reputation-based weighting is enabled
+ pub use_reputation_weighting: bool,
+}
+
+/// Governance proposal with all enhancements.
+#[contracttype]
+#[derive(Clone)]
+pub struct GovernanceProposal {
+ /// Unique proposal ID
+ pub proposal_id: u64,
+ /// Title/description of the proposal
+ pub description: Symbol,
+ /// The address that created this proposal
+ pub proposer: Address,
+ /// When the proposal was created
+ pub created_at: u64,
+ /// When the proposal expires (voting no longer allowed)
+ pub expires_at: u64,
+ /// Minimum number of voters required (absolute count quorum)
+ pub min_quorum: u32,
+ /// Threshold of approving voters required (in basis points, e.g., 5000 = 50%)
+ pub approval_threshold_bps: u32,
+ /// Timelock duration after approval before execution (in seconds)
+ pub timelock_duration: u64,
+ /// When the threshold was reached (0 = not yet)
+ pub threshold_reached_at: u64,
+ /// When execution becomes available (threshold_reached_at + timelock)
+ pub execution_available_at: u64,
+ /// Current status
+ pub status: ProposalStatus,
+ /// Total votes counted
+ pub total_votes: u32,
+ /// Total approval weight
+ pub approval_weight: u64,
+ /// Total rejection weight
+ pub rejection_weight: u64,
+ /// Vote weight provider configuration
+ pub weight_provider: VoteWeightProvider,
+ /// Whether this proposal has been executed
+ pub is_executed: bool,
+}
+
+/// Governance configuration.
+#[contracttype]
+#[derive(Clone)]
+pub struct GovernanceConfig {
+ /// Admin address that can update governance parameters
+ pub admin: Address,
+ /// Default proposal expiry duration
+ pub default_expiry: u64,
+ /// Default timelock duration
+ pub default_timelock: u64,
+ /// Default quorum requirement (absolute voter count)
+ pub default_quorum: u32,
+ /// Default approval threshold (basis points)
+ pub default_approval_threshold_bps: u32,
+ /// Whether governance is enabled
+ pub enabled: bool,
+ /// Vote weight provider configuration
+ pub weight_provider: VoteWeightProvider,
+}
+
+/// Event: Governance proposal created.
+#[contracttype]
+#[derive(Clone)]
+pub struct ProposalCreatedEvent {
+ pub proposal_id: u64,
+ pub proposer: Address,
+ pub description: Symbol,
+ pub expires_at: u64,
+ pub min_quorum: u32,
+}
+
+/// Event: Vote cast on a proposal.
+#[contracttype]
+#[derive(Clone)]
+pub struct VoteCastEvent {
+ pub proposal_id: u64,
+ pub voter: Address,
+ pub approve: bool,
+ pub weight: u64,
+ pub total_approval: u64,
+ pub total_rejection: u64,
+}
+
+/// Event: Proposal status changed.
+#[contracttype]
+#[derive(Clone)]
+pub struct ProposalStatusChangedEvent {
+ pub proposal_id: u64,
+ pub old_status: ProposalStatus,
+ pub new_status: ProposalStatus,
+ pub timestamp: u64,
+}
+
+/// Event: Proposal cancelled.
+#[contracttype]
+#[derive(Clone)]
+pub struct ProposalCancelledEvent {
+ pub proposal_id: u64,
+ pub proposer: Address,
+ pub reason: Symbol,
+}
+
+// ============================================================
+// Governance Module Implementation
+// ============================================================
+
+#[contract]
+pub struct GovernanceModule;
+
+#[contractimpl]
+impl GovernanceModule {
+ /// Initialize governance configuration.
+ pub fn initialize_governance(
+ env: Env,
+ admin: Address,
+ default_expiry: Option,
+ default_timelock: Option,
+ default_quorum: Option,
+ default_approval_threshold_bps: Option,
+ ) {
+ if env.storage().instance().has(&DataKey::GovernanceConfig) {
+ panic_with_error!(&env, ContractError::MultiSigAlreadyConfigured);
+ }
+
+ let config = GovernanceConfig {
+ admin: admin.clone(),
+ default_expiry: default_expiry.unwrap_or(DEFAULT_PROPOSAL_EXPIRY),
+ default_timelock: default_timelock.unwrap_or(DEFAULT_TIMELOCK_DURATION),
+ default_quorum: default_quorum.unwrap_or(5),
+ default_approval_threshold_bps: default_approval_threshold_bps.unwrap_or(5000),
+ enabled: true,
+ weight_provider: VoteWeightProvider {
+ use_token_weighting: false,
+ token_address: None,
+ use_reputation_weighting: false,
+ },
+ };
+
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceConfig, &config);
+
+ env.events()
+ .publish((soroban_sdk::symbol_short!("GovInit"),), admin);
+ }
+
+ /// Create a new governance proposal.
+ pub fn create_proposal(
+ env: Env,
+ description: Symbol,
+ expiry: Option,
+ min_quorum: Option,
+ approval_threshold_bps: Option,
+ timelock_duration: Option,
+ ) -> u64 {
+ let config: GovernanceConfig = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceConfig)
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::MultiSigNotConfigured));
+
+ if !config.enabled {
+ panic_with_error!(&env, ContractError::GovernanceDisabled);
+ }
+
+ let proposer = env.current_contract_address();
+ proposer.require_auth();
+
+ // Get next proposal ID
+ let counter: u64 = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceProposalCounter)
+ .unwrap_or(0);
+ let proposal_id = counter.saturating_add(1);
+
+ let now = env.ledger().timestamp();
+ let proposal_expiry = expiry.unwrap_or(config.default_expiry);
+ let quorum = min_quorum.unwrap_or(config.default_quorum);
+ let threshold = approval_threshold_bps.unwrap_or(config.default_approval_threshold_bps);
+ let timelock = timelock_duration.unwrap_or(config.default_timelock);
+
+ // Validate parameters - quorum is absolute voter count
+ if quorum == 0 || quorum > MAX_QUORUM_VOTERS {
+ panic_with_error!(&env, ContractError::InvalidSignatureThreshold);
+ }
+ if threshold > 10000 || threshold < 1000 {
+ panic_with_error!(&env, ContractError::InvalidSignatureThreshold);
+ }
+
+ let proposal = GovernanceProposal {
+ proposal_id,
+ description: description.clone(),
+ proposer: proposer.clone(),
+ created_at: now,
+ expires_at: now.saturating_add(proposal_expiry),
+ min_quorum: quorum,
+ approval_threshold_bps: threshold,
+ timelock_duration: timelock,
+ threshold_reached_at: 0,
+ execution_available_at: 0,
+ status: ProposalStatus::Active,
+ total_votes: 0,
+ approval_weight: 0,
+ rejection_weight: 0,
+ weight_provider: config.weight_provider.clone(),
+ is_executed: false,
+ };
+
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceProposalCounter, &proposal_id);
+
+ env.events().publish(
+ (soroban_sdk::symbol_short!("PropCrtd"),),
+ ProposalCreatedEvent {
+ proposal_id,
+ proposer,
+ description,
+ expires_at: now.saturating_add(proposal_expiry),
+ min_quorum: quorum,
+ },
+ );
+
+ proposal_id
+ }
+
+ /// Cast a vote on a proposal.
+ pub fn cast_vote(env: Env, proposal_id: u64, approve: bool) {
+ let mut proposal: GovernanceProposal = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceProposal(proposal_id))
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::UpgradeProposalNotFound));
+
+ // Check proposal is in active state
+ if proposal.status != ProposalStatus::Active {
+ if proposal.status == ProposalStatus::Executed {
+ panic_with_error!(&env, ContractError::UpgradeAlreadyExecuted);
+ } else if proposal.status == ProposalStatus::Expired {
+ panic_with_error!(&env, ContractError::UpgradeProposalExpired);
+ } else if proposal.status == ProposalStatus::Cancelled {
+ panic_with_error!(&env, ContractError::UpgradeAlreadyCancelled);
+ }
+ panic_with_error!(&env, ContractError::UpgradeProposalNotFound);
+ }
+
+ // Check if proposal has expired
+ let now = env.ledger().timestamp();
+ if now > proposal.expires_at {
+ proposal.status = ProposalStatus::Expired;
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
+ panic_with_error!(&env, ContractError::UpgradeProposalExpired);
+ }
+
+ let voter = env.current_contract_address();
+ voter.require_auth();
+
+ // Check if voter already voted
+ let vote_key = DataKey::GovernanceVote(proposal_id, voter.clone());
+ if env.storage().instance().has(&vote_key) {
+ panic_with_error!(&env, ContractError::AlreadyVoted);
+ }
+
+ // Calculate vote weight
+ let weight = Self::calculate_vote_weight(&env, &voter, &proposal.weight_provider);
+
+ // Record the vote
+ let vote = Vote {
+ voter: voter.clone(),
+ approve,
+ weight,
+ voted_at: now,
+ };
+
+ env.storage().instance().set(&vote_key, &vote);
+
+ // Update proposal tally
+ proposal.total_votes = proposal.total_votes.saturating_add(1);
+ if approve {
+ proposal.approval_weight = proposal.approval_weight.saturating_add(weight as u64);
+ } else {
+ proposal.rejection_weight = proposal.rejection_weight.saturating_add(weight as u64);
+ }
+
+ // Check quorum and threshold
+ let total_weight = proposal
+ .approval_weight
+ .saturating_add(proposal.rejection_weight);
+
+ // Quorum check: Need minimum participation
+ let quorum_satisfied = proposal.total_votes >= proposal.min_quorum;
+
+ // Threshold check: Need approval_bps % of participating weight
+ let threshold_met = if total_weight > 0 {
+ (proposal.approval_weight as u128)
+ .saturating_mul(10000)
+ .saturating_div(total_weight as u128)
+ >= proposal.approval_threshold_bps as u128
+ } else {
+ false
+ };
+
+ if quorum_satisfied && threshold_met {
+ // Mark as approved and start timelock
+ proposal.status = ProposalStatus::Approved;
+ proposal.threshold_reached_at = now;
+ proposal.execution_available_at = now.saturating_add(proposal.timelock_duration);
+ }
+
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
+
+ env.events().publish(
+ (soroban_sdk::symbol_short!("VoteCstd"),),
+ VoteCastEvent {
+ proposal_id,
+ voter,
+ approve,
+ weight,
+ total_approval: proposal.approval_weight,
+ total_rejection: proposal.rejection_weight,
+ },
+ );
+ }
+
+ /// Execute an approved proposal after timelock has passed.
+ pub fn execute_proposal(env: Env, proposal_id: u64) {
+ let mut proposal: GovernanceProposal = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceProposal(proposal_id))
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::UpgradeProposalNotFound));
+
+ // Check proposal is approved
+ if proposal.status != ProposalStatus::Approved {
+ if proposal.status == ProposalStatus::Active {
+ panic_with_error!(&env, ContractError::InsufficientUpgradeApprovals);
+ } else if proposal.status == ProposalStatus::Executed {
+ panic_with_error!(&env, ContractError::UpgradeAlreadyExecuted);
+ } else if proposal.status == ProposalStatus::Expired {
+ panic_with_error!(&env, ContractError::UpgradeProposalExpired);
+ } else if proposal.status == ProposalStatus::Cancelled {
+ panic_with_error!(&env, ContractError::UpgradeAlreadyCancelled);
+ }
+ panic_with_error!(&env, ContractError::UpgradeProposalNotFound);
+ }
+
+ let now = env.ledger().timestamp();
+
+ // Check timelock has passed
+ if now < proposal.execution_available_at {
+ panic_with_error!(&env, ContractError::UpgradeTimelockActive);
+ }
+
+ // Check proposal hasn't expired during timelock
+ if now > proposal.expires_at {
+ proposal.status = ProposalStatus::Expired;
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
+ panic_with_error!(&env, ContractError::UpgradeProposalExpired);
+ }
+
+ // Mark as executed
+ proposal.status = ProposalStatus::Executed;
+ proposal.is_executed = true;
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
+
+ env.events().publish(
+ (soroban_sdk::symbol_short!("PropExed"),),
+ ProposalStatusChangedEvent {
+ proposal_id,
+ old_status: ProposalStatus::Approved,
+ new_status: ProposalStatus::Executed,
+ timestamp: now,
+ },
+ );
+ }
+
+ /// Cancel a proposal. Only the proposer can cancel.
+ pub fn cancel_proposal(env: Env, proposal_id: u64, reason: Symbol) {
+ let mut proposal: GovernanceProposal = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceProposal(proposal_id))
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::UpgradeProposalNotFound));
+
+ // Only proposer can cancel
+ let caller = env.current_contract_address();
+ caller.require_auth();
+ if caller != proposal.proposer {
+ panic_with_error!(&env, ContractError::NotAuthorizedUpgradeSigner);
+ }
+
+ // Cannot cancel if already executed or cancelled
+ if proposal.status == ProposalStatus::Executed {
+ panic_with_error!(&env, ContractError::UpgradeAlreadyExecuted);
+ }
+ if proposal.status == ProposalStatus::Cancelled {
+ panic_with_error!(&env, ContractError::UpgradeAlreadyCancelled);
+ }
+
+ let old_status = proposal.status;
+ proposal.status = ProposalStatus::Cancelled;
+
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceProposal(proposal_id), &proposal);
+
+ env.events().publish(
+ (soroban_sdk::symbol_short!("PropCand"),),
+ ProposalCancelledEvent {
+ proposal_id,
+ proposer: caller,
+ reason,
+ },
+ );
+ }
+
+ /// Get proposal status.
+ pub fn get_proposal_status(env: Env, proposal_id: u64) -> ProposalStatus {
+ let proposal: GovernanceProposal = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceProposal(proposal_id))
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::UpgradeProposalNotFound));
+ proposal.status
+ }
+
+ /// Get full proposal details.
+ pub fn get_proposal(env: Env, proposal_id: u64) -> GovernanceProposal {
+ env.storage()
+ .instance()
+ .get(&DataKey::GovernanceProposal(proposal_id))
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::UpgradeProposalNotFound))
+ }
+
+ /// Get governance configuration.
+ pub fn get_governance_config(env: Env) -> GovernanceConfig {
+ env.storage()
+ .instance()
+ .get(&DataKey::GovernanceConfig)
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::MultiSigNotConfigured))
+ }
+
+ /// Update governance configuration (admin only).
+ pub fn update_governance_config(
+ env: Env,
+ default_expiry: Option,
+ default_timelock: Option,
+ default_quorum: Option,
+ default_approval_threshold_bps: Option,
+ enabled: Option,
+ ) {
+ let mut config: GovernanceConfig = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceConfig)
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::MultiSigNotConfigured));
+
+ config.admin.require_auth();
+
+ if let Some(expiry) = default_expiry {
+ config.default_expiry = expiry;
+ }
+ if let Some(timelock) = default_timelock {
+ config.default_timelock = timelock;
+ }
+ if let Some(quorum) = default_quorum {
+ if quorum == 0 || quorum > MAX_QUORUM_VOTERS {
+ panic_with_error!(&env, ContractError::InvalidSignatureThreshold);
+ }
+ config.default_quorum = quorum;
+ }
+ if let Some(threshold) = default_approval_threshold_bps {
+ if threshold > 10000 || threshold < 1000 {
+ panic_with_error!(&env, ContractError::InvalidSignatureThreshold);
+ }
+ config.default_approval_threshold_bps = threshold;
+ }
+ if let Some(en) = enabled {
+ config.enabled = en;
+ }
+
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceConfig, &config);
+ }
+
+ /// Update vote weight provider configuration (admin only).
+ pub fn update_vote_weight_provider(
+ env: Env,
+ use_token_weighting: Option,
+ token_address: Option,
+ use_reputation_weighting: Option,
+ ) {
+ let mut config: GovernanceConfig = env
+ .storage()
+ .instance()
+ .get(&DataKey::GovernanceConfig)
+ .unwrap_or_else(|| panic_with_error!(&env, ContractError::MultiSigNotConfigured));
+
+ config.admin.require_auth();
+
+ if let Some(use_token) = use_token_weighting {
+ config.weight_provider.use_token_weighting = use_token;
+ }
+ if let Some(token_addr) = token_address {
+ config.weight_provider.token_address = Some(token_addr);
+ }
+ if let Some(use_reputation) = use_reputation_weighting {
+ config.weight_provider.use_reputation_weighting = use_reputation;
+ }
+
+ env.storage()
+ .instance()
+ .set(&DataKey::GovernanceConfig, &config);
+ }
+}
+
+impl GovernanceModule {
+ /// Calculate vote weight for a voter based on the configured weight provider.
+ fn calculate_vote_weight(
+ env: &Env,
+ voter: &Address,
+ weight_provider: &VoteWeightProvider,
+ ) -> u64 {
+ if weight_provider.use_token_weighting {
+ // Token-based weighting
+ if let Some(ref token) = weight_provider.token_address {
+ let client = soroban_sdk::token::Client::new(env, token);
+ let balance = client.balance(voter);
+ // Convert i128 to u64, cap at u64::MAX
+ if balance <= 0 {
+ 1 // Minimum weight of 1
+ } else if balance > u64::MAX as i128 {
+ u64::MAX
+ } else {
+ balance as u64
+ }
+ } else {
+ 1 // No token configured, default to 1
+ }
+ } else if weight_provider.use_reputation_weighting {
+ // Reputation-based weighting could be added here
+ // For now, default to 1-person-1-vote
+ 1
+ } else {
+ // 1-person-1-vote
+ 1
+ }
+ }
+}
diff --git a/contracts/utility_contracts/src/lib.rs b/contracts/utility_contracts/src/lib.rs
index d8ebf43..da4b08c 100644
--- a/contracts/utility_contracts/src/lib.rs
+++ b/contracts/utility_contracts/src/lib.rs
@@ -328,6 +328,7 @@ use gas_estimator::GasCostEstimator;
pub mod enterprise;
pub mod ghost_sweeper;
+pub mod governance;
pub mod grant_stream_listener;
pub mod nonce_sync;
pub mod secure_call_interface;
@@ -1037,6 +1038,13 @@ pub enum DataKey {
UpgradeApproval(u64, Address),
UpgradeProposalCounter,
ActiveUpgradeProposalId,
+ // Issue #18 - Governance Enhancements
+ GovernanceConfig,
+ GovernanceProposal(u64),
+ GovernanceProposalCounter,
+ GovernanceVote(u64, Address),
+ // Issue #18 - Nonce Sync Safety
+ PendingNonceLock(BytesN<32>),
}
#[contracterror(export = false)]
@@ -1174,6 +1182,10 @@ pub enum ContractError {
// Issue #23 - Token Security
UnapprovedToken = 117,
TokenBalanceMismatch = 118,
+ // Issue #18 - Governance Enhancements
+ QuorumNotSatisfied = 124,
+ GovernanceDisabled = 125,
+ NonceLockActive = 126,
}
#[contracttype]
@@ -1419,9 +1431,7 @@ fn require_approved_token(env: &Env, token: &Address) {
// Skip whitelist enforcement in test mode
#[cfg(not(test))]
{
- let approved: Option> = env.storage()
- .instance()
- .get(&DataKey::ApprovedTokens);
+ let approved: Option> = env.storage().instance().get(&DataKey::ApprovedTokens);
if let Some(tokens) = approved {
if tokens.len() > 0 && !tokens.contains(token) {
panic_with_error!(env, ContractError::UnapprovedToken);
@@ -3056,13 +3066,16 @@ impl UtilityContract {
/// Only callable by the contract admin.
pub fn approve_token(env: Env, token: Address, decimals: u32) {
require_admin_auth(&env);
- let mut approved: Vec = env.storage()
+ let mut approved: Vec = env
+ .storage()
.instance()
.get(&DataKey::ApprovedTokens)
.unwrap_or(Vec::new(&env));
if !approved.contains(&token) {
approved.push_back(token.clone());
- env.storage().instance().set(&DataKey::ApprovedTokens, &approved);
+ env.storage()
+ .instance()
+ .set(&DataKey::ApprovedTokens, &approved);
}
let info = TokenInfo {
token: token.clone(),
@@ -3071,20 +3084,25 @@ impl UtilityContract {
approved_at: env.ledger().timestamp(),
approved_by: get_admin_or_panic(&env),
};
- env.storage().instance().set(&DataKey::TokenInfo(token), &info);
+ env.storage()
+ .instance()
+ .set(&DataKey::TokenInfo(token), &info);
}
/// Revoke a token from the protocol whitelist.
/// Only callable by the contract admin.
pub fn revoke_token(env: Env, token: Address) {
require_admin_auth(&env);
- let mut approved: Vec = env.storage()
+ let mut approved: Vec = env
+ .storage()
.instance()
.get(&DataKey::ApprovedTokens)
.unwrap_or(Vec::new(&env));
if let Some(pos) = approved.first_index_of(&token) {
approved.remove(pos);
- env.storage().instance().set(&DataKey::ApprovedTokens, &approved);
+ env.storage()
+ .instance()
+ .set(&DataKey::ApprovedTokens, &approved);
env.storage().instance().remove(&DataKey::TokenInfo(token));
}
}
@@ -3099,9 +3117,7 @@ impl UtilityContract {
/// Get token info for a specific token.
pub fn get_token_info(env: Env, token: Address) -> Option {
- env.storage()
- .instance()
- .get(&DataKey::TokenInfo(token))
+ env.storage().instance().get(&DataKey::TokenInfo(token))
}
pub fn set_admin(env: Env, admin_address: Address) {
@@ -4854,14 +4870,12 @@ impl UtilityContract {
let mut cost = signed_data.units_consumed.saturating_mul(discounted_rate);
// Apply SLA Penalty if active
- if let Some(config) = &meter.sla_config {
- if meter.sla_state.is_penalty_active
- || meter.sla_state.accumulated_downtime >= config.threshold_seconds
- {
- cost = cost
- .saturating_mul(config.penalty_multiplier_bps)
- .saturating_div(10000);
- }
+ if meter.sla_state.is_penalty_active
+ || meter.sla_state.accumulated_downtime >= meter.sla_config.threshold_seconds
+ {
+ cost = cost
+ .saturating_mul(meter.sla_config.penalty_multiplier_bps)
+ .saturating_div(10000);
}
// Apply provider withdrawal limits
diff --git a/contracts/utility_contracts/src/nonce_sync.rs b/contracts/utility_contracts/src/nonce_sync.rs
index 7e11078..b834b40 100644
--- a/contracts/utility_contracts/src/nonce_sync.rs
+++ b/contracts/utility_contracts/src/nonce_sync.rs
@@ -441,6 +441,9 @@ impl NonceSyncManager {
/// the heartbeat signature, checks the nonce sequence, and updates the device
/// state if the heartbeat is valid. Invalid heartbeats trigger desync alerts.
///
+ /// Issue #18: Added pending_nonce_lock to prevent concurrent nonce updates
+ /// from the same device, eliminating race conditions in the nonce sync mechanism.
+ ///
/// # Arguments
///
/// * `env` - The contract environment
@@ -455,6 +458,7 @@ impl NonceSyncManager {
///
/// * `ContractError::InvalidSignature` - if signature verification fails
/// * `ContractError::PublicKeyMismatch` - if public key doesn't match device
+ /// * `ContractError::NonceLockActive` - if concurrent nonce update in progress
///
/// # Security Behavior
///
@@ -481,6 +485,17 @@ impl NonceSyncManager {
panic_with_error!(&env, ContractError::InvalidSignature);
}
+ // Issue #18: Acquire pending nonce lock to prevent concurrent updates
+ let lock_key = DataKey::PendingNonceLock(heartbeat.device_mac.clone());
+ if env.storage().persistent().has(&lock_key) {
+ // Another heartbeat verification is in progress for this device
+ panic_with_error!(&env, ContractError::NonceLockActive);
+ }
+ // Lock with a short TTL (will be released after this call completes)
+ env.storage().persistent().set(&lock_key, &true);
+ // Note: The lock is implicitly released at the end of this function
+ // as it's not persisted across Soroban contract calls in the same transaction.
+
// Get current device nonce state
let device_key = DataKey::DeviceNonce(heartbeat.device_mac.clone());
let mut nonce_state: DeviceNonceState = env
@@ -510,6 +525,9 @@ impl NonceSyncManager {
// Store updated state
env.storage().persistent().set(&device_key, &nonce_state);
+ // Issue #18: Release pending nonce lock
+ env.storage().persistent().remove(&lock_key);
+
// Emit success event
env.events().publish(
(symbol_short!("HbValid"),),
@@ -521,6 +539,8 @@ impl NonceSyncManager {
NonceValidationResult::Desync(alert_type) => {
// Handle desync
Self::handle_nonce_desync(&env, &heartbeat, &mut nonce_state, alert_type);
+ // Issue #18: Release pending nonce lock
+ env.storage().persistent().remove(&lock_key);
false
}
}
diff --git a/contracts/utility_contracts/src/nonce_sync_tests.rs b/contracts/utility_contracts/src/nonce_sync_tests.rs
index 148223a..ea64927 100644
--- a/contracts/utility_contracts/src/nonce_sync_tests.rs
+++ b/contracts/utility_contracts/src/nonce_sync_tests.rs
@@ -2,9 +2,10 @@ use crate::nonce_sync::{
DeviceNonceState, NonceAlertType, NonceDesyncAlert, NonceResetRequest, NonceSyncManager,
SignedHeartbeat, NONCE_WINDOW_SIZE,
};
-use crate::std::string::ToString;
use crate::{ContractError, DataKey};
-use soroban_sdk::{testutils::Address as _, testutils::BytesN as _, Address, BytesN, Env};
+use soroban_sdk::{
+ testutils::Address as _, testutils::BytesN as _, Address, BytesN, Env, String, Vec,
+};
#[cfg(test)]
pub mod nonce_sync_fuzz_tests {
@@ -329,6 +330,7 @@ pub mod nonce_sync_fuzz_tests {
/// and prevents any form of nonce reuse or manipulation.
#[cfg(test)]
mod property_tests {
+ use super::nonce_sync_fuzz_tests::create_test_heartbeat;
use super::*;
use proptest::prelude::*;
diff --git a/contracts/utility_contracts/src/tariff_oracle_tests.rs b/contracts/utility_contracts/src/tariff_oracle_tests.rs
index b5c18bb..5479785 100644
--- a/contracts/utility_contracts/src/tariff_oracle_tests.rs
+++ b/contracts/utility_contracts/src/tariff_oracle_tests.rs
@@ -460,6 +460,7 @@ pub mod tariff_oracle_tests {
/// Property-based tests for tariff calculations
#[cfg(test)]
mod tariff_property_tests {
+ use super::tariff_oracle_tests::create_test_schedule;
use super::*;
use proptest::prelude::*;
diff --git a/contracts/utility_contracts/src/temporary_storage_tests.rs b/contracts/utility_contracts/src/temporary_storage_tests.rs
index cb9d870..39a60a4 100644
--- a/contracts/utility_contracts/src/temporary_storage_tests.rs
+++ b/contracts/utility_contracts/src/temporary_storage_tests.rs
@@ -9,7 +9,9 @@ mod tests {
temporary_storage::{OptimizedFlowCalculator, OptimizedUsageTracker, TempStorageManager},
BillingType, ContinuousFlow, DataKey, Meter, StreamStatus, UsageData,
};
- use soroban_sdk::{testutils::Address as _, testutils::Ledger, Address, BytesN, Env, Symbol};
+ use soroban_sdk::{
+ testutils::Address as _, testutils::Ledger, Address, BytesN, Env, Symbol, Vec,
+ };
fn create_test_env() -> Env {
let env = Env::default();