Repository: public-awesome/launchpad
Pull request #535 (2023-03-31 18:01:51)

sg721 updatable v1


Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
index 9e0e63c5..b9b57744 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -4,6 +4,7 @@ workflows:
   test:
     jobs:
       - contract_sg721
+      - contract_sg721_updatable
       - contract_minter
       - contract_whitelist
       - contract_royalty_group
@@ -23,7 +24,7 @@ workflows:
 jobs:
   contract_sg721:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     working_directory: ~/project/contracts/sg721
     steps:
       - checkout:
@@ -33,7 +34,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version
       - restore_cache:
           keys:
-            - cargocache-sg721-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+            - cargocache-sg721-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Unit Tests
           environment:
@@ -55,11 +56,46 @@ jobs:
           paths:
             - /usr/local/cargo/registry
             - target
-          key: cargocache-sg721-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+          key: cargocache-sg721-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
+  contract_sg721_updatable:
+    docker:
+      - image: rust:1.64.0
+    working_directory: ~/project/contracts/sg721-updatable
+    steps:
+      - checkout:
+          path: ~/project
+      - run:
+          name: Version information
+          command: rustc --version; cargo --version; rustup --version
+      - restore_cache:
+          keys:
+            - cargocache-sg721-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
+      - run:
+          name: Unit Tests
+          environment:
+            RUST_BACKTRACE: 1
+          command: cargo unit-test --locked
+      - run:
+          name: Build and run schema generator
+          command: cargo schema --locked
+      - run:
+          name: Ensure checked-in schemas are up-to-date
+          command: |
+            CHANGES_IN_REPO=$(git status --porcelain)
+            if [[ -n "$CHANGES_IN_REPO" ]]; then
+              echo "Repository is dirty. Showing 'git status' and 'git --no-pager diff' for debugging now:"
+              git status && git --no-pager diff
+              exit 1
+            fi
+      - save_cache:
+          paths:
+            - /usr/local/cargo/registry
+            - target
+          key: cargocache-sg721-updatable-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
 
   contract_minter:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     working_directory: ~/project/contracts/minter
     steps:
       - checkout:
@@ -69,7 +105,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version
       - restore_cache:
           keys:
-            - cargocache-minter-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+            - cargocache-minter-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Unit Tests
           environment:
@@ -91,11 +127,11 @@ jobs:
           paths:
             - /usr/local/cargo/registry
             - target
-          key: cargocache-minter-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+          key: cargocache-minter-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
 
   contract_whitelist:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     working_directory: ~/project/contracts/whitelist
     steps:
       - checkout:
@@ -105,7 +141,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version
       - restore_cache:
           keys:
-            - cargocache-whitelist-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+            - cargocache-whitelist-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Unit Tests
           environment:
@@ -127,11 +163,11 @@ jobs:
           paths:
             - /usr/local/cargo/registry
             - target
-          key: cargocache-whitelist-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+          key: cargocache-whitelist-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
 
   contract_royalty_group:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     working_directory: ~/project/contracts/royalty-group
     steps:
       - checkout:
@@ -141,7 +177,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version
       - restore_cache:
           keys:
-            - cargocache-royalty-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+            - cargocache-royalty-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Unit Tests
           environment:
@@ -163,11 +199,11 @@ jobs:
           paths:
             - /usr/local/cargo/registry
             - target
-          key: cargocache-royalty-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+          key: cargocache-royalty-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
 
   package_sg_std:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     working_directory: ~/project/package/sg-std
     steps:
       - checkout:
@@ -177,7 +213,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version
       - restore_cache:
           keys:
-            - cargocache-sg-std-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+            - cargocache-sg-std-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Unit Tests
           environment:
@@ -187,11 +223,11 @@ jobs:
           paths:
             - /usr/local/cargo/registry
             - target
-          key: cargocache-sg-std-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+          key: cargocache-sg-std-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
 
   package_sg_utils:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     working_directory: ~/project/package/sg-utils
     steps:
       - checkout:
@@ -201,7 +237,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version
       - restore_cache:
           keys:
-            - cargocache-sg-utils-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+            - cargocache-sg-utils-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Unit Tests
           environment:
@@ -211,11 +247,11 @@ jobs:
           paths:
             - /usr/local/cargo/registry
             - target
-          key: cargocache-sg-utils-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+          key: cargocache-sg-utils-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
 
   lint:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     steps:
       - checkout
       - run:
@@ -223,7 +259,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version; rustup target list --installed
       - restore_cache:
           keys:
-            - cargocache-v2-lint-rust:1.63.0-{{ checksum "Cargo.lock" }}
+            - cargocache-v2-lint-rust:1.64.0-{{ checksum "Cargo.lock" }}
       - run:
           name: Add rustfmt component
           command: rustup component add rustfmt
@@ -242,7 +278,7 @@ jobs:
             - target/debug/.fingerprint
             - target/debug/build
             - target/debug/deps
-          key: cargocache-v2-lint-rust:1.63.0-{{ checksum "Cargo.lock" }}
+          key: cargocache-v2-lint-rust:1.64.0-{{ checksum "Cargo.lock" }}
 
   # This runs one time on the top level to ensure all contracts compile properly into wasm.
   # We don't run the wasm build per contract build, and then reuse a lot of the same dependencies, so this speeds up CI time
@@ -250,7 +286,7 @@ jobs:
   # We also sanity-check the resultant wasm files.
   wasm-build:
     docker:
-      - image: rust:1.63.0
+      - image: rust:1.64.0
     steps:
       - checkout:
           path: ~/project
@@ -259,7 +295,7 @@ jobs:
           command: rustc --version; cargo --version; rustup --version
       - restore_cache:
           keys:
-            - cargocache-wasm-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+            - cargocache-wasm-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Add wasm32 target
           command: rustup target add wasm32-unknown-unknown
@@ -279,7 +315,7 @@ jobs:
           paths:
             - /usr/local/cargo/registry
             - target
-          key: cargocache-wasm-rust:1.63.0-{{ checksum "~/project/Cargo.lock" }}
+          key: cargocache-wasm-rust:1.64.0-{{ checksum "~/project/Cargo.lock" }}
       - run:
           name: Check wasm contracts
           command: |
@@ -315,7 +351,7 @@ jobs:
       - run:
           name: Build development contracts
           command: |
-            docker run --volumes-from with_code cosmwasm/workspace-optimizer:0.12.8
+            docker run --volumes-from with_code cosmwasm/workspace-optimizer:0.12.13
             docker cp with_code:/code/artifacts ./artifacts
       - run:
           name: Show data
diff --git a/Cargo.lock b/Cargo.lock
index 89b77179..7f604357 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -636,6 +636,7 @@ dependencies = [
  "serde",
  "sg-std",
  "sg721",
+ "sg721-updatable",
  "thiserror",
  "url",
  "whitelist",
@@ -912,6 +913,26 @@ dependencies = [
  "url",
 ]
 
+[[package]]
+name = "sg721-updatable"
+version = "0.8.3"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cosmwasm-storage",
+ "cw-storage-plus 0.13.4",
+ "cw-utils 0.13.4",
+ "cw2 0.13.4",
+ "cw721",
+ "cw721-base",
+ "schemars",
+ "semver",
+ "serde",
+ "sg-std",
+ "thiserror",
+ "url",
+]
+
 [[package]]
 name = "sha2"
 version = "0.9.9"
diff --git a/contracts/minter/Cargo.toml b/contracts/minter/Cargo.toml
index 6ef1012a..245448c7 100644
--- a/contracts/minter/Cargo.toml
+++ b/contracts/minter/Cargo.toml
@@ -48,6 +48,7 @@ cw-utils = "0.13.4"
 schemars = "0.8"
 serde = { version = "1.0", default-features = false, features = ["derive"] }
 sg721 = { path = "../sg721", features = ["library"] }
+sg721-updatable = { path = "../sg721-updatable", features = ["library"] }
 thiserror = { version = "1.0" }
 url = "2.2.0"
 sg-std = { path = "../../packages/sg-std" }
diff --git a/contracts/minter/src/lib.rs b/contracts/minter/src/lib.rs
index e11a698b..0d83db17 100644
--- a/contracts/minter/src/lib.rs
+++ b/contracts/minter/src/lib.rs
@@ -2,6 +2,8 @@ pub mod contract;
 #[cfg(test)]
 mod contract_tests;
 #[cfg(test)]
+mod contract_updatable_tests;
+#[cfg(test)]
 pub mod multi;
 
 mod error;
