Repository: DA0-DA0/dao-contracts
Pull request #635 (2023-01-27 17:57:57)

add update rationale to multiple choice contract
also cleans up a duplicated MultipleChoiceVote type 

I wanted to get this in before frontend changes land because it is easier without having to worry about migration 

JakeHartnell: Looks good, just needs lints.
github-actions[bot]: <h1>Cosm-Orc Gas Usage</h1> 
 
 | Contract | Op Name | Gas Used | Old Gas Used | Gas Diff | File | 
 | --- | --- | --- | --- | --- | --- | 
| dao_proposal_multiple | Store__Store | 6470201 | 6374183 | +1.5064% | ci/integration-tests/src/helpers/chain.rs:97 |
 
 <details><summary>Raw Report for 63107921225040e8040f2abc7347533bbc14ac4c </summary><br/> 

| Contract | Op Name | Gas Used | Gas Wanted | File | 
 | --- | --- | --- | --- | --- |
| dao_core | Execute__exc_items_rm | 192820 | 266513 | ci/integration-tests/src/tests/cw_core_test.rs:171 |
| dao_core | Execute__exc_items_set | 194487 | 269013 | ci/integration-tests/src/tests/cw_core_test.rs:136 |
| dao_core | Instantiate__exc_items_create_dao | 1276869 | 1892501 | ci/integration-tests/src/helpers/helper.rs:98 |
| dao_core | Instantiate__inst_dao_no_admin | 1275819 | 1890926 | ci/integration-tests/src/helpers/helper.rs:98 |
| dao_core | Instantiate__exc_stake_create_dao | 1275795 | 1890890 | ci/integration-tests/src/helpers/helper.rs:98 |
| dao_core | Store__Store | 6110725 | 9143298 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_core | Instantiate__inst_admin_create_dao | 1276869 | 1892501 | ci/integration-tests/src/helpers/helper.rs:98 |
| dao_core | Execute__exc_admin_msgs_pause_dao | 194613 | 269202 | ci/integration-tests/src/tests/cw_core_test.rs:76 |
| dao_core | Instantiate__exc_admin_msgs_create_dao | 1275819 | 1890926 | ci/integration-tests/src/helpers/helper.rs:98 |
| dao_core | Instantiate__exc_admin_msgs_create_dao_with_admin | 1276869 | 1892501 | ci/integration-tests/src/helpers/helper.rs:98 |
| cw20_base | Execute__exc_stake_stake_tokens | 234535 | 320952 | ci/integration-tests/src/tests/cw20_stake_test.rs:76 |
| cw20_base | Store__Store | 4180849 | 6248484 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw721_base | Execute__mint_nft | 140542 | 188097 | ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs:87 |
| cw721_base | Execute__stake_nft | 234591 | 329168 | ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs:70 |
| cw721_base | Instantiate__instantiate_cw721_base | 164837 | 224540 | ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs:22 |
| cw721_base | Store__Store | 3369402 | 5031314 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_voting_cw721_staked | Execute__unstake_nfts | 230319 | 322760 | ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs:105 |
| dao_voting_cw721_staked | Instantiate__instantiate_dao_voting_cw721_staked | 178857 | 245570 | ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs:49 |
| dao_voting_cw721_staked | Store__Store | 3346873 | 4997520 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_voting_cw721_staked | Execute__claim_nfts | 5710869 | 8543393 | ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs:120 |
| cw20_stake | Store__Store | 3933550 | 5877536 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw20_stake_external_rewards | Store__Store | 3347068 | 4997813 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw20_stake_reward_distributor | Store__Store | 2831280 | 4224131 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw4_group | Store__Store | 2783570 | 4152566 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw_admin_factory | Store__Store | 2017025 | 3002748 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw_fund_distributor | Store__Store | 3552364 | 5305757 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw_payroll_factory | Store__Store | 3726642 | 5567174 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw_token_swap | Store__Store | 2380128 | 3547403 | ci/integration-tests/src/helpers/chain.rs:97 |
| cw_vesting | Store__Store | 3580886 | 5348540 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_migrator | Store__Store | 4969793 | 7431900 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_pre_propose_approval_single | Store__Store | 4916038 | 7351268 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_pre_propose_approver | Store__Store | 3674707 | 5489271 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_pre_propose_multiple | Store__Store | 4246668 | 6347213 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_pre_propose_single | Store__Store | 4168304 | 6229667 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_proposal_condorcet | Store__Store | 4628842 | 6920474 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_proposal_multiple | Store__Store | 6470201 | 9682512 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_proposal_single | Store__Store | 6675991 | 9991197 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_voting_cw20_staked | Store__Store | 3623539 | 5412519 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_voting_cw4 | Store__Store | 2684705 | 4004268 | ci/integration-tests/src/helpers/chain.rs:97 |
| dao_voting_native_staked | Store__Store | 2974426 | 4438845 | ci/integration-tests/src/helpers/chain.rs:97 |
| multiple_contracts | Execute__batch_cw721_stake_max_claims | 4196444 | 6254241 | ci/integration-tests/src/tests/dao_voting_cw721_staked_test.rs:251 |
</details>
JakeHartnell: Needs `cargo fmt`.

Patch:
diff --git a/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json
index b4d14040..7eb9ea8b 100644
--- a/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json
+++ b/contracts/proposal/dao-proposal-multiple/schema/dao-proposal-multiple.json
@@ -357,6 +357,13 @@
                 "format": "uint64",
                 "minimum": 0.0
               },
