Repository: DA0-DA0/dao-contracts
Pull request #357 (2022-06-13 23:24:06)

Add min_voting_period to cw-proposal-single.
This adds a minimum voting period. Having a minimum voting period
allows DAO members some time to respond in the event of a DAO hostile
takeover.

For example, say a DAO exists with some tokens in a LP pool. An
attacker may purchase a large number of those tokens and stake them to
take over the DAO and give the DAO's treasury to themselves. If a
minimum voting period is configured DAO members will have some time to
see this occuring and remove liquidity from the pool to reduce the
amount of damage the attacker might do.

See this issue for some context: https://github.com/DA0-DA0/dao-contracts/issues/356#issuecomment-1154476150

IMO if folks are against this change I'd be happy to just close this PR and
move on. I do think that, on balance, this is probably worth the added
complexity.


Patch:
diff --git a/contracts/cw-proposal-single/schema/config_response.json b/contracts/cw-proposal-single/schema/config_response.json
index 3bc0e0fa..4cf3647c 100644
--- a/contracts/cw-proposal-single/schema/config_response.json
+++ b/contracts/cw-proposal-single/schema/config_response.json
@@ -42,6 +42,17 @@
         }
       ]
     },
+    "min_voting_period": {
+      "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.",
+      "anyOf": [
+        {
+          "$ref": "#/definitions/Duration"
+        },
+        {
+          "type": "null"
+        }
+      ]
+    },
     "only_members_execute": {
       "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.",
       "type": "boolean"
diff --git a/contracts/cw-proposal-single/schema/execute_msg.json b/contracts/cw-proposal-single/schema/execute_msg.json
index 16b12b46..cd45e394 100644
--- a/contracts/cw-proposal-single/schema/execute_msg.json
+++ b/contracts/cw-proposal-single/schema/execute_msg.json
@@ -162,6 +162,17 @@
                 }
               ]
             },
+            "min_voting_period": {
+              "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.",
+              "anyOf": [
+                {
+                  "$ref": "#/definitions/Duration"
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
             "only_members_execute": {
               "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal. Applies to all outstanding and future proposals.",
               "type": "boolean"
diff --git a/contracts/cw-proposal-single/schema/instantiate_msg.json b/contracts/cw-proposal-single/schema/instantiate_msg.json
index a33699b6..3fcee7ab 100644
--- a/contracts/cw-proposal-single/schema/instantiate_msg.json
+++ b/contracts/cw-proposal-single/schema/instantiate_msg.json
@@ -32,6 +32,17 @@
         }
       ]
     },
+    "min_voting_period": {
+      "description": "The minimum amount of time a proposal must be open before passing. A proposal may fail before this amount of time has elapsed, but it will not pass. This can be useful for preventing governance attacks wherein an attacker aquires a large number of tokens and forces a proposal through.",
+      "anyOf": [
+        {
+          "$ref": "#/definitions/Duration"
+        },
+        {
+          "type": "null"
+        }
+      ]
+    },
     "only_members_execute": {
       "description": "If set to true only members may execute passed proposals. Otherwise, any address may execute a passed proposal.",
       "type": "boolean"
diff --git a/contracts/cw-proposal-single/schema/list_proposals_response.json b/contracts/cw-proposal-single/schema/list_proposals_response.json
index ade96a90..acc5ab41 100644
--- a/contracts/cw-proposal-single/schema/list_proposals_response.json
+++ b/contracts/cw-proposal-single/schema/list_proposals_response.json
@@ -591,18 +591,41 @@
           "type": "string"
         },
         "expiration": {
-          "$ref": "#/definitions/Expiration"
+          "description": "The the time at which this proposal will expire and close for additional votes.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Expiration"
+            }
+          ]
+        },
+        "min_voting_period": {
+          "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.",
+          "anyOf": [
+            {
+              "$ref": "#/definitions/Expiration"
+            },
+            {
+              "type": "null"
+            }
+          ]
         },
         "msgs": {
+          "description": "The messages that will be executed should this proposal pass.",
           "type": "array",
           "items": {
             "$ref": "#/definitions/CosmosMsg_for_Empty"
           }
         },
         "proposer": {
-          "$ref": "#/definitions/Addr"
+          "description": "The address that created this proposal.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Addr"
+            }
+          ]
         },
         "start_height": {
+          "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.",
           "type": "integer",
           "format": "uint64",
           "minimum": 0.0
@@ -611,13 +634,23 @@
           "$ref": "#/definitions/Status"
         },
         "threshold": {
-          "$ref": "#/definitions/Threshold"
+          "description": "The threshold at which this proposal will pass.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Threshold"
+            }
+          ]
         },
         "title": {
           "type": "string"
         },
         "total_power": {
-          "$ref": "#/definitions/Uint128"
+          "description": "The total amount of voting power at the time of this proposal's creation.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Uint128"
+            }
+          ]
         },
         "votes": {
           "$ref": "#/definitions/Votes"
diff --git a/contracts/cw-proposal-single/schema/proposal_response.json b/contracts/cw-proposal-single/schema/proposal_response.json
index a0ef8ca5..c2c77390 100644
--- a/contracts/cw-proposal-single/schema/proposal_response.json
+++ b/contracts/cw-proposal-single/schema/proposal_response.json
@@ -594,18 +594,41 @@
           "type": "string"
         },
         "expiration": {
-          "$ref": "#/definitions/Expiration"
+          "description": "The the time at which this proposal will expire and close for additional votes.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Expiration"
+            }
+          ]
+        },
+        "min_voting_period": {
+          "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.",
+          "anyOf": [
+            {
+              "$ref": "#/definitions/Expiration"
+            },
+            {
+              "type": "null"
+            }
+          ]
         },
         "msgs": {
+          "description": "The messages that will be executed should this proposal pass.",
           "type": "array",
           "items": {
             "$ref": "#/definitions/CosmosMsg_for_Empty"
           }
         },
         "proposer": {
-          "$ref": "#/definitions/Addr"
+          "description": "The address that created this proposal.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Addr"
+            }
+          ]
         },
         "start_height": {
+          "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.",
           "type": "integer",
           "format": "uint64",
           "minimum": 0.0
@@ -614,13 +637,23 @@
           "$ref": "#/definitions/Status"
         },
         "threshold": {
-          "$ref": "#/definitions/Threshold"
+          "description": "The threshold at which this proposal will pass.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Threshold"
+            }
+          ]
         },
         "title": {
           "type": "string"
         },
         "total_power": {
-          "$ref": "#/definitions/Uint128"
+          "description": "The total amount of voting power at the time of this proposal's creation.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Uint128"
+            }
+          ]
         },
         "votes": {
           "$ref": "#/definitions/Votes"
diff --git a/contracts/cw-proposal-single/schema/reverse_proposals_response.json b/contracts/cw-proposal-single/schema/reverse_proposals_response.json
index fa543295..3c2e3daf 100644
--- a/contracts/cw-proposal-single/schema/reverse_proposals_response.json
+++ b/contracts/cw-proposal-single/schema/reverse_proposals_response.json
@@ -591,18 +591,41 @@
           "type": "string"
         },
         "expiration": {
-          "$ref": "#/definitions/Expiration"
+          "description": "The the time at which this proposal will expire and close for additional votes.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Expiration"
+            }
+          ]
+        },
+        "min_voting_period": {
+          "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.",
+          "anyOf": [
+            {
+              "$ref": "#/definitions/Expiration"
+            },
+            {
+              "type": "null"
+            }
+          ]
         },
         "msgs": {
+          "description": "The messages that will be executed should this proposal pass.",
           "type": "array",
           "items": {
             "$ref": "#/definitions/CosmosMsg_for_Empty"
           }
         },
         "proposer": {
-          "$ref": "#/definitions/Addr"
+          "description": "The address that created this proposal.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Addr"
+            }
+          ]
         },
         "start_height": {
+          "description": "The block height at which this proposal was created. Voting power queries should query for voting power at this block height.",
           "type": "integer",
           "format": "uint64",
           "minimum": 0.0
@@ -611,13 +634,23 @@
           "$ref": "#/definitions/Status"
         },
         "threshold": {
-          "$ref": "#/definitions/Threshold"
+          "description": "The threshold at which this proposal will pass.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Threshold"
+            }
+          ]
         },
         "title": {
           "type": "string"
         },
         "total_power": {
-          "$ref": "#/definitions/Uint128"
+          "description": "The total amount of voting power at the time of this proposal's creation.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Uint128"
+            }
+          ]
         },
         "votes": {
           "$ref": "#/definitions/Votes"
diff --git a/contracts/cw-proposal-single/src/contract.rs b/contracts/cw-proposal-single/src/contract.rs
index a410e8af..194e5dba 100644
--- a/contracts/cw-proposal-single/src/contract.rs
+++ b/contracts/cw-proposal-single/src/contract.rs
@@ -24,7 +24,7 @@ use crate::{
         get_deposit_msg, get_return_deposit_msg, Ballot, Config, BALLOTS, CONFIG, PROPOSALS,
         PROPOSAL_COUNT, PROPOSAL_HOOKS, VOTE_HOOKS,
     },
-    utils::{get_total_power, get_voting_power},
+    utils::{get_total_power, get_voting_power, validate_voting_period},
 };
 
 const CONTRACT_NAME: &str = "crates.io:cw-govmod-single";
