Repository: DA0-DA0/dao-contracts
Pull request #238 (2022-04-13 11:11:50)

First version of proposal hooks
Pulling this up to start to get some feedback on my first implementation.

Tried to make it as portable as possible for the different proposal modules we will want to support.

Feedback appreciated!

JakeHartnell: > Great work! Love how you broke this into a seprate package. Will need to add hooks into `execute_execute` as well here and then I think it will be well covered.
> 
> We should also think about the granularity at which we should allow subscribing to hooks. In this current model you subscribe to all hooks, though I could imagine a world where one only wants so subscribe to vote hooks to do incentivized voting.
> 
> Should we support more granular subscription like that? @JakeHartnell any thoughts?

More granular subscription would be _really_ nice. If only because of frequency and gas usage, the vote hook fires a lot, but `ProposalStatusChange` doesn't happen as often.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 22c65913..6424145b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -279,10 +279,13 @@ dependencies = [
  "cw20-balance-voting",
  "cw20-base 0.12.1",
  "cw3 0.12.1",
+ "indexable-hooks",
+ "proposal-hooks",
  "rand",
  "schemars",
  "serde",
  "thiserror",
+ "vote-hooks",
  "voting",
 ]
 
@@ -783,6 +786,17 @@ dependencies = [
  "digest",
 ]
 
+[[package]]
+name = "indexable-hooks"
+version = "0.1.0"
+dependencies = [
+ "cosmwasm-std",
+ "cw-storage-plus 0.12.1",
+ "schemars",
+ "serde",
+ "thiserror",
+]
+
 [[package]]
 name = "itertools"
 version = "0.10.3"
@@ -847,6 +861,41 @@ dependencies = [
  "unicode-xid",
 ]
 
+[[package]]
+name = "proposal-hooks"
+version = "0.1.0"
+dependencies = [
+ "cosmwasm-std",
+ "indexable-hooks",
+ "schemars",
+ "serde",
+]
+
+[[package]]
+name = "proposal-hooks-counter"
+version = "0.1.0"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cosmwasm-storage",
+ "cw-core",
+ "cw-multi-test 0.12.1",
+ "cw-proposal-single",
+ "cw-storage-plus 0.12.1",
+ "cw-utils 0.12.1",
+ "cw2 0.12.1",
+ "cw20 0.12.1",
+ "cw20-balance-voting",
+ "cw20-base 0.12.1",
+ "indexable-hooks",
+ "proposal-hooks",
+ "schemars",
+ "serde",
+ "thiserror",
+ "vote-hooks",
+ "voting",
+]
+
 [[package]]
 name = "prost"
 version = "0.9.0"
@@ -1124,6 +1173,16 @@ version = "0.9.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
 
+[[package]]
+name = "vote-hooks"
+version = "0.1.0"
+dependencies = [
+ "cosmwasm-std",
+ "indexable-hooks",
+ "schemars",
+ "serde",
+]
+
 [[package]]
 name = "voting"
 version = "0.1.0"
diff --git a/contracts/cw-proposal-single/Cargo.toml b/contracts/cw-proposal-single/Cargo.toml
index 55e5346d..2d69c188 100644
--- a/contracts/cw-proposal-single/Cargo.toml
+++ b/contracts/cw-proposal-single/Cargo.toml
@@ -43,6 +43,9 @@ cw-core = { path = "../cw-core", version = "0.1.0", features = ["library"] }
 cw-core-macros = { path = "../../packages/cw-core-macros", version = "0.1.0" }
 cw-core-interface = { version = "0.1.0", path = "../../packages/cw-core-interface" } 
 voting = { version = "0.1.0", path = "../../packages/voting" }
+indexable-hooks = { version = "0.1.0", path = "../../packages/indexable-hooks" }
+proposal-hooks = { version = "0.1.0", path = "../../packages/proposal-hooks" }
+vote-hooks = { version = "0.1.0", path = "../../packages/vote-hooks" }
 
 [dev-dependencies]
 cosmwasm-schema = { version = "1.0.0-beta5" }
diff --git a/contracts/cw-proposal-single/schema/execute_msg.json b/contracts/cw-proposal-single/schema/execute_msg.json
index 500e21d9..d660d12e 100644
--- a/contracts/cw-proposal-single/schema/execute_msg.json
+++ b/contracts/cw-proposal-single/schema/execute_msg.json
@@ -184,6 +184,86 @@
         }
       },
       "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "add_proposal_hook"
