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

Issue:
support timestamps in proposals
Persist and surface the timestamp of transactions in a proposal (e.g. when it was created, passed, executed) will require some UI work as well 

Pull request:
Add timestamps to proposals
Adds a timestamp for created and last updated, in unix epoch time

closes #343 

Patch:
diff --git a/contracts/cw-proposal-single/schema/list_proposals_response.json b/contracts/cw-proposal-single/schema/list_proposals_response.json
index acc5ab41..74dd8776 100644
--- a/contracts/cw-proposal-single/schema/list_proposals_response.json
+++ b/contracts/cw-proposal-single/schema/list_proposals_response.json
@@ -561,8 +561,10 @@
       "type": "object",
       "required": [
         "allow_revoting",
+        "created",
         "description",
         "expiration",
+        "last_updated",
         "msgs",
         "proposer",
         "start_height",
@@ -576,6 +578,14 @@
         "allow_revoting": {
           "type": "boolean"
         },
+        "created": {
+          "description": "The timestamp at which this proposal was created.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Timestamp"
+            }
+          ]
+        },
         "deposit_info": {
           "description": "Information about the deposit that was sent as part of this proposal. None if no deposit.",
           "anyOf": [
@@ -598,6 +608,14 @@
             }
           ]
         },
+        "last_updated": {
+          "description": "The timestamp at which this proposal's status last changed (e.g. Passed, Executed).",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Timestamp"
+            }
+          ]
+        },
         "min_voting_period": {
           "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.",
           "anyOf": [
diff --git a/contracts/cw-proposal-single/schema/proposal_response.json b/contracts/cw-proposal-single/schema/proposal_response.json
index c2c77390..89230f99 100644
--- a/contracts/cw-proposal-single/schema/proposal_response.json
+++ b/contracts/cw-proposal-single/schema/proposal_response.json
@@ -564,8 +564,10 @@
       "type": "object",
       "required": [
         "allow_revoting",
+        "created",
         "description",
         "expiration",
+        "last_updated",
         "msgs",
         "proposer",
         "start_height",
@@ -579,6 +581,14 @@
         "allow_revoting": {
           "type": "boolean"
         },
+        "created": {
+          "description": "The timestamp at which this proposal was created.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Timestamp"
+            }
+          ]
+        },
         "deposit_info": {
           "description": "Information about the deposit that was sent as part of this proposal. None if no deposit.",
           "anyOf": [
@@ -601,6 +611,14 @@
             }
           ]
         },
+        "last_updated": {
+          "description": "The timestamp at which this proposal's status last changed (e.g. Passed, Executed).",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Timestamp"
+            }
+          ]
+        },
         "min_voting_period": {
           "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.",
           "anyOf": [
diff --git a/contracts/cw-proposal-single/schema/reverse_proposals_response.json b/contracts/cw-proposal-single/schema/reverse_proposals_response.json
index 3c2e3daf..a40f4cb6 100644
--- a/contracts/cw-proposal-single/schema/reverse_proposals_response.json
+++ b/contracts/cw-proposal-single/schema/reverse_proposals_response.json
@@ -561,8 +561,10 @@
       "type": "object",
       "required": [
         "allow_revoting",
+        "created",
         "description",
         "expiration",
+        "last_updated",
         "msgs",
         "proposer",
         "start_height",
@@ -576,6 +578,14 @@
         "allow_revoting": {
           "type": "boolean"
         },
+        "created": {
+          "description": "The timestamp at which this proposal was created.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Timestamp"
+            }
+          ]
+        },
         "deposit_info": {
           "description": "Information about the deposit that was sent as part of this proposal. None if no deposit.",
           "anyOf": [
@@ -598,6 +608,14 @@
             }
           ]
         },