diff --git a/contracts/minter/src/msg.rs b/contracts/minter/src/msg.rs
index af57d786..84e549b5 100644
--- a/contracts/minter/src/msg.rs
+++ b/contracts/minter/src/msg.rs
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
 
 use sg721::msg::InstantiateMsg as Sg721InstantiateMsg;
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct InstantiateMsg {
     pub base_token_uri: String,
     pub num_tokens: u32,
@@ -16,7 +16,7 @@ pub struct InstantiateMsg {
     pub whitelist: Option<String>,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum ExecuteMsg {
     Mint {},
@@ -30,7 +30,7 @@ pub enum ExecuteMsg {
     RemoveDiscountPrice {},
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum QueryMsg {
     Config {},
@@ -40,7 +40,7 @@ pub enum QueryMsg {
     MintCount { address: String },
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct ConfigResponse {
     pub admin: String,
     pub base_token_uri: String,
@@ -54,17 +54,17 @@ pub struct ConfigResponse {
     pub whitelist: Option<String>,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct MintableNumTokensResponse {
     pub count: u32,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct StartTimeResponse {
     pub start_time: String,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct MintPriceResponse {
     pub public_price: Coin,
     pub whitelist_price: Option<Coin>,
@@ -72,7 +72,7 @@ pub struct MintPriceResponse {
     pub current_price: Coin,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct MintCountResponse {
     pub address: String,
     pub count: u32,
diff --git a/contracts/minter/src/state.rs b/contracts/minter/src/state.rs
index 29f7e292..5b33b4a2 100644
--- a/contracts/minter/src/state.rs
+++ b/contracts/minter/src/state.rs
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
 use cosmwasm_std::{Addr, Coin, Timestamp};
 use cw_storage_plus::{Item, Map};
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct Config {
     pub admin: Addr,
     pub base_token_uri: String,
diff --git a/contracts/royalty-group/src/msg.rs b/contracts/royalty-group/src/msg.rs
index ca561307..854f8c01 100644
--- a/contracts/royalty-group/src/msg.rs
+++ b/contracts/royalty-group/src/msg.rs
@@ -31,7 +31,7 @@ pub enum ExecuteMsg {
     Distribute { funds: Coin },
 }
 
-#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum QueryMsg {
     /// Return AdminResponse
diff --git a/contracts/sg721-updatable/.cargo/config b/contracts/sg721-updatable/.cargo/config
new file mode 100644
index 00000000..336b618a
--- /dev/null
+++ b/contracts/sg721-updatable/.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/sg721-updatable/Cargo.toml b/contracts/sg721-updatable/Cargo.toml
new file mode 100644
index 00000000..e4da37af
--- /dev/null
+++ b/contracts/sg721-updatable/Cargo.toml
@@ -0,0 +1,47 @@
+[package]
+name    = "sg721-updatable"
+version = "0.8.3"
+authors = ["John Y <j@noreply.publicawesome.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.3
+"""
+
+[dependencies]
+cosmwasm-std     = { version = "1.0.0" }
+cosmwasm-storage = { version = "1.0.0" }
+cw-storage-plus  = "0.13.4"
+cw-utils         = "0.13.4"
+cw2              = "0.13.4"
+schemars         = "0.8.8"
+serde            = { version = "1.0.133", default-features = false, features = ["derive"] }
+semver           = "1"
+thiserror        = { version = "1.0.30" }
+sg-std           = { path = "../../packages/sg-std" }
+url              = "2.2.2"
+cw721            = "0.12.0"
+cw721-base       = { version = "0.12.0", features = ["library"] }
+
+[dev-dependencies]
+cosmwasm-schema = { version = "1.0.0" }
diff --git a/contracts/sg721-updatable/examples/schema.rs b/contracts/sg721-updatable/examples/schema.rs
new file mode 100644
index 00000000..4f516a69
--- /dev/null
+++ b/contracts/sg721-updatable/examples/schema.rs
@@ -0,0 +1,19 @@
+use std::env::current_dir;
+use std::fs::create_dir_all;
+
+use cosmwasm_schema::{export_schema, remove_schemas, schema_for};
+
+use cw721_base::Extension;
+use sg721_updatable::msg::{CollectionInfoResponse, 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<Extension>), &out_dir);
+    export_schema(&schema_for!(QueryMsg), &out_dir);
+    export_schema(&schema_for!(CollectionInfoResponse), &out_dir);
+}
diff --git a/contracts/sg721-updatable/schema/collection_info_response.json b/contracts/sg721-updatable/schema/collection_info_response.json
new file mode 100644
index 00000000..d285d111
--- /dev/null
+++ b/contracts/sg721-updatable/schema/collection_info_response.json
@@ -0,0 +1,58 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "CollectionInfoResponse",
+  "type": "object",
+  "required": [
+    "creator",
+    "description",
+    "image"
+  ],
+  "properties": {
+    "creator": {
+      "type": "string"
+    },
+    "description": {
+      "type": "string"
+    },
+    "external_link": {
+      "type": [
+        "string",
+        "null"
+      ]
+    },
+    "image": {
+      "type": "string"
+    },
+    "royalty_info": {
+      "anyOf": [
+        {
+          "$ref": "#/definitions/RoyaltyInfoResponse"
+        },
+        {
+          "type": "null"
+        }
+      ]
+    }
+  },
+  "definitions": {
+    "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"
+    },
+    "RoyaltyInfoResponse": {
+      "type": "object",
+      "required": [
+        "payment_address",
+        "share"
+      ],
+      "properties": {
+        "payment_address": {
+          "type": "string"
+        },
+        "share": {
+          "$ref": "#/definitions/Decimal"
+        }
+      }
+    }
+  }
+}
diff --git a/contracts/sg721-updatable/schema/execute_msg_for__nullable__empty.json b/contracts/sg721-updatable/schema/execute_msg_for__nullable__empty.json
new file mode 100644
index 00000000..fa73c2bd
--- /dev/null
+++ b/contracts/sg721-updatable/schema/execute_msg_for__nullable__empty.json
@@ -0,0 +1,386 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "ExecuteMsg_for_Nullable_Empty",
+  "oneOf": [
+    {
+      "description": "Freeze token metadata so creator can no longer update token uris",
+      "type": "object",
+      "required": [
+        "freeze_token_metadata"
+      ],
+      "properties": {
+        "freeze_token_metadata": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "description": "Creator calls can update token uris",
+      "type": "object",
+      "required": [
+        "update_token_metadata"
+      ],
+      "properties": {
+        "update_token_metadata": {
+          "type": "object",
+          "required": [
+            "token_id"
+          ],
+          "properties": {
+            "token_id": {
+              "type": "string"
+            },
+            "token_uri": {
+              "type": [
+                "string",
+                "null"
+              ]
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "transfer_nft"
+      ],
+      "properties": {
+        "transfer_nft": {
+          "type": "object",
+          "required": [
+            "recipient",
+            "token_id"
+          ],
+          "properties": {
+            "recipient": {
+              "type": "string"
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "send_nft"
+      ],
+      "properties": {
+        "send_nft": {
+          "type": "object",
+          "required": [
+            "contract",
+            "msg",
+            "token_id"
+          ],
+          "properties": {
+            "contract": {
+              "type": "string"
+            },
+            "msg": {
+              "$ref": "#/definitions/Binary"
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "approve"
+      ],
+      "properties": {
+        "approve": {
+          "type": "object",
+          "required": [
+            "spender",
+            "token_id"
+          ],
+          "properties": {
+            "expires": {
+              "anyOf": [
+                {
+                  "$ref": "#/definitions/Expiration"
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "spender": {
+              "type": "string"
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "revoke"
+      ],
+      "properties": {
+        "revoke": {
+          "type": "object",
+          "required": [
+            "spender",
+            "token_id"
+          ],
+          "properties": {
+            "spender": {
+              "type": "string"
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "approve_all"
+      ],
+      "properties": {
+        "approve_all": {
+          "type": "object",
+          "required": [
+            "operator"
+          ],
+          "properties": {
+            "expires": {
+              "anyOf": [
+                {
+                  "$ref": "#/definitions/Expiration"
+                },
+                {
+                  "type": "null"
+                }
+              ]
+            },
+            "operator": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "revoke_all"
+      ],
+      "properties": {
+        "revoke_all": {
+          "type": "object",
+          "required": [
+            "operator"
+          ],
+          "properties": {
+            "operator": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "burn"
+      ],
+      "properties": {
+        "burn": {
+          "type": "object",
+          "required": [
+            "token_id"
+          ],
+          "properties": {
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "update_royalty_info"
+      ],
+      "properties": {
+        "update_royalty_info": {
+          "type": "object",
+          "required": [
+            "payment_address",
+            "share_bps"
+          ],
+          "properties": {
+            "payment_address": {
+              "type": "string"
+            },
+            "share_bps": {
+              "type": "integer",
+              "format": "uint64",
+              "minimum": 0.0
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "update_trading_start_time"
+      ],
+      "properties": {
+        "update_trading_start_time": {
+          "anyOf": [
+            {
+              "$ref": "#/definitions/Timestamp"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "mint"
+      ],
+      "properties": {
+        "mint": {
+          "$ref": "#/definitions/MintMsg_for_Nullable_Empty"
+        }
+      },
+      "additionalProperties": false
+    }
+  ],
+  "definitions": {
+    "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>. See also <https://github.com/CosmWasm/cosmwasm/blob/main/docs/MESSAGE_TYPES.md>.",
+      "type": "string"
+    },
+    "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
+        }
+      ]
+    },
+    "MintMsg_for_Nullable_Empty": {
+      "type": "object",
+      "required": [
+        "owner",
+        "token_id"
+      ],
+      "properties": {
+        "extension": {
+          "description": "Any custom extension used by this contract",
+          "anyOf": [
+            {
+              "$ref": "#/definitions/Empty"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
+        "owner": {
+          "description": "The owner of the newly minter NFT",
+          "type": "string"
+        },
+        "token_id": {
+          "description": "Unique ID of the NFT",
+          "type": "string"
+        },
+        "token_uri": {
+          "description": "Universal resource identifier for this NFT Should point to a JSON file that conforms to the ERC721 Metadata JSON Schema",
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "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"
+        }
+      ]
+    },
+    "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"
+    }
+  }
+}
diff --git a/contracts/sg721-updatable/schema/instantiate_msg.json b/contracts/sg721-updatable/schema/instantiate_msg.json
new file mode 100644
index 00000000..fb28071b
--- /dev/null
+++ b/contracts/sg721-updatable/schema/instantiate_msg.json
@@ -0,0 +1,81 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "InstantiateMsg",
+  "type": "object",
+  "required": [
+    "collection_info",
+    "minter",
+    "name",
+    "symbol"
+  ],
+  "properties": {
+    "collection_info": {
+      "$ref": "#/definitions/CollectionInfo_for_RoyaltyInfoResponse"
+    },
+    "minter": {
+      "type": "string"
+    },
+    "name": {
+      "type": "string"
+    },
+    "symbol": {
+      "type": "string"
+    }
+  },
+  "definitions": {
+    "CollectionInfo_for_RoyaltyInfoResponse": {
+      "type": "object",
+      "required": [
+        "creator",
+        "description",
+        "image"
+      ],
+      "properties": {
+        "creator": {
+          "type": "string"
+        },
+        "description": {
+          "type": "string"
+        },
+        "external_link": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "image": {
+          "type": "string"
+        },
+        "royalty_info": {
+          "anyOf": [
+            {
+              "$ref": "#/definitions/RoyaltyInfoResponse"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        }
+      }
+    },
+    "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"
+    },
+    "RoyaltyInfoResponse": {
+      "type": "object",
+      "required": [
+        "payment_address",
+        "share"
+      ],
+      "properties": {
+        "payment_address": {
+          "type": "string"
+        },
+        "share": {
+          "$ref": "#/definitions/Decimal"
+        }
+      }
+    }
+  }
+}
diff --git a/contracts/sg721-updatable/schema/query_msg.json b/contracts/sg721-updatable/schema/query_msg.json
new file mode 100644
index 00000000..3cb27165
--- /dev/null
+++ b/contracts/sg721-updatable/schema/query_msg.json
@@ -0,0 +1,284 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "QueryMsg",
+  "oneOf": [
+    {
+      "type": "object",
+      "required": [
+        "owner_of"
+      ],
+      "properties": {
+        "owner_of": {
+          "type": "object",
+          "required": [
+            "token_id"
+          ],
+          "properties": {
+            "include_expired": {
+              "type": [
+                "boolean",
+                "null"
+              ]
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "approval"
+      ],
+      "properties": {
+        "approval": {
+          "type": "object",
+          "required": [
+            "spender",
+            "token_id"
+          ],
+          "properties": {
+            "include_expired": {
+              "type": [
+                "boolean",
+                "null"
+              ]
+            },
+            "spender": {
+              "type": "string"
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "approvals"
+      ],
+      "properties": {
+        "approvals": {
+          "type": "object",
+          "required": [
+            "token_id"
+          ],
+          "properties": {
+            "include_expired": {
+              "type": [
+                "boolean",
+                "null"
+              ]
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "all_operators"
+      ],
+      "properties": {
+        "all_operators": {
+          "type": "object",
+          "required": [
+            "owner"
+          ],
+          "properties": {
+            "include_expired": {
+              "type": [
+                "boolean",
+                "null"
+              ]
+            },
+            "limit": {
+              "type": [
+                "integer",
+                "null"
+              ],
+              "format": "uint32",
+              "minimum": 0.0
+            },
+            "owner": {
+              "type": "string"
+            },
+            "start_after": {
+              "type": [
+                "string",
+                "null"
+              ]
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "num_tokens"
+      ],
+      "properties": {
+        "num_tokens": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "contract_info"
+      ],
+      "properties": {
+        "contract_info": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "nft_info"
+      ],
+      "properties": {
+        "nft_info": {
+          "type": "object",
+          "required": [
+            "token_id"
+          ],
+          "properties": {
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "all_nft_info"
+      ],
+      "properties": {
+        "all_nft_info": {
+          "type": "object",
+          "required": [
+            "token_id"
+          ],
+          "properties": {
+            "include_expired": {
+              "type": [
+                "boolean",
+                "null"
+              ]
+            },
+            "token_id": {
+              "type": "string"
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "tokens"
+      ],
+      "properties": {
+        "tokens": {
+          "type": "object",
+          "required": [
+            "owner"
+          ],
+          "properties": {
+            "limit": {
+              "type": [
+                "integer",
+                "null"
+              ],
+              "format": "uint32",
+              "minimum": 0.0
+            },
+            "owner": {
+              "type": "string"
+            },
+            "start_after": {
+              "type": [
+                "string",
+                "null"
+              ]
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "all_tokens"
+      ],
+      "properties": {
+        "all_tokens": {
+          "type": "object",
+          "properties": {
+            "limit": {
+              "type": [
+                "integer",
+                "null"
+              ],
+              "format": "uint32",
+              "minimum": 0.0
+            },
+            "start_after": {
+              "type": [
+                "string",
+                "null"
+              ]
+            }
+          }
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "minter"
+      ],
+      "properties": {
+        "minter": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "collection_info"
+      ],
+      "properties": {
+        "collection_info": {
+          "type": "object"
+        }
+      },
+      "additionalProperties": false
+    }
+  ]
+}
diff --git a/contracts/sg721-updatable/src/contract.rs b/contracts/sg721-updatable/src/contract.rs
new file mode 100644
index 00000000..250cd4ec
--- /dev/null
+++ b/contracts/sg721-updatable/src/contract.rs
@@ -0,0 +1,369 @@
+use semver::Version;
+
+#[cfg(not(feature = "library"))]
+use cosmwasm_std::entry_point;
+use cosmwasm_std::{
+    to_binary, Binary, Decimal, Deps, DepsMut, Empty, Env, Event, MessageInfo, StdError, StdResult,
+    Uint128,
+};
+use cw2::set_contract_version;
+
+use cw_utils::nonpayable;
+use sg_std::fees::burn_and_distribute_fee;
+use sg_std::StargazeMsgWrapper;
+
+use crate::ContractError;
+use cw721::ContractInfoResponse;
+use url::Url;
+
+use crate::msg::{
+    CollectionInfoResponse, ExecuteMsg, InstantiateMsg, QueryMsg, RoyaltyInfoResponse,
+};
+use crate::state::{CollectionInfo, RoyaltyInfo, COLLECTION_INFO, FROZEN_TOKEN_METADATA};
+
+// version info for migration info
+const COMPATIBLE_MIGRATION_CONTRACT_NAME: &str = "crates.io:sg-721";
+const CONTRACT_NAME: &str = "crates.io:sg721-updatable";
+const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
+
+const CREATION_FEE: u128 = 1_000_000_000;
+
+// Disable instantiation for production build
+#[cfg(target = "wasm32-unknown-unknown")]
+const ENABLE_INSTANTIATE: bool = false;
+
+#[cfg(not(target = "wasm32-unknown-unknown"))]
+const ENABLE_INSTANTIATE: bool = true;
+
+type Response = cosmwasm_std::Response<StargazeMsgWrapper>;
+pub type Sg721Contract<'a> = cw721_base::Cw721Contract<'a, Empty, StargazeMsgWrapper>;
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn instantiate(
+    deps: DepsMut,
+    env: Env,
+    info: MessageInfo,
+    msg: InstantiateMsg,
+) -> Result<Response, ContractError> {
+    if !ENABLE_INSTANTIATE {
+        return Err(ContractError::Unauthorized {});
+    }
+    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
+
+    let fee_msgs = burn_and_distribute_fee(env, &info, CREATION_FEE)?;
+
+    // cw721 instantiation
+    let info = ContractInfoResponse {
+        name: msg.name,
+        symbol: msg.symbol,
+    };
+    Sg721Contract::default()
+        .contract_info
+        .save(deps.storage, &info)?;
+
+    let minter = deps.api.addr_validate(&msg.minter)?;
+    Sg721Contract::default()
+        .minter
+        .save(deps.storage, &minter)?;
+
+    // sg721 instantiation
+    if msg.collection_info.description.len() > 256 {
+        return Err(ContractError::DescriptionTooLong {});
+    }
+
+    let image = Url::parse(&msg.collection_info.image)?;
+
+    if let Some(ref external_link) = msg.collection_info.external_link {
+        Url::parse(external_link)?;
+    }
+
+    let royalty_info: Option<RoyaltyInfo> = match msg.collection_info.royalty_info {
+        Some(royalty_info) => Some(RoyaltyInfo {
+            payment_address: deps.api.addr_validate(&royalty_info.payment_address)?,
+            share: royalty_info.share_validate()?,
+        }),
+        None => None,
+    };
+
+    deps.api.addr_validate(&msg.collection_info.creator)?;
+
+    let collection_info = CollectionInfo {
+        creator: msg.collection_info.creator,
+        description: msg.collection_info.description,
+        image: msg.collection_info.image,
+        external_link: msg.collection_info.external_link,
+        royalty_info,
+    };
+
+    FROZEN_TOKEN_METADATA.save(deps.storage, &false)?;
+    COLLECTION_INFO.save(deps.storage, &collection_info)?;
+
+    Ok(Response::default()
+        .add_attribute("action", "instantiate")
+        .add_attribute("contract_name", CONTRACT_NAME)
+        .add_attribute("contract_version", CONTRACT_VERSION)
+        .add_attribute("image", image.to_string())
+        .add_messages(fee_msgs))
+}
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn execute(
+    deps: DepsMut,
+    env: Env,
+    info: MessageInfo,
+    msg: ExecuteMsg<Empty>,
+) -> Result<Response, ContractError> {
+    match msg {
+        ExecuteMsg::FreezeTokenMetadata {} => execute_freeze_token_metadata(deps, env, info),
+        ExecuteMsg::UpdateTokenMetadata {
+            token_id,
+            token_uri,
+        } => execute_update_token_metadata(deps, env, info, token_id, token_uri),
+        ExecuteMsg::UpdateRoyaltyInfo {
+            payment_address,
+            share_bps,
+        } => execute_update_royalty_info(deps, info, payment_address, share_bps),
+        _ => Sg721Contract::default()
+            .execute(deps, env, info, msg.into())
+            .map_err(|e| e.into()),
+    }
+}
+
+pub fn execute_update_royalty_info(
+    deps: DepsMut,
+    info: MessageInfo,
+    payment_address: String,
+    share_bps: u64,
+) -> Result<Response, ContractError> {
+    nonpayable(&info)?;
+    let mut collection_info = COLLECTION_INFO.load(deps.storage)?;
+    // check sender is authorized
+    if info.sender != collection_info.creator {
+        return Err(ContractError::Unauthorized {});
+    }
+
+    let payment_addr = deps.api.addr_validate(&payment_address)?;
+    let share = Decimal::percent(share_bps) / Uint128::from(100u128);
+
+    let current_royalty_info = collection_info.royalty_info;
+
+    if let Some(current_royalty_info) = current_royalty_info {
+        if share > current_royalty_info.share {
+            return Err(ContractError::RoyaltyShareIncreased {});
+        }
+    } else {
+        return Err(ContractError::RoyaltyShareIncreased {});
+    }
+
+    collection_info.royalty_info = Some(RoyaltyInfo {
+        payment_address: payment_addr,
+        share,
+    });
+    COLLECTION_INFO.save(deps.storage, &collection_info)?;
+
+    let event = Event::new("update_royalty_info")
+        .add_attribute("sender", info.sender)
+        .add_attribute("payment_address", payment_address)
+        .add_attribute("share", share.to_string());
+    Ok(Response::new().add_event(event))
+}
+
+pub fn execute_freeze_token_metadata(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+) -> Result<Response, ContractError> {
+    nonpayable(&info)?;
+    // Check if sender is creator
+    let collection_info: CollectionInfoResponse = query_config(deps.as_ref())?;
+    if info.sender != collection_info.creator {
+        return Err(ContractError::Unauthorized {});
+    }
+
+    FROZEN_TOKEN_METADATA.save(deps.storage, &true)?;
+
+    let event = Event::new("freeze_token_metadata").add_attribute("frozen", "true");
+    Ok(Response::new().add_event(event))
+}
+
+pub fn execute_update_token_metadata(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+    token_id: String,
+    token_uri: Option<String>,
+) -> Result<Response, ContractError> {
+    nonpayable(&info)?;
+    // Check if sender is creator
+    let creator = deps.api.addr_validate(info.sender.as_ref())?;
+    let collection_info: CollectionInfoResponse = query_config(deps.as_ref())?;
+    if creator != collection_info.creator {
+        return Err(ContractError::Unauthorized {});
+    }
+
+    // Check if token metadata is frozen
+    let frozen = FROZEN_TOKEN_METADATA.load(deps.storage)?;
+    if frozen {
+        return Err(ContractError::TokenMetadataFrozen {});
+    }
+
+    // Update token metadata
+    Sg721Contract::default()
+        .tokens
+        .update(deps.storage, &token_id, |token| match token {
+            Some(mut token_info) => {
+                token_info.token_uri = token_uri.clone();
+                Ok(token_info)
+            }
+            None => Err(ContractError::TokenIdNotFound {}),
+        })?;
+
+    let event = Event::new("update_update_token_metadata")
+        .add_attribute("sender", info.sender)
+        .add_attribute("token_id", token_id)
+        .add_attribute("token_uri", token_uri.unwrap_or_default());
+    Ok(Response::new().add_event(event))
+}
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
+    match msg {
+        QueryMsg::CollectionInfo {} => to_binary(&query_config(deps)?),
+        _ => Sg721Contract::default().query(deps, env, msg.into()),
+    }
+}
+
+fn query_config(deps: Deps) -> StdResult<CollectionInfoResponse> {
+    let info = COLLECTION_INFO.load(deps.storage)?;
+
+    let royalty_info_res: Option<RoyaltyInfoResponse> = match info.royalty_info {
+        Some(royalty_info) => Some(RoyaltyInfoResponse {
+            payment_address: royalty_info.payment_address.to_string(),
+            share: royalty_info.share,
+        }),
+        None => None,
+    };
+
+    Ok(CollectionInfoResponse {
+        creator: info.creator,
+        description: info.description,
+        image: info.image,
+        external_link: info.external_link,
+        royalty_info: royalty_info_res,
+    })
+}
+
+#[cfg_attr(not(feature = "library"), entry_point)]
+pub fn migrate(deps: DepsMut, _env: Env, _msg: Empty) -> Result<Response, ContractError> {
+    let current_version = cw2::get_contract_version(deps.storage)?;
+    if ![CONTRACT_NAME, COMPATIBLE_MIGRATION_CONTRACT_NAME]
+        .contains(&current_version.contract.as_str())
+    {
+        return Err(StdError::generic_err("Cannot upgrade to a different contract").into());
+    }
+    let version: Version = current_version
+        .version
+        .parse()
+        .map_err(|_| StdError::generic_err("Invalid contract version"))?;
+    let new_version: Version = CONTRACT_VERSION
+        .parse()
+        .map_err(|_| StdError::generic_err("Invalid contract version"))?;
+
+    if version > new_version {
+        return Err(StdError::generic_err("Cannot upgrade to a previous contract version").into());
+    }
+    // if same version return
+    if version == new_version {
+        return Ok(Response::new());
+    }
+
+    FROZEN_TOKEN_METADATA.save(deps.storage, &false)?;
+
+    // set new contract version
+    set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?;
+    Ok(Response::new())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    use crate::state::CollectionInfo;
+    use cosmwasm_std::testing::{mock_dependencies, mock_env, mock_info};
+    use cosmwasm_std::{coins, from_binary, Decimal};
+    use sg_std::NATIVE_DENOM;
+
+    #[test]
+    fn proper_initialization_no_royalties() {
+        let mut deps = mock_dependencies();
+        let collection = String::from("collection0");
+
+        let msg = InstantiateMsg {
+            name: collection,
+            symbol: String::from("BOBO"),
+            minter: String::from("minter"),
+            collection_info: CollectionInfo {
+                creator: String::from("creator"),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: None,
+            },
+        };
+        let info = mock_info("creator", &coins(CREATION_FEE, NATIVE_DENOM));
+
+        // make sure instantiate has the burn messages
+        let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
+        assert_eq!(2, res.messages.len());
+
+        // let's query the collection info
+        let res = query(deps.as_ref(), mock_env(), QueryMsg::CollectionInfo {}).unwrap();
+        let value: CollectionInfoResponse = from_binary(&res).unwrap();
+        assert_eq!("https://example.com/image.png", value.image);
+        assert_eq!("Stargaze Monkeys", value.description);
+        assert_eq!(
+            "https://example.com/external.html",
+            value.external_link.unwrap()
+        );
+        assert_eq!(None, value.royalty_info);
+    }
+
+    #[test]
+    fn proper_initialization_with_royalties() {
+        let mut deps = mock_dependencies();
+        let creator = String::from("creator");
+        let collection = String::from("collection0");
+
+        let msg = InstantiateMsg {
+            name: collection,
+            symbol: String::from("BOBO"),
+            minter: String::from("minter"),
+            collection_info: CollectionInfo {
+                creator: String::from("creator"),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: creator.clone(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        };
+        let info = mock_info("creator", &coins(CREATION_FEE, NATIVE_DENOM));
+
+        // make sure instantiate has the burn messages
+        let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
+        assert_eq!(2, res.messages.len());
+
+        // let's query the collection info
+        let res = query(deps.as_ref(), mock_env(), QueryMsg::CollectionInfo {}).unwrap();
+        let value: CollectionInfoResponse = from_binary(&res).unwrap();
+        assert_eq!(
+            Some(RoyaltyInfoResponse {
+                payment_address: creator,
+                share: Decimal::percent(10),
+            }),
+            value.royalty_info
+        );
+    }
+}
diff --git a/contracts/sg721-updatable/src/error.rs b/contracts/sg721-updatable/src/error.rs
new file mode 100644
index 00000000..651a6aa4
--- /dev/null
+++ b/contracts/sg721-updatable/src/error.rs
@@ -0,0 +1,53 @@
+use cosmwasm_std::StdError;
+use cw_utils::PaymentError;
+use sg_std::fees::FeeError;
+use thiserror::Error;
+use url::ParseError;
+
+#[derive(Error, Debug)]
+pub enum ContractError {
+    #[error("{0}")]
+    Std(#[from] StdError),
+
+    #[error("TokenMetadataFrozen")]
+    TokenMetadataFrozen {},
+
+    #[error("TokenIdNotFound")]
+    TokenIdNotFound {},
+
+    #[error("Unauthorized")]
+    Unauthorized {},
+
+    #[error("InvalidCreationFee")]
+    InvalidCreationFee {},
+
+    #[error("token_id already claimed")]
+    Claimed {},
+
+    #[error("Cannot set approval that is already expired")]
+    Expired {},
+
+    #[error("Approval not found for: {spender}")]
+    ApprovalNotFound { spender: String },
+
+    #[error("Invalid Royalities")]
+    InvalidRoyalities {},
+
+    #[error("Description too long")]
+    DescriptionTooLong {},
+
+    #[error("Royalty share increased")]
+    RoyaltyShareIncreased {},
+
+    #[error("{0}")]
+    Payment(#[from] PaymentError),
+
+    #[error("{0}")]
+    Fee(#[from] FeeError),
+
+    #[error("{0}")]
+    Parse(#[from] ParseError),
+
+    #[error("{0}")]
+    Base(#[from] cw721_base::ContractError),
+}
diff --git a/contracts/sg721-updatable/src/lib.rs b/contracts/sg721-updatable/src/lib.rs
new file mode 100644
index 00000000..dfedc9dc
--- /dev/null
+++ b/contracts/sg721-updatable/src/lib.rs
@@ -0,0 +1,6 @@
+pub mod contract;
+mod error;
+pub mod msg;
+pub mod state;
+
+pub use crate::error::ContractError;
diff --git a/contracts/sg721-updatable/src/msg.rs b/contracts/sg721-updatable/src/msg.rs
new file mode 100644
index 00000000..96269d5c
--- /dev/null
+++ b/contracts/sg721-updatable/src/msg.rs
@@ -0,0 +1,255 @@
+use crate::{state::CollectionInfo, ContractError};
+use cosmwasm_std::{Binary, Decimal, Empty, Timestamp};
+use cw721::Expiration;
+use cw721_base::{msg::QueryMsg as Cw721QueryMsg, MintMsg};
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
+pub struct InstantiateMsg {
+    pub name: String,
+    pub symbol: String,
+    pub minter: String,
+    pub collection_info: CollectionInfo<RoyaltyInfoResponse>,
+}
+
+pub type Cw721ExecuteMsg = cw721_base::msg::ExecuteMsg<Empty>;
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
+pub struct RoyaltyInfoResponse {
+    pub payment_address: String,
+    pub share: Decimal,
+}
+
+impl RoyaltyInfoResponse {
+    pub fn share_validate(&self) -> Result<Decimal, ContractError> {
+        if self.share > Decimal::one() {
+            return Err(ContractError::InvalidRoyalities {});
+        }
+
+        Ok(self.share)
+    }
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum ExecuteMsg<T> {
+    /// Freeze token metadata so creator can no longer update token uris
+    FreezeTokenMetadata {},
+    /// Creator calls can update token uris
+    UpdateTokenMetadata {
+        token_id: String,
+        token_uri: Option<String>,
+    },
+    // Sg721Base msgs
+    TransferNft {
+        recipient: String,
+        token_id: String,
+    },
+    SendNft {
+        contract: String,
+        token_id: String,
+        msg: Binary,
+    },
+    Approve {
+        spender: String,
+        token_id: String,
+        expires: Option<Expiration>,
+    },
+    Revoke {
+        spender: String,
+        token_id: String,
+    },
+    ApproveAll {
+        operator: String,
+        expires: Option<Expiration>,
+    },
+    RevokeAll {
+        operator: String,
+    },
+    Burn {
+        token_id: String,
+    },
+    UpdateRoyaltyInfo {
+        payment_address: String,
+        share_bps: u64,
+    },
+    UpdateTradingStartTime(Option<Timestamp>),
+
+    Mint(MintMsg<T>),
+}
+
+impl<T> From<ExecuteMsg<T>> for Cw721ExecuteMsg
+where
+    T: Clone + PartialEq + Into<Option<cosmwasm_std::Empty>>,
+    Option<cosmwasm_std::Empty>: From<T>,
+{
+    fn from(msg: ExecuteMsg<T>) -> Cw721ExecuteMsg {
+        match msg {
+            ExecuteMsg::TransferNft {
+                recipient,
+                token_id,
+            } => Cw721ExecuteMsg::TransferNft {
+                recipient,
+                token_id,
+            },
+            ExecuteMsg::SendNft {
+                contract,
+                token_id,
+                msg,
+            } => Cw721ExecuteMsg::SendNft {
+                contract,
+                token_id,
+                msg,
+            },
+            ExecuteMsg::Approve {
+                spender,
+                token_id,
+                expires,
+            } => Cw721ExecuteMsg::Approve {
+                spender,
+                token_id,
+                expires,
+            },
+            ExecuteMsg::ApproveAll { operator, expires } => {
+                Cw721ExecuteMsg::ApproveAll { operator, expires }
+            }
+            ExecuteMsg::Revoke { spender, token_id } => {
+                Cw721ExecuteMsg::Revoke { spender, token_id }
+            }
+            ExecuteMsg::RevokeAll { operator } => Cw721ExecuteMsg::RevokeAll { operator },
+            ExecuteMsg::Burn { token_id } => Cw721ExecuteMsg::Burn { token_id },
+            // assume that the extension is always empty
+            ExecuteMsg::Mint(MintMsg {
+                token_id,
+                owner,
+                token_uri,
+                extension: _,
+            }) => Cw721ExecuteMsg::Mint(MintMsg {
+                token_id,
+                owner,
+                token_uri,
+                extension: Empty {},
+            }),
+            _ => unreachable!("Invalid ExecuteMsg"),
+        }
+    }
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "snake_case")]
+pub enum QueryMsg {
+    OwnerOf {
+        token_id: String,
+        include_expired: Option<bool>,
+    },
+    Approval {
+        token_id: String,
+        spender: String,
+        include_expired: Option<bool>,
+    },
+    Approvals {
+        token_id: String,
+        include_expired: Option<bool>,
+    },
+    AllOperators {
+        owner: String,
+        include_expired: Option<bool>,
+        start_after: Option<String>,
+        limit: Option<u32>,
+    },
+    NumTokens {},
+    ContractInfo {},
+    NftInfo {
+        token_id: String,
+    },
+    AllNftInfo {
+        token_id: String,
+        include_expired: Option<bool>,
+    },
+    Tokens {
+        owner: String,
+        start_after: Option<String>,
+        limit: Option<u32>,
+    },
+    AllTokens {
+        start_after: Option<String>,
+        limit: Option<u32>,
+    },
+    Minter {},
+    CollectionInfo {},
+}
+
+impl From<QueryMsg> for Cw721QueryMsg {
+    fn from(msg: QueryMsg) -> Cw721QueryMsg {
+        match msg {
+            QueryMsg::OwnerOf {
+                token_id,
+                include_expired,
+            } => Cw721QueryMsg::OwnerOf {
+                token_id,
+                include_expired,
+            },
+            QueryMsg::Approval {
+                token_id,
+                spender,
+                include_expired,
+            } => Cw721QueryMsg::Approval {
+                token_id,
+                spender,
+                include_expired,
+            },
+            QueryMsg::Approvals {
+                token_id,
+                include_expired,
+            } => Cw721QueryMsg::Approvals {
+                token_id,
+                include_expired,
+            },
+            QueryMsg::AllOperators {
+                owner,
+                include_expired,
+                start_after,
+                limit,
+            } => Cw721QueryMsg::AllOperators {
+                owner,
+                include_expired,
+                start_after,
+                limit,
+            },
+            QueryMsg::NumTokens {} => Cw721QueryMsg::NumTokens {},
+            QueryMsg::ContractInfo {} => Cw721QueryMsg::ContractInfo {},
+            QueryMsg::NftInfo { token_id } => Cw721QueryMsg::NftInfo { token_id },
+            QueryMsg::AllNftInfo {
+                token_id,
+                include_expired,
+            } => Cw721QueryMsg::AllNftInfo {
+                token_id,
+                include_expired,
+            },
+            QueryMsg::Tokens {
+                owner,
+                start_after,
+                limit,
+            } => Cw721QueryMsg::Tokens {
+                owner,
+                start_after,
+                limit,
+            },
+            QueryMsg::AllTokens { start_after, limit } => {
+                Cw721QueryMsg::AllTokens { start_after, limit }
+            }
+            QueryMsg::Minter {} => Cw721QueryMsg::Minter {},
+            _ => unreachable!("cannot convert {:?} to Cw721QueryMsg", msg),
+        }
+    }
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
+pub struct CollectionInfoResponse {
+    pub creator: String,
+    pub description: String,
+    pub image: String,
+    pub external_link: Option<String>,
+    pub royalty_info: Option<RoyaltyInfoResponse>,
+}
diff --git a/contracts/sg721-updatable/src/state.rs b/contracts/sg721-updatable/src/state.rs
new file mode 100644
index 00000000..7a66330a
--- /dev/null
+++ b/contracts/sg721-updatable/src/state.rs
@@ -0,0 +1,22 @@
+use cosmwasm_std::{Addr, Decimal};
+use cw_storage_plus::Item;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
+pub struct CollectionInfo<T> {
+    pub creator: String,
+    pub description: String,
+    pub image: String,
+    pub external_link: Option<String>,
+    pub royalty_info: Option<T>,
+}
+
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
+pub struct RoyaltyInfo {
+    pub payment_address: Addr,
+    pub share: Decimal,
+}
+
+pub const FROZEN_TOKEN_METADATA: Item<bool> = Item::new("frozen_token_metadata");
+pub const COLLECTION_INFO: Item<CollectionInfo<RoyaltyInfo>> = Item::new("collection_info");
diff --git a/contracts/sg721/src/msg.rs b/contracts/sg721/src/msg.rs
index b8cd1c16..522b6cb9 100644
--- a/contracts/sg721/src/msg.rs
+++ b/contracts/sg721/src/msg.rs
@@ -4,7 +4,7 @@ use cw721_base::msg::QueryMsg as Cw721QueryMsg;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct InstantiateMsg {
     pub name: String,
     pub symbol: String,
@@ -12,7 +12,7 @@ pub struct InstantiateMsg {
     pub collection_info: CollectionInfo<RoyaltyInfoResponse>,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct RoyaltyInfoResponse {
     pub payment_address: String,
     pub share: Decimal,
@@ -30,7 +30,7 @@ impl RoyaltyInfoResponse {
 
 pub type ExecuteMsg = cw721_base::ExecuteMsg<Empty>;
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum QueryMsg {
     OwnerOf {
@@ -139,7 +139,7 @@ impl From<QueryMsg> for Cw721QueryMsg {
     }
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct CollectionInfoResponse {
     pub creator: String,
     pub description: String,
diff --git a/contracts/sg721/src/state.rs b/contracts/sg721/src/state.rs
index beb5151d..1d2a1a9e 100644
--- a/contracts/sg721/src/state.rs
+++ b/contracts/sg721/src/state.rs
@@ -3,7 +3,7 @@ use cw_storage_plus::Item;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct CollectionInfo<T> {
     pub creator: String,
     pub description: String,
@@ -12,7 +12,7 @@ pub struct CollectionInfo<T> {
     pub royalty_info: Option<T>,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct RoyaltyInfo {
     pub payment_address: Addr,
     pub share: Decimal,
diff --git a/contracts/whitelist/src/msg.rs b/contracts/whitelist/src/msg.rs
index 300e4c0c..8a6709fb 100644
--- a/contracts/whitelist/src/msg.rs
+++ b/contracts/whitelist/src/msg.rs
@@ -2,7 +2,7 @@ use cosmwasm_std::{Coin, Timestamp};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct InstantiateMsg {
     pub members: Vec<String>,
     pub start_time: Timestamp,
@@ -12,7 +12,7 @@ pub struct InstantiateMsg {
     pub member_limit: u32,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum ExecuteMsg {
     UpdateStartTime(Timestamp),
@@ -23,17 +23,17 @@ pub enum ExecuteMsg {
     IncreaseMemberLimit(u32),
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct AddMembersMsg {
     pub to_add: Vec<String>,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct RemoveMembersMsg {
     pub to_remove: Vec<String>,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 #[serde(rename_all = "snake_case")]
 pub enum QueryMsg {
     HasStarted {},
@@ -49,37 +49,37 @@ pub enum QueryMsg {
     Config {},
 }
 
-#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct MembersResponse {
     pub members: Vec<String>,
 }
 
-#[derive(Serialize, Deserialize, Clone, PartialEq, JsonSchema, Debug)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct HasMemberResponse {
     pub has_member: bool,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct HasEndedResponse {
     pub has_ended: bool,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct HasStartedResponse {
     pub has_started: bool,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct IsActiveResponse {
     pub is_active: bool,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct UnitPriceResponse {
     pub unit_price: Coin,
 }
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct ConfigResponse {
     pub num_members: u32,
     pub per_address_limit: u32,
diff --git a/contracts/whitelist/src/state.rs b/contracts/whitelist/src/state.rs
index b266996f..2b76f521 100644
--- a/contracts/whitelist/src/state.rs
+++ b/contracts/whitelist/src/state.rs
@@ -3,7 +3,7 @@ use cw_storage_plus::{Item, Map};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
-#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, JsonSchema)]
+#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema)]
 pub struct Config {
     pub admin: Addr,
     pub start_time: Timestamp,
diff --git a/scripts/optimize.sh b/scripts/optimize.sh
index de1b5158..08f11b9c 100644
--- a/scripts/optimize.sh
+++ b/scripts/optimize.sh
@@ -1,4 +1,4 @@
 docker run --rm -v "$(pwd)":/code --platform linux/amd64 \
 	--mount type=volume,source="$(basename "$(pwd)")_cache",target=/code/target \
 	--mount type=volume,source=registry_cache,target=/usr/local/cargo/registry \
-	cosmwasm/workspace-optimizer:0.12.8
+	cosmwasm/workspace-optimizer:0.12.13


Test patch:
diff --git a/contracts/minter/src/contract_tests.rs b/contracts/minter/src/contract_tests.rs
index d6a248bd..0d367e44 100644
--- a/contracts/minter/src/contract_tests.rs
+++ b/contracts/minter/src/contract_tests.rs
@@ -198,7 +198,7 @@ fn initialization() {
 
     // Check valid addr
     let addr = "earth1";
-    let res = deps.api.addr_validate(&(*addr));
+    let res = deps.api.addr_validate(addr);
     assert!(res.is_ok());
 
     // 0 per address limit returns error
diff --git a/contracts/minter/src/contract_updatable_tests.rs b/contracts/minter/src/contract_updatable_tests.rs
new file mode 100644
index 00000000..4f348429
--- /dev/null
+++ b/contracts/minter/src/contract_updatable_tests.rs
@@ -0,0 +1,1738 @@
+use crate::multi::StargazeApp;
+use cosmwasm_std::testing::{mock_dependencies_with_balance, mock_env, mock_info};
+use cosmwasm_std::{coin, coins, Addr, Decimal, Empty, Timestamp, Uint128};
+use cosmwasm_std::{Api, Coin};
+use cw721::{Cw721QueryMsg, NftInfoResponse, OwnerOfResponse};
+use cw721_base::{ExecuteMsg as Cw721ExecuteMsg, Extension};
+use cw_multi_test::{BankSudo, Contract, ContractWrapper, Executor, SudoMsg};
+use sg721::msg::{InstantiateMsg as Sg721InstantiateMsg, RoyaltyInfoResponse};
+use sg721::state::CollectionInfo;
+use sg721_updatable::msg::ExecuteMsg as Sg721UpdatableExecuteMsg;
+
+use sg_std::{StargazeMsgWrapper, GENESIS_MINT_START_TIME, NATIVE_DENOM};
+use whitelist::msg::InstantiateMsg as WhitelistInstantiateMsg;
+use whitelist::msg::{AddMembersMsg, ExecuteMsg as WhitelistExecuteMsg};
+
+use crate::contract::instantiate;
+use crate::msg::{
+    ConfigResponse, ExecuteMsg, InstantiateMsg, MintCountResponse, MintPriceResponse,
+    MintableNumTokensResponse, QueryMsg, StartTimeResponse,
+};
+use crate::ContractError;
+
+const CREATION_FEE: u128 = 1_000_000_000;
+const INITIAL_BALANCE: u128 = 2_000_000_000;
+
+const UNIT_PRICE: u128 = 100_000_000;
+const MINT_FEE: u128 = 10_000_000;
+const MAX_TOKEN_LIMIT: u32 = 10000;
+const WHITELIST_AMOUNT: u128 = 66_000_000;
+const WL_PER_ADDRESS_LIMIT: u32 = 1;
+const ADMIN_MINT_PRICE: u128 = 0;
+
+fn custom_mock_app() -> StargazeApp {
+    StargazeApp::default()
+}
+pub fn contract_whitelist() -> Box<dyn Contract<StargazeMsgWrapper>> {
+    let contract = ContractWrapper::new(
+        whitelist::contract::execute,
+        whitelist::contract::instantiate,
+        whitelist::contract::query,
+    );
+    Box::new(contract)
+}
+
+pub fn contract_minter() -> Box<dyn Contract<StargazeMsgWrapper>> {
+    let contract = ContractWrapper::new(
+        crate::contract::execute,
+        crate::contract::instantiate,
+        crate::contract::query,
+    )
+    .with_reply(crate::contract::reply);
+    Box::new(contract)
+}
+
+pub fn contract_sg721() -> Box<dyn Contract<StargazeMsgWrapper>> {
+    let contract = ContractWrapper::new(
+        sg721_updatable::contract::execute,
+        sg721_updatable::contract::instantiate,
+        sg721_updatable::contract::query,
+    )
+    .with_migrate(sg721_updatable::contract::migrate);
+    Box::new(contract)
+}
+
+fn setup_whitelist_contract(router: &mut StargazeApp, creator: &Addr) -> Addr {
+    let whitelist_code_id = router.store_code(contract_whitelist());
+
+    let msg = WhitelistInstantiateMsg {
+        members: vec![],
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME + 100),
+        end_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME + 10000000),
+        unit_price: coin(WHITELIST_AMOUNT, NATIVE_DENOM),
+        per_address_limit: WL_PER_ADDRESS_LIMIT,
+        member_limit: 1000,
+    };
+    router
+        .instantiate_contract(
+            whitelist_code_id,
+            creator.clone(),
+            &msg,
+            &[coin(100_000_000, NATIVE_DENOM)],
+            "whitelist",
+            None,
+        )
+        .unwrap()
+}
+
+// Upload contract code and instantiate minter contract
+fn setup_minter_contract(
+    router: &mut StargazeApp,
+    creator: &Addr,
+    num_tokens: u32,
+) -> (Addr, ConfigResponse) {
+    // Upload contract code
+    let sg721_code_id = router.store_code(contract_sg721());
+    let minter_code_id = router.store_code(contract_minter());
+    let creation_fee = coins(CREATION_FEE, NATIVE_DENOM);
+
+    // Instantiate minter contract
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: creator.to_string(),
+            collection_info: CollectionInfo {
+                creator: creator.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: creator.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    let minter_addr = router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap();
+
+    let config: ConfigResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::Config {})
+        .unwrap();
+
+    (minter_addr, config)
+}
+
+// Add a creator account with initial balances
+fn setup_accounts(router: &mut StargazeApp) -> (Addr, Addr) {
+    let buyer = Addr::unchecked("buyer");
+    let creator = Addr::unchecked("creator");
+    // 3,000 tokens
+    let creator_funds = coins(INITIAL_BALANCE + CREATION_FEE, NATIVE_DENOM);
+    // 2,000 tokens
+    let buyer_funds = coins(INITIAL_BALANCE, NATIVE_DENOM);
+    router
+        .sudo(SudoMsg::Bank({
+            BankSudo::Mint {
+                to_address: creator.to_string(),
+                amount: creator_funds.clone(),
+            }
+        }))
+        .map_err(|err| println!("{:?}", err))
+        .ok();
+
+    router
+        .sudo(SudoMsg::Bank({
+            BankSudo::Mint {
+                to_address: buyer.to_string(),
+                amount: buyer_funds.clone(),
+            }
+        }))
+        .map_err(|err| println!("{:?}", err))
+        .ok();
+
+    // Check native balances
+    let creator_native_balances = router.wrap().query_all_balances(creator.clone()).unwrap();
+    assert_eq!(creator_native_balances, creator_funds);
+
+    // Check native balances
+    let buyer_native_balances = router.wrap().query_all_balances(buyer.clone()).unwrap();
+    assert_eq!(buyer_native_balances, buyer_funds);
+
+    (creator, buyer)
+}
+
+// Set blockchain time to after mint by default
+fn setup_block_time(router: &mut StargazeApp, nanos: u64) {
+    let mut block = router.block_info();
+    block.time = Timestamp::from_nanos(nanos);
+    router.set_block(block);
+}
+
+// Deal with zero and non-zero coin amounts for msgs
+fn coins_for_msg(msg_coin: Coin) -> Vec<Coin> {
+    if msg_coin.amount > Uint128::zero() {
+        vec![msg_coin]
+    } else {
+        vec![]
+    }
+}
+
+#[test]
+fn initialization() {
+    let mut deps = mock_dependencies_with_balance(&coins(2, "token"));
+
+    // Check valid addr
+    let addr = "earth1";
+    let res = deps.api.addr_validate(addr);
+    assert!(res.is_ok());
+
+    // 0 per address limit returns error
+    let info = mock_info("creator", &coins(INITIAL_BALANCE, NATIVE_DENOM));
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens: 100,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 0,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id: 1,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: info.sender.to_string(),
+            collection_info: CollectionInfo {
+                creator: info.sender.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: info.sender.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+
+    // Invalid uri returns error
+    let info = mock_info("creator", &coins(INITIAL_BALANCE, NATIVE_DENOM));
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens: 100,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "https://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id: 1,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: info.sender.to_string(),
+            collection_info: CollectionInfo {
+                creator: info.sender.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: info.sender.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+
+    // Invalid denom returns error
+    let wrong_denom = "uosmo";
+    let info = mock_info("creator", &coins(INITIAL_BALANCE, NATIVE_DENOM));
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, wrong_denom),
+        num_tokens: 100,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id: 1,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: info.sender.to_string(),
+            collection_info: CollectionInfo {
+                creator: info.sender.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: info.sender.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+
+    // Insufficient mint price returns error
+    let info = mock_info("creator", &coins(INITIAL_BALANCE, NATIVE_DENOM));
+    let msg = InstantiateMsg {
+        unit_price: coin(1, NATIVE_DENOM),
+        num_tokens: 100,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id: 1,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: info.sender.to_string(),
+            collection_info: CollectionInfo {
+                creator: info.sender.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: info.sender.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+
+    // Over max token limit
+    let info = mock_info("creator", &coins(INITIAL_BALANCE, NATIVE_DENOM));
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens: (MAX_TOKEN_LIMIT + 1),
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id: 1,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: info.sender.to_string(),
+            collection_info: CollectionInfo {
+                creator: info.sender.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: info.sender.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+
+    // Under min token limit
+    let info = mock_info("creator", &coins(INITIAL_BALANCE, NATIVE_DENOM));
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens: 0,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id: 1,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: info.sender.to_string(),
+            collection_info: CollectionInfo {
+                creator: info.sender.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: info.sender.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+}
+
+#[test]
+fn happy_path() {
+    let mut router = custom_mock_app();
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME - 1);
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 2;
+    let (minter_addr, config) = setup_minter_contract(&mut router, &creator, num_tokens);
+
+    // Default start time genesis mint time
+    let res: StartTimeResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::StartTime {})
+        .unwrap();
+    assert_eq!(
+        res.start_time,
+        Timestamp::from_nanos(GENESIS_MINT_START_TIME).to_string()
+    );
+
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 1);
+
+    // Fail with incorrect tokens
+    let mint_msg = ExecuteMsg::Mint {};
+    let err = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE + 100, NATIVE_DENOM),
+    );
+    assert!(err.is_err());
+
+    // Succeeds if funds are sent
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // Balances are correct
+    // The creator should get the unit price - mint fee for the mint above
+    let creator_balances = router.wrap().query_all_balances(creator.clone()).unwrap();
+    assert_eq!(
+        creator_balances,
+        coins(INITIAL_BALANCE + UNIT_PRICE - MINT_FEE, NATIVE_DENOM)
+    );
+    // The buyer's tokens should reduce by unit price
+    let buyer_balances = router.wrap().query_all_balances(buyer.clone()).unwrap();
+    assert_eq!(
+        buyer_balances,
+        coins(INITIAL_BALANCE - UNIT_PRICE, NATIVE_DENOM)
+    );
+
+    let res: MintCountResponse = router
+        .wrap()
+        .query_wasm_smart(
+            minter_addr.clone(),
+            &QueryMsg::MintCount {
+                address: buyer.to_string(),
+            },
+        )
+        .unwrap();
+    assert_eq!(res.count, 1);
+    assert_eq!(res.address, buyer.to_string());
+
+    // Check NFT is transferred
+    let query_owner_msg = Cw721QueryMsg::OwnerOf {
+        token_id: String::from("1"),
+        include_expired: None,
+    };
+    let res: OwnerOfResponse = router
+        .wrap()
+        .query_wasm_smart(config.sg721_address.clone(), &query_owner_msg)
+        .unwrap();
+    assert_eq!(res.owner, buyer.to_string());
+
+    // Buyer can't call MintTo
+    let mint_to_msg = ExecuteMsg::MintTo {
+        recipient: buyer.to_string(),
+    };
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_to_msg,
+        &coins_for_msg(Coin {
+            amount: Uint128::from(ADMIN_MINT_PRICE),
+            denom: NATIVE_DENOM.to_string(),
+        }),
+    );
+    assert!(res.is_err());
+
+    // Creator mints an extra NFT for the buyer (who is a friend)
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &mint_to_msg,
+        &coins_for_msg(Coin {
+            amount: Uint128::from(ADMIN_MINT_PRICE),
+            denom: NATIVE_DENOM.to_string(),
+        }),
+    );
+    assert!(res.is_ok());
+
+    // Mint count is not increased if admin mints for the user
+    let res: MintCountResponse = router
+        .wrap()
+        .query_wasm_smart(
+            minter_addr.clone(),
+            &QueryMsg::MintCount {
+                address: buyer.to_string(),
+            },
+        )
+        .unwrap();
+    assert_eq!(res.count, 1);
+    assert_eq!(res.address, buyer.to_string());
+
+    // Minter contract should have no balance
+    let minter_balance = router
+        .wrap()
+        .query_all_balances(minter_addr.clone())
+        .unwrap();
+    assert_eq!(0, minter_balance.len());
+
+    // Check that NFT is transferred
+    let query_owner_msg = Cw721QueryMsg::OwnerOf {
+        token_id: String::from("1"),
+        include_expired: None,
+    };
+    let res: OwnerOfResponse = router
+        .wrap()
+        .query_wasm_smart(config.sg721_address, &query_owner_msg)
+        .unwrap();
+    assert_eq!(res.owner, buyer.to_string());
+
+    // Errors if sold out
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer,
+        minter_addr.clone(),
+        &mint_msg,
+        &coins_for_msg(Coin {
+            amount: Uint128::from(UNIT_PRICE),
+            denom: NATIVE_DENOM.to_string(),
+        }),
+    );
+    assert!(res.is_err());
+
+    // Creator can't use MintTo if sold out
+    let res = router.execute_contract(
+        creator,
+        minter_addr,
+        &mint_to_msg,
+        &coins_for_msg(Coin {
+            amount: Uint128::from(ADMIN_MINT_PRICE),
+            denom: NATIVE_DENOM.to_string(),
+        }),
+    );
+    assert!(res.is_err());
+}
+#[test]
+fn mint_count_query() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 10;
+    let (minter_addr, config) = setup_minter_contract(&mut router, &creator, num_tokens);
+    let sg721_addr = Addr::unchecked(config.sg721_address);
+    let whitelist_addr = setup_whitelist_contract(&mut router, &creator);
+    const EXPIRATION_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME + 10_000);
+
+    // Set block to before genesis mint start time
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME - 1000);
+
+    let wl_msg = WhitelistExecuteMsg::UpdateEndTime(EXPIRATION_TIME);
+    let res = router.execute_contract(creator.clone(), whitelist_addr.clone(), &wl_msg, &[]);
+    assert!(res.is_ok());
+
+    let wl_msg = WhitelistExecuteMsg::UpdateStartTime(Timestamp::from_nanos(0));
+    let res = router.execute_contract(creator.clone(), whitelist_addr.clone(), &wl_msg, &[]);
+    assert!(res.is_ok());
+
+    // Set whitelist in minter contract
+    let set_whitelist_msg = ExecuteMsg::SetWhitelist {
+        whitelist: whitelist_addr.to_string(),
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &set_whitelist_msg,
+        &[],
+    );
+    assert!(res.is_ok());
+
+    // Update per address_limit
+    let set_whitelist_msg = ExecuteMsg::UpdatePerAddressLimit {
+        per_address_limit: 3,
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &set_whitelist_msg,
+        &[],
+    );
+    assert!(res.is_ok());
+
+    // Add buyer to whitelist
+    let inner_msg = AddMembersMsg {
+        to_add: vec![buyer.to_string()],
+    };
+    let wasm_msg = WhitelistExecuteMsg::AddMembers(inner_msg);
+    let res = router.execute_contract(creator.clone(), whitelist_addr, &wasm_msg, &[]);
+    assert!(res.is_ok());
+
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME);
+
+    // Mint succeeds
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(WHITELIST_AMOUNT, NATIVE_DENOM),
+    );
+
+    assert!(res.is_ok());
+
+    // Query count
+    let res: MintCountResponse = router
+        .wrap()
+        .query_wasm_smart(
+            minter_addr.clone(),
+            &QueryMsg::MintCount {
+                address: buyer.to_string(),
+            },
+        )
+        .unwrap();
+    assert_eq!(res.count, 1);
+    assert_eq!(res.address, buyer.to_string());
+
+    // Mint fails, over whitelist per address limit
+    let mint_msg = ExecuteMsg::Mint {};
+    let err = router
+        .execute_contract(
+            buyer.clone(),
+            minter_addr.clone(),
+            &mint_msg,
+            &coins(WHITELIST_AMOUNT, NATIVE_DENOM),
+        )
+        .unwrap_err();
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        ContractError::MaxPerAddressLimitExceeded {}.to_string()
+    );
+
+    // Set time after wl ends
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 20_000);
+
+    // Public mint succeeds
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // Query count
+    let res: MintCountResponse = router
+        .wrap()
+        .query_wasm_smart(
+            minter_addr.clone(),
+            &QueryMsg::MintCount {
+                address: buyer.to_string(),
+            },
+        )
+        .unwrap();
+    assert_eq!(res.count, 2);
+    assert_eq!(res.address, buyer.to_string());
+
+    // Buyer transfers NFT to creator
+    let transfer_msg: Cw721ExecuteMsg<Empty> = Cw721ExecuteMsg::TransferNft {
+        recipient: creator.to_string(),
+        token_id: "1".to_string(),
+    };
+    let res = router.execute_contract(
+        buyer.clone(),
+        sg721_addr,
+        &transfer_msg,
+        &coins_for_msg(coin(123, NATIVE_DENOM)),
+    );
+    assert!(res.is_ok());
+
+    // Mint succeeds
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // Query count
+    let res: MintCountResponse = router
+        .wrap()
+        .query_wasm_smart(
+            minter_addr.clone(),
+            &QueryMsg::MintCount {
+                address: buyer.to_string(),
+            },
+        )
+        .unwrap();
+    assert_eq!(res.count, 3);
+    assert_eq!(res.address, buyer.to_string());
+
+    // Mint fails
+    let mint_msg = ExecuteMsg::Mint {};
+    let err = router
+        .execute_contract(
+            buyer.clone(),
+            minter_addr.clone(),
+            &mint_msg,
+            &coins(WHITELIST_AMOUNT, NATIVE_DENOM),
+        )
+        .unwrap_err();
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        ContractError::MaxPerAddressLimitExceeded {}.to_string()
+    );
+
+    // Query count
+    let res: MintCountResponse = router
+        .wrap()
+        .query_wasm_smart(
+            minter_addr,
+            &QueryMsg::MintCount {
+                address: buyer.to_string(),
+            },
+        )
+        .unwrap();
+    assert_eq!(res.count, 3);
+    assert_eq!(res.address, buyer.to_string());
+}
+
+#[test]
+fn whitelist_already_started() {
+    let mut router = custom_mock_app();
+    let (creator, _) = setup_accounts(&mut router);
+    let num_tokens = 1;
+    let (minter_addr, _) = setup_minter_contract(&mut router, &creator, num_tokens);
+    let whitelist_addr = setup_whitelist_contract(&mut router, &creator);
+
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 101);
+
+    // set whitelist in minter contract
+    let set_whitelist_msg = ExecuteMsg::SetWhitelist {
+        whitelist: whitelist_addr.to_string(),
+    };
+    router
+        .execute_contract(
+            creator.clone(),
+            minter_addr,
+            &set_whitelist_msg,
+            &coins(UNIT_PRICE, NATIVE_DENOM),
+        )
+        .unwrap_err();
+}
+
+#[test]
+fn whitelist_can_update_before_start() {
+    let mut router = custom_mock_app();
+    let (creator, _) = setup_accounts(&mut router);
+    let num_tokens = 1;
+    let (minter_addr, _) = setup_minter_contract(&mut router, &creator, num_tokens);
+    let whitelist_addr = setup_whitelist_contract(&mut router, &creator);
+
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME - 1000);
+
+    // set whitelist in minter contract
+    let set_whitelist_msg = ExecuteMsg::SetWhitelist {
+        whitelist: whitelist_addr.to_string(),
+    };
+    router
+        .execute_contract(
+            creator.clone(),
+            minter_addr.clone(),
+            &set_whitelist_msg,
+            &coins(UNIT_PRICE, NATIVE_DENOM),
+        )
+        .unwrap();
+
+    // can set twice before starting
+    router
+        .execute_contract(
+            creator.clone(),
+            minter_addr,
+            &set_whitelist_msg,
+            &coins(UNIT_PRICE, NATIVE_DENOM),
+        )
+        .unwrap();
+}
+
+#[test]
+fn whitelist_access_len_add_remove_expiration() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 1;
+    let (minter_addr, config) = setup_minter_contract(&mut router, &creator, num_tokens);
+    let sg721_addr = config.sg721_address;
+    let whitelist_addr = setup_whitelist_contract(&mut router, &creator);
+    const AFTER_GENESIS_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME + 100);
+
+    // Set to just before genesis mint start time
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME - 10);
+
+    // Update whitelist_expiration fails if not admin
+    let wl_msg = WhitelistExecuteMsg::UpdateEndTime(AFTER_GENESIS_TIME);
+    router
+        .execute_contract(buyer.clone(), whitelist_addr.clone(), &wl_msg, &[])
+        .unwrap_err();
+
+    // Update whitelist_expiration succeeds when from admin
+    let wl_msg = WhitelistExecuteMsg::UpdateEndTime(AFTER_GENESIS_TIME);
+    let res = router.execute_contract(creator.clone(), whitelist_addr.clone(), &wl_msg, &[]);
+    assert!(res.is_ok());
+
+    let wl_msg = WhitelistExecuteMsg::UpdateStartTime(Timestamp::from_nanos(0));
+    let res = router.execute_contract(creator.clone(), whitelist_addr.clone(), &wl_msg, &[]);
+    assert!(res.is_ok());
+
+    // Set whitelist in minter contract
+    let set_whitelist_msg = ExecuteMsg::SetWhitelist {
+        whitelist: whitelist_addr.to_string(),
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &set_whitelist_msg,
+        &[],
+    );
+    assert!(res.is_ok());
+
+    // Mint fails, buyer is not on whitelist
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Add buyer to whitelist
+    let inner_msg = AddMembersMsg {
+        to_add: vec![buyer.to_string()],
+    };
+    let wasm_msg = WhitelistExecuteMsg::AddMembers(inner_msg);
+    let res = router.execute_contract(creator.clone(), whitelist_addr.clone(), &wasm_msg, &[]);
+    assert!(res.is_ok());
+
+    // Mint fails, not whitelist price
+    let mint_msg = ExecuteMsg::Mint {};
+    router
+        .execute_contract(
+            buyer.clone(),
+            minter_addr.clone(),
+            &mint_msg,
+            &coins(UNIT_PRICE, NATIVE_DENOM),
+        )
+        .unwrap_err();
+
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME);
+
+    // Query mint price
+    let mint_price_response: MintPriceResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::MintPrice {})
+        .unwrap();
+    assert_eq!(
+        coin(WHITELIST_AMOUNT, NATIVE_DENOM),
+        mint_price_response.whitelist_price.unwrap()
+    );
+    assert_eq!(
+        coin(WHITELIST_AMOUNT, NATIVE_DENOM),
+        mint_price_response.current_price
+    );
+    assert_eq!(
+        coin(UNIT_PRICE, NATIVE_DENOM),
+        mint_price_response.public_price
+    );
+
+    // Mint succeeds with whitelist price
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(WHITELIST_AMOUNT, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // Mint fails, over whitelist per address limit
+    let mint_msg = ExecuteMsg::Mint {};
+    let err = router
+        .execute_contract(
+            buyer.clone(),
+            minter_addr.clone(),
+            &mint_msg,
+            &coins(WHITELIST_AMOUNT, NATIVE_DENOM),
+        )
+        .unwrap_err();
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        ContractError::MaxPerAddressLimitExceeded {}.to_string()
+    );
+
+    // Muyer is generous and transfers to creator
+    let transfer_msg: Cw721ExecuteMsg<Empty> = Cw721ExecuteMsg::TransferNft {
+        recipient: creator.to_string(),
+        token_id: "1".to_string(),
+    };
+    let res = router.execute_contract(
+        buyer.clone(),
+        Addr::unchecked(sg721_addr),
+        &transfer_msg,
+        &coins_for_msg(coin(123, NATIVE_DENOM)),
+    );
+    assert!(res.is_ok());
+
+    // Mint fails, buyer exceeded per address limit
+    let mint_msg = ExecuteMsg::Mint {};
+    let err = router
+        .execute_contract(
+            buyer.clone(),
+            minter_addr.clone(),
+            &mint_msg,
+            &coins(WHITELIST_AMOUNT, NATIVE_DENOM),
+        )
+        .unwrap_err();
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        ContractError::MaxPerAddressLimitExceeded {}.to_string()
+    );
+
+    // Remove buyer from whitelist
+    let inner_msg = AddMembersMsg { to_add: vec![] };
+    let wasm_msg = WhitelistExecuteMsg::AddMembers(inner_msg);
+    let res = router.execute_contract(creator.clone(), whitelist_addr, &wasm_msg, &[]);
+    assert!(res.is_ok());
+
+    // Mint fails
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer,
+        minter_addr,
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+}
+
+#[test]
+fn before_start_time() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 1;
+    let (minter_addr, _) = setup_minter_contract(&mut router, &creator, num_tokens);
+
+    // Set to before genesis mint start time
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME - 10);
+
+    // Set start_time fails if not admin
+    let start_time_msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(0));
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &start_time_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Buyer can't mint before start_time
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Query start_time, confirm expired
+    let start_time_response: StartTimeResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::StartTime {})
+        .unwrap();
+    assert_eq!(
+        Timestamp::from_nanos(GENESIS_MINT_START_TIME).to_string(),
+        start_time_response.start_time
+    );
+
+    // Set block forward, after start time. mint succeeds
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 10_000_000);
+
+    // Mint succeeds
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer,
+        minter_addr,
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+}
+
+#[test]
+fn check_per_address_limit() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 2;
+    let (minter_addr, _config) = setup_minter_contract(&mut router, &creator, num_tokens);
+
+    // Set to genesis mint start time
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME);
+
+    // Set limit, check unauthorized
+    let per_address_limit_msg = ExecuteMsg::UpdatePerAddressLimit {
+        per_address_limit: 30,
+    };
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &per_address_limit_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Set limit errors, invalid limit == 0
+    let per_address_limit_msg = ExecuteMsg::UpdatePerAddressLimit {
+        per_address_limit: 0,
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &per_address_limit_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Set limit errors, invalid limit over max
+    let per_address_limit_msg = ExecuteMsg::UpdatePerAddressLimit {
+        per_address_limit: 100,
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &per_address_limit_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Set limit succeeds, mint fails, over max
+    let per_address_limit_msg = ExecuteMsg::UpdatePerAddressLimit {
+        per_address_limit: 1,
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &per_address_limit_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // First mint succeeds
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+
+    assert!(res.is_ok());
+
+    // Second mint fails from exceeding per address limit
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer,
+        minter_addr,
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+}
+
+#[test]
+fn mint_for_token_id_addr() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 4;
+    let (minter_addr, _config) = setup_minter_contract(&mut router, &creator, num_tokens);
+
+    // Set to genesis mint start time
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME);
+
+    // Try mint_for, test unauthorized
+    let mint_for_msg = ExecuteMsg::MintFor {
+        token_id: 1,
+        recipient: buyer.to_string(),
+    };
+    let err = router
+        .execute_contract(
+            buyer.clone(),
+            minter_addr.clone(),
+            &mint_for_msg,
+            &coins_for_msg(Coin {
+                amount: Uint128::from(ADMIN_MINT_PRICE),
+                denom: NATIVE_DENOM.to_string(),
+            }),
+        )
+        .unwrap_err();
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        ContractError::Unauthorized("Sender is not an admin".to_string()).to_string(),
+    );
+
+    // Test token id already sold
+    // 1. mint token_id 1
+    // 2. mint_for token_id 1
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // Minter contract should have no balance
+    let minter_balance = router
+        .wrap()
+        .query_all_balances(minter_addr.clone())
+        .unwrap();
+    assert_eq!(0, minter_balance.len());
+
+    // Mint fails, invalid token_id
+    let token_id = 0;
+    let mint_for_msg = ExecuteMsg::MintFor {
+        token_id,
+        recipient: buyer.to_string(),
+    };
+    let err = router
+        .execute_contract(
+            creator.clone(),
+            minter_addr.clone(),
+            &mint_for_msg,
+            &coins_for_msg(Coin {
+                amount: Uint128::from(ADMIN_MINT_PRICE),
+                denom: NATIVE_DENOM.to_string(),
+            }),
+        )
+        .unwrap_err();
+    assert_eq!(
+        ContractError::InvalidTokenId {}.to_string(),
+        err.source().unwrap().to_string()
+    );
+
+    // Mint fails, token_id already sold
+    let token_id = 1;
+    let mint_for_msg = ExecuteMsg::MintFor {
+        token_id,
+        recipient: buyer.to_string(),
+    };
+    let err = router
+        .execute_contract(
+            creator.clone(),
+            minter_addr.clone(),
+            &mint_for_msg,
+            &coins_for_msg(Coin {
+                amount: Uint128::from(ADMIN_MINT_PRICE),
+                denom: NATIVE_DENOM.to_string(),
+            }),
+        )
+        .unwrap_err();
+    assert_eq!(
+        ContractError::TokenIdAlreadySold { token_id }.to_string(),
+        err.source().unwrap().to_string()
+    );
+
+    let mintable_num_tokens_response: MintableNumTokensResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::MintableNumTokens {})
+        .unwrap();
+    assert_eq!(mintable_num_tokens_response.count, 3);
+
+    // Test mint_for token_id 2 then normal mint
+    let token_id = 2;
+    let mint_for_msg = ExecuteMsg::MintFor {
+        token_id,
+        recipient: buyer.to_string(),
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &mint_for_msg,
+        &coins_for_msg(Coin {
+            amount: Uint128::from(ADMIN_MINT_PRICE),
+            denom: NATIVE_DENOM.to_string(),
+        }),
+    );
+    assert!(res.is_ok());
+
+    let mintable_num_tokens_response: MintableNumTokensResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr, &QueryMsg::MintableNumTokens {})
+        .unwrap();
+    assert_eq!(mintable_num_tokens_response.count, 2);
+}
+
+#[test]
+fn test_start_time_before_genesis() {
+    let mut router = custom_mock_app();
+    let (creator, _) = setup_accounts(&mut router);
+    let num_tokens = 10;
+
+    // Upload contract code
+    let sg721_code_id = router.store_code(contract_sg721());
+    let minter_code_id = router.store_code(contract_minter());
+    let creation_fee = coins(CREATION_FEE, NATIVE_DENOM);
+
+    // Instantiate sale contract
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: creator.to_string(),
+            collection_info: CollectionInfo {
+                creator: creator.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: creator.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    let minter_addr = router
+        .instantiate_contract(minter_code_id, creator, &msg, &creation_fee, "Minter", None)
+        .unwrap();
+
+    let res: StartTimeResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr, &QueryMsg::StartTime {})
+        .unwrap();
+    assert_eq!(
+        res.start_time,
+        Timestamp::from_nanos(GENESIS_MINT_START_TIME).to_string()
+    );
+}
+
+#[test]
+fn test_update_start_time() {
+    let mut router = custom_mock_app();
+    let (creator, _) = setup_accounts(&mut router);
+    let num_tokens = 10;
+
+    // Upload contract code
+    let sg721_code_id = router.store_code(contract_sg721());
+    let minter_code_id = router.store_code(contract_minter());
+    let creation_fee = coins(CREATION_FEE, NATIVE_DENOM);
+
+    // Instantiate sale contract
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: creator.to_string(),
+            collection_info: CollectionInfo {
+                creator: creator.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: None,
+            },
+        },
+    };
+    let minter_addr = router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap();
+
+    // Public mint has started
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 100);
+
+    // Update to a start time in the past
+    let msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME - 1000));
+    let err = router
+        .execute_contract(creator, minter_addr, &msg, &[])
+        .unwrap_err();
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        ContractError::AlreadyStarted {}.to_string(),
+    );
+}
+
+#[test]
+fn test_invalid_start_time() {
+    let mut router = custom_mock_app();
+    let (creator, _) = setup_accounts(&mut router);
+    let num_tokens = 10;
+
+    // Upload contract code
+    let sg721_code_id = router.store_code(contract_sg721());
+    let minter_code_id = router.store_code(contract_minter());
+    let creation_fee = coins(CREATION_FEE, NATIVE_DENOM);
+
+    // Instantiate sale contract before genesis mint
+    let mut msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME - 100),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: creator.to_string(),
+            collection_info: CollectionInfo {
+                creator: creator.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: None,
+            },
+        },
+    };
+    // set time before the start_time above
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME - 1000);
+
+    let err = router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap_err();
+    assert_eq!(err.source().unwrap().to_string(), "BeforeGenesisTime");
+
+    // move date after genesis mint
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 1000);
+
+    // move start time after genesis but before current time
+    msg.start_time = Timestamp::from_nanos(GENESIS_MINT_START_TIME + 500);
+
+    router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap_err();
+
+    // position block time before the start time
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 400);
+
+    let minter_addr = router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap();
+
+    // Update to a start time in the past
+    let msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME - 100));
+    let res = router.execute_contract(creator.clone(), minter_addr.clone(), &msg, &[]);
+    assert!(res.is_err());
+
+    // Update to a time after genesis but before the current block_time (GENESIS+400)
+    let msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME + 300));
+    let res = router.execute_contract(creator.clone(), minter_addr.clone(), &msg, &[]);
+    assert!(res.is_err());
+
+    // Update to a time after genesis and after current blocktime (GENESIS+400)
+    let msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME + 450));
+    let res = router.execute_contract(creator.clone(), minter_addr.clone(), &msg, &[]);
+    assert!(res.is_ok());
+
+    // position block after start time (GENESIS+450);
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 500);
+
+    // Update to a time after genesis and after current blocktime (GENESIS+400)
+    let msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME + 450));
+    let err = router
+        .execute_contract(creator, minter_addr, &msg, &[])
+        .unwrap_err();
+    assert_eq!(err.source().unwrap().to_string(), "AlreadyStarted");
+}
+
+#[test]
+fn unhappy_path() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 1;
+    let (minter_addr, _config) = setup_minter_contract(&mut router, &creator, num_tokens);
+
+    // Fails if too little funds are sent
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(1, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Fails if too many funds are sent
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer.clone(),
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(11111, NATIVE_DENOM),
+    );
+    assert!(res.is_err());
+
+    // Fails wrong denom is sent
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(buyer, minter_addr, &mint_msg, &coins(UNIT_PRICE, "uatom"));
+    assert!(res.is_err());
+}
+
+//
+//
+// v1.1.0 tests
+//
+//
+#[test]
+fn test_update_none_royalties() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 10;
+
+    // Upload contract code
+    let sg721_code_id = router.store_code(contract_sg721());
+    let minter_code_id = router.store_code(contract_minter());
+    let creation_fee = coins(CREATION_FEE, NATIVE_DENOM);
+
+    // Instantiate sale contract
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: creator.to_string(),
+            collection_info: CollectionInfo {
+                creator: creator.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: None,
+            },
+        },
+    };
+    let minter_addr = router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap();
+    let config: ConfigResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr, &QueryMsg::Config {})
+        .unwrap();
+
+    let update_royalty_msg: Sg721UpdatableExecuteMsg<Extension> =
+        Sg721UpdatableExecuteMsg::UpdateRoyaltyInfo {
+            payment_address: "creator2".to_string(),
+            share_bps: 5,
+        };
+    let sg71_address = config.sg721_address;
+
+    let res = router.execute_contract(
+        buyer,
+        Addr::unchecked(sg71_address.clone()),
+        &update_royalty_msg,
+        &[],
+    );
+    let err = res.unwrap_err();
+
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        sg721_updatable::ContractError::Unauthorized {}.to_string()
+    );
+
+    let res = router.execute_contract(
+        creator,
+        Addr::unchecked(sg71_address),
+        &update_royalty_msg,
+        &[],
+    );
+    let err = res.unwrap_err();
+
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        sg721_updatable::ContractError::RoyaltyShareIncreased {}.to_string()
+    );
+}
+
+#[test]
+fn test_update_royalties() {
+    let mut router = custom_mock_app();
+    let (creator, _) = setup_accounts(&mut router);
+    let num_tokens = 10;
+
+    // Upload contract code
+    let sg721_code_id = router.store_code(contract_sg721());
+    let minter_code_id = router.store_code(contract_minter());
+    let creation_fee = coins(CREATION_FEE, NATIVE_DENOM);
+
+    // Instantiate sale contract
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: creator.to_string(),
+            collection_info: CollectionInfo {
+                creator: creator.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: creator.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    let minter_addr = router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap();
+    let config: ConfigResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr, &QueryMsg::Config {})
+        .unwrap();
+
+    let update_royalty_msg: sg721_updatable::msg::ExecuteMsg<Extension> =
+        sg721_updatable::msg::ExecuteMsg::UpdateRoyaltyInfo {
+            payment_address: "creator2".to_string(),
+            share_bps: 1100,
+        };
+    let sg71_address = config.sg721_address;
+
+    let res = router.execute_contract(
+        creator.clone(),
+        Addr::unchecked(sg71_address.clone()),
+        &update_royalty_msg,
+        &[],
+    );
+    let err = res.unwrap_err();
+
+    assert_eq!(
+        err.source().unwrap().to_string(),
+        sg721_updatable::ContractError::RoyaltyShareIncreased {}.to_string()
+    );
+
+    let update_royalty_msg: sg721_updatable::msg::ExecuteMsg<Extension> =
+        sg721_updatable::msg::ExecuteMsg::UpdateRoyaltyInfo {
+            payment_address: "creator2".to_string(),
+            share_bps: 900,
+        };
+    let res = router.execute_contract(
+        creator,
+        Addr::unchecked(sg71_address),
+        &update_royalty_msg,
+        &[],
+    );
+    assert!(res.is_ok());
+}
+
+#[test]
+fn test_update_metadata() {
+    let mut router = custom_mock_app();
+    let (creator, buyer) = setup_accounts(&mut router);
+    let num_tokens = 10;
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME);
+
+    // Upload contract code
+    let sg721_code_id = router.store_code(contract_sg721());
+    let minter_code_id = router.store_code(contract_minter());
+    let creation_fee = coins(CREATION_FEE, NATIVE_DENOM);
+
+    // Instantiate sale contract
+    let msg = InstantiateMsg {
+        unit_price: coin(UNIT_PRICE, NATIVE_DENOM),
+        num_tokens,
+        start_time: Timestamp::from_nanos(GENESIS_MINT_START_TIME),
+        per_address_limit: 5,
+        whitelist: None,
+        base_token_uri: "ipfs://QmYxw1rURvnbQbBRTfmVaZtxSrkrfsbodNzibgBrVrUrtN".to_string(),
+        sg721_code_id,
+        sg721_instantiate_msg: Sg721InstantiateMsg {
+            name: String::from("TEST"),
+            symbol: String::from("TEST"),
+            minter: creator.to_string(),
+            collection_info: CollectionInfo {
+                creator: creator.to_string(),
+                description: String::from("Stargaze Monkeys"),
+                image: "https://example.com/image.png".to_string(),
+                external_link: Some("https://example.com/external.html".to_string()),
+                royalty_info: Some(RoyaltyInfoResponse {
+                    payment_address: creator.to_string(),
+                    share: Decimal::percent(10),
+                }),
+            },
+        },
+    };
+    let minter_addr = router
+        .instantiate_contract(
+            minter_code_id,
+            creator.clone(),
+            &msg,
+            &creation_fee,
+            "Minter",
+            None,
+        )
+        .unwrap();
+    let config: ConfigResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::Config {})
+        .unwrap();
+    let sg721_addr = config.sg721_address;
+
+    // mint
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer,
+        minter_addr,
+        &mint_msg,
+        &coins(UNIT_PRICE, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // update metadata
+    let token_uri = "ipfs://something/1.json".to_string();
+    let update_metadata_msg: sg721_updatable::msg::ExecuteMsg<Extension> =
+        sg721_updatable::msg::ExecuteMsg::UpdateTokenMetadata {
+            token_id: "1".to_string(),
+            token_uri: Some(token_uri.clone()),
+        };
+    let res = router.execute_contract(
+        creator,
+        Addr::unchecked(sg721_addr.clone()),
+        &update_metadata_msg,
+        &[],
+    );
+    assert!(res.is_ok());
+
+    // check token uri updated
+    let query_token_info_msg = Cw721QueryMsg::NftInfo {
+        token_id: String::from("1"),
+    };
+    let res: NftInfoResponse<Extension> = router
+        .wrap()
+        .query_wasm_smart(sg721_addr, &query_token_info_msg)
+        .unwrap();
+    assert_eq!(res.token_uri, Some(token_uri));
+}
+
+use cw2::set_contract_version;
+#[test]
+fn try_migrate() {
+    let mut deps = mock_dependencies_with_balance(&coins(2, "token"));
+    let env = mock_env();
+
+    // should error when different name
+    set_contract_version(&mut deps.storage, "crates.io:another-contract", "0.8.0").unwrap();
+    let err = sg721_updatable::contract::migrate(deps.as_mut(), env.clone(), Empty {}).unwrap_err();
+
+    assert_eq!(
+        err.to_string(),
+        "Generic error: Cannot upgrade to a different contract"
+    );
+
+    // should error when version is greater version
+    set_contract_version(&mut deps.storage, "crates.io:sg-721", "2.0.0").unwrap();
+    let err = sg721_updatable::contract::migrate(deps.as_mut(), env.clone(), Empty {}).unwrap_err();
+    assert_eq!(
+        err.to_string(),
+        "Generic error: Cannot upgrade to a previous contract version"
+    );
+
+    // // no op when same version
+    set_contract_version(
+        &mut deps.storage,
+        "crates.io:sg-721",
+        env!("CARGO_PKG_VERSION"),
+    )
+    .unwrap();
+    sg721_updatable::contract::migrate(deps.as_mut(), env.clone(), Empty {}).unwrap();
+
+    set_contract_version(&mut deps.storage, "crates.io:sg721-updatable", "0.8.0").unwrap();
+    sg721_updatable::contract::migrate(deps.as_mut(), env, Empty {}).unwrap();
+}