+              "rationale": {
+                "description": "An optional rationale for why this vote was cast. This can be updated, set, or removed later by the address casting the vote.",
+                "type": [
+                  "string",
+                  "null"
+                ]
+              },
               "vote": {
                 "description": "The senders position on the proposal.",
                 "allOf": [
@@ -488,6 +495,36 @@
         },
         "additionalProperties": false
       },
+      {
+        "description": "Updates the sender's rationale for their vote on the specified proposal. Errors if no vote vote has been cast.",
+        "type": "object",
+        "required": [
+          "update_rationale"
+        ],
+        "properties": {
+          "update_rationale": {
+            "type": "object",
+            "required": [
+              "proposal_id"
+            ],
+            "properties": {
+              "proposal_id": {
+                "type": "integer",
+                "format": "uint64",
+                "minimum": 0.0
+              },
+              "rationale": {
+                "type": [
+                  "string",
+                  "null"
+                ]
+              }
+            },
+            "additionalProperties": false
+          }
+        },
+        "additionalProperties": false
+      },
       {
         "description": "Update's the proposal creation policy used for this module. Only the DAO may call this method.",
         "type": "object",
@@ -2268,6 +2305,13 @@
                 }
               ]
             },
+            "rationale": {
+              "description": "The rationale behind the vote.",
+              "type": [
+                "string",
+                "null"
+              ]
+            },
             "vote": {
               "description": "Position on the vote.",
               "allOf": [
@@ -3464,6 +3508,13 @@
                 }
               ]
             },
+            "rationale": {
+              "description": "The rationale behind the vote.",
+              "type": [
+                "string",
+                "null"
+              ]
+            },
             "vote": {
               "description": "Position on the vote.",
               "allOf": [
diff --git a/contracts/proposal/dao-proposal-multiple/src/contract.rs b/contracts/proposal/dao-proposal-multiple/src/contract.rs
index 6a1ac801..ccd3fdf9 100644
--- a/contracts/proposal/dao-proposal-multiple/src/contract.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/contract.rs
@@ -104,7 +104,11 @@ pub fn execute(
             choices,
             proposer,
         ),
-        ExecuteMsg::Vote { proposal_id, vote } => execute_vote(deps, env, info, proposal_id, vote),
+        ExecuteMsg::Vote {
+            proposal_id,
+            vote,
+            rationale,
+        } => execute_vote(deps, env, info, proposal_id, vote, rationale),
         ExecuteMsg::Execute { proposal_id } => execute_execute(deps, env, info, proposal_id),
         ExecuteMsg::Close { proposal_id } => execute_close(deps, env, info, proposal_id),
         ExecuteMsg::UpdateConfig {
@@ -139,6 +143,10 @@ pub fn execute(
         ExecuteMsg::RemoveVoteHook { address } => {
             execute_remove_vote_hook(deps, env, info, address)
         }
+        ExecuteMsg::UpdateRationale {
+            proposal_id,
+            rationale,
+        } => execute_update_rationale(deps, info, proposal_id, rationale),
     }
 }
 
@@ -257,6 +265,7 @@ pub fn execute_vote(
     info: MessageInfo,
     proposal_id: u64,
     vote: MultipleChoiceVote,
+    rationale: Option<String>,
 ) -> Result<Response<Empty>, ContractError> {
     let config = CONFIG.load(deps.storage)?;
     let mut prop = PROPOSALS
@@ -289,36 +298,34 @@ pub fn execute_vote(
         return Err(ContractError::NotRegistered {});
     }
 
-    BALLOTS.update(
-        deps.storage,
-        (proposal_id, info.sender.clone()),
-        |bal| match bal {
-            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,
-                        })
-                    }
+    BALLOTS.update(deps.storage, (proposal_id, &info.sender), |bal| match bal {
+        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 {
-                    Err(ContractError::AlreadyVoted {})
+                    // 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,
+                        rationale,
+                    })
                 }
+            } else {
+                Err(ContractError::AlreadyVoted {})
             }
-            None => Ok(Ballot {
-                vote,
-                power: vote_power,
-            }),
-        },
-    )?;
+        }
+        None => Ok(Ballot {
+            vote,
+            power: vote_power,
+            rationale,
+        }),
+    })?;
 
     let old_status = prop.status;
 
@@ -571,6 +578,36 @@ pub fn execute_update_proposal_creation_policy(
         .add_attribute("new_policy", format!("{initial_policy:?}")))
 }
 