@@ -51,9 +51,13 @@ pub fn instantiate(
         .map(|info| info.into_checked(deps.as_ref(), dao.clone()))
         .transpose()?;
 
+    let (min_voting_period, max_voting_period) =
+        validate_voting_period(msg.min_voting_period, msg.max_voting_period)?;
+
     let config = Config {
         threshold: msg.threshold,
-        max_voting_period: msg.max_voting_period,
+        max_voting_period,
+        min_voting_period,
         only_members_execute: msg.only_members_execute,
         dao: dao.clone(),
         deposit_info,
@@ -89,6 +93,7 @@ pub fn execute(
         ExecuteMsg::UpdateConfig {
             threshold,
             max_voting_period,
+            min_voting_period,
             only_members_execute,
             allow_revoting,
             dao,
@@ -98,6 +103,7 @@ pub fn execute(
             info,
             threshold,
             max_voting_period,
+            min_voting_period,
             only_members_execute,
             allow_revoting,
             dao,
@@ -166,6 +172,7 @@ pub fn execute_propose(
             description,
             proposer: sender.clone(),
             start_height: env.block.height,
+            min_voting_period: config.min_voting_period.map(|min| min.after(&env.block)),
             expiration,
             threshold: config.threshold,
             total_power,
@@ -424,6 +431,7 @@ pub fn execute_update_config(
     info: MessageInfo,
     threshold: Threshold,
     max_voting_period: Duration,
+    min_voting_period: Option<Duration>,
     only_members_execute: bool,
     allow_revoting: bool,
     dao: String,
@@ -442,11 +450,15 @@ pub fn execute_update_config(
         .map(|info| info.into_checked(deps.as_ref(), dao.clone()))
         .transpose()?;
 
+    let (min_voting_period, max_voting_period) =
+        validate_voting_period(min_voting_period, max_voting_period)?;
+
     CONFIG.save(
         deps.storage,
         &Config {
             threshold,
             max_voting_period,
+            min_voting_period,
             only_members_execute,
             allow_revoting,
             dao,
diff --git a/contracts/cw-proposal-single/src/error.rs b/contracts/cw-proposal-single/src/error.rs
index e1edd83a..297bd6b4 100644
--- a/contracts/cw-proposal-single/src/error.rs
+++ b/contracts/cw-proposal-single/src/error.rs
@@ -56,4 +56,10 @@ pub enum ContractError {
 
     #[error("The DAO is currently inactive, you cannot create proposals")]
     InactiveDao {},
+
+    #[error("min_voting_period and max_voting_period must have the same units (height or time)")]
+    DurationUnitsConflict {},
+
+    #[error("Min voting period must be less than or equal to max voting period")]
+    InvalidMinVotingPeriod {},
 }
diff --git a/contracts/cw-proposal-single/src/msg.rs b/contracts/cw-proposal-single/src/msg.rs
index 23d38328..f281f850 100644
--- a/contracts/cw-proposal-single/src/msg.rs
+++ b/contracts/cw-proposal-single/src/msg.rs
@@ -13,6 +13,12 @@ pub struct InstantiateMsg {
     /// The default maximum amount of time a proposal may be voted on
     /// before expiring.
     pub max_voting_period: Duration,
+    /// The minimum amount of time a proposal must be open before
+    /// passing. A proposal may fail before this amount of time has
+    /// elapsed, but it will not pass. This can be useful for
+    /// preventing governance attacks wherein an attacker aquires a
+    /// large number of tokens and forces a proposal through.
+    pub min_voting_period: Option<Duration>,
     /// If set to true only members may execute passed
     /// proposals. Otherwise, any address may execute a passed
     /// proposal.
@@ -99,6 +105,12 @@ pub enum ExecuteMsg {
         /// on before expiring. This will only apply to proposals
         /// created after the config update.
         max_voting_period: Duration,
+        /// The minimum amount of time a proposal must be open before
+        /// passing. A proposal may fail before this amount of time has
+        /// elapsed, but it will not pass. This can be useful for
+        /// preventing governance attacks wherein an attacker aquires a
+        /// large number of tokens and forces a proposal through.
+        min_voting_period: Option<Duration>,
         /// If set to true only members may execute passed
         /// proposals. Otherwise, any address may execute a passed
         /// proposal. Applies to all outstanding and future proposals.
diff --git a/contracts/cw-proposal-single/src/proposal.rs b/contracts/cw-proposal-single/src/proposal.rs
index b0266302..b054600b 100644
--- a/contracts/cw-proposal-single/src/proposal.rs
+++ b/contracts/cw-proposal-single/src/proposal.rs
@@ -13,14 +13,25 @@ use crate::{
 pub struct Proposal {
     pub title: String,
     pub description: String,
+    /// The address that created this proposal.
     pub proposer: Addr,
-
+    /// The block height at which this proposal was created. Voting
+    /// power queries should query for voting power at this block
+    /// height.
     pub start_height: u64,
+    /// The minimum amount of time this proposal must remain open for
+    /// voting. The proposal may not pass unless this is expired or
+    /// None.
+    pub min_voting_period: Option<Expiration>,
+    /// The the time at which this proposal will expire and close for
+    /// additional votes.
     pub expiration: Expiration,
-
+    /// The threshold at which this proposal will pass.
     pub threshold: Threshold,
+    /// The total amount of voting power at the time of this
+    /// proposal's creation.
     pub total_power: Uint128,
-
+    /// The messages that will be executed should this proposal pass.
     pub msgs: Vec<CosmosMsg<Empty>>,
 
     pub status: Status,
@@ -118,6 +129,16 @@ impl Proposal {
         if self.allow_revoting && !self.expiration.is_expired(block) {
             return false;
         }
+        // If the min voting period is set and not expired the
+        // proposal can not yet be passed. This gives DAO members some
+        // time to remove liquidity / scheme on a recovery plan if a
+        // single actor accumulates enough tokens to unilaterally pass
+        // proposals.
+        if let Some(min) = self.min_voting_period {
+            if !min.is_expired(block) {
+                return false;
+            }
+        }
 
         match self.threshold {
             Threshold::AbsolutePercentage { percentage } => {
@@ -269,6 +290,7 @@ mod test {
         votes: Votes,
         total_power: Uint128,
         is_expired: bool,
+        min_voting_period_elapsed: bool,
         allow_revoting: bool,
     ) -> (Proposal, BlockInfo) {
         let block = mock_env().block;
@@ -276,12 +298,17 @@ mod test {
             true => Expiration::AtHeight(block.height - 5),
             false => Expiration::AtHeight(block.height + 100),
         };
+        let min_voting_period = match min_voting_period_elapsed {
+            true => Expiration::AtHeight(block.height - 5),
+            false => Expiration::AtHeight(block.height + 5),
+        };
         let prop = Proposal {
             title: "Demo".to_string(),
             description: "Info".to_string(),
             proposer: Addr::unchecked("test"),
             start_height: 100,
             expiration,
+            min_voting_period: Some(min_voting_period),
             allow_revoting,
             msgs: vec![],
             status: Status::Open,
@@ -298,9 +325,17 @@ mod test {
         votes: Votes,
         total_power: Uint128,
         is_expired: bool,
+        min_voting_period_elapsed: bool,
         allow_revoting: bool,
     ) -> bool {
-        let (prop, block) = setup_prop(threshold, votes, total_power, is_expired, allow_revoting);
+        let (prop, block) = setup_prop(
+            threshold,
+            votes,
+            total_power,
+            is_expired,
+            min_voting_period_elapsed,
+            allow_revoting,
+        );
         prop.is_passed(&block)
     }
 
@@ -309,9 +344,17 @@ mod test {
         votes: Votes,
         total_power: Uint128,
         is_expired: bool,
+        min_voting_period_elapsed: bool,
         allow_revoting: bool,
     ) -> bool {
-        let (prop, block) = setup_prop(threshold, votes, total_power, is_expired, allow_revoting);
+        let (prop, block) = setup_prop(
+            threshold,
+            votes,
+            total_power,
+            is_expired,
+            min_voting_period_elapsed,
+            allow_revoting,
+        );
         prop.is_rejected(&block)
     }
 
@@ -333,6 +376,7 @@ mod test {
             votes.clone(),
             Uint128::new(15),
             false,
+            true,
             false,
         ));
         // Proposal being expired should not effect those results.
@@ -341,6 +385,7 @@ mod test {
             votes.clone(),
             Uint128::new(15),
             true,
+            true,
             false
         ));
 
@@ -350,10 +395,84 @@ mod test {
             votes,
             Uint128::new(17),
             false,
+            true,
+            false
+        ));
+    }
+
+    #[test]
+    fn test_min_voting_period_no_early_pass() {
+        let threshold = Threshold::AbsolutePercentage {
+            percentage: PercentageThreshold::Majority {},
+        };
+        let votes = Votes {
+            yes: Uint128::new(7),
+            no: Uint128::new(4),
+            abstain: Uint128::new(2),
+        };
+
+        // Does not pass if min voting period is not expired.
+        assert!(!check_is_passed(
+            threshold.clone(),
+            votes.clone(),
+            Uint128::new(15),
+            false,
+            false,
+            false,
+        ));
+        // Should not be rejected either.
+        assert!(!check_is_rejected(
+            threshold.clone(),
+            votes.clone(),
+            Uint128::new(15),
+            false,
+            false,
+            false,
+        ));
+
+        // Min voting period being expired makes this pass.
+        assert!(check_is_passed(
+            threshold,
+            votes,
+            Uint128::new(15),
+            false,
+            true,
             false
         ));
     }
 
+    #[test]
+    fn test_min_voting_period_early_rejection() {
+        let threshold = Threshold::AbsolutePercentage {
+            percentage: PercentageThreshold::Majority {},
+        };
+        let votes = Votes {
+            yes: Uint128::new(4),
+            no: Uint128::new(7),
+            abstain: Uint128::new(2),
+        };
+
+        // Proposal has not passed.
+        assert!(!check_is_passed(
+            threshold.clone(),
+            votes.clone(),
+            Uint128::new(15),
+            false,
+            false,
+            false,
+        ));
+        // Should be rejected despite the min voting period not being
+        // passed.
+        assert!(check_is_rejected(
+            threshold,
+            votes,
+            Uint128::new(15),
+            false,
+            false,
+            false,
+        ));
+    }
+
     #[test]
     fn test_revoting_majority_no_pass() {
         // Revoting being allowed means that proposals may not be
@@ -375,6 +494,7 @@ mod test {
             Uint128::new(15),
             false,
             true,
+            true,
         ));
         // Proposal being expired should cause the proposal to be
         // passed as votes may no longer be cast.
@@ -383,6 +503,7 @@ mod test {
             votes,
             Uint128::new(15),
             true,
+            true,
             true
         ));
     }
@@ -406,6 +527,7 @@ mod test {
             votes.clone(),
             Uint128::new(15),
             false,
+            true,
             true
         ));
 
@@ -415,6 +537,7 @@ mod test {
             votes,
             Uint128::new(15),
             true,
+            true,
             true
         ));
     }
@@ -436,6 +559,7 @@ mod test {
             },
             Uint128::new(100),
             false,
+            true,
             false
         ));
 
@@ -448,6 +572,7 @@ mod test {
             },
             Uint128::new(10),
             false,
+            true,
             false
         ));
 
@@ -460,6 +585,7 @@ mod test {
             },
             Uint128::new(11),
             false,
+            true,
             false
         ));
 
@@ -472,6 +598,7 @@ mod test {
             },
             Uint128::new(11),
             false,
+            true,
             false
         ));
     }
@@ -493,6 +620,7 @@ mod test {
             },
             Uint128::new(100),
             false,
+            true,
             true
         ));
         assert!(check_is_passed(
@@ -504,6 +632,7 @@ mod test {
             },
             Uint128::new(100),
             true,
+            true,
             true
         ));
 