+      ],
+      "properties": {
+        "add_proposal_hook": {
+          "type": "object",
+          "required": [
+            "address"
+          ],
+          "properties": {
+            "address": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "remove_proposal_hook"
+      ],
+      "properties": {
+        "remove_proposal_hook": {
+          "type": "object",
+          "required": [
+            "address"
+          ],
+          "properties": {
+            "address": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "add_vote_hook"
+      ],
+      "properties": {
+        "add_vote_hook": {
+          "type": "object",
+          "required": [
+            "address"
+          ],
+          "properties": {
+            "address": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "remove_vote_hook"
+      ],
+      "properties": {
+        "remove_vote_hook": {
+          "type": "object",
+          "required": [
+            "address"
+          ],
+          "properties": {
+            "address": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
     }
   ],
   "definitions": {
diff --git a/contracts/cw-proposal-single/schema/proposal_list_response.json b/contracts/cw-proposal-single/schema/proposal_list_response.json
new file mode 100644
index 00000000..e5b74e66
--- /dev/null
+++ b/contracts/cw-proposal-single/schema/proposal_list_response.json
@@ -0,0 +1,764 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "ProposalListResponse",
+  "description": "A list of proposals returned by `ListProposals` and `ReverseProposals`.",
+  "type": "object",
+  "required": [
+    "proposals"
+  ],
+  "properties": {
+    "proposals": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/ProposalResponse"
+      }
+    }
+  },
+  "definitions": {
+    "Addr": {
+      "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.",
+      "type": "string"
+    },
+    "BankMsg": {
+      "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "send"
+          ],
+          "properties": {
+            "send": {
+              "type": "object",
+              "required": [
+                "amount",
+                "to_address"
+              ],
+              "properties": {
+                "amount": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "to_address": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.",
+          "type": "object",
+          "required": [
+            "burn"
+          ],
+          "properties": {
+            "burn": {
+              "type": "object",
+              "required": [
+                "amount"
+              ],
+              "properties": {
+                "amount": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Binary": {
+      "description": "Binary is a wrapper around Vec<u8> to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec<u8>",
+      "type": "string"
+    },
+    "CheckedDepositInfo": {
+      "description": "Counterpart to the `DepositInfo` struct which has been processed.",
+      "type": "object",
+      "required": [
+        "deposit",
+        "refund_failed_proposals",
+        "token"
+      ],
+      "properties": {
+        "deposit": {
+          "description": "The number of tokens that must be deposited to create a proposal.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Uint128"
+            }
+          ]
+        },
+        "refund_failed_proposals": {
+          "description": "If failed proposals should have their deposits refunded.",
+          "type": "boolean"
+        },
+        "token": {
+          "description": "The address of the cw20 token to be used for proposal deposits.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Addr"
+            }
+          ]
+        }
+      }
+    },
+    "Coin": {
+      "type": "object",
+      "required": [
+        "amount",
+        "denom"
+      ],
+      "properties": {
+        "amount": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "denom": {
+          "type": "string"
+        }
+      }
+    },
+    "CosmosMsg_for_Empty": {
+      "oneOf": [
+        {
+          "type": "object",
+          "required": [
+            "bank"
+          ],
+          "properties": {
+            "bank": {
+              "$ref": "#/definitions/BankMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "custom"
+          ],
+          "properties": {
+            "custom": {
+              "$ref": "#/definitions/Empty"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "staking"
+          ],
+          "properties": {
+            "staking": {
+              "$ref": "#/definitions/StakingMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "distribution"
+          ],
+          "properties": {
+            "distribution": {
+              "$ref": "#/definitions/DistributionMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "wasm"
+          ],
+          "properties": {
+            "wasm": {
+              "$ref": "#/definitions/WasmMsg"
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Decimal": {
+      "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)",
+      "type": "string"
+    },
+    "DistributionMsg": {
+      "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "set_withdraw_address"
+          ],
+          "properties": {
+            "set_withdraw_address": {
+              "type": "object",
+              "required": [
+                "address"
+              ],
+              "properties": {
+                "address": {
+                  "description": "The `withdraw_address`",
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "withdraw_delegator_reward"
+          ],
+          "properties": {
+            "withdraw_delegator_reward": {
+              "type": "object",
+              "required": [
+                "validator"
+              ],
+              "properties": {
+                "validator": {
+                  "description": "The `validator_address`",
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Empty": {
+      "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)",
+      "type": "object"
+    },
+    "Expiration": {
+      "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)",
+      "oneOf": [
+        {
+          "description": "AtHeight will expire when `env.block.height` >= height",
+          "type": "object",
+          "required": [
+            "at_height"
+          ],
+          "properties": {
+            "at_height": {
+              "type": "integer",
+              "format": "uint64",
+              "minimum": 0.0
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "AtTime will expire when `env.block.time` >= time",
+          "type": "object",
+          "required": [
+            "at_time"
+          ],
+          "properties": {
+            "at_time": {
+              "$ref": "#/definitions/Timestamp"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Never will never expire. Used to express the empty variant",
+          "type": "object",
+          "required": [
+            "never"
+          ],
+          "properties": {
+            "never": {
+              "type": "object"
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "PercentageThreshold": {
+      "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `yes_votes >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.",
+      "oneOf": [
+        {
+          "description": "The majority of voters must vote yes for the proposal to pass.",
+          "type": "object",
+          "required": [
+            "majority"
+          ],
+          "properties": {
+            "majority": {
+              "type": "object"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.",
+          "type": "object",
+          "required": [
+            "percent"
+          ],
+          "properties": {
+            "percent": {
+              "$ref": "#/definitions/Decimal"
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Proposal": {
+      "type": "object",
+      "required": [
+        "description",
+        "expiration",
+        "msgs",
+        "proposer",
+        "start_height",
+        "status",
+        "threshold",
+        "title",
+        "total_power",
+        "votes"
+      ],
+      "properties": {
+        "deposit_info": {
+          "description": "Information about the deposit that was sent as part of this proposal. None if no deposit.",
+          "anyOf": [
+            {
+              "$ref": "#/definitions/CheckedDepositInfo"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "description": {
+          "type": "string"
+        },
+        "expiration": {
+          "$ref": "#/definitions/Expiration"
+        },
+        "msgs": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/CosmosMsg_for_Empty"
+          }
+        },
+        "proposer": {
+          "$ref": "#/definitions/Addr"
+        },
+        "start_height": {
+          "type": "integer",
+          "format": "uint64",
+          "minimum": 0.0
+        },
+        "status": {
+          "$ref": "#/definitions/Status"
+        },
+        "threshold": {
+          "$ref": "#/definitions/Threshold"
+        },
+        "title": {
+          "type": "string"
+        },
+        "total_power": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "votes": {
+          "$ref": "#/definitions/Votes"
+        }
+      }
+    },
+    "ProposalResponse": {
+      "description": "Information about a proposal returned by proposal queries.",
+      "type": "object",
+      "required": [
+        "id",
+        "proposal"
+      ],
+      "properties": {
+        "id": {
+          "type": "integer",
+          "format": "uint64",
+          "minimum": 0.0
+        },
+        "proposal": {
+          "$ref": "#/definitions/Proposal"
+        }
+      }
+    },
+    "StakingMsg": {
+      "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "delegate"
+          ],
+          "properties": {
+            "delegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "undelegate"
+          ],
+          "properties": {
+            "undelegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "redelegate"
+          ],
+          "properties": {
+            "redelegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "dst_validator",
+                "src_validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "dst_validator": {
+                  "type": "string"
+                },
+                "src_validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Status": {
+      "type": "string",
+      "enum": [
+        "open",
+        "rejected",
+        "passed",
+        "executed",
+        "closed"
+      ]
+    },
+    "Threshold": {
+      "description": "The ways a proposal may reach its passing / failing threshold.",
+      "oneOf": [
+        {
+          "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass.  See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.",
+          "type": "object",
+          "required": [
+            "absolute_percentage"
+          ],
+          "properties": {
+            "absolute_percentage": {
+              "type": "object",
+              "required": [
+                "percentage"
+              ],
+              "properties": {
+                "percentage": {
+                  "$ref": "#/definitions/PercentageThreshold"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.",
+          "type": "object",
+          "required": [
+            "threshold_quorum"
+          ],
+          "properties": {
+            "threshold_quorum": {
+              "type": "object",
+              "required": [
+                "quorum",
+                "threshold"
+              ],
+              "properties": {
+                "quorum": {
+                  "$ref": "#/definitions/PercentageThreshold"
+                },
+                "threshold": {
+                  "$ref": "#/definitions/PercentageThreshold"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Timestamp": {
+      "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```",
+      "allOf": [
+        {
+          "$ref": "#/definitions/Uint64"
+        }
+      ]
+    },
+    "Uint128": {
+      "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
+      "type": "string"
+    },
+    "Uint64": {
+      "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```",
+      "type": "string"
+    },
+    "Votes": {
+      "type": "object",
+      "required": [
+        "abstain",
+        "no",
+        "yes"
+      ],
+      "properties": {
+        "abstain": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "no": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "yes": {
+          "$ref": "#/definitions/Uint128"
+        }
+      }
+    },
+    "WasmMsg": {
+      "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto",
+      "oneOf": [
+        {
+          "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "execute"
+          ],
+          "properties": {
+            "execute": {
+              "type": "object",
+              "required": [
+                "contract_addr",
+                "funds",
+                "msg"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                },
+                "funds": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "msg": {
+                  "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.16.0-alpha1/x/wasm/internal/types/tx.proto#L47-L61). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "instantiate"
+          ],
+          "properties": {
+            "instantiate": {
+              "type": "object",
+              "required": [
+                "code_id",
+                "funds",
+                "label",
+                "msg"
+              ],
+              "properties": {
+                "admin": {
+                  "type": [
+                    "string",
+                    "null"
+                  ]
+                },
+                "code_id": {
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                },
+                "funds": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "label": {
+                  "description": "A human-readbale label for the contract",
+                  "type": "string"
+                },
+                "msg": {
+                  "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "migrate"
+          ],
+          "properties": {
+            "migrate": {
+              "type": "object",
+              "required": [
+                "contract_addr",
+                "msg",
+                "new_code_id"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                },
+                "msg": {
+                  "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                },
+                "new_code_id": {
+                  "description": "the code_id of the new logic to place in the given contract",
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.",
+          "type": "object",
+          "required": [
+            "update_admin"
+          ],
+          "properties": {
+            "update_admin": {
+              "type": "object",
+              "required": [
+                "admin",
+                "contract_addr"
+              ],
+              "properties": {
+                "admin": {
+                  "type": "string"
+                },
+                "contract_addr": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.",
+          "type": "object",
+          "required": [
+            "clear_admin"
+          ],
+          "properties": {
+            "clear_admin": {
+              "type": "object",
+              "required": [
+                "contract_addr"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    }
+  }
+}
diff --git a/contracts/cw-proposal-single/schema/proposal_response.json b/contracts/cw-proposal-single/schema/proposal_response.json
new file mode 100644
index 00000000..7deb8c1c
--- /dev/null
+++ b/contracts/cw-proposal-single/schema/proposal_response.json
@@ -0,0 +1,749 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "ProposalResponse",
+  "description": "Information about a proposal returned by proposal queries.",
+  "type": "object",
+  "required": [
+    "id",
+    "proposal"
+  ],
+  "properties": {
+    "id": {
+      "type": "integer",
+      "format": "uint64",
+      "minimum": 0.0
+    },
+    "proposal": {
+      "$ref": "#/definitions/Proposal"
+    }
+  },
+  "definitions": {
+    "Addr": {
+      "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.",
+      "type": "string"
+    },
+    "BankMsg": {
+      "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "send"
+          ],
+          "properties": {
+            "send": {
+              "type": "object",
+              "required": [
+                "amount",
+                "to_address"
+              ],
+              "properties": {
+                "amount": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "to_address": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.",
+          "type": "object",
+          "required": [
+            "burn"
+          ],
+          "properties": {
+            "burn": {
+              "type": "object",
+              "required": [
+                "amount"
+              ],
+              "properties": {
+                "amount": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Binary": {
+      "description": "Binary is a wrapper around Vec<u8> to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec<u8>",
+      "type": "string"
+    },
+    "CheckedDepositInfo": {
+      "description": "Counterpart to the `DepositInfo` struct which has been processed.",
+      "type": "object",
+      "required": [
+        "deposit",
+        "refund_failed_proposals",
+        "token"
+      ],
+      "properties": {
+        "deposit": {
+          "description": "The number of tokens that must be deposited to create a proposal.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Uint128"
+            }
+          ]
+        },
+        "refund_failed_proposals": {
+          "description": "If failed proposals should have their deposits refunded.",
+          "type": "boolean"
+        },
+        "token": {
+          "description": "The address of the cw20 token to be used for proposal deposits.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Addr"
+            }
+          ]
+        }
+      }
+    },
+    "Coin": {
+      "type": "object",
+      "required": [
+        "amount",
+        "denom"
+      ],
+      "properties": {
+        "amount": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "denom": {
+          "type": "string"
+        }
+      }
+    },
+    "CosmosMsg_for_Empty": {
+      "oneOf": [
+        {
+          "type": "object",
+          "required": [
+            "bank"
+          ],
+          "properties": {
+            "bank": {
+              "$ref": "#/definitions/BankMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "custom"
+          ],
+          "properties": {
+            "custom": {
+              "$ref": "#/definitions/Empty"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "staking"
+          ],
+          "properties": {
+            "staking": {
+              "$ref": "#/definitions/StakingMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "distribution"
+          ],
+          "properties": {
+            "distribution": {
+              "$ref": "#/definitions/DistributionMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "wasm"
+          ],
+          "properties": {
+            "wasm": {
+              "$ref": "#/definitions/WasmMsg"
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Decimal": {
+      "description": "A fixed-point decimal value with 18 fractional digits, i.e. Decimal(1_000_000_000_000_000_000) == 1.0\n\nThe greatest possible value that can be represented is 340282366920938463463.374607431768211455 (which is (2^128 - 1) / 10^18)",
+      "type": "string"
+    },
+    "DistributionMsg": {
+      "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "set_withdraw_address"
+          ],
+          "properties": {
+            "set_withdraw_address": {
+              "type": "object",
+              "required": [
+                "address"
+              ],
+              "properties": {
+                "address": {
+                  "description": "The `withdraw_address`",
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "withdraw_delegator_reward"
+          ],
+          "properties": {
+            "withdraw_delegator_reward": {
+              "type": "object",
+              "required": [
+                "validator"
+              ],
+              "properties": {
+                "validator": {
+                  "description": "The `validator_address`",
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Empty": {
+      "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)",
+      "type": "object"
+    },
+    "Expiration": {
+      "description": "Expiration represents a point in time when some event happens. It can compare with a BlockInfo and will return is_expired() == true once the condition is hit (and for every block in the future)",
+      "oneOf": [
+        {
+          "description": "AtHeight will expire when `env.block.height` >= height",
+          "type": "object",
+          "required": [
+            "at_height"
+          ],
+          "properties": {
+            "at_height": {
+              "type": "integer",
+              "format": "uint64",
+              "minimum": 0.0
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "AtTime will expire when `env.block.time` >= time",
+          "type": "object",
+          "required": [
+            "at_time"
+          ],
+          "properties": {
+            "at_time": {
+              "$ref": "#/definitions/Timestamp"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Never will never expire. Used to express the empty variant",
+          "type": "object",
+          "required": [
+            "never"
+          ],
+          "properties": {
+            "never": {
+              "type": "object"
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "PercentageThreshold": {
+      "description": "A percentage of voting power that must vote yes for a proposal to pass. An example of why this is needed:\n\nIf a user specifies a 60% passing threshold, and there are 10 voters they likely expect that proposal to pass when there are 6 yes votes. This implies that the condition for passing should be `yes_votes >= total_votes * threshold`.\n\nWith this in mind, how should a user specify that they would like proposals to pass if the majority of voters choose yes? Selecting a 50% passing threshold with those rules doesn't properly cover that case as 5 voters voting yes out of 10 would pass the proposal. Selecting 50.0001% or or some variation of that also does not work as a very small yes vote which technically makes the majority yes may not reach that threshold.\n\nTo handle these cases we provide both a majority and percent option for all percentages. If majority is selected passing will be determined by `yes > total_votes * 0.5`. If percent is selected passing is determined by `yes >= total_votes * percent`.\n\nIn both of these cases a proposal with only abstain votes must fail. This requires a special case passing logic.",
+      "oneOf": [
+        {
+          "description": "The majority of voters must vote yes for the proposal to pass.",
+          "type": "object",
+          "required": [
+            "majority"
+          ],
+          "properties": {
+            "majority": {
+              "type": "object"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "A percentage of voting power >= percent must vote yes for the proposal to pass.",
+          "type": "object",
+          "required": [
+            "percent"
+          ],
+          "properties": {
+            "percent": {
+              "$ref": "#/definitions/Decimal"
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Proposal": {
+      "type": "object",
+      "required": [
+        "description",
+        "expiration",
+        "msgs",
+        "proposer",
+        "start_height",
+        "status",
+        "threshold",
+        "title",
+        "total_power",
+        "votes"
+      ],
+      "properties": {
+        "deposit_info": {
+          "description": "Information about the deposit that was sent as part of this proposal. None if no deposit.",
+          "anyOf": [
+            {
+              "$ref": "#/definitions/CheckedDepositInfo"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "description": {
+          "type": "string"
+        },
+        "expiration": {
+          "$ref": "#/definitions/Expiration"
+        },
+        "msgs": {
+          "type": "array",
+          "items": {
+            "$ref": "#/definitions/CosmosMsg_for_Empty"
+          }
+        },
+        "proposer": {
+          "$ref": "#/definitions/Addr"
+        },
+        "start_height": {
+          "type": "integer",
+          "format": "uint64",
+          "minimum": 0.0
+        },
+        "status": {
+          "$ref": "#/definitions/Status"
+        },
+        "threshold": {
+          "$ref": "#/definitions/Threshold"
+        },
+        "title": {
+          "type": "string"
+        },
+        "total_power": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "votes": {
+          "$ref": "#/definitions/Votes"
+        }
+      }
+    },
+    "StakingMsg": {
+      "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "delegate"
+          ],
+          "properties": {
+            "delegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "undelegate"
+          ],
+          "properties": {
+            "undelegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "redelegate"
+          ],
+          "properties": {
+            "redelegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "dst_validator",
+                "src_validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "dst_validator": {
+                  "type": "string"
+                },
+                "src_validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Status": {
+      "type": "string",
+      "enum": [
+        "open",
+        "rejected",
+        "passed",
+        "executed",
+        "closed"
+      ]
+    },
+    "Threshold": {
+      "description": "The ways a proposal may reach its passing / failing threshold.",
+      "oneOf": [
+        {
+          "description": "Declares a percentage of the total weight that must cast Yes votes in order for a proposal to pass.  See `ThresholdResponse::AbsolutePercentage` in the cw3 spec for details.",
+          "type": "object",
+          "required": [
+            "absolute_percentage"
+          ],
+          "properties": {
+            "absolute_percentage": {
+              "type": "object",
+              "required": [
+                "percentage"
+              ],
+              "properties": {
+                "percentage": {
+                  "$ref": "#/definitions/PercentageThreshold"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Declares a `quorum` of the total votes that must participate in the election in order for the vote to be considered at all. See `ThresholdResponse::ThresholdQuorum` in the cw3 spec for details.",
+          "type": "object",
+          "required": [
+            "threshold_quorum"
+          ],
+          "properties": {
+            "threshold_quorum": {
+              "type": "object",
+              "required": [
+                "quorum",
+                "threshold"
+              ],
+              "properties": {
+                "quorum": {
+                  "$ref": "#/definitions/PercentageThreshold"
+                },
+                "threshold": {
+                  "$ref": "#/definitions/PercentageThreshold"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Timestamp": {
+      "description": "A point in time in nanosecond precision.\n\nThis type can represent times from 1970-01-01T00:00:00Z to 2554-07-21T23:34:33Z.\n\n## Examples\n\n``` # use cosmwasm_std::Timestamp; let ts = Timestamp::from_nanos(1_000_000_202); assert_eq!(ts.nanos(), 1_000_000_202); assert_eq!(ts.seconds(), 1); assert_eq!(ts.subsec_nanos(), 202);\n\nlet ts = ts.plus_seconds(2); assert_eq!(ts.nanos(), 3_000_000_202); assert_eq!(ts.seconds(), 3); assert_eq!(ts.subsec_nanos(), 202); ```",
+      "allOf": [
+        {
+          "$ref": "#/definitions/Uint64"
+        }
+      ]
+    },
+    "Uint128": {
+      "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
+      "type": "string"
+    },
+    "Uint64": {
+      "description": "A thin wrapper around u64 that is using strings for JSON encoding/decoding, such that the full u64 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u64` to get the value out:\n\n``` # use cosmwasm_std::Uint64; let a = Uint64::from(42u64); assert_eq!(a.u64(), 42);\n\nlet b = Uint64::from(70u32); assert_eq!(b.u64(), 70); ```",
+      "type": "string"
+    },
+    "Votes": {
+      "type": "object",
+      "required": [
+        "abstain",
+        "no",
+        "yes"
+      ],
+      "properties": {
+        "abstain": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "no": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "yes": {
+          "$ref": "#/definitions/Uint128"
+        }
+      }
+    },
+    "WasmMsg": {
+      "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto",
+      "oneOf": [
+        {
+          "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "execute"
+          ],
+          "properties": {
+            "execute": {
+              "type": "object",
+              "required": [
+                "contract_addr",
+                "funds",
+                "msg"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                },
+                "funds": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "msg": {
+                  "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.16.0-alpha1/x/wasm/internal/types/tx.proto#L47-L61). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "instantiate"
+          ],
+          "properties": {
+            "instantiate": {
+              "type": "object",
+              "required": [
+                "code_id",
+                "funds",
+                "label",
+                "msg"
+              ],
+              "properties": {
+                "admin": {
+                  "type": [
+                    "string",
+                    "null"
+                  ]
+                },
+                "code_id": {
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                },
+                "funds": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "label": {
+                  "description": "A human-readbale label for the contract",
+                  "type": "string"
+                },
+                "msg": {
+                  "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "migrate"
+          ],
+          "properties": {
+            "migrate": {
+              "type": "object",
+              "required": [
+                "contract_addr",
+                "msg",
+                "new_code_id"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                },
+                "msg": {
+                  "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                },
+                "new_code_id": {
+                  "description": "the code_id of the new logic to place in the given contract",
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.",
+          "type": "object",
+          "required": [
+            "update_admin"
+          ],
+          "properties": {
+            "update_admin": {
+              "type": "object",
+              "required": [
+                "admin",
+                "contract_addr"
+              ],
+              "properties": {
+                "admin": {
+                  "type": "string"
+                },
+                "contract_addr": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.",
+          "type": "object",
+          "required": [
+            "clear_admin"
+          ],
+          "properties": {
+            "clear_admin": {
+              "type": "object",
+              "required": [
+                "contract_addr"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    }
+  }
+}
diff --git a/contracts/cw-proposal-single/schema/query_msg.json b/contracts/cw-proposal-single/schema/query_msg.json
index 3b4f16c8..c88c6dd3 100644
--- a/contracts/cw-proposal-single/schema/query_msg.json
+++ b/contracts/cw-proposal-single/schema/query_msg.json
@@ -172,6 +172,30 @@
       },
       "additionalProperties": false
     },
+    {
+      "type": "object",
+      "required": [
+        "proposal_hooks"
+      ],
+      "properties": {
+        "proposal_hooks": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "vote_hooks"
+      ],
+      "properties": {
+        "vote_hooks": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
     {
       "type": "object",
       "required": [
diff --git a/contracts/cw-proposal-single/schema/vote_list_response.json b/contracts/cw-proposal-single/schema/vote_list_response.json
new file mode 100644
index 00000000..1f6db2fb
--- /dev/null
+++ b/contracts/cw-proposal-single/schema/vote_list_response.json
@@ -0,0 +1,70 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "VoteListResponse",
+  "description": "Information about the votes for a proposal.",
+  "type": "object",
+  "required": [
+    "votes"
+  ],
+  "properties": {
+    "votes": {
+      "type": "array",
+      "items": {
+        "$ref": "#/definitions/VoteInfo"
+      }
+    }
+  },
+  "definitions": {
+    "Addr": {
+      "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.",
+      "type": "string"
+    },
+    "Uint128": {
+      "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
+      "type": "string"
+    },
+    "Vote": {
+      "type": "string",
+      "enum": [
+        "yes",
+        "no",
+        "abstain"
+      ]
+    },
+    "VoteInfo": {
+      "description": "Information about a vote that was cast.",
+      "type": "object",
+      "required": [
+        "power",
+        "vote",
+        "voter"
+      ],
+      "properties": {
+        "power": {
+          "description": "The voting power behind the vote.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Uint128"
+            }
+          ]
+        },
+        "vote": {
+          "description": "Position on the vote.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Vote"
+            }
+          ]
+        },
+        "voter": {
+          "description": "The address that voted.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Addr"
+            }
+          ]
+        }
+      }
+    }
+  }
+}
diff --git a/contracts/cw-proposal-single/schema/vote_response.json b/contracts/cw-proposal-single/schema/vote_response.json
new file mode 100644
index 00000000..af7fa3d5
--- /dev/null
+++ b/contracts/cw-proposal-single/schema/vote_response.json
@@ -0,0 +1,72 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "VoteResponse",
+  "description": "Information about a vote.",
+  "type": "object",
+  "properties": {
+    "vote": {
+      "description": "None if no such vote, Some otherwise.",
+      "anyOf": [
+        {
+          "$ref": "#/definitions/VoteInfo"
+        },
+        {
+          "type": "null"
+        }
+      ]
+    }
+  },
+  "definitions": {
+    "Addr": {
+      "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.",
+      "type": "string"
+    },
+    "Uint128": {
+      "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
+      "type": "string"
+    },
+    "Vote": {
+      "type": "string",
+      "enum": [
+        "yes",
+        "no",
+        "abstain"
+      ]
+    },
+    "VoteInfo": {
+      "description": "Information about a vote that was cast.",
+      "type": "object",
+      "required": [
+        "power",
+        "vote",
+        "voter"
+      ],
+      "properties": {
+        "power": {
+          "description": "The voting power behind the vote.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Uint128"
+            }
+          ]
+        },
+        "vote": {
+          "description": "Position on the vote.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Vote"
+            }
+          ]
+        },
+        "voter": {
+          "description": "The address that voted.",
+          "allOf": [
+            {
+              "$ref": "#/definitions/Addr"
+            }
+          ]
+        }
+      }
+    }
+  }
+}
diff --git a/contracts/cw-proposal-single/src/contract.rs b/contracts/cw-proposal-single/src/contract.rs
index 87902396..4c7e438e 100644
--- a/contracts/cw-proposal-single/src/contract.rs
+++ b/contracts/cw-proposal-single/src/contract.rs
@@ -1,12 +1,15 @@
 #[cfg(not(feature = "library"))]
 use cosmwasm_std::entry_point;
 use cosmwasm_std::{
-    to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Response,
-    StdResult, WasmMsg,
+    to_binary, Addr, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Reply, Response,
+    StdResult, Storage, WasmMsg,
 };
 use cw2::set_contract_version;
 use cw_storage_plus::Bound;
 use cw_utils::{Duration, Expiration};
+use indexable_hooks::Hooks;
+use proposal_hooks::{new_proposal_hooks, proposal_status_changed_hooks};
+use vote_hooks::new_vote_hooks;
 
 use voting::{Vote, Votes};
 
@@ -18,7 +21,7 @@ use crate::{
     query::{ProposalResponse, VoteInfo, VoteListResponse, VoteResponse},
     state::{
         get_deposit_msg, get_return_deposit_msg, Ballot, Config, BALLOTS, CONFIG, PROPOSALS,
-        PROPOSAL_COUNT,
+        PROPOSAL_COUNT, PROPOSAL_HOOKS, VOTE_HOOKS,
     },
     threshold::Threshold,
     utils::{get_total_power, get_voting_power},
@@ -94,6 +97,16 @@ pub fn execute(
             dao,
             deposit_info,
         ),
+        ExecuteMsg::AddProposalHook { address } => {
+            execute_add_proposal_hook(deps, env, info, address)
+        }
+        ExecuteMsg::RemoveProposalHook { address } => {
+            execute_remove_proposal_hook(deps, env, info, address)
+        }
+        ExecuteMsg::AddVoteHook { address } => execute_add_vote_hook(deps, env, info, address),
+        ExecuteMsg::RemoveVoteHook { address } => {
+            execute_remove_vote_hook(deps, env, info, address)
+        }
     }
 }
 
@@ -158,9 +171,10 @@ pub fn execute_propose(
     PROPOSALS.save(deps.storage, id, &proposal)?;
 
     let deposit_msg = get_deposit_msg(&config.deposit_info, &env.contract.address, &sender)?;
-
+    let hooks = new_proposal_hooks(PROPOSAL_HOOKS, deps.storage, id)?;
     Ok(Response::default()
         .add_messages(deposit_msg)
+        .add_submessages(hooks)
         .add_attribute("action", "propose")
         .add_attribute("sender", sender)
         .add_attribute("proposal_id", id.to_string())
@@ -193,6 +207,7 @@ pub fn execute_execute(
     // Check here that the proposal is passed. Allow it to be
     // executed even if it is expired so long as it passed during its
     // voting period.
+    let old_status = prop.status;
     if !prop.is_passed(&env.block) {
         return Err(ContractError::NotPassed {});
     }
@@ -212,8 +227,16 @@ pub fn execute_execute(
         Response::default()
     };
 
+    let hooks = proposal_status_changed_hooks(
+        PROPOSAL_HOOKS,
+        deps.storage,
+        proposal_id,
+        old_status.to_string(),
+        prop.status.to_string(),
+    )?;
     Ok(response
         .add_messages(return_deposit)
+        .add_submessages(hooks)
         .add_attribute("action", "execute")
         .add_attribute("sender", info.sender)
         .add_attribute("proposal_id", proposal_id.to_string())
@@ -257,11 +280,28 @@ pub fn execute_vote(
         },
     )?;
 
+    let old_status = prop.status;
     prop.votes.add_vote(vote, vote_power);
     prop.update_status(&env.block);
     PROPOSALS.save(deps.storage, proposal_id, &prop)?;
-
+    let new_status = prop.status;
+    let change_hooks = proposal_status_changed_hooks(
+        PROPOSAL_HOOKS,
+        deps.storage,
+        proposal_id,
+        old_status.to_string(),
+        new_status.to_string(),
+    )?;
+    let vote_hooks = new_vote_hooks(
+        VOTE_HOOKS,
+        deps.storage,
+        proposal_id,
+        info.sender.to_string(),
+        vote.to_string(),
+    )?;
     Ok(Response::default()
+        .add_submessages(change_hooks)
+        .add_submessages(vote_hooks)
         .add_attribute("action", "vote")
         .add_attribute("sender", info.sender)
         .add_attribute("proposal_id", proposal_id.to_string())
@@ -287,13 +327,22 @@ pub fn execute_close(
         }
         _ => return Err(ContractError::WrongCloseStatus {}),
     }
-
+    let old_status = prop.status;
     let refund_message = get_return_deposit_msg(&prop)?;
 
     prop.status = Status::Closed;
     PROPOSALS.save(deps.storage, proposal_id, &prop)?;
 
+    let changed_hooks = proposal_status_changed_hooks(
+        PROPOSAL_HOOKS,
+        deps.storage,
+        proposal_id,
+        old_status.to_string(),
+        prop.status.to_string(),
+    )?;
+
     Ok(Response::default()
+        .add_submessages(changed_hooks)
         .add_attribute("action", "close")
         .add_attribute("sender", info.sender)
         .add_messages(refund_message)
@@ -337,6 +386,111 @@ pub fn execute_update_config(
         .add_attribute("action", "update_config")
         .add_attribute("sender", info.sender))
 }
+pub fn add_hook(
+    hooks: Hooks,
+    storage: &mut dyn Storage,
+    validated_address: Addr,
+) -> Result<(), ContractError> {
+    hooks
+        .add_hook(storage, validated_address)
+        .map_err(ContractError::HookError)?;
+    Ok(())
+}
+
+pub fn remove_hook(
+    hooks: Hooks,
+    storage: &mut dyn Storage,
+    validate_address: Addr,
+) -> Result<(), ContractError> {
+    hooks
+        .remove_hook(storage, validate_address)
+        .map_err(ContractError::HookError)?;
+    Ok(())
+}
+
+pub fn execute_add_proposal_hook(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+    address: String,
+) -> Result<Response, ContractError> {
+    let config = CONFIG.load(deps.storage)?;
+    if config.dao != info.sender {
+        // Only DAO can add hooks
+        return Err(ContractError::Unauthorized {});
+    }
+
+    let validated_address = deps.api.addr_validate(&address)?;
+
+    add_hook(PROPOSAL_HOOKS, deps.storage, validated_address)?;
+
+    Ok(Response::default()
+        .add_attribute("action", "add_proposal_hook")
+        .add_attribute("address", address))
+}
+
+pub fn execute_remove_proposal_hook(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+    address: String,
+) -> Result<Response, ContractError> {
+    let config = CONFIG.load(deps.storage)?;
+    if config.dao != info.sender {
+        // Only DAO can remove hooks
+        return Err(ContractError::Unauthorized {});
+    }
+
+    let validated_address = deps.api.addr_validate(&address)?;
+
+    remove_hook(PROPOSAL_HOOKS, deps.storage, validated_address)?;
+
+    Ok(Response::default()
+        .add_attribute("action", "remove_proposal_hook")
+        .add_attribute("address", address))
+}
+
+pub fn execute_add_vote_hook(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+    address: String,
+) -> Result<Response, ContractError> {
+    let config = CONFIG.load(deps.storage)?;
+    if config.dao != info.sender {
+        // Only DAO can add hooks
+        return Err(ContractError::Unauthorized {});
+    }
+
+    let validated_address = deps.api.addr_validate(&address)?;
+
+    add_hook(VOTE_HOOKS, deps.storage, validated_address)?;
+
+    Ok(Response::default()
+        .add_attribute("action", "add_vote_hook")
+        .add_attribute("address", address))
+}
+
+pub fn execute_remove_vote_hook(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+    address: String,
+) -> Result<Response, ContractError> {
+    let config = CONFIG.load(deps.storage)?;
+    if config.dao != info.sender {
+        // Only DAO can remove hooks
+        return Err(ContractError::Unauthorized {});
+    }
+
+    let validated_address = deps.api.addr_validate(&address)?;
+
+    remove_hook(VOTE_HOOKS, deps.storage, validated_address)?;
+
+    Ok(Response::default()
+        .add_attribute("action", "remove_vote_hook")
+        .add_attribute("address", address))
+}
 
 #[cfg_attr(not(feature = "library"), entry_point)]
 pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
@@ -358,6 +512,8 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
             start_before,
             limit,
         } => query_reverse_proposals(deps, env, start_before, limit),
+        QueryMsg::ProposalHooks {} => to_binary(&PROPOSAL_HOOKS.query_hooks(deps)?),
+        QueryMsg::VoteHooks {} => to_binary(&VOTE_HOOKS.query_hooks(deps)?),
     }
 }
 
@@ -458,3 +614,18 @@ pub fn query_info(deps: Deps) -> StdResult<Binary> {
     let info = cw2::get_contract_version(deps.storage)?;
     to_binary(&cw_core_interface::voting::InfoResponse { info })
 }
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result<Response, ContractError> {
+    if msg.id % 2 == 0 {
+        // Proposal hook so we can just divide by two for index
+        let idx = msg.id / 2;
+        PROPOSAL_HOOKS.remove_hook_by_index(deps.storage, idx)?;
+        Ok(Response::new())
+    } else {
+        // Vote hook so we can minus one then divid by two for index
+        let idx = (msg.id - 1) / 2;
+        VOTE_HOOKS.remove_hook_by_index(deps.storage, idx)?;
+        Ok(Response::new())
+    }
+}
diff --git a/contracts/cw-proposal-single/src/error.rs b/contracts/cw-proposal-single/src/error.rs
index 61e857c9..8a7ac090 100644
--- a/contracts/cw-proposal-single/src/error.rs
+++ b/contracts/cw-proposal-single/src/error.rs
@@ -1,6 +1,7 @@
 use std::u64;
 
 use cosmwasm_std::StdError;
+use indexable_hooks::HookError;
 use thiserror::Error;
 
 #[derive(Error, Debug)]
@@ -8,6 +9,9 @@ pub enum ContractError {
     #[error("{0}")]
     Std(#[from] StdError),
 
+    #[error("{0}")]
+    HookError(#[from] HookError),
+
     #[error("Unauthorized")]
     Unauthorized {},
 
diff --git a/contracts/cw-proposal-single/src/msg.rs b/contracts/cw-proposal-single/src/msg.rs
index 5b0f9318..6dbeaf1d 100644
--- a/contracts/cw-proposal-single/src/msg.rs
+++ b/contracts/cw-proposal-single/src/msg.rs
@@ -109,6 +109,18 @@ pub enum ExecuteMsg {
         /// proposal. None if no deposit, Some otherwise.
         deposit_info: Option<DepositInfo>,
     },
+    AddProposalHook {
+        address: String,
+    },
+    RemoveProposalHook {
+        address: String,
+    },
+    AddVoteHook {
+        address: String,
+    },
+    RemoveVoteHook {
+        address: String,
+    },
 }
 
 #[govmod_query]
@@ -140,4 +152,6 @@ pub enum QueryMsg {
         start_after: Option<String>,
         limit: Option<u64>,
     },
+    ProposalHooks {},
+    VoteHooks {},
 }
diff --git a/contracts/cw-proposal-single/src/proposal.rs b/contracts/cw-proposal-single/src/proposal.rs
index cad126aa..a94be87a 100644
--- a/contracts/cw-proposal-single/src/proposal.rs
+++ b/contracts/cw-proposal-single/src/proposal.rs
@@ -65,9 +65,7 @@ pub fn does_vote_count_pass(
         return false;
     }
     match percent {
-        PercentageThreshold::Majority {} => {
-            compare_vote_count(yes_votes, VoteCmp::Greater, options, Decimal::percent(50))
-        }
+        PercentageThreshold::Majority {} => yes_votes.full_mul(2u64) > options.into(),
         PercentageThreshold::Percent(percent) => {
             compare_vote_count(yes_votes, VoteCmp::Geq, options, percent)
         }
@@ -86,7 +84,7 @@ pub fn does_vote_count_fail(
     match percent {
         PercentageThreshold::Majority {} => {
             // Fails if no votes have >= half of all votes.
-            compare_vote_count(no_votes, VoteCmp::Geq, options, Decimal::percent(50))
+            no_votes.full_mul(2u64) >= options.into()
         }
         PercentageThreshold::Percent(percent) => compare_vote_count(
             no_votes,
@@ -332,7 +330,7 @@ mod test {
     }
 
     #[test]
-    fn test_pass_absolute_percentage() {
+    fn test_pass_majority_percentage() {
         let threshold = Threshold::AbsolutePercentage {
             percentage: PercentageThreshold::Majority {},
         };
@@ -342,7 +340,7 @@ mod test {
             abstain: Uint128::new(2),
         };
 
-        // 15 total votes. 7 yes and 2 abstain. 50% threshold. This
+        // 15 total votes. 7 yes and 2 abstain. Majority threshold. This
         // should pass.
         assert!(check_is_passed(
             threshold.clone(),
@@ -363,7 +361,63 @@ mod test {
     }
 
     #[test]
-    fn test_reject_absolute_percentage() {
+    fn test_tricky_pass() {
+        let threshold = Threshold::AbsolutePercentage {
+            percentage: PercentageThreshold::Percent(Decimal::from_ratio(7u32, 13u32)),
+        };
+        let votes = Votes {
+            yes: Uint128::new(7),
+            no: Uint128::new(6),
+            abstain: Uint128::zero(),
+        };
+        assert!(check_is_passed(threshold, votes, Uint128::new(13), false))
+    }
+
+    #[test]
+    fn test_weird_failure_rounding() {
+        let threshold = Threshold::AbsolutePercentage {
+            percentage: PercentageThreshold::Percent(Decimal::from_ratio(6u32, 13u32)),
+        };
+        let votes = Votes {
+            yes: Uint128::new(6),
+            no: Uint128::new(7),
+            abstain: Uint128::zero(),
+        };
+        assert!(check_is_passed(
+            threshold.clone(),
+            votes.clone(),
+            Uint128::new(13),
+            false
+        ));
+        assert!(!check_is_rejected(
+            threshold,
+            votes,
+            Uint128::new(13),
+            false
+        ));
+    }
+
+    #[test]
+    fn test_tricky_pass_majority() {
+        let threshold = Threshold::AbsolutePercentage {
+            percentage: PercentageThreshold::Majority {},
+        };
+        let votes = Votes {
+            yes: Uint128::new(7),
+            no: Uint128::new(6),
+            abstain: Uint128::zero(),
+        };
+        assert!(check_is_passed(
+            threshold.clone(),
+            votes.clone(),
+            Uint128::new(13),
+            false
+        ));
+        assert!(!check_is_passed(threshold, votes, Uint128::new(14), false))
+    }
+
+    #[test]
+    fn test_reject_majority_percentage() {
         let percent = Threshold::AbsolutePercentage {
             percentage: PercentageThreshold::Majority {},
         };
diff --git a/contracts/cw-proposal-single/src/state.rs b/contracts/cw-proposal-single/src/state.rs
index eaf39cee..d38a3671 100644
--- a/contracts/cw-proposal-single/src/state.rs
+++ b/contracts/cw-proposal-single/src/state.rs
@@ -2,6 +2,7 @@ use cosmwasm_std::{to_binary, Addr, CosmosMsg, Deps, StdResult, Uint128, WasmMsg
 use cw_storage_plus::{Item, Map};
 use cw_utils::Duration;
 
+use indexable_hooks::Hooks;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
@@ -59,6 +60,8 @@ pub const CONFIG: Item<Config> = Item::new("config");
 pub const PROPOSAL_COUNT: Item<u64> = Item::new("proposal_count");
 pub const PROPOSALS: Map<u64, Proposal> = Map::new("proposals");
 pub const BALLOTS: Map<(u64, Addr), Ballot> = Map::new("ballots");
+pub const PROPOSAL_HOOKS: Hooks = Hooks::new("proposal_hooks");
+pub const VOTE_HOOKS: Hooks = Hooks::new("vote_hooks");
 
 impl DepositInfo {
     /// Converts deposit info into checked deposit info.
diff --git a/contracts/proposal-hooks-counter/.cargo/config b/contracts/proposal-hooks-counter/.cargo/config
new file mode 100644
index 00000000..336b618a
--- /dev/null
+++ b/contracts/proposal-hooks-counter/.cargo/config
@@ -0,0 +1,4 @@
+[alias]
+wasm = "build --release --target wasm32-unknown-unknown"
+unit-test = "test --lib"
+schema = "run --example schema"
diff --git a/contracts/proposal-hooks-counter/.gitignore b/contracts/proposal-hooks-counter/.gitignore
new file mode 100644
index 00000000..dfdaaa6b
--- /dev/null
+++ b/contracts/proposal-hooks-counter/.gitignore
@@ -0,0 +1,15 @@
+# Build results
+/target
+
+# Cargo+Git helper file (https://github.com/rust-lang/cargo/blob/0.44.1/src/cargo/sources/git/utils.rs#L320-L327)
+.cargo-ok
+
+# Text file backups
+**/*.rs.bk
+
+# macOS
+.DS_Store
+
+# IDEs
+*.iml
+.idea
diff --git a/contracts/proposal-hooks-counter/Cargo.lock b/contracts/proposal-hooks-counter/Cargo.lock
new file mode 100644
index 00000000..c9737778
--- /dev/null
+++ b/contracts/proposal-hooks-counter/Cargo.lock
@@ -0,0 +1,596 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "base64"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd"
+
+[[package]]
+name = "block-buffer"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "const-oid"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c32f031ea41b4291d695026c023b95d59db2d8a2c7640800ed56bc8f510f22"
+
+[[package]]
+name = "cosmwasm-crypto"
+version = "1.0.0-beta4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f903ebbabc0d4880dbc76148efb8be8fc29fa4bf294c440c3d70da1c8bcafff7"
+dependencies = [
+ "digest",
+ "ed25519-zebra",
+ "k256",
+ "rand_core 0.5.1",
+ "thiserror",
+]
+
+[[package]]
+name = "cosmwasm-derive"
+version = "1.0.0-beta4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "832bebef577ecb394603de8e2bf0de429b74aa364e17dec18e15ce37e71b0cae"
+dependencies = [
+ "syn",
+]
+
+[[package]]
+name = "cosmwasm-schema"
+version = "1.0.0-beta"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e4b3f6933f94acdd3ddb931af4870c2002e3331a4a8b247a4ef070dd31ccb0"
+dependencies = [
+ "schemars",
+ "serde_json",
+]
+
+[[package]]
+name = "cosmwasm-std"
+version = "1.0.0-beta4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6238c45840cc9de5a39f0f619e3a4f7c38c5d2c6ac9e3e4d72751ee045e6d7da"
+dependencies = [
+ "base64",
+ "cosmwasm-crypto",
+ "cosmwasm-derive",
+ "schemars",
+ "serde",
+ "serde-json-wasm",
+ "thiserror",
+ "uint",
+]
+
+[[package]]
+name = "cosmwasm-storage"
+version = "1.0.0-beta"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "665cf97ad42be46936f6e6739711824a9bf21c440a6c98e185a3404aef0c3b81"
+dependencies = [
+ "cosmwasm-std",
+ "serde",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66c99696f6c9dd7f35d486b9d04d7e6e202aa3e8c40d553f2fdf5e7e0c6a71ef"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crunchy"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7"
+
+[[package]]
+name = "crypto-bigint"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b32a398eb1ccfbe7e4f452bc749c44d38dd732e9a253f19da224c416f00ee7f4"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.3",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-mac"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1d1a86f49236c215f271d40892d5fc950490551400b02ef360692c29815c714"
+dependencies = [
+ "generic-array",
+ "subtle",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "3.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b9fdf9972b2bd6af2d913799d9ebc165ea4d2e65878e329d9c6b372c4491b61"
+dependencies = [
+ "byteorder",
+ "digest",
+ "rand_core 0.5.1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "cw-storage-plus"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d7ee1963302b0ac2a9d42fe0faec826209c17452bfd36fbfd9d002a88929261"
+dependencies = [
+ "cosmwasm-std",
+ "schemars",
+ "serde",
+]
+
+[[package]]
+name = "cw2"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1d81d7c359d6c1fba3aa83dad7ec6f999e512571380ae62f81257c3db569743"
+dependencies = [
+ "cosmwasm-std",
+ "cw-storage-plus",
+ "schemars",
+ "serde",
+]
+
+[[package]]
+name = "der"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31e21d2d0f22cde6e88694108429775c0219760a07779bf96503b434a03d7412"
+dependencies = [
+ "const-oid",
+]
+
+[[package]]
+name = "digest"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee2626afccd7561a06cf1367e2950c4718ea04565e20fb5029b6c7d8ad09abcf"
+
+[[package]]
+name = "ecdsa"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "713c32426287891008edb98f8b5c6abb2130aa043c93a818728fcda78606f274"
+dependencies = [
+ "der",
+ "elliptic-curve",
+ "hmac",
+ "signature",
+]
+
+[[package]]
+name = "ed25519-zebra"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a128b76af6dd4b427e34a6fd43dc78dbfe73672ec41ff615a2414c1a0ad0409"
+dependencies = [
+ "curve25519-dalek",
+ "hex",
+ "rand_core 0.5.1",
+ "serde",
+ "sha2",
+ "thiserror",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "069397e10739989e400628cbc0556a817a8a64119d7a2315767f4456e1332c23"
+dependencies = [
+ "crypto-bigint",
+ "ff",
+ "generic-array",
+ "group",
+ "pkcs8",
+ "rand_core 0.6.3",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "ff"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63eec06c61e487eecf0f7e6e6372e596a81922c28d33e645d6983ca6493a1af0"
+dependencies = [
+ "rand_core 0.6.3",
+ "subtle",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.9.0+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.10.2+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "group"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c363a5301b8f153d80747126a04b3c82073b9fe3130571a9d170cacdeaf7912"
+dependencies = [
+ "ff",
+ "rand_core 0.6.3",
+ "subtle",
+]
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "hmac"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b"
+dependencies = [
+ "crypto-mac",
+ "digest",
+]
+
+[[package]]
+name = "itoa"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736"
+
+[[package]]
+name = "k256"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "903ae2481bcdfdb7b68e0a9baa4b7c9aff600b9ae2e8e5bb5833b8c91ab851ea"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "sha2",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.99"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765"
+
+[[package]]
+name = "opaque-debug"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5"
+
+[[package]]
+name = "pkcs8"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbee84ed13e44dd82689fa18348a49934fa79cc774a344c42fc9b301c71b140a"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612"
+dependencies = [
+ "unicode-xid",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19"
+dependencies = [
+ "getrandom 0.1.16",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d34f1408f55294453790c48b2f1ebbb1c5b4b7563eb1f418bcfcfdbb06ebb4e7"
+dependencies = [
+ "getrandom 0.2.3",
+]
+
+[[package]]
+name = "ryu"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e"
+
+[[package]]
+name = "schemars"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc6ab463ae35acccb5cba66c0084c985257b797d288b6050cc2f6ac1b266cb78"
+dependencies = [
+ "dyn-clone",
+ "schemars_derive",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars_derive"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "902fdfbcf871ae8f653bddf4b2c05905ddaabc08f69d32a915787e3be0d31356"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde_derive_internals",
+ "syn",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f03b9878abf6d14e6779d3f24f07b2cfa90352cfec4acc5aab8f1ac7f146fae8"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-json-wasm"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "042ac496d97e5885149d34139bad1d617192770d7eb8f1866da2317ff4501853"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.127"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a024926d3432516606328597e0f224a51355a493b49fdd67e9209187cbe55ecc"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_derive_internals"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dbab34ca63057a1f15280bdf3c39f2b1eb1b54c17e98360e511637aef7418c6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.66"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "336b10da19a12ad094b59d870ebde26a45402e5b470add4b5fd03c5048a32127"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b362ae5752fd2137731f9fa25fd4d9058af34666ca1966fb969119cc35719f12"
+dependencies = [
+ "block-buffer",
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+ "opaque-debug",
+]
+
+[[package]]
+name = "signature"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c19772be3c4dd2ceaacf03cb41d5885f2a02c4d8804884918e3a258480803335"
+dependencies = [
+ "digest",
+ "rand_core 0.6.3",
+]
+
+[[package]]
+name = "spki"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "987637c5ae6b3121aba9d513f869bd2bff11c4cc086c22473befd6649c0bd521"
+dependencies = [
+ "der",
+]
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "subtle"
+version = "2.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6bdef32e8150c2a081110b42772ffe7d7c9032b606bc226c8260fd97e0976601"
+
+[[package]]
+name = "syn"
+version = "1.0.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "testgen-local"
+version = "0.1.0"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cosmwasm-storage",
+ "cw-storage-plus",
+ "cw2",
+ "schemars",
+ "serde",
+ "thiserror",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93119e4feac1cbe6c798c34d3a53ea0026b0b1de6a120deef895137c0529bfe2"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "060d69a0afe7796bf42e9e2ff91f5ee691fb15c53d38b4b62a9a53eb23164745"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "typenum"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06"
+
+[[package]]
+name = "uint"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6470ab50f482bde894a037a57064480a246dbfdd5960bd65a44824693f08da5f"
+dependencies = [
+ "byteorder",
+ "crunchy",
+ "hex",
+ "static_assertions",
+]
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3"
+
+[[package]]
+name = "version_check"
+version = "0.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe"
+
+[[package]]
+name = "wasi"
+version = "0.9.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519"
+
+[[package]]
+name = "wasi"
+version = "0.10.2+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6"
+
+[[package]]
+name = "zeroize"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "377db0846015f7ae377174787dd452e1c5f5a9050bc6f954911d01f116daa0cd"
diff --git a/contracts/proposal-hooks-counter/Cargo.toml b/contracts/proposal-hooks-counter/Cargo.toml
new file mode 100644
index 00000000..070b3eb1
--- /dev/null
+++ b/contracts/proposal-hooks-counter/Cargo.toml
@@ -0,0 +1,52 @@
+[package]
+name = "proposal-hooks-counter"
+version = "0.1.0"
+authors = ["Callum Anderson <callumanderson745@gmail.com>"]
+edition = "2018"
+
+exclude = [
+  # Those files are rust-optimizer artifacts. You might want to commit them for convenience but they should not be part of the source code publication.
+  "contract.wasm",
+  "hash.txt",
+]
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[features]
+# for more explicit tests, cargo test --features=backtraces
+backtraces = ["cosmwasm-std/backtraces"]
+# use library feature to disable all instantiate/execute/query exports
+library = []
+
+[package.metadata.scripts]
+optimize = """docker run --rm -v "$(pwd)":/code \
+  --mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \
+  --mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
+  cosmwasm/rust-optimizer:0.12.5
+"""
+
+[dependencies]
+cosmwasm-std = { version = "1.0.0-beta5" }
+cosmwasm-storage = { version = "1.0.0-beta5" }
+cw-storage-plus = "0.12"
+cw2 = "0.12"
+schemars = "0.8"
+serde = { version = "1.0", default-features = false, features = ["derive"] }
+thiserror = { version = "1.0" }
+proposal-hooks = { version = "0.1.0", path = "../../packages/proposal-hooks" }
+vote-hooks = { version = "0.1.0", path = "../../packages/vote-hooks" }
+
+[dev-dependencies]
+indexable-hooks = { version = "0.1.0", path = "../../packages/indexable-hooks" }
+cw20 = "0.12"
+cw20-balance-voting = { path = "../cw20-balance-voting", version = "0.1.0" }
+cw20-base = "0.12"
+cw-utils = "0.12"
+voting = { version = "0.1.0", path = "../../packages/voting" }
+cw-core = { path = "../cw-core", version = "0.1.0", features = ["library"] }
+cw-proposal-single = { path = "../cw-proposal-single" }
+cosmwasm-schema = { version = "1.0.0-beta5" }
+cw-multi-test = "0.12"
diff --git a/contracts/proposal-hooks-counter/README.md b/contracts/proposal-hooks-counter/README.md
new file mode 100644
index 00000000..b585ebe5
--- /dev/null
+++ b/contracts/proposal-hooks-counter/README.md
@@ -0,0 +1,6 @@
+# Proposal Hooks Counter
+
+## Purely for testing for hooks integration. (DO NOT USE IN MAINNET)
+
+Contract that integrates with the new proposal hooks
+system to test if they work and are reverted correctly.
diff --git a/contracts/proposal-hooks-counter/examples/schema.rs b/contracts/proposal-hooks-counter/examples/schema.rs
new file mode 100644
index 00000000..9d4302de
--- /dev/null
+++ b/contracts/proposal-hooks-counter/examples/schema.rs
@@ -0,0 +1,17 @@
+use std::env::current_dir;
+use std::fs::create_dir_all;
+
+use cosmwasm_schema::{export_schema, remove_schemas, schema_for};
+
+use proposal_hooks_counter::msg::{ExecuteMsg, InstantiateMsg, QueryMsg};
+
+fn main() {
+    let mut out_dir = current_dir().unwrap();
+    out_dir.push("schema");
+    create_dir_all(&out_dir).unwrap();
+    remove_schemas(&out_dir).unwrap();
+
+    export_schema(&schema_for!(InstantiateMsg), &out_dir);
+    export_schema(&schema_for!(ExecuteMsg), &out_dir);
+    export_schema(&schema_for!(QueryMsg), &out_dir);
+}
diff --git a/contracts/proposal-hooks-counter/schema/execute_msg.json b/contracts/proposal-hooks-counter/schema/execute_msg.json
new file mode 100644
index 00000000..59b28eff
--- /dev/null
+++ b/contracts/proposal-hooks-counter/schema/execute_msg.json
@@ -0,0 +1,122 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "ExecuteMsg",
+  "oneOf": [
+    {
+      "type": "object",
+      "required": [
+        "proposal_hook"
+      ],
+      "properties": {
+        "proposal_hook": {
+          "$ref": "#/definitions/ProposalHookMsg"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "vote_hook"
+      ],
+      "properties": {
+        "vote_hook": {
+          "$ref": "#/definitions/VoteHookMsg"
+        }
+      },
+      "additionalProperties": false
+    }
+  ],
+  "definitions": {
+    "ProposalHookMsg": {
+      "oneOf": [
+        {
+          "type": "object",
+          "required": [
+            "new_proposal"
+          ],
+          "properties": {
+            "new_proposal": {
+              "type": "object",
+              "required": [
+                "id"
+              ],
+              "properties": {
+                "id": {
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "proposal_status_changed"
+          ],
+          "properties": {
+            "proposal_status_changed": {
+              "type": "object",
+              "required": [
+                "id",
+                "new_status",
+                "old_status"
+              ],
+              "properties": {
+                "id": {
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                },
+                "new_status": {
+                  "type": "string"
+                },
+                "old_status": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "VoteHookMsg": {
+      "oneOf": [
+        {
+          "type": "object",
+          "required": [
+            "new_vote"
+          ],
+          "properties": {
+            "new_vote": {
+              "type": "object",
+              "required": [
+                "proposal_id",
+                "vote",
+                "voter"
+              ],
+              "properties": {
+                "proposal_id": {
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                },
+                "vote": {
+                  "type": "string"
+                },
+                "voter": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    }
+  }
+}
diff --git a/contracts/proposal-hooks-counter/schema/instantiate_msg.json b/contracts/proposal-hooks-counter/schema/instantiate_msg.json
new file mode 100644
index 00000000..89ab392c
--- /dev/null
+++ b/contracts/proposal-hooks-counter/schema/instantiate_msg.json
@@ -0,0 +1,13 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "InstantiateMsg",
+  "type": "object",
+  "required": [
+    "should_error"
+  ],
+  "properties": {
+    "should_error": {
+      "type": "boolean"
+    }
+  }
+}
diff --git a/contracts/proposal-hooks-counter/schema/query_msg.json b/contracts/proposal-hooks-counter/schema/query_msg.json
new file mode 100644
index 00000000..128ecece
--- /dev/null
+++ b/contracts/proposal-hooks-counter/schema/query_msg.json
@@ -0,0 +1,42 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "QueryMsg",
+  "oneOf": [
+    {
+      "type": "object",
+      "required": [
+        "vote_counter"
+      ],
+      "properties": {
+        "vote_counter": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "proposal_counter"
+      ],
+      "properties": {
+        "proposal_counter": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "status_changed_counter"
+      ],
+      "properties": {
+        "status_changed_counter": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    }
+  ]
+}
diff --git a/contracts/proposal-hooks-counter/src/contract.rs b/contracts/proposal-hooks-counter/src/contract.rs
new file mode 100644
index 00000000..590fb5d4
--- /dev/null
+++ b/contracts/proposal-hooks-counter/src/contract.rs
@@ -0,0 +1,106 @@
+#[cfg(not(feature = "library"))]
+use cosmwasm_std::entry_point;
+use cosmwasm_std::{to_binary, Binary, Deps, DepsMut, Env, MessageInfo, Response, StdResult};
+use cw2::set_contract_version;
+use proposal_hooks::ProposalHookMsg;
+use vote_hooks::VoteHookMsg;
+
+use crate::error::ContractError;
+use crate::msg::{CountResponse, ExecuteMsg, InstantiateMsg, QueryMsg};
+use crate::state::{Config, CONFIG, PROPOSAL_COUNTER, STATUS_CHANGED_COUNTER, VOTE_COUNTER};
+
+const CONTRACT_NAME: &str = "crates.io:proposal-hooks-counter";
+const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn instantiate(
+    deps: DepsMut,
+    _env: Env,
+    _info: MessageInfo,
+    msg: InstantiateMsg,
+) -> Result<Response, ContractError> {
+    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
+
+    let config = Config {
+        should_error: msg.should_error,
+    };
+    CONFIG.save(deps.storage, &config)?;
+    PROPOSAL_COUNTER.save(deps.storage, &0)?;
+    VOTE_COUNTER.save(deps.storage, &0)?;
+    STATUS_CHANGED_COUNTER.save(deps.storage, &0)?;
+    Ok(Response::new().add_attribute("action", "instantiate"))
+}
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn execute(
+    deps: DepsMut,
+    env: Env,
+    info: MessageInfo,
+    msg: ExecuteMsg,
+) -> Result<Response, ContractError> {
+    let config = CONFIG.load(deps.storage)?;
+    if config.should_error {
+        return Err(ContractError::Unauthorized {});
+    }
+
+    match msg {
+        ExecuteMsg::ProposalHook(proposal_hook) => {
+            execute_proposal_hook(deps, env, info, proposal_hook)
+        }
+        ExecuteMsg::VoteHook(vote_hook) => execute_vote_hook(deps, env, info, vote_hook),
+    }
+}
+
+pub fn execute_proposal_hook(
+    deps: DepsMut,
+    _env: Env,
+    _info: MessageInfo,
+    proposal_hook: ProposalHookMsg,
+) -> Result<Response, ContractError> {
+    match proposal_hook {
+        ProposalHookMsg::NewProposal { .. } => {
+            let mut count = PROPOSAL_COUNTER.load(deps.storage)?;
+            count += 1;
+            PROPOSAL_COUNTER.save(deps.storage, &count)?;
+        }
+        ProposalHookMsg::ProposalStatusChanged { .. } => {
+            let mut count = STATUS_CHANGED_COUNTER.load(deps.storage)?;
+            count += 1;
+            STATUS_CHANGED_COUNTER.save(deps.storage, &count)?;
+        }
+    }
+
+    Ok(Response::new().add_attribute("action", "proposal_hook"))
+}
+
+pub fn execute_vote_hook(
+    deps: DepsMut,
+    _env: Env,
+    _info: MessageInfo,
+    vote_hook: VoteHookMsg,
+) -> Result<Response, ContractError> {
+    match vote_hook {
+        VoteHookMsg::NewVote { .. } => {
+            let mut count = VOTE_COUNTER.load(deps.storage)?;
+            count += 1;
+            VOTE_COUNTER.save(deps.storage, &count)?;
+        }
+    }
+
+    Ok(Response::new().add_attribute("action", "vote_hook"))
+}
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult<Binary> {
+    match msg {
+        QueryMsg::VoteCounter {} => to_binary(&CountResponse {
+            count: VOTE_COUNTER.load(deps.storage)?,
+        }),
+        QueryMsg::ProposalCounter {} => to_binary(&CountResponse {
+            count: PROPOSAL_COUNTER.load(deps.storage)?,
+        }),
+        QueryMsg::StatusChangedCounter {} => to_binary(&CountResponse {
+            count: STATUS_CHANGED_COUNTER.load(deps.storage)?,
+        }),
+    }
+}
diff --git a/contracts/proposal-hooks-counter/src/error.rs b/contracts/proposal-hooks-counter/src/error.rs
new file mode 100644
index 00000000..dc19f103
--- /dev/null
+++ b/contracts/proposal-hooks-counter/src/error.rs
@@ -0,0 +1,11 @@
+use cosmwasm_std::StdError;
+use thiserror::Error;
+
+#[derive(Error, Debug)]
+pub enum ContractError {
+    #[error("{0}")]
+    Std(#[from] StdError),
+
+    #[error("Unauthorized")]
+    Unauthorized {},
+}
diff --git a/contracts/proposal-hooks-counter/src/lib.rs b/contracts/proposal-hooks-counter/src/lib.rs
new file mode 100644
index 00000000..8e58a2f1
--- /dev/null
+++ b/contracts/proposal-hooks-counter/src/lib.rs
@@ -0,0 +1,9 @@
+pub mod contract;
+mod error;
+pub mod msg;
+pub mod state;
+
+#[cfg(test)]
+mod tests;
+
+pub use crate::error::ContractError;
diff --git a/contracts/proposal-hooks-counter/src/msg.rs b/contracts/proposal-hooks-counter/src/msg.rs
new file mode 100644
index 00000000..1a458329
--- /dev/null
+++ b/contracts/proposal-hooks-counter/src/msg.rs
@@ -0,0 +1,30 @@
+use proposal_hooks::ProposalHookMsg;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use vote_hooks::VoteHookMsg;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+pub struct InstantiateMsg {
+    pub should_error: bool, // Debug flag to test when hooks fail over
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ExecuteMsg {
+    ProposalHook(ProposalHookMsg),
+    VoteHook(VoteHookMsg),
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum QueryMsg {
+    VoteCounter {},
+    ProposalCounter {},
+    StatusChangedCounter {},
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub struct CountResponse {
+    pub count: u64,
+}
diff --git a/contracts/proposal-hooks-counter/src/state.rs b/contracts/proposal-hooks-counter/src/state.rs
new file mode 100644
index 00000000..1f732f4d
--- /dev/null
+++ b/contracts/proposal-hooks-counter/src/state.rs
@@ -0,0 +1,13 @@
+use cw_storage_plus::Item;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub struct Config {
+    pub should_error: bool,
+}
+pub const CONFIG: Item<Config> = Item::new("config");
+pub const VOTE_COUNTER: Item<u64> = Item::new("vote_counter");
+pub const PROPOSAL_COUNTER: Item<u64> = Item::new("proposal_counter");
+pub const STATUS_CHANGED_COUNTER: Item<u64> = Item::new("stauts_changed_counter");
diff --git a/packages/indexable-hooks/Cargo.toml b/packages/indexable-hooks/Cargo.toml
new file mode 100644
index 00000000..ae280c2e
--- /dev/null
+++ b/packages/indexable-hooks/Cargo.toml
@@ -0,0 +1,13 @@
+[package]
+name = "indexable-hooks"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+schemars = "0.8"
+serde = { version = "1.0", default-features = false, features = ["derive"] }
+thiserror = { version = "1.0" }
+cosmwasm-std = "1.0.0-beta5"
+cw-storage-plus = "0.12"
diff --git a/packages/indexable-hooks/src/lib.rs b/packages/indexable-hooks/src/lib.rs
new file mode 100644
index 00000000..5f070888
--- /dev/null
+++ b/packages/indexable-hooks/src/lib.rs
@@ -0,0 +1,81 @@
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use cosmwasm_std::{Addr, CustomQuery, Deps, StdError, StdResult, Storage, SubMsg};
+use cw_storage_plus::Item;
+
+#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+pub struct HooksResponse {
+    pub hooks: Vec<String>,
+}
+
+#[derive(Error, Debug, PartialEq)]
+pub enum HookError {
+    #[error("{0}")]
+    Std(#[from] StdError),
+
+    #[error("Given address already registered as a hook")]
+    HookAlreadyRegistered {},
+
+    #[error("Given address not registered as a hook")]
+    HookNotRegistered {},
+}
+
+// store all hook addresses in one item. We cannot have many of them before the contract becomes unusable anyway.
+pub struct Hooks<'a>(Item<'a, Vec<Addr>>);
+
+impl<'a> Hooks<'a> {
+    pub const fn new(storage_key: &'a str) -> Self {
+        Hooks(Item::new(storage_key))
+    }
+
+    pub fn add_hook(&self, storage: &mut dyn Storage, addr: Addr) -> Result<(), HookError> {
+        let mut hooks = self.0.may_load(storage)?.unwrap_or_default();
+        if !hooks.iter().any(|h| h == &addr) {
+            hooks.push(addr);
+        } else {
+            return Err(HookError::HookAlreadyRegistered {});
+        }
+        Ok(self.0.save(storage, &hooks)?)
+    }
+
+    pub fn remove_hook(&self, storage: &mut dyn Storage, addr: Addr) -> Result<(), HookError> {
+        let mut hooks = self.0.load(storage)?;
+        if let Some(p) = hooks.iter().position(|x| x == &addr) {
+            hooks.remove(p);
+        } else {
+            return Err(HookError::HookNotRegistered {});
+        }
+        Ok(self.0.save(storage, &hooks)?)
+    }
+
+    pub fn remove_hook_by_index(
+        &self,
+        storage: &mut dyn Storage,
+        index: u64,
+    ) -> Result<(), HookError> {
+        let mut hooks = self.0.load(storage)?;
+        hooks.remove(index as usize);
+        Ok(self.0.save(storage, &hooks)?)
+    }
+
+    pub fn prepare_hooks<F: FnMut(Addr) -> StdResult<SubMsg>>(
+        &self,
+        storage: &dyn Storage,
+        prep: F,
+    ) -> StdResult<Vec<SubMsg>> {
+        self.0
+            .may_load(storage)?
+            .unwrap_or_default()
+            .into_iter()
+            .map(prep)
+            .collect()
+    }
+
+    pub fn query_hooks<Q: CustomQuery>(&self, deps: Deps<Q>) -> StdResult<HooksResponse> {
+        let hooks = self.0.may_load(deps.storage)?.unwrap_or_default();
+        let hooks = hooks.into_iter().map(String::from).collect();
+        Ok(HooksResponse { hooks })
+    }
+}
diff --git a/packages/proposal-hooks/Cargo.toml b/packages/proposal-hooks/Cargo.toml
new file mode 100644
index 00000000..4c68f159
--- /dev/null
+++ b/packages/proposal-hooks/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "proposal-hooks"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+schemars = "0.8"
+serde = { version = "1.0", default-features = false, features = ["derive"] }
+cosmwasm-std = "1.0.0-beta5"
+indexable-hooks = { version = "0.1.0", path = "../indexable-hooks" }
\ No newline at end of file
diff --git a/packages/proposal-hooks/src/lib.rs b/packages/proposal-hooks/src/lib.rs
new file mode 100644
index 00000000..137b2c81
--- /dev/null
+++ b/packages/proposal-hooks/src/lib.rs
@@ -0,0 +1,77 @@
+use cosmwasm_std::{to_binary, StdResult, Storage, SubMsg, WasmMsg};
+use indexable_hooks::Hooks;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum ProposalHookMsg {
+    NewProposal {
+        id: u64,
+    },
+    ProposalStatusChanged {
+        id: u64,
+        old_status: String,
+        new_status: String,
+    },
+}
+
+// This is just a helper to properly serialize the above message
+#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum ProposalHookExecuteMsg {
+    ProposalHook(ProposalHookMsg),
+}
+/// Prepares new proposal hook messages. These messages reply on error
+/// and have even reply IDs.
+/// IDs are set to even numbers to then be interleaved with the vote hooks.
+pub fn new_proposal_hooks(hooks: Hooks, storage: &dyn Storage, id: u64) -> StdResult<Vec<SubMsg>> {
+    let msg = to_binary(&ProposalHookExecuteMsg::ProposalHook(
+        ProposalHookMsg::NewProposal { id },
+    ))?;
+    let mut index: u64 = 0;
+    hooks.prepare_hooks(storage, |a| {
+        let execute = WasmMsg::Execute {
+            contract_addr: a.to_string(),
+            msg: msg.clone(),
+            funds: vec![],
+        };
+        let tmp = SubMsg::reply_on_error(execute, index * 2);
+        index += 1;
+        Ok(tmp)
+    })
+}
+
+/// Prepares proposal status hook messages. These messages reply on error
+/// and have even reply IDs.
+/// IDs are set to even numbers to then be interleaved with the vote hooks.
+pub fn proposal_status_changed_hooks(
+    hooks: Hooks,
+    storage: &dyn Storage,
+    id: u64,
+    old_status: String,
+    new_status: String,
+) -> StdResult<Vec<SubMsg>> {
+    if old_status == new_status {
+        return Ok(vec![]);
+    }
+
+    let msg = to_binary(&ProposalHookExecuteMsg::ProposalHook(
+        ProposalHookMsg::ProposalStatusChanged {
+            id,
+            old_status,
+            new_status,
+        },
+    ))?;
+    let mut index: u64 = 0;
+    hooks.prepare_hooks(storage, |a| {
+        let execute = WasmMsg::Execute {
+            contract_addr: a.to_string(),
+            msg: msg.clone(),
+            funds: vec![],
+        };
+        let tmp = SubMsg::reply_on_error(execute, index * 2);
+        index += 1;
+        Ok(tmp)
+    })
+}
diff --git a/packages/vote-hooks/Cargo.toml b/packages/vote-hooks/Cargo.toml
new file mode 100644
index 00000000..615eb565
--- /dev/null
+++ b/packages/vote-hooks/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "vote-hooks"
+version = "0.1.0"
+edition = "2021"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+schemars = "0.8"
+serde = { version = "1.0", default-features = false, features = ["derive"] }
+cosmwasm-std = "1.0.0-beta5"
+indexable-hooks = { version = "0.1.0", path = "../indexable-hooks" }
\ No newline at end of file
diff --git a/packages/vote-hooks/src/lib.rs b/packages/vote-hooks/src/lib.rs
new file mode 100644
index 00000000..7115e452
--- /dev/null
+++ b/packages/vote-hooks/src/lib.rs
@@ -0,0 +1,49 @@
+use cosmwasm_std::{to_binary, StdResult, Storage, SubMsg, WasmMsg};
+use indexable_hooks::Hooks;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum VoteHookMsg {
+    NewVote {
+        proposal_id: u64,
+        voter: String,
+        vote: String,
+    },
+}
+
+// This is just a helper to properly serialize the above message
+#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+#[serde(rename_all = "snake_case")]
+pub enum VoteHookExecuteMsg {
+    VoteHook(VoteHookMsg),
+}
+
+/// Prepares new vote hook messages. These messages reply on error
+/// and have even reply IDs.
+/// IDs are set to odd numbers to then be interleaved with the proposal hooks.
+pub fn new_vote_hooks(
+    hooks: Hooks,
+    storage: &dyn Storage,
+    proposal_id: u64,
+    voter: String,
+    vote: String,
+) -> StdResult<Vec<SubMsg>> {
+    let msg = to_binary(&VoteHookExecuteMsg::VoteHook(VoteHookMsg::NewVote {
+        proposal_id,
+        voter,
+        vote,
+    }))?;
+    let mut index: u64 = 0;
+    hooks.prepare_hooks(storage, |a| {
+        let execute = WasmMsg::Execute {
+            contract_addr: a.to_string(),
+            msg: msg.clone(),
+            funds: vec![],
+        };
+        let tmp = SubMsg::reply_on_error(execute, index * 2 + 1);
+        index += 1;
+        Ok(tmp)
+    })
+}
diff --git a/packages/voting/src/lib.rs b/packages/voting/src/lib.rs
index effcd9cb..184d2213 100644
--- a/packages/voting/src/lib.rs
+++ b/packages/voting/src/lib.rs
@@ -34,6 +34,37 @@ pub enum VoteCmp {
 /// Compares `votes` with `total_power * passing_percentage`. The
 /// comparason function used depends on the `VoteCmp` variation
 /// selected.
+///
+/// !!NOTE!! THIS FUNCTION DOES NOT ROUND UP.
+///
+/// For example, the following assertion will succede:
+///
+/// ```rust
+/// use voting::{compare_vote_count, VoteCmp};
+/// use cosmwasm_std::{Uint128, Decimal};
+/// fn test() {
+///     assert!(compare_vote_count(
+///         Uint128::new(7),
+///         VoteCmp::Greater,
+///         Uint128::new(13),
+///         Decimal::from_ratio(7u64, 13u64)
+///     ));
+/// }
+/// ```
+///
+/// This is because `7 * (7/13)` is `6.999...` after rounding. You
+/// MUST ensure this is the behavior you want when calling this
+/// function.
+///
+/// For our current purposes this is OK as the only place we use the
+/// `Greater` comparason is when looking to see if no votes have
+/// reached `> (1 - threshold)` and thus made the proposal
+/// unpassable. As threshold will be a rounded down version of the
+/// infinite percision real version `1 - threshold` will actually be a
+/// higher magnitured than the real one meaning that we won't ever be
+/// in the position of simeltaniously reporting a proposal as both
+/// rejected and passed.
+///
 pub fn compare_vote_count(
     votes: Uint128,
     cmp: VoteCmp,
@@ -52,60 +83,6 @@ pub fn compare_vote_count(
     }
 }
 
-/// Computes the number of votes needed for a proposal to pass. This
-/// must round up. For example, with a 50% passing percentage and 15
-/// total votes 8 votes are required, not 7.
-///
-/// Note that it is possible, though unlikely, that no number of votes
-/// will ever meet the threshold. This happens if the total power is
-/// zero. For example, this may happen if all votes are abstain.
-pub fn votes_needed(total_power: Uint128, passing_percentage: Decimal) -> Uint128 {
-    // Voting power is counted with a Uint128. In order to avoid an
-    // overflow while multiplying by the percision factor we need to
-    // do a full mul which results in a Uint256.
-    //
-    // Percision factor here ensures that any rounding down here that
-    // happens in the VM happens in the 9th decimal place. This makes
-    // it reasonably likely that we successfully round up.
-    let total_power = total_power.full_mul(PRECISION_FACTOR);
-    // Multiplication of a Uint256 and a Decimal is not implemented so
-    // we first multiply by the decimal's raw form and then divide by
-    // the number of decimal places in the decimal. This is the same
-    // thing that happens under the hood when a Uint128 is multiplied
-    // by Decimal.
-    //
-    // This multiplication will round down but because we have
-    // multiplied by `PERCISION_FACTOR` above that rounding ought to
-    // occur in the 9th decimal place.
-    let applied = total_power.multiply_ratio(
-        passing_percentage.atomics(),
-        Uint256::from(10u64).pow(passing_percentage.decimal_places()),
-    );
-    // The maximum possible value for applied occurs if `total_power`
-    // is 2^128. Given a percision factor of 10^9 we confirm that the
-    // numerator value will not overflow as:
-    //
-    // 2^128 * 10^9 + 10^9 < 2^256
-    //
-    // In the interest of being percise, this will not overflow so
-    // long as our percision factor is less than `3.4*10^38`. We don't
-    // have to worry about this because that value won't even fit into
-    // a u128 (the type of PERCISION_FACTOR).
-    let rounded = (applied + Uint256::from(PRECISION_FACTOR) - Uint256::from(1u128))
-        / Uint256::from(PRECISION_FACTOR);
-    // Truncated should be strictly <= than the largest possible
-    // Uint128. Imagine the pathalogical case where the passing
-    // threshold is 100% and there are 2^128 total votes. In this case
-    // rounded is:
-    //
-    // Floor[(2^128 * 10^9 - 1) / 10^9 + 1]
-    //
-    // This number is 2^128 which will fit into a 128 bit
-    // integer. Note: if we didn't floor here this would not be the
-    // case, happily unsigned integer division does indeed do that.
-    rounded.try_into().unwrap()
-}
-
 impl Votes {
     /// Constructs an zero'd out votes struct.
     pub fn zero() -> Self {
@@ -162,16 +139,6 @@ impl std::fmt::Display for Vote {
 mod test {
     use super::*;
 
-    /// Determines if a number of votes meets the provided threshold given
-    /// the total number of votes outstanding.
-    fn votes_meet_threshold(
-        votes: Uint128,
-        total_power: Uint128,
-        passing_percentage: Decimal,
-    ) -> bool {
-        votes >= votes_needed(total_power, passing_percentage)
-    }
-
     #[test]
     fn count_votes() {
         let mut votes = Votes::with_yes(Uint128::new(5));
@@ -186,83 +153,197 @@ mod test {
     }
 
     #[test]
-    fn votes_needed_rounds_properly() {
-        // round up right below 1
-        assert_eq!(
+    fn vote_comparasons() {
+        assert!(!compare_vote_count(
+            Uint128::new(7),
+            VoteCmp::Geq,
+            Uint128::new(15),
+            Decimal::percent(50)
+        ));
+        assert!(!compare_vote_count(
+            Uint128::new(7),
+            VoteCmp::Greater,
+            Uint128::new(15),
+            Decimal::percent(50)
+        ));
+
+        assert!(compare_vote_count(
+            Uint128::new(7),
+            VoteCmp::Geq,
+            Uint128::new(14),
+            Decimal::percent(50)
+        ));
+        assert!(!compare_vote_count(
+            Uint128::new(7),
+            VoteCmp::Greater,
+            Uint128::new(14),
+            Decimal::percent(50)
+        ));
+
+        assert!(compare_vote_count(
+            Uint128::new(7),
+            VoteCmp::Geq,
+            Uint128::new(13),
+            Decimal::from_ratio(7u64, 13u64)
+        ));
+
+        assert!(!compare_vote_count(
+            Uint128::new(6),
+            VoteCmp::Greater,
+            Uint128::new(13),
+            Decimal::one() - Decimal::from_ratio(7u64, 13u64)
+        ));
+        assert!(compare_vote_count(
+            Uint128::new(7),
+            VoteCmp::Greater,
+            Uint128::new(13),
+            Decimal::from_ratio(7u64, 13u64)
+        ));
+
+        assert!(!compare_vote_count(
+            Uint128::new(4),
+            VoteCmp::Geq,
+            Uint128::new(9),
+            Decimal::percent(50)
+        ))
+    }
+
+    #[test]
+    fn more_votes_tests() {
+        assert!(compare_vote_count(
             Uint128::new(1),
-            votes_needed(Uint128::new(3), Decimal::permille(333))
-        );
-        // round up right over 1
-        assert_eq!(
+            VoteCmp::Geq,
+            Uint128::new(3),
+            Decimal::permille(333)
+        ));
+
+        assert!(!compare_vote_count(
+            Uint128::new(1),
+            VoteCmp::Geq,
+            Uint128::new(3),
+            Decimal::permille(334)
+        ));
+        assert!(compare_vote_count(
             Uint128::new(2),
-            votes_needed(Uint128::new(3), Decimal::permille(334))
-        );
-        assert_eq!(
+            VoteCmp::Geq,
+            Uint128::new(3),
+            Decimal::permille(334)
+        ));
+
+        assert!(compare_vote_count(
             Uint128::new(11),
-            votes_needed(Uint128::new(30), Decimal::permille(334))
-        );
+            VoteCmp::Geq,
+            Uint128::new(30),
+            Decimal::permille(333)
+        ));
 
-        // exact matches don't round
-        assert_eq!(
-            Uint128::new(17),
-            votes_needed(Uint128::new(34), Decimal::percent(50))
-        );
-        assert_eq!(
-            Uint128::new(12),
-            votes_needed(Uint128::new(48), Decimal::percent(25))
-        );
+        assert!(compare_vote_count(
+            Uint128::new(15),
+            VoteCmp::Geq,
+            Uint128::new(30),
+            Decimal::permille(500)
+        ));
+        assert!(!compare_vote_count(
+            Uint128::new(15),
+            VoteCmp::Greater,
+            Uint128::new(30),
+            Decimal::permille(500)
+        ));
 
-        assert_eq!(
-            Uint128::new(7),
-            votes_needed(Uint128::new(13), Decimal::percent(50))
-        );
+        assert!(compare_vote_count(
+            Uint128::new(0),
+            VoteCmp::Geq,
+            Uint128::new(0),
+            Decimal::permille(500)
+        ));
+        assert!(!compare_vote_count(
+            Uint128::new(0),
+            VoteCmp::Greater,
+            Uint128::new(0),
+            Decimal::permille(500)
+        ));
 
-        assert_eq!(
-            Uint128::zero(),
-            votes_needed(Uint128::zero(), Decimal::percent(50))
-        );
+        assert!(!compare_vote_count(
+            Uint128::new(0),
+            VoteCmp::Geq,
+            Uint128::new(1),
+            Decimal::permille(1)
+        ));
+        assert!(!compare_vote_count(
+            Uint128::new(0),
+            VoteCmp::Greater,
+            Uint128::new(1),
+            Decimal::permille(1)
+        ));
 
-        assert_eq!(
+        assert!(compare_vote_count(
+            Uint128::new(1),
+            VoteCmp::Geq,
+            Uint128::new(1),
+            Decimal::permille(1)
+        ));
+        assert!(compare_vote_count(
             Uint128::new(1),
-            votes_needed(Uint128::new(1), Decimal::percent(1))
-        );
+            VoteCmp::Greater,
+            Uint128::new(1),
+            Decimal::permille(1)
+        ));
 
-        assert_eq!(
+        assert!(!compare_vote_count(
             Uint128::new(0),
-            votes_needed(Uint128::new(1), Decimal::percent(0))
-        );
-
-        assert_eq!(
-            Uint128::zero(),
-            votes_needed(Uint128::zero(), Decimal::percent(99))
-        );
+            VoteCmp::Geq,
+            Uint128::new(1),
+            Decimal::permille(999)
+        ));
+        assert!(!compare_vote_count(
+            Uint128::new(0),
+            VoteCmp::Greater,
+            Uint128::new(1),
+            Decimal::permille(999)
+        ));
     }
 
     #[test]
     fn tricky_vote_counts() {
         let threshold = Decimal::percent(50);
         for count in 1..50_000 {
-            assert!(votes_meet_threshold(
+            assert!(compare_vote_count(
+                Uint128::new(count),
+                VoteCmp::Geq,
+                Uint128::new(count * 2),
+                threshold
+            ));
+            assert!(!compare_vote_count(
                 Uint128::new(count),
+                VoteCmp::Greater,
                 Uint128::new(count * 2),
                 threshold
             ))
         }
-        /*
-        Zero votes out of zero total power meet any threshold.
-        */
-        assert!(votes_meet_threshold(
-            Uint128::new(0),
+
+        // Zero votes out of zero total power meet any threshold. When
+        // Geq is used. Always fail otherwise.
+        assert!(compare_vote_count(
+            Uint128::zero(),
+            VoteCmp::Geq,
+            Uint128::new(1),
+            Decimal::percent(0)
+        ));
+        assert!(compare_vote_count(
+            Uint128::zero(),
+            VoteCmp::Geq,
             Uint128::new(0),
-            threshold
+            Decimal::percent(0)
         ));
-        assert!(votes_meet_threshold(
+        assert!(!compare_vote_count(
             Uint128::zero(),
+            VoteCmp::Greater,
             Uint128::new(1),
             Decimal::percent(0)
         ));
-        assert!(votes_meet_threshold(
+        assert!(!compare_vote_count(
             Uint128::zero(),
+            VoteCmp::Greater,
             Uint128::new(0),
             Decimal::percent(0)
         ))


Test patch:
diff --git a/contracts/cw-proposal-single/src/tests.rs b/contracts/cw-proposal-single/src/tests.rs
index 6a702da6..eed3ee4b 100644
--- a/contracts/cw-proposal-single/src/tests.rs
+++ b/contracts/cw-proposal-single/src/tests.rs
@@ -1,8 +1,8 @@
-use rand::{prelude::SliceRandom, Rng};
-
 use cosmwasm_std::{to_binary, Addr, Decimal, Empty, Uint128};
 use cw20::Cw20Coin;
 use cw_multi_test::{App, Contract, ContractWrapper, Executor};
+use indexable_hooks::HooksResponse;
+use rand::{prelude::SliceRandom, Rng};
 
 use voting::{Vote, Votes};
 
@@ -30,7 +30,8 @@ fn single_govmod_contract() -> Box<dyn Contract<Empty>> {
         crate::contract::execute,
         crate::contract::instantiate,
         crate::contract::query,
-    );
+    )
+    .with_reply(crate::contract::reply);
     Box::new(contract)
 }
 
@@ -1674,3 +1675,175 @@ fn test_query_list_proposals() {
     proposals_backward.proposals.reverse();
     assert_eq!(proposals_forward.proposals, proposals_backward.proposals);
 }
+
+#[test]
+fn test_hooks() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(single_govmod_contract());
+
+    let threshold = Threshold::AbsolutePercentage {
+        percentage: PercentageThreshold::Majority {},
+    };
+    let max_voting_period = cw_utils::Duration::Height(6);
+    let instantiate = InstantiateMsg {
+        threshold,
+        max_voting_period,
+        only_members_execute: false,
+        deposit_info: None,
+    };
+
+    let governance_addr =
+        instantiate_with_default_governance(&mut app, govmod_id, instantiate, None);
+    let governance_modules: Vec<Addr> = app
+        .wrap()
+        .query_wasm_smart(
+            governance_addr,
+            &cw_core::msg::QueryMsg::GovernanceModules {
+                start_at: None,
+                limit: None,
+            },
+        )
+        .unwrap();
+
+    assert_eq!(governance_modules.len(), 1);
+    let govmod_single = governance_modules.into_iter().next().unwrap();
+
+    let govmod_config: Config = app
+        .wrap()
+        .query_wasm_smart(govmod_single.clone(), &QueryMsg::Config {})
+        .unwrap();
+    let dao = govmod_config.dao;
+
+    // Expect no hooks
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(govmod_single.clone(), &QueryMsg::ProposalHooks {})
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 0);
+
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(govmod_single.clone(), &QueryMsg::VoteHooks {})
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 0);
+
+    let msg = ExecuteMsg::AddProposalHook {
+        address: "some_addr".to_string(),
+    };
+
+    // Expect error as sender is not DAO
+    let _err = app
+        .execute_contract(
+            Addr::unchecked(CREATOR_ADDR),
+            govmod_single.clone(),
+            &msg,
+            &[],
+        )
+        .unwrap_err();
+
+    // Expect success as sender is now DAO
+    let _res = app
+        .execute_contract(dao.clone(), govmod_single.clone(), &msg, &[])
+        .unwrap();
+
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(govmod_single.clone(), &QueryMsg::ProposalHooks {})
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+
+    // Expect error as hook is already set
+    let _err = app
+        .execute_contract(dao.clone(), govmod_single.clone(), &msg, &[])
+        .unwrap_err();
+
+    // Expect error as hook does not exist
+    let _err = app
+        .execute_contract(
+            dao.clone(),
+            govmod_single.clone(),
+            &ExecuteMsg::RemoveProposalHook {
+                address: "not_exist".to_string(),
+            },
+            &[],
+        )
+        .unwrap_err();
+
+    let msg = ExecuteMsg::RemoveProposalHook {
+        address: "some_addr".to_string(),
+    };
+
+    // Expect error as sender is not DAO
+    let _err = app
+        .execute_contract(
+            Addr::unchecked(CREATOR_ADDR),
+            govmod_single.clone(),
+            &msg,
+            &[],
+        )
+        .unwrap_err();
+
+    // Expect success
+    let _res = app
+        .execute_contract(dao.clone(), govmod_single.clone(), &msg, &[])
+        .unwrap();
+
+    let msg = ExecuteMsg::AddVoteHook {
+        address: "some_addr".to_string(),
+    };
+
+    // Expect error as sender is not DAO
+    let _err = app
+        .execute_contract(
+            Addr::unchecked(CREATOR_ADDR),
+            govmod_single.clone(),
+            &msg,
+            &[],
+        )
+        .unwrap_err();
+
+    // Expect success as sender is now DAO
+    let _res = app
+        .execute_contract(dao.clone(), govmod_single.clone(), &msg, &[])
+        .unwrap();
+
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(govmod_single.clone(), &QueryMsg::VoteHooks {})
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+
+    // Expect error as hook is already set
+    let _err = app
+        .execute_contract(dao.clone(), govmod_single.clone(), &msg, &[])
+        .unwrap_err();
+
+    // Expect error as hook does not exist
+    let _err = app
+        .execute_contract(
+            dao.clone(),
+            govmod_single.clone(),
+            &ExecuteMsg::RemoveVoteHook {
+                address: "not_exist".to_string(),
+            },
+            &[],
+        )
+        .unwrap_err();
+
+    let msg = ExecuteMsg::RemoveVoteHook {
+        address: "some_addr".to_string(),
+    };
+
+    // Expect error as sender is not DAO
+    let _err = app
+        .execute_contract(
+            Addr::unchecked(CREATOR_ADDR),
+            govmod_single.clone(),
+            &msg,
+            &[],
+        )
+        .unwrap_err();
+
+    // Expect success
+    let _res = app.execute_contract(dao, govmod_single, &msg, &[]).unwrap();
+}
diff --git a/contracts/proposal-hooks-counter/src/tests.rs b/contracts/proposal-hooks-counter/src/tests.rs
new file mode 100644
index 00000000..005517da
--- /dev/null
+++ b/contracts/proposal-hooks-counter/src/tests.rs
@@ -0,0 +1,458 @@
+use cosmwasm_std::{to_binary, Addr, Empty, Uint128};
+use cw20::Cw20Coin;
+use cw_multi_test::{App, Contract, ContractWrapper, Executor};
+use indexable_hooks::HooksResponse;
+
+use voting::Vote;
+
+use crate::msg::{CountResponse, InstantiateMsg, QueryMsg};
+use cw_proposal_single::{
+    state::Config,
+    threshold::{PercentageThreshold, Threshold},
+};
+
+const CREATOR_ADDR: &str = "creator";
+
+fn cw20_contract() -> Box<dyn Contract<Empty>> {
+    let contract = ContractWrapper::new(
+        cw20_base::contract::execute,
+        cw20_base::contract::instantiate,
+        cw20_base::contract::query,
+    );
+    Box::new(contract)
+}
+
+fn single_govmod_contract() -> Box<dyn Contract<Empty>> {
+    let contract = ContractWrapper::new(
+        cw_proposal_single::contract::execute,
+        cw_proposal_single::contract::instantiate,
+        cw_proposal_single::contract::query,
+    )
+    .with_reply(cw_proposal_single::contract::reply);
+    Box::new(contract)
+}
+
+fn cw20_balances_voting() -> Box<dyn Contract<Empty>> {
+    let contract = ContractWrapper::new(
+        cw20_balance_voting::contract::execute,
+        cw20_balance_voting::contract::instantiate,
+        cw20_balance_voting::contract::query,
+    )
+    .with_reply(cw20_balance_voting::contract::reply);
+    Box::new(contract)
+}
+
+fn cw_gov_contract() -> Box<dyn Contract<Empty>> {
+    let contract = ContractWrapper::new(
+        cw_core::contract::execute,
+        cw_core::contract::instantiate,
+        cw_core::contract::query,
+    )
+    .with_reply(cw_core::contract::reply);
+    Box::new(contract)
+}
+
+fn counters_contract() -> Box<dyn Contract<Empty>> {
+    let contract = ContractWrapper::new(
+        crate::contract::execute,
+        crate::contract::instantiate,
+        crate::contract::query,
+    );
+    Box::new(contract)
+}
+
+fn instantiate_governance(app: &mut App, code_id: u64, msg: cw_core::msg::InstantiateMsg) -> Addr {
+    app.instantiate_contract(
+        code_id,
+        Addr::unchecked(CREATOR_ADDR),
+        &msg,
+        &[],
+        "cw-governance",
+        None,
+    )
+    .unwrap()
+}
+
+fn instantiate_with_default_governance(
+    app: &mut App,
+    code_id: u64,
+    msg: cw_proposal_single::msg::InstantiateMsg,
+    initial_balances: Option<Vec<Cw20Coin>>,
+) -> Addr {
+    let cw20_id = app.store_code(cw20_contract());
+    let governance_id = app.store_code(cw_gov_contract());
+    let votemod_id = app.store_code(cw20_balances_voting());
+
+    let initial_balances = initial_balances.unwrap_or_else(|| {
+        vec![Cw20Coin {
+            address: CREATOR_ADDR.to_string(),
+            amount: Uint128::new(100_000_000),
+        }]
+    });
+
+    let governance_instantiate = cw_core::msg::InstantiateMsg {
+        name: "DAO DAO".to_string(),
+        description: "A DAO that builds DAOs".to_string(),
+        image_url: None,
+        automatically_add_cw20s: true,
+        automatically_add_cw721s: true,
+        voting_module_instantiate_info: cw_core::msg::ModuleInstantiateInfo {
+            code_id: votemod_id,
+            msg: to_binary(&cw20_balance_voting::msg::InstantiateMsg {
+                token_info: cw20_balance_voting::msg::TokenInfo::New {
+                    code_id: cw20_id,
+                    label: "DAO DAO governance token".to_string(),
+                    name: "DAO".to_string(),
+                    symbol: "DAO".to_string(),
+                    decimals: 6,
+                    initial_balances,
+                    marketing: None,
+                },
+            })
+            .unwrap(),
+            admin: cw_core::msg::Admin::GovernanceContract {},
+            label: "DAO DAO voting module".to_string(),
+        },
+        governance_modules_instantiate_info: vec![cw_core::msg::ModuleInstantiateInfo {
+            code_id,
+            msg: to_binary(&msg).unwrap(),
+            admin: cw_core::msg::Admin::GovernanceContract {},
+            label: "DAO DAO governance module".to_string(),
+        }],
+        initial_items: None,
+    };
+
+    instantiate_governance(app, governance_id, governance_instantiate)
+}
+
+#[test]
+fn test_counters() {
+    let mut app = App::default();
+    let govmod_id = app.store_code(single_govmod_contract());
+    let counters_id = app.store_code(counters_contract());
+
+    let threshold = Threshold::AbsolutePercentage {
+        percentage: PercentageThreshold::Majority {},
+    };
+    let max_voting_period = cw_utils::Duration::Height(6);
+    let instantiate = cw_proposal_single::msg::InstantiateMsg {
+        threshold,
+        max_voting_period,
+        only_members_execute: false,
+        deposit_info: None,
+    };
+
+    let governance_addr =
+        instantiate_with_default_governance(&mut app, govmod_id, instantiate, None);
+    let governance_modules: Vec<Addr> = app
+        .wrap()
+        .query_wasm_smart(
+            governance_addr,
+            &cw_core::msg::QueryMsg::GovernanceModules {
+                start_at: None,
+                limit: None,
+            },
+        )
+        .unwrap();
+
+    assert_eq!(governance_modules.len(), 1);
+    let govmod_single = governance_modules.into_iter().next().unwrap();
+
+    let govmod_config: Config = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::Config {},
+        )
+        .unwrap();
+    let dao = govmod_config.dao;
+
+    let counters: Addr = app
+        .instantiate_contract(
+            counters_id,
+            Addr::unchecked(CREATOR_ADDR),
+            &InstantiateMsg {
+                should_error: false,
+            },
+            &[],
+            "counters",
+            None,
+        )
+        .unwrap();
+    let failing_counters: Addr = app
+        .instantiate_contract(
+            counters_id,
+            Addr::unchecked(CREATOR_ADDR),
+            &InstantiateMsg { should_error: true },
+            &[],
+            "failing counters",
+            None,
+        )
+        .unwrap();
+
+    // Register both hooks
+    app.execute_contract(
+        dao.clone(),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::AddProposalHook {
+            address: counters.to_string(),
+        },
+        &[],
+    )
+    .unwrap();
+    app.execute_contract(
+        dao.clone(),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::AddVoteHook {
+            address: counters.to_string(),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query both hooks
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::ProposalHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::VoteHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+
+    // Query proposal counter, expect 0
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::ProposalCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 0);
+
+    // Create a new proposal.
+    app.execute_contract(
+        Addr::unchecked(CREATOR_ADDR),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::Propose {
+            title: "A simple text proposal".to_string(),
+            description: "This is a simple text proposal".to_string(),
+            msgs: vec![],
+            latest: None,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query proposal counter, expect 1
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::ProposalCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 1);
+
+    // Query vote counter, expect 0
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::VoteCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 0);
+
+    // Query status changed counter, expect 0
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::StatusChangedCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 0);
+
+    // Vote
+    app.execute_contract(
+        Addr::unchecked(CREATOR_ADDR),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::Vote {
+            proposal_id: 1,
+            vote: Vote::Yes,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Query vote counter, expect 1
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::VoteCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 1);
+
+    // Query status changed counter, expect 1
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::StatusChangedCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 1);
+
+    // Register the failing hooks
+    app.execute_contract(
+        dao.clone(),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::AddProposalHook {
+            address: failing_counters.to_string(),
+        },
+        &[],
+    )
+    .unwrap();
+    app.execute_contract(
+        dao.clone(),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::AddVoteHook {
+            address: failing_counters.to_string(),
+        },
+        &[],
+    )
+    .unwrap();
+
+    // Expect 2 for each hook
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::ProposalHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 2);
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::VoteHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 2);
+
+    // Create a new proposal.
+    app.execute_contract(
+        Addr::unchecked(CREATOR_ADDR),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::Propose {
+            title: "A simple text proposal 2nd".to_string(),
+            description: "This is a simple text proposal 2nd".to_string(),
+            msgs: vec![],
+            latest: None,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // The success counters should still work
+    // Query proposal counter, expect 2
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::ProposalCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 2);
+
+    // The contract should of removed the failing counters
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::ProposalHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+
+    // To verify it removed the right one, lets try and remove failing counters
+    // will fail as it does not exist.
+    let _err = app
+        .execute_contract(
+            dao.clone(),
+            govmod_single.clone(),
+            &cw_proposal_single::msg::ExecuteMsg::RemoveProposalHook {
+                address: failing_counters.to_string(),
+            },
+            &[],
+        )
+        .unwrap_err();
+
+    // It should still have the vote hook as that has not technically failed yet
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::VoteHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 2);
+
+    // Vote on the new proposal to fail the other hook
+    app.execute_contract(
+        Addr::unchecked(CREATOR_ADDR),
+        govmod_single.clone(),
+        &cw_proposal_single::msg::ExecuteMsg::Vote {
+            proposal_id: 2,
+            vote: Vote::Yes,
+        },
+        &[],
+    )
+    .unwrap();
+
+    // The success counters should still work
+    // Query vote counter, expect 2
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters.clone(), &QueryMsg::VoteCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 2);
+    // Query status changed counter, expect 2
+    let resp: CountResponse = app
+        .wrap()
+        .query_wasm_smart(counters, &QueryMsg::StatusChangedCounter {})
+        .unwrap();
+    assert_eq!(resp.count, 2);
+
+    // The contract should of removed the failing counters
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::VoteHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+
+    // To verify it removed the right one, lets try and remove failing counters
+    // will fail as it does not exist.
+    let _err = app
+        .execute_contract(
+            dao,
+            govmod_single.clone(),
+            &cw_proposal_single::msg::ExecuteMsg::RemoveVoteHook {
+                address: failing_counters.to_string(),
+            },
+            &[],
+        )
+        .unwrap_err();
+
+    // Verify only one hook remains for each
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single.clone(),
+            &cw_proposal_single::msg::QueryMsg::ProposalHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+    let hooks: HooksResponse = app
+        .wrap()
+        .query_wasm_smart(
+            govmod_single,
+            &cw_proposal_single::msg::QueryMsg::VoteHooks {},
+        )
+        .unwrap();
+    assert_eq!(hooks.hooks.len(), 1);
+}