+pub fn execute_update_rationale(
+    deps: DepsMut,
+    info: MessageInfo,
+    proposal_id: u64,
+    rationale: Option<String>,
+) -> Result<Response, ContractError> {
+    BALLOTS.update(
+        deps.storage,
+        // info.sender can't be forged so we implicitly access control
+        // with the key.
+        (proposal_id, &info.sender),
+        |ballot| match ballot {
+            Some(ballot) => Ok(Ballot {
+                rationale: rationale.clone(),
+                ..ballot
+            }),
+            None => Err(ContractError::NoSuchVote {
+                id: proposal_id,
+                voter: info.sender.to_string(),
+            }),
+        },
+    )?;
+
+    Ok(Response::default()
+        .add_attribute("action", "update_rationale")
+        .add_attribute("sender", info.sender)
+        .add_attribute("proposal_id", proposal_id.to_string())
+        .add_attribute("rationale", rationale.as_deref().unwrap_or("none")))
+}
+
 pub fn execute_add_proposal_hook(
     deps: DepsMut,
     _env: Env,
@@ -784,11 +821,12 @@ pub fn query_proposal_count(deps: Deps) -> StdResult<Binary> {
 
 pub fn query_vote(deps: Deps, proposal_id: u64, voter: String) -> StdResult<Binary> {
     let voter = deps.api.addr_validate(&voter)?;
-    let ballot = BALLOTS.may_load(deps.storage, (proposal_id, voter.clone()))?;
+    let ballot = BALLOTS.may_load(deps.storage, (proposal_id, &voter))?;
     let vote = ballot.map(|ballot| VoteInfo {
         voter,
         vote: ballot.vote,
         power: ballot.power,
+        rationale: ballot.rationale,
     });
     to_binary(&VoteResponse { vote })
 }
@@ -803,7 +841,7 @@ pub fn query_list_votes(
     let start_after = start_after
         .map(|addr| deps.api.addr_validate(&addr))
         .transpose()?;
-    let min = start_after.map(Bound::<Addr>::exclusive);
+    let min = start_after.as_ref().map(Bound::<&Addr>::exclusive);
 
     let votes = BALLOTS
         .prefix(proposal_id)
@@ -815,6 +853,7 @@ pub fn query_list_votes(
                 voter,
                 vote: ballot.vote,
                 power: ballot.power,
+                rationale: ballot.rationale,
             })
         })
         .collect::<StdResult<Vec<_>>>()?;