@@ -516,6 +645,7 @@ mod test {
             },
             Uint128::new(10),
             false,
+            true,
             true
         ));
         assert!(check_is_rejected(
@@ -527,6 +657,7 @@ mod test {
             },
             Uint128::new(10),
             true,
+            true,
             true
         ));
     }
@@ -546,6 +677,7 @@ mod test {
             votes,
             Uint128::new(13),
             false,
+            true,
             false
         ))
     }
@@ -565,6 +697,7 @@ mod test {
             votes.clone(),
             Uint128::new(13),
             false,
+            true,
             false
         ));
         assert!(!check_is_rejected(
@@ -572,6 +705,7 @@ mod test {
             votes,
             Uint128::new(13),
             false,
+            true,
             false
         ));
     }
@@ -591,6 +725,7 @@ mod test {
             votes.clone(),
             Uint128::new(13),
             false,
+            true,
             false
         ));
         assert!(!check_is_passed(
@@ -598,6 +733,7 @@ mod test {
             votes,
             Uint128::new(14),
             false,
+            true,
             false
         ))
     }
@@ -623,6 +759,7 @@ mod test {
             votes.clone(),
             Uint128::new(15),
             false,
+            true,
             false,
         ));
         assert!(check_is_rejected(
@@ -630,6 +767,7 @@ mod test {
             votes.clone(),
             Uint128::new(15),
             true,
+            true,
             false
         ));
 
@@ -640,6 +778,7 @@ mod test {
             votes.clone(),
             Uint128::new(17),
             false,
+            true,
             false
         ));
         assert!(!check_is_rejected(
@@ -647,6 +786,7 @@ mod test {
             votes.clone(),
             Uint128::new(17),
             true,
+            true,
             false
         ));
 
@@ -656,6 +796,7 @@ mod test {
             votes.clone(),
             Uint128::new(14),
             false,
+            true,
             false
         ));
         assert!(check_is_rejected(
@@ -663,6 +804,7 @@ mod test {
             votes,
             Uint128::new(14),
             true,
+            true,
             false
         ));
     }
