Repository: DA0-DA0/dao-contracts (rust)

Issue:
Add 'allow revoting' to multiple choice proposals

Pull request description:
Revoting multiple
Closes #367 

Follows the style of revoting implemented on proposal-single

Patch:
diff --git a/contracts/cw-proposal-multiple/schema/config_response.json b/contracts/cw-proposal-multiple/schema/config_response.json
index 0a0d59d1..4c87d9d7 100644
--- a/contracts/cw-proposal-multiple/schema/config_response.json
+++ b/contracts/cw-proposal-multiple/schema/config_response.json
@@ -4,6 +4,7 @@
   "description": "The governance module's configuration.",
   "type": "object",
   "required": [
+    "allow_revoting",
     "close_proposal_on_execution_failure",
     "dao",
     "max_voting_period",
@@ -11,6 +12,10 @@
     "voting_strategy"
   ],
   "properties": {
+    "allow_revoting": {
+      "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.",
+      "type": "boolean"
+    },
     "close_proposal_on_execution_failure": {
       "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.",
       "type": "boolean"
diff --git a/contracts/cw-proposal-multiple/schema/execute_msg.json b/contracts/cw-proposal-multiple/schema/execute_msg.json
index 6eda1c0b..03326979 100644
--- a/contracts/cw-proposal-multiple/schema/execute_msg.json
+++ b/contracts/cw-proposal-multiple/schema/execute_msg.json
@@ -129,6 +129,7 @@
         "update_config": {
           "type": "object",
           "required": [
+            "allow_revoting",
             "close_proposal_on_execution_failure",
             "dao",
             "max_voting_period",
@@ -136,6 +137,10 @@
             "voting_strategy"
           ],
           "properties": {
+            "allow_revoting": {
+              "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.",
+              "type": "boolean"
+            },
             "close_proposal_on_execution_failure": {
               "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.",
               "type": "boolean"
diff --git a/contracts/cw-proposal-multiple/schema/instantiate_msg.json b/contracts/cw-proposal-multiple/schema/instantiate_msg.json
index 4dfa2967..1529297f 100644
--- a/contracts/cw-proposal-multiple/schema/instantiate_msg.json
+++ b/contracts/cw-proposal-multiple/schema/instantiate_msg.json
@@ -3,12 +3,17 @@
   "title": "InstantiateMsg",
   "type": "object",
   "required": [
+    "allow_revoting",
     "close_proposal_on_execution_failure",
     "max_voting_period",
     "only_members_execute",
     "voting_strategy"
   ],
   "properties": {
+    "allow_revoting": {
+      "description": "Allows changing votes before the proposal expires. If this is enabled proposals will not be able to complete early as final vote information is not known until the time of proposal expiration.",
+      "type": "boolean"
+    },
     "close_proposal_on_execution_failure": {
       "description": "If set to true proposals will be closed if their execution fails. Otherwise, proposals will remain open after execution failure. For example, with this enabled a proposal to send 5 tokens out of a DAO's treasury with 4 tokens would be closed when it is executed. With this disabled, that same proposal would remain open until the DAO's treasury was large enough for it to be executed.",
       "type": "boolean"
diff --git a/contracts/cw-proposal-multiple/schema/list_proposals_response.json b/contracts/cw-proposal-multiple/schema/list_proposals_response.json
index 32a63fef..b31f468d 100644
--- a/contracts/cw-proposal-multiple/schema/list_proposals_response.json
+++ b/contracts/cw-proposal-multiple/schema/list_proposals_response.json
@@ -569,6 +569,7 @@
     "MultipleChoiceProposal": {
       "type": "object",
       "required": [
+        "allow_revoting",
         "choices",
         "description",
         "expiration",
@@ -581,6 +582,9 @@
         "voting_strategy"
       ],
       "properties": {
+        "allow_revoting": {
+          "type": "boolean"
+        },
         "choices": {
           "type": "array",
           "items": {
diff --git a/contracts/cw-proposal-multiple/schema/proposal_response.json b/contracts/cw-proposal-multiple/schema/proposal_response.json
index d12b51df..5fab5168 100644
--- a/contracts/cw-proposal-multiple/schema/proposal_response.json
+++ b/contracts/cw-proposal-multiple/schema/proposal_response.json
@@ -573,6 +573,7 @@
     "MultipleChoiceProposal": {
       "type": "object",
       "required": [
+        "allow_revoting",
         "choices",
         "description",
         "expiration",
@@ -585,6 +586,9 @@
         "voting_strategy"
       ],
       "properties": {
+        "allow_revoting": {
+          "type": "boolean"
+        },
         "choices": {
           "type": "array",
           "items": {
diff --git a/contracts/cw-proposal-multiple/schema/reverse_proposals_response.json b/contracts/cw-proposal-multiple/schema/reverse_proposals_response.json
index 82dedbb6..75e019a9 100644
--- a/contracts/cw-proposal-multiple/schema/reverse_proposals_response.json
+++ b/contracts/cw-proposal-multiple/schema/reverse_proposals_response.json
@@ -569,6 +569,7 @@
     "MultipleChoiceProposal": {
       "type": "object",
       "required": [
+        "allow_revoting",
         "choices",
         "description",
         "expiration",
@@ -581,6 +582,9 @@
         "voting_strategy"
       ],
       "properties": {
+        "allow_revoting": {
+          "type": "boolean"
+        },
         "choices": {
           "type": "array",
           "items": {
diff --git a/contracts/cw-proposal-multiple/src/contract.rs b/contracts/cw-proposal-multiple/src/contract.rs
index 45e26539..090292cc 100644
--- a/contracts/cw-proposal-multiple/src/contract.rs
+++ b/contracts/cw-proposal-multiple/src/contract.rs
@@ -61,6 +61,7 @@ pub fn instantiate(
         min_voting_period,
         max_voting_period,
         only_members_execute: msg.only_members_execute,
+        allow_revoting: msg.allow_revoting,
         dao,
         deposit_info,
         close_proposal_on_execution_failure: msg.close_proposal_on_execution_failure,
@@ -96,6 +97,7 @@ pub fn execute(
             min_voting_period,
             max_voting_period,
             only_members_execute,
+            allow_revoting,
             dao,
             deposit_info,
             close_proposal_on_execution_failure,
@@ -106,6 +108,7 @@ pub fn execute(
             min_voting_period,
             max_voting_period,
             only_members_execute,
+            allow_revoting,
             dao,
             deposit_info,
             close_proposal_on_execution_failure,
@@ -176,6 +179,7 @@ pub fn execute_propose(
             total_power,
             status: Status::Open,
             votes: MultipleChoiceVotes::zero(checked_multiple_choice_options.len()),
+            allow_revoting: config.allow_revoting,
             deposit_info: config.deposit_info.clone(),
             choices: checked_multiple_choice_options,
         };
@@ -256,7 +260,26 @@ pub fn execute_vote(
         deps.storage,
         (proposal_id, info.sender.clone()),
         |bal| match bal {
-            Some(_) => Err(ContractError::AlreadyVoted {}),
+            Some(current_ballot) => {
+                if prop.allow_revoting {
+                    if current_ballot.vote == vote {
+                        // Don't allow casting the same vote more than
+                        // once. This seems liable to be confusing
+                        // behavior.
+                        Err(ContractError::AlreadyCast {})
+                    } else {
+                        // Remove the old vote if this is a re-vote.
+                        prop.votes
+                            .remove_vote(current_ballot.vote, current_ballot.power)?;
+                        Ok(Ballot {
+                            power: vote_power,
+                            vote,
+                        })
+                    }
+                } else {
+                    Err(ContractError::AlreadyVoted {})
+                }
+            }
             None => Ok(Ballot {
                 vote,
                 power: vote_power,
@@ -265,7 +288,8 @@ pub fn execute_vote(
     )?;
 
     let old_status = prop.status;
-    prop.votes.add_vote(vote, vote_power);
+
+    prop.votes.add_vote(vote, vote_power)?;
     prop.update_status(&env.block)?;
     PROPOSALS.save(deps.storage, proposal_id, &prop)?;
     let new_status = prop.status;
@@ -440,6 +464,7 @@ pub fn execute_update_config(
     min_voting_period: Option<Duration>,
     max_voting_period: Duration,
     only_members_execute: bool,
+    allow_revoting: bool,
     dao: String,
     deposit_info: Option<DepositInfo>,
     close_proposal_on_execution_failure: bool,
@@ -468,6 +493,7 @@ pub fn execute_update_config(
             min_voting_period,
             max_voting_period,
             only_members_execute,
+            allow_revoting,
             dao,
             deposit_info,
             close_proposal_on_execution_failure,
diff --git a/contracts/cw-proposal-multiple/src/error.rs b/contracts/cw-proposal-multiple/src/error.rs
index bb5f7516..1adcde14 100644
--- a/contracts/cw-proposal-multiple/src/error.rs
+++ b/contracts/cw-proposal-multiple/src/error.rs
@@ -40,9 +40,12 @@ pub enum ContractError {
     #[error("Not registered to vote (no voting power) at time of proposal creation.")]
     NotRegistered {},
 
-    #[error("Already voted")]
+    #[error("Already voted. This proposal does not support revoting.")]
     AlreadyVoted {},
 
+    #[error("Already cast a vote with that option. Change your vote to revote.")]
+    AlreadyCast {},
+
     #[error("Proposal must be in 'passed' state to be executed.")]
     NotPassed {},
 
diff --git a/contracts/cw-proposal-multiple/src/msg.rs b/contracts/cw-proposal-multiple/src/msg.rs
index d08f4f27..57f91d61 100644
--- a/contracts/cw-proposal-multiple/src/msg.rs
+++ b/contracts/cw-proposal-multiple/src/msg.rs
@@ -22,6 +22,11 @@ pub struct InstantiateMsg {
     /// proposals. Otherwise, any address may execute a passed
     /// proposal.
     pub only_members_execute: bool,
+    /// Allows changing votes before the proposal expires. If this is
+    /// enabled proposals will not be able to complete early as final
+    /// vote information is not known until the time of proposal
+    /// expiration.
+    pub allow_revoting: bool,
     /// Information about the deposit required to create a
     /// proposal. None if there is no deposit requirement, Some
     /// otherwise.
@@ -88,6 +93,11 @@ pub enum ExecuteMsg {
         /// proposals. Otherwise, any address may execute a passed
         /// proposal. Applies to all outstanding and future proposals.
         only_members_execute: bool,
+        /// Allows changing votes before the proposal expires. If this is
+        /// enabled proposals will not be able to complete early as final
+        /// vote information is not known until the time of proposal
+        /// expiration.
+        allow_revoting: bool,
         /// The address if tge DAO that this governance module is
         /// associated with.
         dao: String,
diff --git a/contracts/cw-proposal-multiple/src/proposal.rs b/contracts/cw-proposal-multiple/src/proposal.rs
index d387fa12..5262705f 100644
--- a/contracts/cw-proposal-multiple/src/proposal.rs
+++ b/contracts/cw-proposal-multiple/src/proposal.rs
@@ -32,6 +32,7 @@ pub struct MultipleChoiceProposal {
     /// The total power when the proposal started (used to calculate percentages)
     pub total_power: Uint128,
     pub votes: MultipleChoiceVotes,
+    pub allow_revoting: bool,
     /// Information about the deposit that was sent as part of this
     /// proposal. None if no deposit.
     pub deposit_info: Option<CheckedDepositInfo>,
@@ -93,6 +94,11 @@ impl MultipleChoiceProposal {
     /// one of the options that is not "None of the above"
     /// has won the most votes, and there is no tie.
     pub fn is_passed(&self, block: &BlockInfo) -> StdResult<bool> {
+        // If re-voting is allowed nothing is known until the proposal
+        // has expired.
+        if self.allow_revoting && !self.expiration.is_expired(block) {
+            return Ok(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
@@ -133,6 +139,12 @@ impl MultipleChoiceProposal {
     }
 
     pub fn is_rejected(&self, block: &BlockInfo) -> StdResult<bool> {
+        // If re-voting is allowed and the proposal is not expired no
+        // information is known.
+        if self.allow_revoting && !self.expiration.is_expired(block) {
+            return Ok(false);
+        }
+
         let vote_result = self.calculate_vote_result()?;
         match vote_result {
             // Proposal is rejected if there is a tie, and either the proposal is expired or
@@ -262,6 +274,7 @@ mod tests {
         votes: MultipleChoiceVotes,
         total_power: Uint128,
         is_expired: bool,
+        allow_revoting: bool,
     ) -> MultipleChoiceProposal {
         // The last option that gets added in into_checked is always the none of the above option
         let options = vec![
@@ -294,6 +307,7 @@ mod tests {
             voting_strategy,
             total_power,
             votes,
+            allow_revoting,
             deposit_info: None,
             min_voting_period: None,
         }
@@ -316,6 +330,7 @@ mod tests {
             votes,
             Uint128::new(1),
             false,
+            false,
         );
 
         // Quorum was met and all votes were cast, should be passed.
@@ -331,6 +346,7 @@ mod tests {
             votes,
             Uint128::new(1),
             false,
+            false,
         );
 
         // Quorum was met but none of the above won, should be rejected.
@@ -346,6 +362,7 @@ mod tests {
             votes,
             Uint128::new(100),
             false,
+            false,
         );
 
         // Quorum was not met and is not expired, should be open.
@@ -361,6 +378,7 @@ mod tests {
             votes,
             Uint128::new(100),
             true,
+            false,
         );
 
         // Quorum was not met and it is expired, should be rejected.
@@ -376,6 +394,7 @@ mod tests {
             votes,
             Uint128::new(100),
             true,
+            false,
         );
 
         // Quorum was met but it is a tie and expired, should be rejected.
@@ -385,7 +404,14 @@ mod tests {
         let votes = MultipleChoiceVotes {
             vote_weights: vec![Uint128::new(50), Uint128::new(50), Uint128::new(0)],
         };
-        let prop = create_proposal(&env.block, voting_strategy, votes, Uint128::new(150), false);
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(150),
+            false,
+            false,
+        );
 
         // Quorum was met but it is a tie but not expired and still voting power remains, should be open.
         assert!(!prop.is_passed(&env.block).unwrap());
@@ -411,6 +437,7 @@ mod tests {
             votes,
             Uint128::new(1),
             false,
+            false,
         );
 
         // Quorum was met and all votes were cast, should be passed.
@@ -426,6 +453,7 @@ mod tests {
             votes,
             Uint128::new(1),
             false,
+            false,
         );
 
         // Quorum was met but none of the above won, should be rejected.
@@ -441,6 +469,7 @@ mod tests {
             votes,
             Uint128::new(100),
             false,
+            false,
         );
 
         // Quorum was not met and is not expired, should be open.
@@ -456,6 +485,7 @@ mod tests {
             votes,
             Uint128::new(101),
             true,
+            false,
         );
 
         // Quorum was not met and it is expired, should be rejected.
@@ -471,6 +501,7 @@ mod tests {
             votes,
             Uint128::new(10000),
             true,
+            false,
         );
 
         // Quorum was met but it is a tie and expired, should be rejected.
@@ -480,7 +511,14 @@ mod tests {
         let votes = MultipleChoiceVotes {
             vote_weights: vec![Uint128::new(50), Uint128::new(50), Uint128::new(0)],
         };
-        let prop = create_proposal(&env.block, voting_strategy, votes, Uint128::new(150), false);
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(150),
+            false,
+            false,
+        );
 
         // Quorum was met but it is a tie but not expired and still voting power remains, should be open.
         assert!(!prop.is_passed(&env.block).unwrap());
@@ -504,6 +542,7 @@ mod tests {
             votes,
             Uint128::new(1000),
             false,
+            false,
         );
 
         // Quorum was met but none of the above is winning, but it also can't be beat (only a tie at best), should be rejected
@@ -522,7 +561,14 @@ mod tests {
         let votes = MultipleChoiceVotes {
             vote_weights: vec![Uint128::new(10), Uint128::new(0), Uint128::new(0)],
         };
-        let prop = create_proposal(&env.block, voting_strategy, votes, Uint128::new(100), true);
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(100),
+            true,
+            false,
+        );
 
         // Quorum was met and proposal expired, should pass
         assert!(prop.is_passed(&env.block).unwrap());
@@ -544,6 +590,7 @@ mod tests {
             votes,
             Uint128::new(1000000),
             true,
+            false,
         );
 
         // Quorum was not met and expired, should reject
@@ -566,6 +613,7 @@ mod tests {
             votes,
             Uint128::new(10000000),
             true,
+            false,
         );
 
         // Quorum was not met and expired, should reject
@@ -590,13 +638,21 @@ mod tests {
             votes.clone(),
             Uint128::new(13),
             true,
+            false,
         );
 
         // Should pass if expired
         assert!(prop.is_passed(&env.block).unwrap());
         assert!(!prop.is_rejected(&env.block).unwrap());
 
-        let prop = create_proposal(&env.block, voting_strategy, votes, Uint128::new(13), false);
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(13),
+            false,
+            false,
+        );
 
         // Should pass if not expired
         assert!(prop.is_passed(&env.block).unwrap());
@@ -619,16 +675,178 @@ mod tests {
             votes.clone(),
             Uint128::new(13),
             true,
+            false,
         );
 
         // Should pass if majority voted
         assert!(prop.is_passed(&env.block).unwrap());
         assert!(!prop.is_rejected(&env.block).unwrap());
 
-        let prop = create_proposal(&env.block, voting_strategy, votes, Uint128::new(14), true);
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(14),
+            true,
+            false,
+        );
 
         // Shouldn't pass if only half voted
         assert!(!prop.is_passed(&env.block).unwrap());
         assert!(prop.is_rejected(&env.block).unwrap());
     }
+
+    #[test]
+    fn test_majority_revote_pass() {
+        // Revoting being allowed means that proposals may not be
+        // passed or rejected before they expire.
+        let env = mock_env();
+        let voting_strategy = VotingStrategy::SingleChoice {
+            quorum: voting::threshold::PercentageThreshold::Majority {},
+        };
+        let votes = MultipleChoiceVotes {
+            vote_weights: vec![Uint128::new(6), Uint128::new(0), Uint128::new(0)],
+        };
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy.clone(),
+            votes.clone(),
+            Uint128::new(10),
+            false,
+            true,
+        );
+        // Quorum reached, but proposal is still active => no pass
+        assert!(!prop.is_passed(&env.block).unwrap());
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(10),
+            true,
+            true,
+        );
+        // Quorum reached & proposal has expired => pass
+        assert!(prop.is_passed(&env.block).unwrap());
+    }
+
+    #[test]
+    fn test_majority_revote_rejection() {
+        // Revoting being allowed means that proposals may not be
+        // passed or rejected before they expire.
+        let env = mock_env();
+        let voting_strategy = VotingStrategy::SingleChoice {
+            quorum: voting::threshold::PercentageThreshold::Majority {},
+        };
+        let votes = MultipleChoiceVotes {
+            vote_weights: vec![Uint128::new(5), Uint128::new(5), Uint128::new(0)],
+        };
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy.clone(),
+            votes.clone(),
+            Uint128::new(10),
+            false,
+            true,
+        );
+        // Everyone voted and proposal is in a tie...
+        assert_eq!(prop.total_power, prop.votes.total());
+        assert_eq!(prop.votes.vote_weights[0], prop.votes.vote_weights[1]);
+        // ... but proposal is still active => no rejection
+        assert!(!prop.is_rejected(&env.block).unwrap());
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(10),
+            true,
+            true,
+        );
+        // Proposal has expired and ended in a tie => rejection
+        assert_eq!(prop.votes.vote_weights[0], prop.votes.vote_weights[1]);
+        assert!(prop.is_rejected(&env.block).unwrap());
+    }
+
+    #[test]
+    fn test_percentage_revote_pass() {
+        // Revoting being allowed means that proposals may not be
+        // passed or rejected before they expire.
+        let env = mock_env();
+        let voting_strategy = VotingStrategy::SingleChoice {
+            quorum: voting::threshold::PercentageThreshold::Percent(
+                cosmwasm_std::Decimal::percent(80),
+            ),
+        };
+
+        let votes = MultipleChoiceVotes {
+            vote_weights: vec![Uint128::new(81), Uint128::new(0), Uint128::new(0)],
+        };
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy.clone(),
+            votes.clone(),
+            Uint128::new(100),
+            false,
+            true,
+        );
+        // Quorum reached, but proposal is still active => no pass
+        assert!(!prop.is_passed(&env.block).unwrap());
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(100),
+            true,
+            true,
+        );
+        // Quorum reached & proposal has expired => pass
+        assert!(prop.is_passed(&env.block).unwrap());
+    }
+
+    #[test]
+    fn test_percentage_revote_rejection() {
+        // Revoting being allowed means that proposals may not be
+        // passed or rejected before they expire.
+        let env = mock_env();
+        let voting_strategy = VotingStrategy::SingleChoice {
+            quorum: voting::threshold::PercentageThreshold::Percent(
+                cosmwasm_std::Decimal::percent(80),
+            ),
+        };
+
+        let votes = MultipleChoiceVotes {
+            vote_weights: vec![Uint128::new(90), Uint128::new(0), Uint128::new(0)],
+        };
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy.clone(),
+            votes,
+            Uint128::new(100),
+            false,
+            true,
+        );
+        // Quorum reached, but proposal is still active => no rejection
+        assert!(!prop.is_rejected(&env.block).unwrap());
+
+        let votes = MultipleChoiceVotes {
+            vote_weights: vec![Uint128::new(50), Uint128::new(0), Uint128::new(0)],
+        };
+
+        let prop = create_proposal(
+            &env.block,
+            voting_strategy,
+            votes,
+            Uint128::new(100),
+            true,
+            true,
+        );
+        // No quorum reached & proposal has expired => rejection
+        assert!(prop.is_rejected(&env.block).unwrap());
+    }
 }
diff --git a/contracts/cw-proposal-multiple/src/state.rs b/contracts/cw-proposal-multiple/src/state.rs
index 6eebf9df..2e685e87 100644
--- a/contracts/cw-proposal-multiple/src/state.rs
+++ b/contracts/cw-proposal-multiple/src/state.rs
@@ -28,6 +28,11 @@ pub struct Config {
     /// proposals. Otherwise, any address may execute a passed
     /// proposal.
     pub only_members_execute: bool,
+    /// Allows changing votes before the proposal expires. If this is
+    /// enabled proposals will not be able to complete early as final
+    /// vote information is not known until the time of proposal
+    /// expiration.
+    pub allow_revoting: bool,
     /// The address of the DAO that this governance module is
     /// associated with.
     pub dao: Addr,
diff --git a/contracts/cw-proposal-single/src/contract.rs b/contracts/cw-proposal-single/src/contract.rs
index 5f473f7e..5dd3b01f 100644
--- a/contracts/cw-proposal-single/src/contract.rs
+++ b/contracts/cw-proposal-single/src/contract.rs
@@ -328,7 +328,6 @@ pub fn execute_vote(
         return Err(ContractError::NotRegistered {});
     }
 
-    let mut previous_ballot = None;
     BALLOTS.update(
         deps.storage,
         (proposal_id, info.sender.clone()),
@@ -341,7 +340,9 @@ pub fn execute_vote(
                         // behavior.
                         Err(ContractError::AlreadyCast {})
                     } else {
-                        previous_ballot = Some(current_ballot);
+                        // Remove the old vote if this is a re-vote.
+                        prop.votes
+                            .remove_vote(current_ballot.vote, current_ballot.power);
                         Ok(Ballot {
                             power: vote_power,
                             vote,
@@ -360,11 +361,6 @@ pub fn execute_vote(
 
     let old_status = prop.status;
 
-    // Remove the old vote if this is a re-vote.
-    if let Some(ballot) = previous_ballot {
-        prop.votes.remove_vote(ballot.vote, ballot.power)
-    }
-
     prop.votes.add_vote(vote, vote_power);
     prop.update_status(&env.block);
 
diff --git a/packages/voting/src/voting.rs b/packages/voting/src/voting.rs
index 23a4d126..3ae520d2 100644
--- a/packages/voting/src/voting.rs
+++ b/packages/voting/src/voting.rs
@@ -1,4 +1,4 @@
-use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128, Uint256};
+use cosmwasm_std::{Addr, Decimal, Deps, StdError, StdResult, Uint128, Uint256};
 use cw_core_interface::voting;
 use cw_utils::Duration;
 use schemars::JsonSchema;
@@ -55,8 +55,18 @@ impl MultipleChoiceVotes {
         self.vote_weights.iter().sum()
     }
 
-    pub fn add_vote(&mut self, vote: MultipleChoiceVote, weight: Uint128) {
-        self.vote_weights[vote.option_id as usize] += weight;
+    pub fn add_vote(&mut self, vote: MultipleChoiceVote, weight: Uint128) -> StdResult<()> {
+        self.vote_weights[vote.option_id as usize] = self.vote_weights[vote.option_id as usize]
+            .checked_add(weight)
+            .map_err(StdError::overflow)?;
+        Ok(())
+    }
+
+    pub fn remove_vote(&mut self, vote: MultipleChoiceVote, weight: Uint128) -> StdResult<()> {
+        self.vote_weights[vote.option_id as usize] = self.vote_weights[vote.option_id as usize]
+            .checked_sub(weight)
+            .map_err(StdError::overflow)?;
+        Ok(())
     }
 
     pub fn zero(num_choices: usize) -> Self {
diff --git a/types/contracts/cw-proposal-multiple/CwProposalMultipleContract.ts b/types/contracts/cw-proposal-multiple/CwProposalMultipleContract.ts
index 416a0b61..c79cabfd 100644
--- a/types/contracts/cw-proposal-multiple/CwProposalMultipleContract.ts
+++ b/types/contracts/cw-proposal-multiple/CwProposalMultipleContract.ts
@@ -28,6 +28,7 @@ export type PercentageThreshold = {
 };
 export type Decimal = string;
 export interface ConfigResponse {
+  allow_revoting: boolean;
   close_proposal_on_execution_failure: boolean;
   dao: Addr;
   deposit_info?: CheckedDepositInfo | null;
@@ -68,6 +69,7 @@ export type ExecuteMsg = {
   };
 } | {
   update_config: {
+    allow_revoting: boolean;
     close_proposal_on_execution_failure: boolean;
     dao: string;
     deposit_info?: DepositInfo | null;
@@ -297,6 +299,7 @@ export interface ContractVersion {
   [k: string]: unknown;
 }
 export interface InstantiateMsg {
+  allow_revoting: boolean;
   close_proposal_on_execution_failure: boolean;
   deposit_info?: DepositInfo | null;
   max_voting_period: Duration;
@@ -326,6 +329,7 @@ export interface ProposalResponse {
   [k: string]: unknown;
 }
 export interface MultipleChoiceProposal {
+  allow_revoting: boolean;
   choices: CheckedMultipleChoiceOption[];
   deposit_info?: CheckedDepositInfo | null;
   description: string;
@@ -615,6 +619,7 @@ export interface CwProposalMultipleInterface extends CwProposalMultipleReadOnlyI
     proposalId: number;
   }, fee?: number | StdFee | "auto", memo?: string, funds?: readonly Coin[]) => Promise<ExecuteResult>;
   updateConfig: ({
+    allowRevoting,
     closeProposalOnExecutionFailure,
     dao,
     depositInfo,
@@ -623,6 +628,7 @@ export interface CwProposalMultipleInterface extends CwProposalMultipleReadOnlyI
     onlyMembersExecute,
     votingStrategy
   }: {
+    allowRevoting: boolean;
     closeProposalOnExecutionFailure: boolean;
     dao: string;
     depositInfo?: DepositInfo;
@@ -727,6 +733,7 @@ export class CwProposalMultipleClient extends CwProposalMultipleQueryClient impl
     }, fee, memo, funds);
   };
   updateConfig = async ({
+    allowRevoting,
     closeProposalOnExecutionFailure,
     dao,
     depositInfo,
@@ -735,6 +742,7 @@ export class CwProposalMultipleClient extends CwProposalMultipleQueryClient impl
     onlyMembersExecute,
     votingStrategy
   }: {
+    allowRevoting: boolean;
     closeProposalOnExecutionFailure: boolean;
     dao: string;
     depositInfo?: DepositInfo;
@@ -745,6 +753,7 @@ export class CwProposalMultipleClient extends CwProposalMultipleQueryClient impl
   }, fee: number | StdFee | "auto" = "auto", memo?: string, funds?: readonly Coin[]): Promise<ExecuteResult> => {
     return await this.client.execute(this.sender, this.contractAddress, {
       update_config: {
+        allow_revoting: allowRevoting,
         close_proposal_on_execution_failure: closeProposalOnExecutionFailure,
         dao,
         deposit_info: depositInfo,


Test patch:
diff --git a/contracts/cw-proposal-multiple/src/tests.rs b/contracts/cw-proposal-multiple/src/tests.rs
index 27619c85..9e051f78 100644
--- a/contracts/cw-proposal-multiple/src/tests.rs
+++ b/contracts/cw-proposal-multiple/src/tests.rs
@@ -149,6 +149,7 @@ where
         min_voting_period: None,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info,
         voting_strategy,
         close_proposal_on_execution_failure: true,
@@ -765,6 +766,7 @@ fn test_propose() {
     let instantiate = InstantiateMsg {
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy: voting_strategy.clone(),
         min_voting_period: None,
@@ -801,6 +803,7 @@ fn test_propose() {
     let expected = Config {
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         dao: governance_addr,
         deposit_info: None,
         voting_strategy: voting_strategy.clone(),
@@ -855,6 +858,7 @@ fn test_propose() {
         votes: MultipleChoiceVotes {
             vote_weights: vec![Uint128::zero(); 3],
         },
+        allow_revoting: false,
         deposit_info: None,
         min_voting_period: None,
     };
@@ -878,6 +882,7 @@ fn test_propose_wrong_num_choices() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy: voting_strategy.clone(),
     };
@@ -914,6 +919,7 @@ fn test_propose_wrong_num_choices() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         dao: governance_addr,
         deposit_info: None,
         voting_strategy,
@@ -1072,6 +1078,7 @@ fn test_migrate() {
         min_voting_period: None,
         close_proposal_on_execution_failure: true,
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
     };
 
@@ -1130,6 +1137,7 @@ fn test_proposal_count_initialized_to_zero() {
         min_voting_period: None,
         close_proposal_on_execution_failure: true,
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
     };
     let core_addr = instantiate_with_staked_balances_governance(
@@ -1167,6 +1175,7 @@ fn test_no_early_pass_with_min_duration() {
         max_voting_period: Duration::Height(10),
         min_voting_period: Some(Duration::Height(2)),
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
         close_proposal_on_execution_failure: true,
     };
@@ -1264,6 +1273,7 @@ fn test_propose_with_messages() {
         min_voting_period: None,
         close_proposal_on_execution_failure: true,
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
     };
 
@@ -1300,6 +1310,7 @@ fn test_propose_with_messages() {
         close_proposal_on_execution_failure: true,
         max_voting_period: cw_utils::Duration::Height(20),
         only_members_execute: false,
+        allow_revoting: false,
         dao: "dao".to_string(),
         deposit_info: None,
     };
@@ -1384,6 +1395,7 @@ fn test_min_duration_units_missmatch() {
         max_voting_period: Duration::Height(10),
         min_voting_period: Some(Duration::Time(2)),
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
         close_proposal_on_execution_failure: true,
     };
@@ -1416,6 +1428,7 @@ fn test_min_duration_larger_than_proposal_duration() {
         max_voting_period: Duration::Height(10),
         min_voting_period: Some(Duration::Height(11)),
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
         close_proposal_on_execution_failure: true,
     };
@@ -1447,6 +1460,7 @@ fn test_min_duration_same_as_proposal_duration() {
         max_voting_period: Duration::Time(10),
         min_voting_period: Some(Duration::Time(10)),
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
         close_proposal_on_execution_failure: true,
     };
@@ -1565,6 +1579,7 @@ fn test_voting_module_token_proposal_deposit_instantiate() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info,
         voting_strategy,
     };
@@ -1641,6 +1656,7 @@ fn test_different_token_proposal_deposit() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: Some(DepositInfo {
             token: DepositToken::Token {
                 address: cw20_addr.to_string(),
@@ -1710,6 +1726,7 @@ fn test_bad_token_proposal_deposit() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info,
         voting_strategy,
     };
@@ -1741,6 +1758,7 @@ fn test_take_proposal_deposit() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info,
         voting_strategy,
     };
@@ -2165,6 +2183,7 @@ fn test_cant_propose_zero_power() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: Some(DepositInfo {
             token: DepositToken::VotingModuleToken {},
             deposit: Uint128::new(1),
@@ -2331,6 +2350,7 @@ fn test_cant_execute_not_member() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: true,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy,
     };
@@ -2669,6 +2689,7 @@ fn test_execute_expired_proposal() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy,
     };
@@ -2844,6 +2865,7 @@ fn test_update_config() {
             close_proposal_on_execution_failure: true,
             max_voting_period: cw_utils::Duration::Height(10),
             only_members_execute: false,
+            allow_revoting: false,
             dao: dao.to_string(),
             deposit_info: None,
         },
@@ -2863,6 +2885,7 @@ fn test_update_config() {
             close_proposal_on_execution_failure: true,
             max_voting_period: cw_utils::Duration::Height(10),
             only_members_execute: false,
+            allow_revoting: false,
             dao: Addr::unchecked(CREATOR_ADDR).to_string(),
             deposit_info: None,
         },
@@ -2883,6 +2906,7 @@ fn test_update_config() {
         close_proposal_on_execution_failure: true,
         max_voting_period: cw_utils::Duration::Height(10),
         only_members_execute: false,
+        allow_revoting: false,
         dao: Addr::unchecked(CREATOR_ADDR),
         deposit_info: None,
     };
@@ -2901,6 +2925,7 @@ fn test_update_config() {
             close_proposal_on_execution_failure: true,
             max_voting_period: cw_utils::Duration::Height(10),
             only_members_execute: false,
+            allow_revoting: false,
             dao: Addr::unchecked(CREATOR_ADDR).to_string(),
             deposit_info: None,
         },
@@ -2981,6 +3006,7 @@ fn test_query_list_proposals() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy: voting_strategy.clone(),
     };
@@ -3076,6 +3102,7 @@ fn test_query_list_proposals() {
             votes: MultipleChoiceVotes {
                 vote_weights: vec![Uint128::zero(); 3],
             },
+            allow_revoting: false,
             deposit_info: None,
             min_voting_period: None,
         },
@@ -3119,6 +3146,7 @@ fn test_query_list_proposals() {
             votes: MultipleChoiceVotes {
                 vote_weights: vec![Uint128::zero(); 3],
             },
+            allow_revoting: false,
             deposit_info: None,
             min_voting_period: None,
         },
@@ -3143,6 +3171,7 @@ fn test_hooks() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy,
     };
@@ -3300,6 +3329,7 @@ fn test_active_threshold_absolute() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy,
     };
@@ -3437,6 +3467,7 @@ fn test_active_threshold_percent() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy,
     };
@@ -3575,6 +3606,7 @@ fn test_active_threshold_none() {
         close_proposal_on_execution_failure: true,
         max_voting_period,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         voting_strategy,
     };
@@ -3703,6 +3735,559 @@ fn test_active_threshold_none() {
         .unwrap();
 }
 
+/// Basic test for revoting on prop-multiple
+#[test]
+fn test_revoting() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(proposal_contract());
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        to_binary(&InstantiateMsg {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: true,
+            deposit_info: None,
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+        })
+        .unwrap(),
+        Some(vec![
+            Cw20Coin {
+                address: "a-1".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+            Cw20Coin {
+                address: "a-2".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+        ]),
+    );
+
+    let governance_modules: Vec<Addr> = app
+        .wrap()
+        .query_wasm_smart(
+            core_addr,
+            &cw_core::msg::QueryMsg::ProposalModules {
+                start_after: None,
+                limit: None,
+            },
+        )
+        .unwrap();
+    let proposal_module = governance_modules.into_iter().next().unwrap();
+
+    let options = vec![
+        MultipleChoiceOption {
+            description: "multiple choice option 1".to_string(),
+            msgs: None,
+        },
+        MultipleChoiceOption {
+            description: "multiple choice option 2".to_string(),
+            msgs: None,
+        },
+    ];
+    let mc_options = MultipleChoiceOptions { options };
+
+    // Create a basic proposal with 2 options
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "A simple text proposal".to_string(),
+            choices: mc_options,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // a-1 votes, vote_weights: [100_000_000, 0]
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    // a-2 votes, vote_weights: [100_000_000, 100_000_000]
+    app.execute_contract(
+        Addr::unchecked("a-2"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 1 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Time passes..
+    app.update_block(|b| b.height += 2);
+
+    // Assert that both vote options have equal vote weights at some block
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            proposal_module.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+    assert_eq!(proposal.proposal.status, Status::Open);
+    assert_eq!(
+        proposal.proposal.votes.vote_weights[0],
+        Uint128::new(100_000_000),
+    );
+    assert_eq!(
+        proposal.proposal.votes.vote_weights[1],
+        Uint128::new(100_000_000),
+    );
+
+    // More time passes..
+    app.update_block(|b| b.height += 3);
+
+    // Last moment a-2 has a change of mind,
+    // votes shift to [200_000_000, 0]
+    app.execute_contract(
+        Addr::unchecked("a-2"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    app.update_block(next_block);
+
+    // Assert that revote succeeded
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(proposal_module, &QueryMsg::Proposal { proposal_id: 1 })
+        .unwrap();
+    assert_eq!(proposal.proposal.status, Status::Passed);
+    assert_eq!(
+        proposal.proposal.votes.vote_weights[0],
+        Uint128::new(200_000_000),
+    );
+    assert_eq!(proposal.proposal.votes.vote_weights[1], Uint128::new(0),);
+}
+
+/// Tests that revoting is stored at a per-proposal level.
+/// Proposals created while revoting is enabled should not
+/// have it disabled if a config change turns if off.
+#[test]
+fn test_allow_revoting_config_changes() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(proposal_contract());
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        to_binary(&InstantiateMsg {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: true,
+            deposit_info: None,
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+        })
+        .unwrap(),
+        Some(vec![
+            Cw20Coin {
+                address: "a-1".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+            Cw20Coin {
+                address: "a-2".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+        ]),
+    );
+
+    let governance_modules: Vec<Addr> = app
+        .wrap()
+        .query_wasm_smart(
+            core_addr.clone(),
+            &cw_core::msg::QueryMsg::ProposalModules {
+                start_after: None,
+                limit: None,
+            },
+        )
+        .unwrap();
+    let proposal_module = governance_modules.into_iter().next().unwrap();
+
+    let options = vec![
+        MultipleChoiceOption {
+            description: "multiple choice option 1".to_string(),
+            msgs: None,
+        },
+        MultipleChoiceOption {
+            description: "multiple choice option 2".to_string(),
+            msgs: None,
+        },
+    ];
+    let mc_options = MultipleChoiceOptions { options };
+
+    // Create a basic proposal with 2 options that allows revoting
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "A simple text proposal".to_string(),
+            choices: mc_options.clone(),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Disable revoting
+    app.execute_contract(
+        core_addr.clone(),
+        proposal_module.clone(),
+        &ExecuteMsg::UpdateConfig {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: false,
+            dao: core_addr.to_string(),
+            deposit_info: None,
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Assert that proposal_id: 1 still allows revoting
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            proposal_module.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+    assert!(proposal.proposal.allow_revoting);
+
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+        },
+        &[],
+    )
+    .unwrap();
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 1 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    // New proposals should not allow revoting
+    app.execute_contract(
+        Addr::unchecked("a-2"),
+        proposal_module.clone(),
+        &ExecuteMsg::Propose {
+            title: "A very complex text proposal".to_string(),
+            description: "A very complex text proposal".to_string(),
+            choices: mc_options,
+        },
+        &[],
+    )
+    .unwrap();
+
+    app.execute_contract(
+        Addr::unchecked("a-2"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 2,
+            vote: MultipleChoiceVote { option_id: 0 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    let err: ContractError = app
+        .execute_contract(
+            Addr::unchecked("a-2"),
+            proposal_module,
+            &ExecuteMsg::Vote {
+                proposal_id: 2,
+                vote: MultipleChoiceVote { option_id: 1 },
+            },
+            &[],
+        )
+        .unwrap_err()
+        .downcast()
+        .unwrap();
+
+    assert!(matches!(err, ContractError::AlreadyVoted {}));
+}
+
+/// Tests that we error if a revote casts the same vote as the
+/// previous vote.
+#[test]
+fn test_revoting_same_vote_twice() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(proposal_contract());
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        to_binary(&InstantiateMsg {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: true,
+            deposit_info: None,
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+        })
+        .unwrap(),
+        Some(vec![
+            Cw20Coin {
+                address: "a-1".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+            Cw20Coin {
+                address: "a-2".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+        ]),
+    );
+
+    let governance_modules: Vec<Addr> = app
+        .wrap()
+        .query_wasm_smart(
+            core_addr,
+            &cw_core::msg::QueryMsg::ProposalModules {
+                start_after: None,
+                limit: None,
+            },
+        )
+        .unwrap();
+    let proposal_module = governance_modules.into_iter().next().unwrap();
+
+    let options = vec![
+        MultipleChoiceOption {
+            description: "multiple choice option 1".to_string(),
+            msgs: None,
+        },
+        MultipleChoiceOption {
+            description: "multiple choice option 2".to_string(),
+            msgs: None,
+        },
+    ];
+    let mc_options = MultipleChoiceOptions { options };
+
+    // Create a basic proposal with 2 options that allows revoting
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "A simple text proposal".to_string(),
+            choices: mc_options,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Cast a vote
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Revote for the same option as currently voted
+    let err: ContractError = app
+        .execute_contract(
+            Addr::unchecked("a-1"),
+            proposal_module,
+            &ExecuteMsg::Vote {
+                proposal_id: 1,
+                vote: MultipleChoiceVote { option_id: 0 },
+            },
+            &[],
+        )
+        .unwrap_err()
+        .downcast()
+        .unwrap();
+
+    // Can't cast the same vote twice.
+    assert!(matches!(err, ContractError::AlreadyCast {}));
+}
+
+/// Tests that revoting into a non-existing vote option
+/// does not invalidate the initial vote
+#[test]
+fn test_invalid_revote_does_not_invalidate_initial_vote() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(proposal_contract());
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        govmod_id,
+        to_binary(&InstantiateMsg {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: true,
+            deposit_info: None,
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+        })
+        .unwrap(),
+        Some(vec![
+            Cw20Coin {
+                address: "a-1".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+            Cw20Coin {
+                address: "a-2".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+        ]),
+    );
+
+    let governance_modules: Vec<Addr> = app
+        .wrap()
+        .query_wasm_smart(
+            core_addr,
+            &cw_core::msg::QueryMsg::ProposalModules {
+                start_after: None,
+                limit: None,
+            },
+        )
+        .unwrap();
+    let proposal_module = governance_modules.into_iter().next().unwrap();
+
+    let options = vec![
+        MultipleChoiceOption {
+            description: "multiple choice option 1".to_string(),
+            msgs: None,
+        },
+        MultipleChoiceOption {
+            description: "multiple choice option 2".to_string(),
+            msgs: None,
+        },
+    ];
+    let mc_options = MultipleChoiceOptions { options };
+
+    // Create a basic proposal with 2 options
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "A simple text proposal".to_string(),
+            choices: mc_options,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // a-1 votes, vote_weights: [100_000_000, 0]
+    app.execute_contract(
+        Addr::unchecked("a-1"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    // a-2 votes, vote_weights: [100_000_000, 100_000_000]
+    app.execute_contract(
+        Addr::unchecked("a-2"),
+        proposal_module.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 1 },
+        },
+        &[],
+    )
+    .unwrap();
+
+    app.update_block(next_block);
+
+    // Assert that both vote options have equal vote weights at some block
+    let proposal: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            proposal_module.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+    assert_eq!(proposal.proposal.status, Status::Open);
+    assert_eq!(
+        proposal.proposal.votes.vote_weights[0],
+        Uint128::new(100_000_000),
+    );
+    assert_eq!(
+        proposal.proposal.votes.vote_weights[1],
+        Uint128::new(100_000_000),
+    );
+
+    // Time passes..
+    app.update_block(|b| b.height += 3);
+
+    // Last moment a-2 has a change of mind and attempts
+    // to vote for a non-existing option
+    let err: ContractError = app
+        .execute_contract(
+            Addr::unchecked("a-2"),
+            proposal_module,
+            &ExecuteMsg::Vote {
+                proposal_id: 1,
+                vote: MultipleChoiceVote { option_id: 99 },
+            },
+            &[],
+        )
+        .unwrap_err()
+        .downcast()
+        .unwrap();
+    // Assert that prior votes remained the same
+    assert_eq!(
+        proposal.proposal.votes.vote_weights[0],
+        Uint128::new(100_000_000),
+    );
+    assert_eq!(
+        proposal.proposal.votes.vote_weights[1],
+        Uint128::new(100_000_000),
+    );
+    assert!(matches!(err, ContractError::InvalidVote {}));
+}
+
 #[test]
 fn test_return_deposit_to_dao_on_proposal_failure() {
     let (mut app, core_addr) = do_test_votes_cw20_balances(
@@ -3780,6 +4365,7 @@ fn test_close_failed_proposal() {
         voting_strategy,
         min_voting_period: None,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: None,
         close_proposal_on_execution_failure: true,
     };
@@ -3936,6 +4522,7 @@ fn test_close_failed_proposal() {
                                     max_voting_period: original.max_voting_period,
                                     min_voting_period: original.min_voting_period,
                                     only_members_execute: original.only_members_execute,
+                                    allow_revoting: false,
                                     dao: original.dao.to_string(),
                                     deposit_info: None,
                                     close_proposal_on_execution_failure: false,
@@ -4036,6 +4623,7 @@ fn test_no_double_refund_on_execute_fail_and_close() {
         max_voting_period,
         min_voting_period: None,
         only_members_execute: false,
+        allow_revoting: false,
         deposit_info: Some(DepositInfo {
             token: DepositToken::VotingModuleToken {},
             deposit: Uint128::new(1),