diff --git a/contracts/proposal/dao-proposal-multiple/src/error.rs b/contracts/proposal/dao-proposal-multiple/src/error.rs
index 0e45675a..a1d1df10 100644
--- a/contracts/proposal/dao-proposal-multiple/src/error.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/error.rs
@@ -29,7 +29,7 @@ pub enum ContractError {
     #[error("Suggested proposal expiration is larger than the maximum proposal duration")]
     InvalidExpiration {},
 
-    #[error("Proposal ({id}) is expired")]
+    #[error("No such proposal ({id})")]
     NoSuchProposal { id: u64 },
 
     #[error("Proposal is ({size}) bytes, must be <= ({max}) bytes")]
@@ -41,6 +41,9 @@ pub enum ContractError {
     #[error("Not registered to vote (no voting power) at time of proposal creation.")]
     NotRegistered {},
 
+    #[error("No vote exists for proposal ({id}) and voter ({voter})")]
+    NoSuchVote { id: u64, voter: String },
+
     #[error("Already voted. This proposal does not support revoting.")]
     AlreadyVoted {},
 
diff --git a/contracts/proposal/dao-proposal-multiple/src/msg.rs b/contracts/proposal/dao-proposal-multiple/src/msg.rs
index f5edb91e..1f4173cd 100644
--- a/contracts/proposal/dao-proposal-multiple/src/msg.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/msg.rs
@@ -63,6 +63,10 @@ pub enum ExecuteMsg {
         proposal_id: u64,
         /// The senders position on the proposal.
         vote: MultipleChoiceVote,
+        /// An optional rationale for why this vote was cast. This can
+        /// be updated, set, or removed later by the address casting
+        /// the vote.
+        rationale: Option<String>,
     },
     /// Causes the messages associated with a passed proposal to be
     /// executed by the DAO.
@@ -113,6 +117,12 @@ pub enum ExecuteMsg {
         /// executed.
         close_proposal_on_execution_failure: bool,
     },
+    /// Updates the sender's rationale for their vote on the specified
+    /// proposal. Errors if no vote vote has been cast.
+    UpdateRationale {
+        proposal_id: u64,
+        rationale: Option<String>,
+    },
     /// Update's the proposal creation policy used for this
     /// module. Only the DAO may call this method.
     UpdatePreProposeInfo {
diff --git a/contracts/proposal/dao-proposal-multiple/src/query.rs b/contracts/proposal/dao-proposal-multiple/src/query.rs
index 722fba27..35862161 100644
--- a/contracts/proposal/dao-proposal-multiple/src/query.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/query.rs
@@ -25,6 +25,8 @@ pub struct VoteInfo {
     pub vote: MultipleChoiceVote,
     /// The voting power behind the vote.
     pub power: Uint128,
+    /// The rationale behind the vote.
+    pub rationale: Option<String>,
 }
 
 #[cw_serde]
diff --git a/contracts/proposal/dao-proposal-multiple/src/state.rs b/contracts/proposal/dao-proposal-multiple/src/state.rs
index f9e81980..f9c6baa5 100644
--- a/contracts/proposal/dao-proposal-multiple/src/state.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/state.rs
@@ -45,21 +45,22 @@ pub struct Config {
     pub close_proposal_on_execution_failure: bool,
 }
 
-// we cast a ballot with our chosen vote and a given weight
-// stored under the key that voted
+// Each ballot stores a chosen vote and corresponding voting power and rationale.
 #[cw_serde]
 pub struct Ballot {
     /// The amount of voting power behind the vote.
     pub power: Uint128,
     /// The position.
     pub vote: MultipleChoiceVote,
+    /// An optional rationale for why this vote was cast.
+    pub rationale: Option<String>,
 }
 
 /// The current top level config for the module.
 pub const CONFIG: Item<Config> = Item::new("config");
 pub const PROPOSAL_COUNT: Item<u64> = Item::new("proposal_count");
 pub const PROPOSALS: Map<u64, MultipleChoiceProposal> = Map::new("proposals");
-pub const BALLOTS: Map<(u64, Addr), Ballot> = Map::new("ballots");
+pub const BALLOTS: Map<(u64, &Addr), Ballot> = Map::new("ballots");
 /// Consumers of proposal state change hooks.
 pub const PROPOSAL_HOOKS: Hooks = Hooks::new("proposal_hooks");
 /// Consumers of vote hooks.
diff --git a/packages/cw-denom/src/lib.rs b/packages/cw-denom/src/lib.rs
index 1529039f..ec6a5dfc 100644
--- a/packages/cw-denom/src/lib.rs
+++ b/packages/cw-denom/src/lib.rs
@@ -294,14 +294,22 @@ mod tests {
     #[test]
     fn test_validate_native_denom_invalid() {
         let invalids = [
-            "ab".to_string(),                          // Too short.
-            (0..129).map(|_| "a").collect::<String>(), // Too long.
-            "1abc".to_string(),                        // Starts with non alphabetic character.
-            "abc~d".to_string(),                       // Contains invalid character.
-            "".to_string(),                            // Too short, also empty.
-            "🥵abc".to_string(),                       // Weird unicode start.
-            "ab:12🥵a".to_string(),                    // Weird unocide in non-head position.
-            "ab,cd".to_string(),                       // Comma is not a valid seperator.
+            // Too short.
+            "ab".to_string(),
+            // Too long.
+            (0..129).map(|_| "a").collect::<String>(),
+            // Starts with non alphabetic character.
+            "1abc".to_string(),
+            // Contains invalid character.
+            "abc~d".to_string(),
+            // Too short, also empty.
+            "".to_string(),
+            // Weird unicode start.
+            "🥵abc".to_string(),
+            // Weird unocide in non-head position.
+            "ab:12🥵a".to_string(),
+            // Comma is not a valid seperator.
+            "ab,cd".to_string(),
         ];
 
         for invalid in invalids {
diff --git a/packages/dao-voting/src/voting.rs b/packages/dao-voting/src/voting.rs
index dba576b6..e8d0afd1 100644
--- a/packages/dao-voting/src/voting.rs
+++ b/packages/dao-voting/src/voting.rs
@@ -1,5 +1,5 @@
 use cosmwasm_schema::cw_serde;
-use cosmwasm_std::{Addr, Decimal, Deps, StdError, StdResult, Uint128, Uint256};
+use cosmwasm_std::{Addr, Decimal, Deps, StdResult, Uint128, Uint256};
 use cw_utils::Duration;
 use dao_interface::voting;
 
@@ -29,52 +29,6 @@ pub enum Vote {
     Abstain,
 }
 
-#[cw_serde]
-pub struct MultipleChoiceVote {
-    // A vote indicates which option the user has selected.
-    pub option_id: u32,
-}
-
-impl std::fmt::Display for MultipleChoiceVote {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        write!(f, "{}", self.option_id)
-    }
-}
-
-#[cw_serde]
-pub struct MultipleChoiceVotes {
-    // Vote counts is a vector of integers indicating the vote weight for each option
-    // (the index corresponds to the option).
-    pub vote_weights: Vec<Uint128>,
-}
-
-impl MultipleChoiceVotes {
-    /// Sum of all vote weights
-    pub fn total(&self) -> Uint128 {
-        self.vote_weights.iter().sum()
-    }
-
-    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 {
-        Self {
-            vote_weights: vec![Uint128::zero(); num_choices],
-        }
-    }
-}
-
 pub enum VoteCmp {
     Greater,
     Geq,
@@ -497,34 +451,4 @@ mod test {
             Decimal::percent(0)
         ))
     }
-
-    #[test]
-    fn test_display_multiple_choice_vote() {
-        let vote = MultipleChoiceVote { option_id: 0 };
-        assert_eq!("0", format!("{vote}"))
-    }
-
-    #[test]
-    fn test_multiple_choice_votes() {
-        let mut votes = MultipleChoiceVotes {
-            vote_weights: vec![Uint128::new(10), Uint128::new(100)],
-        };
-        let total = votes.total();
-        assert_eq!(total, Uint128::new(110));
-
-        votes
-            .add_vote(MultipleChoiceVote { option_id: 0 }, Uint128::new(10))
-            .unwrap();
-        let total = votes.total();
-        assert_eq!(total, Uint128::new(120));
-
-        votes
-            .remove_vote(MultipleChoiceVote { option_id: 0 }, Uint128::new(20))
-            .unwrap();
-        votes
-            .remove_vote(MultipleChoiceVote { option_id: 1 }, Uint128::new(100))
-            .unwrap();
-
-        assert_eq!(votes, MultipleChoiceVotes::zero(2))
-    }
 }
diff --git a/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.client.ts b/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.client.ts
index 2b961fd5..fae35dc3 100644
--- a/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.client.ts
+++ b/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.client.ts
@@ -202,9 +202,11 @@ export interface DaoProposalMultipleInterface extends DaoProposalMultipleReadOnl
   }, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]) => Promise<ExecuteResult>;
   vote: ({
     proposalId,
+    rationale,
     vote
   }: {
     proposalId: number;
+    rationale?: string;
     vote: MultipleChoiceVote;
   }, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]) => Promise<ExecuteResult>;
   execute: ({
@@ -234,6 +236,13 @@ export interface DaoProposalMultipleInterface extends DaoProposalMultipleReadOnl
     onlyMembersExecute: boolean;
     votingStrategy: VotingStrategy;
   }, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]) => Promise<ExecuteResult>;