@@ -699,6 +841,7 @@ mod test {
             passing.clone(),
             Uint128::new(30),
             true,
+            true,
             false,
         ));
         // under quorum it is not passing (40% of 33 = 13.2 > 13)
@@ -707,6 +850,7 @@ mod test {
             passing.clone(),
             Uint128::new(33),
             true,
+            true,
             false
         ));
         // over quorum, threshold passes if we ignore abstain
@@ -717,6 +861,7 @@ mod test {
             passes_ignoring_abstain.clone(),
             Uint128::new(40),
             true,
+            true,
             false,
         ));
         // over quorum, but under threshold fails also
@@ -725,6 +870,7 @@ mod test {
             failing,
             Uint128::new(20),
             true,
+            true,
             false
         ));
 
@@ -735,6 +881,7 @@ mod test {
             passing.clone(),
             Uint128::new(30),
             false,
+            true,
             false
         ));
         assert!(!check_is_passed(
@@ -742,6 +889,7 @@ mod test {
             passes_ignoring_abstain.clone(),
             Uint128::new(40),
             false,
+            true,
             false
         ));
         // if we have threshold * total_weight as yes votes this must pass
@@ -750,6 +898,7 @@ mod test {
             passing.clone(),
             Uint128::new(14),
             false,
+            true,
             false
         ));
         // all votes have been cast, some abstain
@@ -758,6 +907,7 @@ mod test {
             passes_ignoring_abstain,
             Uint128::new(17),
             false,
+            true,
             false
         ));
         // 3 votes uncast, if they all vote no, we have 7 yes, 7 no+veto, 2 abstain (out of 16)
@@ -766,6 +916,7 @@ mod test {
             passing,
             Uint128::new(16),
             false,
+            true,
             false
         ));
     }
@@ -802,6 +953,7 @@ mod test {
             rejecting.clone(),
             Uint128::new(30),
             true,
+            true,
             false
         ));
         // Total power of 33. 13 total votes. 8 no votes, 3 yes, 2
@@ -813,6 +965,7 @@ mod test {
             rejecting.clone(),
             Uint128::new(33),
             true,
+            true,
             false
         ));
 
@@ -824,6 +977,7 @@ mod test {
             rejected_ignoring_abstain.clone(),
             Uint128::new(40),
             true,
+            true,
             false
         ));
 