+        "last_updated": {
+          "description": "The timestamp at which this proposal's status last changed (e.g. Passed, Executed).",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Timestamp"
+            }
+          ]
+        },
         "min_voting_period": {
           "description": "The minimum amount of time this proposal must remain open for voting. The proposal may not pass unless this is expired or None.",
           "anyOf": [
diff --git a/contracts/cw-proposal-single/src/contract.rs b/contracts/cw-proposal-single/src/contract.rs
index 488ff2fd..34490719 100644
--- a/contracts/cw-proposal-single/src/contract.rs
+++ b/contracts/cw-proposal-single/src/contract.rs
@@ -181,6 +181,8 @@ pub fn execute_propose(
             votes: Votes::zero(),
             allow_revoting: config.allow_revoting,
             deposit_info: config.deposit_info.clone(),
+            created: env.block.time,
+            last_updated: env.block.time,
         };
         // Update the proposal's status. Addresses case where proposal
         // expires on the same block as it is created.
@@ -252,6 +254,9 @@ pub fn execute_execute(
     }
 
     prop.status = Status::Executed;
+    // Update proposal's last updated timestamp.
+    prop.last_updated = env.block.time;
+
     PROPOSALS.save(deps.storage, proposal_id, &prop)?;
 
     let refund_message = match prop.deposit_info {
@@ -368,6 +373,7 @@ pub fn execute_vote(
         info.sender.to_string(),
         vote.to_string(),
     )?;
+
     Ok(Response::default()
         .add_submessages(change_hooks)
         .add_submessages(vote_hooks)
@@ -407,6 +413,8 @@ pub fn execute_close(
     };
 
     prop.status = Status::Closed;
+    // Update proposal's last updated timestamp.
+    prop.last_updated = env.block.time;
     PROPOSALS.save(deps.storage, proposal_id, &prop)?;
 
     let changed_hooks = proposal_status_changed_hooks(
diff --git a/contracts/cw-proposal-single/src/proposal.rs b/contracts/cw-proposal-single/src/proposal.rs
index b054600b..e79d5cf6 100644
--- a/contracts/cw-proposal-single/src/proposal.rs
+++ b/contracts/cw-proposal-single/src/proposal.rs
@@ -1,4 +1,6 @@
-use cosmwasm_std::{Addr, BlockInfo, CosmosMsg, Decimal, Empty, StdResult, Storage, Uint128};
+use cosmwasm_std::{
+    Addr, BlockInfo, CosmosMsg, Decimal, Empty, StdResult, Storage, Timestamp, Uint128,
+};
 use cw_utils::Expiration;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -41,6 +43,10 @@ pub struct Proposal {
     /// Information about the deposit that was sent as part of this
     /// proposal. None if no deposit.
     pub deposit_info: Option<CheckedDepositInfo>,
+    /// The timestamp at which this proposal was created.
+    pub created: Timestamp,
+    /// The timestamp at which this proposal's status last changed (e.g. Passed, Executed).
+    pub last_updated: Timestamp,
 }
 
 pub fn advance_proposal_id(store: &mut dyn Storage) -> StdResult<u64> {
@@ -117,7 +123,12 @@ impl Proposal {
 
     /// Sets a proposals status to its current status.
     pub fn update_status(&mut self, block: &BlockInfo) {
-        self.status = self.current_status(block);
+        let new_status = self.current_status(block);
+        // Update last_updated only if status changed.
+        if new_status != self.status {
+            self.last_updated = block.time
+        }
+        self.status = new_status
     }
 
     /// Returns true iff this proposal is sure to pass (even before
@@ -316,6 +327,8 @@ mod test {
             total_power,
             votes,
             deposit_info: None,
+            created: block.time,
+            last_updated: block.time,
         };
         (prop, block)
     }
diff --git a/types/contracts/cw-proposal-single/CwProposalSingleContract.ts b/types/contracts/cw-proposal-single/CwProposalSingleContract.ts
index 4c83be93..6c78f314 100644
--- a/types/contracts/cw-proposal-single/CwProposalSingleContract.ts
+++ b/types/contracts/cw-proposal-single/CwProposalSingleContract.ts
@@ -325,9 +325,11 @@ export interface ProposalResponse {
 }
 export interface Proposal {
   allow_revoting: boolean;
+  created: Timestamp;
   deposit_info?: CheckedDepositInfo | null;
   description: string;
   expiration: Expiration;
+  last_updated: Timestamp;
   min_voting_period?: Expiration | null;
   msgs: CosmosMsgForEmpty[];
   proposer: Addr;
diff --git a/types/package.json b/types/package.json
index cbd6b54e..93413d3d 100644
--- a/types/package.json
+++ b/types/package.json
@@ -18,7 +18,7 @@
   },
   "devDependencies": {
     "@types/node": "^15.14.9",
-    "cosmwasm-typescript-gen": "^0.3.4",
+    "cosmwasm-typescript-gen": "0.3.4",
     "husky": "^7.0.4",
     "prettier": "^2.5.0",
     "pretty-quick": "^3.1.2",
diff --git a/types/yarn.lock b/types/yarn.lock
index 53ca70c2..1e05cbd8 100644
--- a/types/yarn.lock
+++ b/types/yarn.lock
@@ -1870,7 +1870,7 @@ cosmjs-types@^0.4.0:
     long "^4.0.0"
     protobufjs "~6.11.2"
 
-cosmwasm-typescript-gen@^0.3.4:
+cosmwasm-typescript-gen@0.3.4:
   version "0.3.4"
   resolved "https://registry.yarnpkg.com/cosmwasm-typescript-gen/-/cosmwasm-typescript-gen-0.3.4.tgz#f18749fef6209185a5d351f241c6bcd33027ef69"
   integrity sha512-m0H1si09bDNcdc1zEUgdOyqvtYSBx7zLoqtI2wRq4tx8jSAtCXGu27sSC1bXKfcvkhuVgh0g7HFBfjERVX0TIw==


Test patch:
diff --git a/contracts/cw-proposal-single/src/tests.rs b/contracts/cw-proposal-single/src/tests.rs
index 59e64194..740a9d2a 100644
--- a/contracts/cw-proposal-single/src/tests.rs
+++ b/contracts/cw-proposal-single/src/tests.rs
@@ -1,6 +1,6 @@
 use std::u128;
 
-use cosmwasm_std::{to_binary, Addr, CosmosMsg, Decimal, Empty, Uint128, WasmMsg};
+use cosmwasm_std::{to_binary, Addr, CosmosMsg, Decimal, Empty, Timestamp, Uint128, WasmMsg};
 use cw20::Cw20Coin;
 use cw20_staked_balance_voting::msg::ActiveThreshold;
 use cw_multi_test::{next_block, App, Contract, ContractWrapper, Executor};
@@ -776,6 +776,8 @@ fn test_propose() {
         status: Status::Open,
         votes: Votes::zero(),
         deposit_info: None,
+        created: current_block.time,
+        last_updated: current_block.time,
     };
 
     assert_eq!(created.proposal, expected);
@@ -854,6 +856,8 @@ fn test_propose_supports_stargate_message() {
         status: Status::Open,
         votes: Votes::zero(),
         deposit_info: None,
+        created: current_block.time,
+        last_updated: current_block.time,
     };
 
     assert_eq!(created.proposal, expected);
@@ -1859,6 +1863,8 @@ fn test_query_list_proposals() {
             status: Status::Open,
             votes: Votes::zero(),
             deposit_info: None,
+            created: app.block_info().time,
+            last_updated: app.block_info().time,
         },
     };
     assert_eq!(proposals_forward.proposals[0], expected);
@@ -1904,6 +1910,8 @@ fn test_query_list_proposals() {
             status: Status::Open,
             votes: Votes::zero(),
             deposit_info: None,
+            created: app.block_info().time,
+            last_updated: app.block_info().time,
         },
     };
     assert_eq!(proposals_forward.proposals[0], expected);
@@ -3721,3 +3729,205 @@ fn test_min_duration_same_as_proposal_duration() {
 
     assert_eq!(proposal.proposal.status, Status::Passed);
 }
+
+#[test]
+fn test_timestamp_updated() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(single_proposal_contract());
+
+    let threshold = Threshold::AbsolutePercentage {
+        percentage: PercentageThreshold::Majority {},
+    };
+    let max_voting_period = cw_utils::Duration::Height(6);
+    let instantiate = InstantiateMsg {
+        threshold,
+        max_voting_period,
+        min_voting_period: None,
+        only_members_execute: false,
+        allow_revoting: false,
+        deposit_info: None,
+    };
+
+    let governance_addr = instantiate_with_cw20_balances_governance(
+        &mut app,
+        govmod_id,
+        instantiate,
+        Some(vec![
+            Cw20Coin {
+                address: "voter".to_string(),
+                amount: Uint128::new(3),
+            },
+            Cw20Coin {
+                address: "voter2".to_string(),
+                amount: Uint128::new(2),
+            },
+        ]),
+    );
+
+    let governance_modules: Vec<Addr> = app
+        .wrap()
+        .query_wasm_smart(
+            governance_addr,
+            &cw_core::msg::QueryMsg::ProposalModules {
+                start_at: None,
+                limit: None,
+            },
+        )
+        .unwrap();
+
+    let govmod_single = governance_modules.into_iter().next().unwrap();
+
+    // Create 2 proposals.
+    app.execute_contract(
+        Addr::unchecked("voter"),
+        govmod_single.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "This is a simple text proposal".to_string(),
+            msgs: vec![],
+        },
+        &[],
+    )
+    .unwrap();
+
+    app.execute_contract(
+        Addr::unchecked("voter"),
+        govmod_single.clone(),
+        &ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "This is a simple text proposal".to_string(),
+            msgs: vec![],
+        },
+        &[],
+    )
+    .unwrap();
+
+    let created_1: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+    let current_block = app.block_info();
+
+    // Verify created and last updated
+    assert_eq!(created_1.proposal.created, current_block.time);
+    assert_eq!(created_1.proposal.last_updated, current_block.time);
+
+    let created_2: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &QueryMsg::Proposal { proposal_id: 2 },
+        )
+        .unwrap();
+
+    // Verify created and last updated
+    assert_eq!(created_2.proposal.created, current_block.time);
+    assert_eq!(created_2.proposal.last_updated, current_block.time);
+
+    // Update block
+    let timestamp = Timestamp::from_seconds(300_000_000);
+    app.update_block(|block| block.time = timestamp);
+
+    // Vote on proposal
+    app.execute_contract(
+        Addr::unchecked("voter"),
+        govmod_single.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: Vote::Yes,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Expect that last_updated changed because of status change
+    let updated: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+
+    assert_eq!(updated.proposal.last_updated, app.block_info().time);
+    assert_eq!(updated.proposal.status, Status::Passed);
+
+    // Update block
+    let timestamp = Timestamp::from_seconds(500_000_000);
+    app.update_block(|block| block.time = timestamp);
+    let latest_time = app.block_info().time;
+
+    // Execute proposal
+    app.execute_contract(
+        Addr::unchecked("voter"),
+        govmod_single.clone(),
+        &ExecuteMsg::Execute { proposal_id: 1 },
+        &[],
+    )
+    .unwrap();
+
+    // Status should have changed to 'Executed'
+    let updated: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &QueryMsg::Proposal { proposal_id: 1 },
+        )
+        .unwrap();
+
+    assert_eq!(updated.proposal.last_updated, latest_time);
+    assert_eq!(updated.proposal.status, Status::Executed);
+
+    let timestamp = Timestamp::from_seconds(700_000_000);
+    app.update_block(|block| block.time = timestamp);
+    let latest_time = app.block_info().time;
+
+    // Vote no on second proposal
+    app.execute_contract(
+        Addr::unchecked("voter"),
+        govmod_single.clone(),
+        &ExecuteMsg::Vote {
+            proposal_id: 2,
+            vote: Vote::No,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Status should have changed to 'Rejected'
+    let updated: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &QueryMsg::Proposal { proposal_id: 2 },
+        )
+        .unwrap();
+
+    assert_eq!(updated.proposal.last_updated, latest_time);
+    assert_eq!(updated.proposal.status, Status::Rejected);
+
+    let timestamp = Timestamp::from_seconds(900_000_000);
+    app.update_block(|block| block.time = timestamp);
+    let latest_time = app.block_info().time;
+
+    // Close second proposal
+    app.execute_contract(
+        Addr::unchecked("voter"),
+        govmod_single.clone(),
+        &ExecuteMsg::Close { proposal_id: 2 },
+        &[],
+    )
+    .unwrap();
+
+    // Status should have changed to 'Closed'
+    let updated: ProposalResponse = app
+        .wrap()
+        .query_wasm_smart(govmod_single, &QueryMsg::Proposal { proposal_id: 2 })
+        .unwrap();
+
+    assert_eq!(updated.proposal.last_updated, latest_time);
+    assert_eq!(updated.proposal.status, Status::Closed);
+}