+  updateRationale: ({
+    proposalId,
+    rationale
+  }: {
+    proposalId: number;
+    rationale?: string;
+  }, fee?: number | StdFee | "auto", memo?: string, funds?: Coin[]) => Promise<ExecuteResult>;
   updatePreProposeInfo: ({
     info
   }: {
@@ -275,6 +284,7 @@ export class DaoProposalMultipleClient extends DaoProposalMultipleQueryClient im
     this.execute = this.execute.bind(this);
     this.close = this.close.bind(this);
     this.updateConfig = this.updateConfig.bind(this);
+    this.updateRationale = this.updateRationale.bind(this);
     this.updatePreProposeInfo = this.updatePreProposeInfo.bind(this);
     this.addProposalHook = this.addProposalHook.bind(this);
     this.removeProposalHook = this.removeProposalHook.bind(this);
@@ -304,14 +314,17 @@ export class DaoProposalMultipleClient extends DaoProposalMultipleQueryClient im
   };
   vote = async ({
     proposalId,
+    rationale,
     vote
   }: {
     proposalId: number;
+    rationale?: string;
     vote: MultipleChoiceVote;
   }, fee: number | StdFee | "auto" = "auto", memo?: string, funds?: Coin[]): Promise<ExecuteResult> => {
     return await this.client.execute(this.sender, this.contractAddress, {
       vote: {
         proposal_id: proposalId,
+        rationale,
         vote
       }
     }, fee, memo, funds);
@@ -367,6 +380,20 @@ export class DaoProposalMultipleClient extends DaoProposalMultipleQueryClient im
       }
     }, fee, memo, funds);
   };