@@ -836,6 +990,7 @@ mod test {
             failing.clone(),
             Uint128::new(20),
             true,
+            true,
             false
         ));
         assert!(!check_is_rejected(
@@ -843,6 +998,7 @@ mod test {
             failing,
             Uint128::new(20),
             false,
+            true,
             false
         ));
 
@@ -853,6 +1009,7 @@ mod test {
             rejecting.clone(),
             Uint128::new(30),
             false,
+            true,
             false
         ));
         assert!(!check_is_rejected(
@@ -860,6 +1017,7 @@ mod test {
             rejected_ignoring_abstain.clone(),
             Uint128::new(40),
             false,
+            true,
             false
         ));
         // if we have threshold * total_weight as no votes this must reject
@@ -868,6 +1026,7 @@ mod test {
             rejecting.clone(),
             Uint128::new(14),
             false,
+            true,
             false
         ));
         // all votes have been cast, some abstain
@@ -876,6 +1035,7 @@ mod test {
             rejected_ignoring_abstain,
             Uint128::new(17),
             false,
+            true,
             false
         ));
         // 3 votes uncast, if they all vote yes, we have 7 no, 7 yes+veto, 2 abstain (out of 16)
@@ -884,6 +1044,7 @@ mod test {
             rejecting,
             Uint128::new(16),
             false,
+            true,
             false
         ));
     }
@@ -910,6 +1071,7 @@ mod test {
             missing_voters.clone(),
             Uint128::new(15),
             false,
+            true,
             false
         ));
         assert!(!check_is_passed(
@@ -917,6 +1079,7 @@ mod test {
             missing_voters,
             Uint128::new(15),
             true,
+            true,
             false
         ));
 
@@ -931,6 +1094,7 @@ mod test {
             wait_til_expired.clone(),
             Uint128::new(15),
             false,
+            true,
             false
         ));
         assert!(check_is_passed(
@@ -938,6 +1102,7 @@ mod test {
             wait_til_expired,
             Uint128::new(15),
             true,
+            true,
             false
         ));
 
@@ -952,6 +1117,7 @@ mod test {
             passes_early.clone(),
             Uint128::new(15),
             false,
+            true,
             false
         ));
         assert!(check_is_passed(
@@ -959,6 +1125,7 @@ mod test {
             passes_early,
             Uint128::new(15),
             true,
+            true,
             false
         ));
     }
diff --git a/contracts/cw-proposal-single/src/state.rs b/contracts/cw-proposal-single/src/state.rs
index 0546096b..0afd661f 100644
--- a/contracts/cw-proposal-single/src/state.rs
+++ b/contracts/cw-proposal-single/src/state.rs
@@ -34,6 +34,12 @@ pub struct Config {
     /// The default maximum amount of time a proposal may be voted on
     /// before expiring.
     pub max_voting_period: Duration,
+    /// The minimum amount of time a proposal must be open before
+    /// passing. A proposal may fail before this amount of time has
+    /// elapsed, but it will not pass. This can be useful for
+    /// preventing governance attacks wherein an attacker aquires a
+    /// large number of tokens and forces a proposal through.
+    pub min_voting_period: Option<Duration>,
     /// If set to true only members may execute passed
     /// proposals. Otherwise, any address may execute a passed
     /// proposal.
diff --git a/contracts/cw-proposal-single/src/utils.rs b/contracts/cw-proposal-single/src/utils.rs
index 80c0cbab..a31b96b8 100644
--- a/contracts/cw-proposal-single/src/utils.rs
+++ b/contracts/cw-proposal-single/src/utils.rs
@@ -1,6 +1,9 @@
 use cosmwasm_std::{Addr, Deps, StdResult, Uint128};
 
 use cw_core_interface::voting;
+use cw_utils::Duration;
+
+use crate::ContractError;
 
 pub fn get_voting_power(
     deps: Deps,
@@ -24,3 +27,27 @@ pub fn get_total_power(deps: Deps, dao: Addr, height: Option<u64>) -> StdResult<
         .query_wasm_smart(dao, &voting::Query::TotalPowerAtHeight { height })?;
     Ok(response.power)
 }
+
+/// Validates that the min voting period is less than the max voting
+/// period. Passes arguments through the function.
+pub fn validate_voting_period(
+    min: Option<Duration>,
+    max: Duration,
+) -> Result<(Option<Duration>, Duration), ContractError> {
+    let min = min
+        .map(|min| {
+            let valid = match (min, max) {
+                (Duration::Time(min), Duration::Time(max)) => min <= max,
+                (Duration::Height(min), Duration::Height(max)) => min <= max,
+                _ => return Err(ContractError::DurationUnitsConflict {}),
+            };
+            if valid {
+                Ok(min)
+            } else {
+                Err(ContractError::InvalidMinVotingPeriod {})
+            }
+        })
+        .transpose()?;
+
+    Ok((min, max))
+}
diff --git a/contracts/cw20-staked-balance-voting/schema/migrate_msg.json b/contracts/cw20-staked-balance-voting/schema/migrate_msg.json
new file mode 100644
index 00000000..87b18ea7
--- /dev/null
+++ b/contracts/cw20-staked-balance-voting/schema/migrate_msg.json
@@ -0,0 +1,5 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "MigrateMsg",
+  "type": "object"
+}
diff --git a/contracts/cw4-voting/schema/migrate_msg.json b/contracts/cw4-voting/schema/migrate_msg.json
new file mode 100644
index 00000000..87b18ea7
--- /dev/null
+++ b/contracts/cw4-voting/schema/migrate_msg.json
@@ -0,0 +1,5 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "MigrateMsg",
+  "type": "object"
+}
diff --git a/contracts/stake-cw20-reward-distributor/src/contract.rs b/contracts/stake-cw20-reward-distributor/src/contract.rs
index d5f3c50c..a3facc88 100644
--- a/contracts/stake-cw20-reward-distributor/src/contract.rs
+++ b/contracts/stake-cw20-reward-distributor/src/contract.rs
@@ -93,6 +93,7 @@ pub fn execute_update_config(
         return Err(ContractError::Unauthorized {});
     }
 
+    let distribution_msg = get_distribution_msg(deps.as_ref(), &env)?;
     LAST_PAYMENT_BLOCK.save(deps.storage, &env.block.height)?;
 
     let owner = deps.api.addr_validate(&owner)?;
@@ -114,14 +115,8 @@ pub fn execute_update_config(
     };
     CONFIG.save(deps.storage, &config)?;
 