+  updateRationale = async ({
+    proposalId,
+    rationale
+  }: {
+    proposalId: number;
+    rationale?: string;
+  }, fee: number | StdFee | "auto" = "auto", memo?: string, funds?: Coin[]): Promise<ExecuteResult> => {
+    return await this.client.execute(this.sender, this.contractAddress, {
+      update_rationale: {
+        proposal_id: proposalId,
+        rationale
+      }
+    }, fee, memo, funds);
+  };
   updatePreProposeInfo = async ({
     info
   }: {
diff --git a/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.types.ts b/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.types.ts
index 62d18e8a..e8884706 100644
--- a/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.types.ts
+++ b/typescript/contracts/dao-proposal-multiple/DaoProposalMultiple.types.ts
@@ -60,6 +60,7 @@ export type ExecuteMsg = {
 } | {
   vote: {
     proposal_id: number;
+    rationale?: string | null;
     vote: MultipleChoiceVote;
   };
 } | {
@@ -80,6 +81,11 @@ export type ExecuteMsg = {
     only_members_execute: boolean;
     voting_strategy: VotingStrategy;
   };
+} | {
+  update_rationale: {
+    proposal_id: number;
+    rationale?: string | null;
+  };
 } | {
   update_pre_propose_info: {
     info: PreProposeInfo;
@@ -326,6 +332,7 @@ export interface VoteResponse {
 }
 export interface VoteInfo {
   power: Uint128;
+  rationale?: string | null;
   vote: MultipleChoiceVote;
   voter: Addr;
 }


Test patch:
diff --git a/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs
index 284ecb10..7b845aab 100644
--- a/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs
+++ b/contracts/pre-propose/dao-pre-propose-multiple/src/tests.rs
@@ -310,6 +310,7 @@ fn vote(
         &cpm::msg::ExecuteMsg::Vote {
             proposal_id: id,
             vote: position,
+            rationale: None,
         },
         &[],
     )
diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs
index 88bf1723..fe09e69d 100644
--- a/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/testing/adversarial_tests.rs
@@ -116,7 +116,11 @@ fn test_execute_proposal_rejected_closed() {
     app.execute_contract(
         Addr::unchecked(CREATOR_ADDR),
         proposal_module.clone(),
-        &ExecuteMsg::Vote { proposal_id, vote },
+        &ExecuteMsg::Vote {
+            proposal_id,
+            vote,
+            rationale: None,
+        },
         &[],
     )
     .unwrap();
@@ -125,7 +129,11 @@ fn test_execute_proposal_rejected_closed() {
     app.execute_contract(
         Addr::unchecked(ALTERNATIVE_ADDR),
         proposal_module.clone(),
-        &ExecuteMsg::Vote { proposal_id, vote },
+        &ExecuteMsg::Vote {
+            proposal_id,
+            vote,
+            rationale: None,
+        },
         &[],
     )
     .unwrap();
@@ -194,14 +202,22 @@ fn test_execute_proposal_more_than_once() {
     app.execute_contract(
         Addr::unchecked(CREATOR_ADDR),
         proposal_module.clone(),
-        &ExecuteMsg::Vote { proposal_id, vote },
+        &ExecuteMsg::Vote {
+            proposal_id,
+            vote,
+            rationale: None,
+        },
         &[],
     )
     .unwrap();
     app.execute_contract(
         Addr::unchecked(ALTERNATIVE_ADDR),
         proposal_module.clone(),
-        &ExecuteMsg::Vote { proposal_id, vote },
+        &ExecuteMsg::Vote {
+            proposal_id,
+            vote,
+            rationale: None,
+        },
         &[],
     )
     .unwrap();
@@ -327,7 +343,11 @@ pub fn test_allow_voting_after_proposal_execution_pre_expiration_cw20() {
     app.execute_contract(
         Addr::unchecked(CREATOR_ADDR),
         proposal_module.clone(),
-        &ExecuteMsg::Vote { proposal_id, vote },
+        &ExecuteMsg::Vote {
+            proposal_id,
+            vote,
+            rationale: None,
+        },
         &[],
     )
     .unwrap();
@@ -346,7 +366,11 @@ pub fn test_allow_voting_after_proposal_execution_pre_expiration_cw20() {
     app.execute_contract(
         Addr::unchecked(ALTERNATIVE_ADDR),
         proposal_module.clone(),
-        &ExecuteMsg::Vote { proposal_id, vote },
+        &ExecuteMsg::Vote {
+            proposal_id,
+            vote,
+            rationale: None,
+        },
         &[],
     )
     .unwrap();
diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs
index 7432dfc1..6d3ad5d3 100644
--- a/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/testing/do_votes.rs
@@ -233,6 +233,7 @@ where
             &ExecuteMsg::Vote {
                 proposal_id: 1,
                 vote: position,
+                rationale: None,
             },
             &[],
         );
@@ -273,6 +274,7 @@ where
                             // expected voting power.
                             _ => weight,
                         },
+                        rationale: None,
                     }),
                 };
                 assert_eq!(vote, expected)
diff --git a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs
index f779b8eb..69be2772 100644
--- a/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs
+++ b/contracts/proposal/dao-proposal-multiple/src/testing/tests.rs
@@ -23,7 +23,7 @@ use std::panic;
 use crate::{
     msg::{ExecuteMsg, InstantiateMsg, QueryMsg},
     proposal::MultipleChoiceProposal,
-    query::{ProposalListResponse, ProposalResponse, VoteInfo, VoteListResponse},
+    query::{ProposalListResponse, ProposalResponse, VoteInfo, VoteListResponse, VoteResponse},
     state::Config,
     testing::{
         do_votes::do_test_votes_cw20_balances,
@@ -371,6 +371,7 @@ fn test_no_early_pass_with_min_duration() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -480,6 +481,7 @@ fn test_propose_with_messages() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -645,6 +647,7 @@ fn test_min_duration_same_as_proposal_duration() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -661,6 +664,7 @@ fn test_min_duration_same_as_proposal_duration() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 2 },
+            rationale: None,
         },
         &[],
     )
@@ -1057,6 +1061,7 @@ fn test_native_proposal_deposit() {
             &ExecuteMsg::Vote {
                 proposal_id: 1,
                 vote: MultipleChoiceVote { option_id: 1 },
+                rationale: None,
             },
             &[],
         );
@@ -1244,11 +1249,13 @@ fn test_query_list_votes() {
             voter: Addr::unchecked("blue"),
             vote: MultipleChoiceVote { option_id: 0 },
             power: Uint128::new(10),
+            rationale: None,
         },
         VoteInfo {
             voter: Addr::unchecked("note"),
             vote: MultipleChoiceVote { option_id: 1 },
             power: Uint128::new(20),
+            rationale: None,
         },
     ];
 
@@ -1319,6 +1326,7 @@ fn test_cant_vote_executed_or_closed() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -1357,6 +1365,7 @@ fn test_cant_vote_executed_or_closed() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -1519,6 +1528,7 @@ fn test_cant_vote_not_registered() {
             &ExecuteMsg::Vote {
                 proposal_id: 1,
                 vote: MultipleChoiceVote { option_id: 0 },
+                rationale: None,
             },
             &[],
         )
@@ -1597,6 +1607,7 @@ fn test_cant_execute_not_member() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -1686,6 +1697,7 @@ fn test_cant_execute_not_member_when_proposal_created() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -2107,6 +2119,7 @@ fn test_execute_expired_proposal() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3011,6 +3024,7 @@ fn test_revoting() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3023,6 +3037,7 @@ fn test_revoting() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 1 },
+            rationale: None,
         },
         &[],
     )
@@ -3054,6 +3069,7 @@ fn test_revoting() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3162,6 +3178,7 @@ fn test_allow_revoting_config_changes() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3172,6 +3189,7 @@ fn test_allow_revoting_config_changes() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 1 },
+            rationale: None,
         },
         &[],
     )
@@ -3197,6 +3215,7 @@ fn test_allow_revoting_config_changes() {
         &ExecuteMsg::Vote {
             proposal_id: 2,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3209,6 +3228,7 @@ fn test_allow_revoting_config_changes() {
             &ExecuteMsg::Vote {
                 proposal_id: 2,
                 vote: MultipleChoiceVote { option_id: 1 },
+                rationale: None,
             },
             &[],
         )
@@ -3287,6 +3307,7 @@ fn test_revoting_same_vote_twice() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3300,6 +3321,7 @@ fn test_revoting_same_vote_twice() {
             &ExecuteMsg::Vote {
                 proposal_id: 1,
                 vote: MultipleChoiceVote { option_id: 0 },
+                rationale: None,
             },
             &[],
         )
@@ -3379,6 +3401,7 @@ fn test_invalid_revote_does_not_invalidate_initial_vote() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3391,6 +3414,7 @@ fn test_invalid_revote_does_not_invalidate_initial_vote() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 1 },
+            rationale: None,
         },
         &[],
     )
@@ -3422,6 +3446,7 @@ fn test_invalid_revote_does_not_invalidate_initial_vote() {
             &ExecuteMsg::Vote {
                 proposal_id: 1,
                 vote: MultipleChoiceVote { option_id: 99 },
+                rationale: None,
             },
             &[],
         )
@@ -3601,6 +3626,7 @@ fn test_close_failed_proposal() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3674,6 +3700,7 @@ fn test_close_failed_proposal() {
             &ExecuteMsg::Vote {
                 proposal_id: 2,
                 vote: MultipleChoiceVote { option_id: 0 },
+                rationale: None,
             },
             &[],
         )
@@ -3710,6 +3737,7 @@ fn test_close_failed_proposal() {
         &ExecuteMsg::Vote {
             proposal_id: 3,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3876,6 +3904,7 @@ fn test_no_double_refund_on_execute_fail_and_close() {
         &ExecuteMsg::Vote {
             proposal_id: 1,
             vote: MultipleChoiceVote { option_id: 0 },
+            rationale: None,
         },
         &[],
     )
@@ -3996,6 +4025,7 @@ pub fn test_not_allow_voting_on_expired_proposal() {
             &ExecuteMsg::Vote {
                 proposal_id: 1,
                 vote: MultipleChoiceVote { option_id: 0 },
+                rationale: None,
             },
             &[],
         )
@@ -4081,3 +4111,382 @@ fn test_next_proposal_id() {
         .unwrap();
     assert_eq!(next_proposal_id, 2);
 }
+
+#[test]
+fn test_vote_with_rationale() {
+    let mut app = App::default();
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        InstantiateMsg {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: false,
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+            pre_propose_info: PreProposeInfo::AnyoneMayPropose {},
+        },
+        Some(vec![
+            Cw20Coin {
+                address: "blue".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+            Cw20Coin {
+                address: "elub".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+        ]),
+    );
+
+    let gov_state: dao_core::query::DumpStateResponse = app
+        .wrap()
+        .query_wasm_smart(core_addr, &dao_core::msg::QueryMsg::DumpState {})
+        .unwrap();
+    let governance_modules = gov_state.proposal_modules;
+
+    assert_eq!(governance_modules.len(), 1);
+    let govmod = governance_modules.into_iter().next().unwrap().address;
+
+    let options = vec![
+        MultipleChoiceOption {
+            description: "multiple choice option 1".to_string(),
+            msgs: vec![],
+            title: "title 1".to_string(),
+        },
+        MultipleChoiceOption {
+            description: "multiple choice option 2".to_string(),
+            msgs: vec![],
+            title: "title 2".to_string(),
+        },
+    ];
+
+    let mc_options = MultipleChoiceOptions { options };
+    app.execute_contract(
+        Addr::unchecked(CREATOR_ADDR),
+        govmod.clone(),
+        &ExecuteMsg::Propose {
+            title: "A proposal".to_string(),
+            description: "A simple proposal".to_string(),
+            choices: mc_options,
+            proposer: None,
+        },
+        &[],
+    )
+    .unwrap();
+
+    app.execute_contract(
+        Addr::unchecked("blue"),
+        govmod.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+            rationale: Some("I think this is a good idea".to_string()),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query rationale
+    let vote_resp: VoteResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod,
+            &QueryMsg::GetVote {
+                proposal_id: 1,
+                voter: "blue".to_string(),
+            },
+        )
+        .unwrap();
+
+    let vote = vote_resp.vote.unwrap();
+    assert_eq!(vote.vote.option_id, 0);
+    assert_eq!(
+        vote.rationale,
+        Some("I think this is a good idea".to_string())
+    );
+}
+
+#[test]
+fn test_revote_with_rationale() {
+    let mut app = App::default();
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        InstantiateMsg {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: true, // Enable revoting
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+            pre_propose_info: PreProposeInfo::AnyoneMayPropose {},
+        },
+        Some(vec![
+            Cw20Coin {
+                address: "blue".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+            Cw20Coin {
+                address: "elub".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+        ]),
+    );
+
+    let gov_state: dao_core::query::DumpStateResponse = app
+        .wrap()
+        .query_wasm_smart(core_addr, &dao_core::msg::QueryMsg::DumpState {})
+        .unwrap();
+    let governance_modules = gov_state.proposal_modules;
+
+    assert_eq!(governance_modules.len(), 1);
+    let govmod = governance_modules.into_iter().next().unwrap().address;
+
+    let options = vec![
+        MultipleChoiceOption {
+            description: "multiple choice option 1".to_string(),
+            msgs: vec![],
+            title: "title 1".to_string(),
+        },
+        MultipleChoiceOption {
+            description: "multiple choice option 2".to_string(),
+            msgs: vec![],
+            title: "title 2".to_string(),
+        },
+    ];
+
+    let mc_options = MultipleChoiceOptions { options };
+    app.execute_contract(
+        Addr::unchecked(CREATOR_ADDR),
+        govmod.clone(),
+        &ExecuteMsg::Propose {
+            title: "A proposal".to_string(),
+            description: "A simple proposal".to_string(),
+            choices: mc_options,
+            proposer: None,
+        },
+        &[],
+    )
+    .unwrap();
+
+    app.execute_contract(
+        Addr::unchecked("blue"),
+        govmod.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+            rationale: Some("I think this is a good idea".to_string()),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query rationale
+    let vote_resp: VoteResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod.clone(),
+            &QueryMsg::GetVote {
+                proposal_id: 1,
+                voter: "blue".to_string(),
+            },
+        )
+        .unwrap();
+
+    let vote = vote_resp.vote.unwrap();
+    assert_eq!(vote.vote.option_id, 0);
+    assert_eq!(
+        vote.rationale,
+        Some("I think this is a good idea".to_string())
+    );
+
+    // Revote with rationale
+    app.execute_contract(
+        Addr::unchecked("blue"),
+        govmod.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 1 },
+            rationale: Some("Nah".to_string()),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query rationale and ensure it changed
+    let vote_resp: VoteResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod.clone(),
+            &QueryMsg::GetVote {
+                proposal_id: 1,
+                voter: "blue".to_string(),
+            },
+        )
+        .unwrap();
+
+    let vote = vote_resp.vote.unwrap();
+    assert_eq!(vote.vote.option_id, 1);
+    assert_eq!(vote.rationale, Some("Nah".to_string()));
+
+    // Revote without rationale
+    app.execute_contract(
+        Addr::unchecked("blue"),
+        govmod.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 2 },
+            rationale: None,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query rationale and ensure it changed
+    let vote_resp: VoteResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod,
+            &QueryMsg::GetVote {
+                proposal_id: 1,
+                voter: "blue".to_string(),
+            },
+        )
+        .unwrap();
+
+    let vote = vote_resp.vote.unwrap();
+    assert_eq!(vote.vote.option_id, 2);
+    assert_eq!(vote.rationale, None);
+}
+
+#[test]
+fn test_update_rationale() {
+    let mut app = App::default();
+    let core_addr = instantiate_with_staked_balances_governance(
+        &mut app,
+        InstantiateMsg {
+            min_voting_period: None,
+            max_voting_period: Duration::Height(6),
+            only_members_execute: false,
+            allow_revoting: true, // Enable revoting
+            voting_strategy: VotingStrategy::SingleChoice {
+                quorum: PercentageThreshold::Majority {},
+            },
+            close_proposal_on_execution_failure: false,
+            pre_propose_info: PreProposeInfo::AnyoneMayPropose {},
+        },
+        Some(vec![
+            Cw20Coin {
+                address: "blue".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+            Cw20Coin {
+                address: "elub".to_string(),
+                amount: Uint128::new(100_000_000),
+            },
+        ]),
+    );
+
+    let gov_state: dao_core::query::DumpStateResponse = app
+        .wrap()
+        .query_wasm_smart(core_addr, &dao_core::msg::QueryMsg::DumpState {})
+        .unwrap();
+    let governance_modules = gov_state.proposal_modules;
+
+    assert_eq!(governance_modules.len(), 1);
+    let govmod = governance_modules.into_iter().next().unwrap().address;
+
+    let options = vec![
+        MultipleChoiceOption {
+            description: "multiple choice option 1".to_string(),
+            msgs: vec![],
+            title: "title 1".to_string(),
+        },
+        MultipleChoiceOption {
+            description: "multiple choice option 2".to_string(),
+            msgs: vec![],
+            title: "title 2".to_string(),
+        },
+    ];
+
+    // Propose something
+    let mc_options = MultipleChoiceOptions { options };
+    app.execute_contract(
+        Addr::unchecked(CREATOR_ADDR),
+        govmod.clone(),
+        &ExecuteMsg::Propose {
+            title: "A proposal".to_string(),
+            description: "A simple proposal".to_string(),
+            choices: mc_options,
+            proposer: None,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Vote with rationale
+    app.execute_contract(
+        Addr::unchecked("blue"),
+        govmod.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: MultipleChoiceVote { option_id: 0 },
+            rationale: Some("I think this is a good idea".to_string()),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query rationale
+    let vote_resp: VoteResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod.clone(),
+            &QueryMsg::GetVote {
+                proposal_id: 1,
+                voter: "blue".to_string(),
+            },
+        )
+        .unwrap();
+
+    let vote = vote_resp.vote.unwrap();
+    assert_eq!(vote.vote.option_id, 0);
+    assert_eq!(
+        vote.rationale,
+        Some("I think this is a good idea".to_string())
+    );
+
+    // Update rationale
+    app.execute_contract(
+        Addr::unchecked("blue"),
+        govmod.clone(),
+        &ExecuteMsg::UpdateRationale {
+            proposal_id: 1,
+            rationale: Some("This may be a good idea, but I'm not sure. YOLO".to_string()),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query rationale
+    let vote_resp: VoteResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod,
+            &QueryMsg::GetVote {
+                proposal_id: 1,
+                voter: "blue".to_string(),
+            },
+        )
+        .unwrap();
+
+    let vote = vote_resp.vote.unwrap();
+    assert_eq!(vote.vote.option_id, 0);
+    assert_eq!(
+        vote.rationale,
+        Some("This may be a good idea, but I'm not sure. YOLO".to_string())
+    );
+}