-    let resp = match get_distribution_msg(deps.as_ref(), &env) {
-        // distribution succeeded
-        Ok(msg) => Response::new().add_message(msg),
-        // distribution failed (either zero rewards or already distributed for block)
-        _ => Response::new(),
-    };
-
-    Ok(resp
+    Ok(Response::new()
+        .add_messages(distribution_msg)
         .add_attribute("action", "update_config")
         .add_attribute("owner", owner.into_string())
         .add_attribute("staking_addr", staking_addr.into_string())
@@ -145,14 +140,10 @@ pub fn validate_staking(deps: Deps, staking_addr: Addr) -> bool {
     response.is_ok()
 }
 
-fn get_distribution_msg(deps: Deps, env: &Env) -> Result<CosmosMsg, ContractError> {
+fn get_distribution_msg(deps: Deps, env: &Env) -> Result<Option<CosmosMsg>, ContractError> {
     let config = CONFIG.load(deps.storage)?;
     let last_payment_block = LAST_PAYMENT_BLOCK.load(deps.storage)?;
-    if last_payment_block >= env.block.height {
-        return Err(ContractError::RewardsDistributedForBlock {});
-    }
     let block_diff = env.block.height - last_payment_block;
-
     let pending_rewards: Uint128 = config.reward_rate * Uint128::new(block_diff.into());
 
     let balance_info: cw20::BalanceResponse = deps.querier.query_wasm_smart(
@@ -165,7 +156,7 @@ fn get_distribution_msg(deps: Deps, env: &Env) -> Result<CosmosMsg, ContractErro
     let amount = min(balance_info.balance, pending_rewards);
 
     if amount == Uint128::zero() {
-        return Err(ContractError::ZeroRewards {});
+        return Ok(None);
     }
 
     let msg = to_binary(&cw20::Cw20ExecuteMsg::Send {
@@ -180,14 +171,14 @@ fn get_distribution_msg(deps: Deps, env: &Env) -> Result<CosmosMsg, ContractErro
     }
     .into();
 
-    Ok(send_msg)
+    Ok(Some(send_msg))
 }
 
 pub fn execute_distribute(deps: DepsMut, env: Env) -> Result<Response, ContractError> {
     let msg = get_distribution_msg(deps.as_ref(), &env)?;
     LAST_PAYMENT_BLOCK.save(deps.storage, &env.block.height)?;
     Ok(Response::new()
-        .add_message(msg)
+        .add_messages(msg)
         .add_attribute("action", "distribute"))
 }
 
diff --git a/contracts/stake-cw20-reward-distributor/src/error.rs b/contracts/stake-cw20-reward-distributor/src/error.rs
index 6c0bb37e..44fdc288 100644
--- a/contracts/stake-cw20-reward-distributor/src/error.rs
+++ b/contracts/stake-cw20-reward-distributor/src/error.rs
@@ -14,10 +14,4 @@ pub enum ContractError {
 
     #[error("Invalid Staking Contract")]
     InvalidStakingContract {},
-
-    #[error("Zero eligible rewards")]
-    ZeroRewards {},
-
-    #[error("Rewards have already been distributed for this block")]
-    RewardsDistributedForBlock {},
 }


Test patch:
diff --git a/contracts/cw-proposal-single/src/staking_tests.rs b/contracts/cw-proposal-single/src/staking_tests.rs
index 2c0d59d7..393f661f 100644
--- a/contracts/cw-proposal-single/src/staking_tests.rs
+++ b/contracts/cw-proposal-single/src/staking_tests.rs
@@ -109,6 +109,7 @@ fn instantiate_with_staked_balances_voting() {
                     quorum: voting::PercentageThreshold::Percent(Decimal::percent(30)),
                 },
                 max_voting_period: Duration::Height(10u64),
+                min_voting_period: None,
                 only_members_execute: true,
                 allow_revoting: false,
                 deposit_info: None,
diff --git a/contracts/cw-proposal-single/src/tests.rs b/contracts/cw-proposal-single/src/tests.rs
index e7eb702f..fad6ea59 100644
--- a/contracts/cw-proposal-single/src/tests.rs
+++ b/contracts/cw-proposal-single/src/tests.rs
@@ -550,6 +550,7 @@ where
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info,
@@ -705,6 +706,7 @@ fn test_propose() {
     let instantiate = InstantiateMsg {
         threshold: threshold.clone(),
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -734,6 +736,7 @@ fn test_propose() {
     let expected = Config {
         threshold: threshold.clone(),
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         dao: governance_addr,
@@ -765,6 +768,7 @@ fn test_propose() {
         proposer: Addr::unchecked(CREATOR_ADDR),
         start_height: current_block.height,
         expiration: max_voting_period.after(&current_block),
+        min_voting_period: None,
         threshold,
         allow_revoting: false,
         total_power: Uint128::new(100_000_000),
@@ -790,6 +794,7 @@ fn test_propose_supports_stargate_message() {
     let instantiate = InstantiateMsg {
         threshold: threshold.clone(),
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -838,6 +843,7 @@ fn test_propose_supports_stargate_message() {
         proposer: Addr::unchecked(CREATOR_ADDR),
         start_height: current_block.height,
         expiration: max_voting_period.after(&current_block),
+        min_voting_period: None,
         threshold,
         allow_revoting: false,
         total_power: Uint128::new(100_000_000),
@@ -958,6 +964,7 @@ fn test_voting_module_token_proposal_deposit_instantiate() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: Some(DepositInfo {
@@ -1034,6 +1041,7 @@ fn test_different_token_proposal_deposit() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: Some(DepositInfo {
@@ -1090,6 +1098,7 @@ fn test_bad_token_proposal_deposit() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: Some(DepositInfo {
@@ -1116,6 +1125,7 @@ fn test_take_proposal_deposit() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: Some(DepositInfo {
@@ -1465,6 +1475,7 @@ fn test_execute_expired_proposal() {
                 quorum: PercentageThreshold::Percent(Decimal::percent(10)),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: false,
             deposit_info: None,
@@ -1625,6 +1636,7 @@ fn test_update_config() {
                 percentage: PercentageThreshold::Majority {},
             },
             max_voting_period: cw_utils::Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: false,
             allow_revoting: false,
             dao: CREATOR_ADDR.to_string(),
@@ -1643,6 +1655,7 @@ fn test_update_config() {
                 percentage: PercentageThreshold::Majority {},
             },
             max_voting_period: cw_utils::Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: false,
             allow_revoting: false,
             dao: CREATOR_ADDR.to_string(),
@@ -1662,6 +1675,7 @@ fn test_update_config() {
             percentage: PercentageThreshold::Majority {},
         },
         max_voting_period: cw_utils::Duration::Height(10),
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         dao: Addr::unchecked(CREATOR_ADDR),
@@ -1679,6 +1693,7 @@ fn test_update_config() {
                 percentage: PercentageThreshold::Majority {},
             },
             max_voting_period: cw_utils::Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: false,
             allow_revoting: false,
             dao: CREATOR_ADDR.to_string(),
@@ -1761,6 +1776,7 @@ fn test_query_list_proposals() {
                 quorum: PercentageThreshold::Percent(Decimal::percent(0)),
             },
             max_voting_period: cw_utils::Duration::Height(100),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: false,
             deposit_info: None,
@@ -1832,6 +1848,7 @@ fn test_query_list_proposals() {
             proposer: Addr::unchecked(CREATOR_ADDR),
             start_height: app.block_info().height,
             expiration: cw_utils::Expiration::AtHeight(app.block_info().height + 100),
+            min_voting_period: None,
             threshold: Threshold::ThresholdQuorum {
                 threshold: PercentageThreshold::Majority {},
                 quorum: PercentageThreshold::Percent(Decimal::percent(0)),
@@ -1876,6 +1893,7 @@ fn test_query_list_proposals() {
             proposer: Addr::unchecked(CREATOR_ADDR),
             start_height: app.block_info().height,
             expiration: cw_utils::Expiration::AtHeight(app.block_info().height + 100),
+            min_voting_period: None,
             threshold: Threshold::ThresholdQuorum {
                 threshold: PercentageThreshold::Majority {},
                 quorum: PercentageThreshold::Percent(Decimal::percent(0)),
@@ -1907,6 +1925,7 @@ fn test_hooks() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -2080,6 +2099,7 @@ fn test_active_threshold_absolute() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -2205,6 +2225,7 @@ fn test_active_threshold_percent() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -2331,6 +2352,7 @@ fn test_active_threshold_none() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -2409,6 +2431,7 @@ fn test_active_threshold_none() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -2459,6 +2482,7 @@ fn test_revoting() {
                 quorum: PercentageThreshold::Percent(Decimal::percent(10)),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: true,
             deposit_info: None,
@@ -2584,6 +2608,7 @@ fn test_allow_revoting_config_changes() {
                 quorum: PercentageThreshold::Percent(Decimal::percent(10)),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: true,
             deposit_info: None,
@@ -2629,6 +2654,7 @@ fn test_allow_revoting_config_changes() {
                 quorum: PercentageThreshold::Percent(Decimal::percent(10)),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: false,
             deposit_info: None,
@@ -2725,6 +2751,7 @@ fn test_revoting_same_vote_twice() {
                 quorum: PercentageThreshold::Percent(Decimal::percent(10)),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: true,
             deposit_info: None,
@@ -2826,6 +2853,7 @@ fn test_three_of_five_multisig() {
                 threshold: Uint128::new(3),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: false,
             deposit_info: None,
@@ -2951,6 +2979,7 @@ fn test_three_of_five_multisig_reject() {
                 threshold: Uint128::new(3),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: false,
             deposit_info: None,
@@ -3082,6 +3111,7 @@ fn test_voting_module_token_with_multisig_style_voting() {
                 threshold: Uint128::new(3),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: false,
             deposit_info: Some(DepositInfo {
@@ -3120,6 +3150,7 @@ fn test_three_of_five_multisig_revoting() {
                 threshold: Uint128::new(3),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: true,
             deposit_info: None,
@@ -3352,6 +3383,7 @@ fn test_migrate() {
     let instantiate = InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,
@@ -3409,6 +3441,7 @@ fn test_proposal_count_initialized_to_zero() {
                 quorum: PercentageThreshold::Percent(Decimal::percent(10)),
             },
             max_voting_period: Duration::Height(10),
+            min_voting_period: None,
             only_members_execute: true,
             allow_revoting: false,
             deposit_info: None,
@@ -3440,3 +3473,251 @@ fn test_proposal_count_initialized_to_zero() {
         .unwrap();
     assert_eq!(proposal_count, 0);
 }
+
+#[test]
+fn test_no_early_pass_with_min_duration() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(single_proposal_contract());
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        InstantiateMsg {
+            threshold: Threshold::ThresholdQuorum {
+                threshold: PercentageThreshold::Majority {},
+                quorum: PercentageThreshold::Percent(Decimal::percent(10)),
+            },
+            max_voting_period: Duration::Height(10),
+            min_voting_period: Some(Duration::Height(2)),
+            only_members_execute: true,
+            allow_revoting: false,
+            deposit_info: None,
+        },
+        Some(vec![
+            Cw20Coin {
+                address: "ekez".to_string(),
+                amount: Uint128::new(10),
+            },
+            Cw20Coin {
+                address: "wale".to_string(),
+                amount: Uint128::new(90),
+            },
+        ]),
+    );
+
+    let gov_state: cw_core::query::DumpStateResponse = app
+        .wrap()
+        .query_wasm_smart(core_addr, &cw_core::msg::QueryMsg::DumpState {})
+        .unwrap();
+    let proposal_modules = gov_state.proposal_modules;
+
+    assert_eq!(proposal_modules.len(), 1);
+    let proposal_single = proposal_modules.into_iter().next().unwrap();
+
+    app.execute_contract(
+        Addr::unchecked("wale"),
+        proposal_single.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "This is a simple text proposal".to_string(),
+            msgs: vec![],
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Wale votes yes which under normal curcumstances would cause the
+    // proposal to pass. Because there is a min duration it does not.
+    app.execute_contract(
+        Addr::unchecked("wale"),
+        proposal_single.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: Vote::Yes,
+        },
+        &[],
+    )
+    .unwrap();
+
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            proposal_single.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+
+    assert_eq!(proposal.proposal.status, Status::Open);
+
+    // Let the min voting period pass.
+    app.update_block(|b| b.height += 2);
+
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(proposal_single, &QueryMsg::Proposal { proposal_id: 1 })
+        .unwrap();
+
+    assert_eq!(proposal.proposal.status, Status::Passed);
+}
+
+#[test]
+#[should_panic(
+    expected = "min_voting_period and max_voting_period must have the same units (height or time)"
+)]
+fn test_min_duration_units_missmatch() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(single_proposal_contract());
+    instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        InstantiateMsg {
+            threshold: Threshold::ThresholdQuorum {
+                threshold: PercentageThreshold::Majority {},
+                quorum: PercentageThreshold::Percent(Decimal::percent(10)),
+            },
+            max_voting_period: Duration::Height(10),
+            min_voting_period: Some(Duration::Time(2)),
+            only_members_execute: true,
+            allow_revoting: false,
+            deposit_info: None,
+        },
+        Some(vec![
+            Cw20Coin {
+                address: "ekez".to_string(),
+                amount: Uint128::new(10),
+            },
+            Cw20Coin {
+                address: "wale".to_string(),
+                amount: Uint128::new(90),
+            },
+        ]),
+    );
+}
+
+#[test]
+#[should_panic(expected = "Min voting period must be less than or equal to max voting period")]
+fn test_min_duration_larger_than_proposal_duration() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(single_proposal_contract());
+    instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        InstantiateMsg {
+            threshold: Threshold::ThresholdQuorum {
+                threshold: PercentageThreshold::Majority {},
+                quorum: PercentageThreshold::Percent(Decimal::percent(10)),
+            },
+            max_voting_period: Duration::Height(10),
+            min_voting_period: Some(Duration::Height(11)),
+            only_members_execute: true,
+            allow_revoting: false,
+            deposit_info: None,
+        },
+        Some(vec![
+            Cw20Coin {
+                address: "ekez".to_string(),
+                amount: Uint128::new(10),
+            },
+            Cw20Coin {
+                address: "wale".to_string(),
+                amount: Uint128::new(90),
+            },
+        ]),
+    );
+}
+
+#[test]
+fn test_min_duration_same_as_proposal_duration() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(single_proposal_contract());
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        InstantiateMsg {
+            threshold: Threshold::ThresholdQuorum {
+                threshold: PercentageThreshold::Majority {},
+                quorum: PercentageThreshold::Percent(Decimal::percent(10)),
+            },
+            max_voting_period: Duration::Time(10),
+            min_voting_period: Some(Duration::Time(10)),
+            only_members_execute: true,
+            allow_revoting: false,
+            deposit_info: None,
+        },
+        Some(vec![
+            Cw20Coin {
+                address: "ekez".to_string(),
+                amount: Uint128::new(10),
+            },
+            Cw20Coin {
+                address: "wale".to_string(),
+                amount: Uint128::new(90),
+            },
+        ]),
+    );
+
+    let gov_state: cw_core::query::DumpStateResponse = app
+        .wrap()
+        .query_wasm_smart(core_addr, &cw_core::msg::QueryMsg::DumpState {})
+        .unwrap();
+    let proposal_modules = gov_state.proposal_modules;
+
+    assert_eq!(proposal_modules.len(), 1);
+    let proposal_single = proposal_modules.into_iter().next().unwrap();
+
+    app.execute_contract(
+        Addr::unchecked("wale"),
+        proposal_single.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "This is a simple text proposal".to_string(),
+            msgs: vec![],
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Wale votes yes which under normal curcumstances would cause the
+    // proposal to pass. Because there is a min duration it does not.
+    app.execute_contract(
+        Addr::unchecked("wale"),
+        proposal_single.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: Vote::Yes,
+        },
+        &[],
+    )
+    .unwrap();
+
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            proposal_single.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+
+    assert_eq!(proposal.proposal.status, Status::Open);
+
+    // ekez can vote no.
+    app.execute_contract(
+        Addr::unchecked("ekez"),
+        proposal_single.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: Vote::No,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Let the min voting period pass.
+    app.update_block(|b| b.time = b.time.plus_seconds(10));
+
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(proposal_single, &QueryMsg::Proposal { proposal_id: 1 })
+        .unwrap();
+
+    assert_eq!(proposal.proposal.status, Status::Passed);
+}
diff --git a/contracts/stake-cw20-reward-distributor/src/tests.rs b/contracts/stake-cw20-reward-distributor/src/tests.rs
index bd1c055b..55ab6bfd 100644
--- a/contracts/stake-cw20-reward-distributor/src/tests.rs
+++ b/contracts/stake-cw20-reward-distributor/src/tests.rs
@@ -268,43 +268,24 @@ fn test_distribute() {
     let distributor_info = get_info(&app, distributor_addr.clone());
     assert_eq!(distributor_info.balance, Uint128::new(0));
     assert_eq!(distributor_info.last_payment_block, app.block_info().height);
-    let last_payment_block = distributor_info.last_payment_block;
 
     // Pays out nothing
     app.update_block(|mut block| block.height += 1100);
-    let err: ContractError = app
+    let _err = app
         .execute_contract(
             Addr::unchecked(OWNER),
             distributor_addr.clone(),
             &ExecuteMsg::Distribute {},
             &[],
         )
-        .unwrap_err()
-        .downcast()
         .unwrap();
 
-    assert!(matches!(err, ContractError::ZeroRewards {}));
-
     let staking_balance = get_balance_cw20(&app, cw20_addr, staking_addr);
     assert_eq!(staking_balance, Uint128::new(1000));
 
-    let distributor_info = get_info(&app, distributor_addr.clone());
+    let distributor_info = get_info(&app, distributor_addr);
     assert_eq!(distributor_info.balance, Uint128::new(0));
-    assert_eq!(distributor_info.last_payment_block, last_payment_block);
-
-    // go to a block before the last payment
-    app.update_block(|mut block| block.height -= 2000);
-    let err: ContractError = app
-        .execute_contract(
-            Addr::unchecked(OWNER),
-            distributor_addr,
-            &ExecuteMsg::Distribute {},
-            &[],
-        )
-        .unwrap_err()
-        .downcast()
-        .unwrap();
-    assert!(matches!(err, ContractError::RewardsDistributedForBlock {}));
+    assert_eq!(distributor_info.last_payment_block, app.block_info().height);
 }
 
 #[test]
diff --git a/debug/proposal-hooks-counter/src/tests.rs b/debug/proposal-hooks-counter/src/tests.rs
index 1c75ffab..29d240b8 100644
--- a/debug/proposal-hooks-counter/src/tests.rs
+++ b/debug/proposal-hooks-counter/src/tests.rs
@@ -136,6 +136,7 @@ fn test_counters() {
     let instantiate = cw_proposal_single::msg::InstantiateMsg {
         threshold,
         max_voting_period,
+        min_voting_period: None,
         only_members_execute: false,
         allow_revoting: false,
         deposit_info: None,