Repository: public-awesome/launchpad
Pull request #505 (2023-02-08 01:56:27)

SG-396 Port discount price code from launchpad v1 to vending-minter
Linear link: https://linear.app/stargaze/issue/SG-396/port-discount-price-from-launchpad-v1-to-v2

Porting code from launchpad v1 to vending-minter : https://github.com/public-awesome/launchpad/pull/469

All of the required functionality is tested in `update_discount_mint_price` under `test-suite/src/vending_minter/tests/mint_and_burn.rs`. There was not a unit test before as far as I know. 


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index c4cc98fd..801fff38 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -277,7 +277,7 @@ dependencies = [
  "cw2 0.16.0",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg1",
  "sg2",
  "thiserror",
@@ -297,7 +297,7 @@ dependencies = [
  "cw721-base",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg1",
  "sg2",
  "sg4",
@@ -3080,7 +3080,7 @@ dependencies = [
  "cw-utils 0.16.0",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "thiserror",
 ]
 
@@ -3106,7 +3106,7 @@ dependencies = [
  "schemars",
  "serde",
  "sg-multi-test",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg-whitelist",
  "sg1",
  "sg2",
@@ -3137,7 +3137,7 @@ dependencies = [
  "cw-multi-test",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
 ]
 
 [[package]]
@@ -3154,7 +3154,7 @@ dependencies = [
  "schemars",
  "serde",
  "sg-controllers",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "thiserror",
 ]
 
@@ -3171,6 +3171,21 @@ dependencies = [
  "thiserror",
 ]
 
+[[package]]
+name = "sg-std"
+version = "0.22.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fc0a91b72ca7490db1cbccafee08d3a4d633d565077a894d5954550d5d86747"
+dependencies = [
+ "cosmwasm-schema",
+ "cosmwasm-std",
+ "cw-utils 0.16.0",
+ "cw721",
+ "schemars",
+ "serde",
+ "thiserror",
+]
+
 [[package]]
 name = "sg-utils"
 version = "0.22.9"
@@ -3191,7 +3206,7 @@ dependencies = [
  "rust_decimal",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg1",
  "thiserror",
 ]
@@ -3203,7 +3218,7 @@ dependencies = [
  "cosmwasm-std",
  "cw-utils 0.16.0",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "thiserror",
 ]
 
@@ -3256,7 +3271,7 @@ dependencies = [
  "cw721",
  "cw721-base",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg721",
  "thiserror",
  "url",
@@ -3274,7 +3289,7 @@ dependencies = [
  "schemars",
  "serde",
  "sg-metadata",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg721",
  "sg721-base",
 ]
@@ -3291,7 +3306,7 @@ dependencies = [
  "cw721-base",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg721",
  "sg721-base",
 ]
@@ -3642,7 +3657,7 @@ dependencies = [
  "sg-eth-airdrop",
  "sg-multi-test",
  "sg-splits",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg-whitelist",
  "sg1",
  "sg2",
@@ -4068,7 +4083,7 @@ dependencies = [
  "cw2 0.16.0",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg1",
  "sg2",
  "sg721",
@@ -4090,7 +4105,7 @@ dependencies = [
  "rand_xoshiro",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg-whitelist",
  "sg1",
  "sg2",
@@ -4258,7 +4273,7 @@ dependencies = [
  "cw2 0.16.0",
  "schemars",
  "serde",
- "sg-std",
+ "sg-std 0.22.9 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg-whitelist",
  "thiserror",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 6755cdf7..4cd414cb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -35,7 +35,7 @@ sg-controllers      = { version = "0.22.9", path = "packages/controllers" }
 sg-metadata         = { version = "0.22.9", path = "packages/sg-metadata" }
 sg-multi-test       = { version = "0.22.9", path = "packages/sg-multi-test" }
 sg-splits           = { version = "0.22.9", path = "contracts/splits" }
-sg-std              = { version = "0.22.9", path = "packages/sg-std" }
+sg-std              = { version = "0.22.9" }
 sg-whitelist        = { version = "0.22.9", path = "contracts/whitelist" }
 thiserror           = "1.0.31"
 url                 = "2.2.2"
diff --git a/contracts/sg721-base/src/contract.rs b/contracts/sg721-base/src/contract.rs
index ba62e93c..44a74b02 100644
--- a/contracts/sg721-base/src/contract.rs
+++ b/contracts/sg721-base/src/contract.rs
@@ -197,22 +197,24 @@ where
 
         // convert collection royalty info to response for comparison
         // convert from response to royalty info for storage
-        let royalty_info_res = collection
+        let current_royalty_info = collection
             .royalty_info
             .as_ref()
             .map(|royalty_info| royalty_info.to_response());
 
-        let response = collection_msg
+        let new_royalty_info = collection_msg
             .royalty_info
-            .unwrap_or_else(|| royalty_info_res.clone());
+            .unwrap_or_else(|| current_royalty_info.clone());
 
         // reminder: collection_msg.royalty_info is Option<Option<RoyaltyInfoResponse>>
-        collection.royalty_info = if let Some(royalty_info) = response {
+        collection.royalty_info = if let Some(royalty_info) = new_royalty_info {
             // update royalty info to equal or less, else throw error
-            if let Some(royalty_info_res) = royalty_info_res {
+            if let Some(royalty_info_res) = current_royalty_info {
                 if royalty_info.share > royalty_info_res.share {
                     return Err(ContractError::RoyaltyShareIncreased {});
                 }
+            } else {
+                return Err(ContractError::RoyaltyShareIncreased {});
             }
 
             Some(RoyaltyInfo {
diff --git a/contracts/sg721-base/src/msg.rs b/contracts/sg721-base/src/msg.rs
index b87f1940..3a63214b 100644
--- a/contracts/sg721-base/src/msg.rs
+++ b/contracts/sg721-base/src/msg.rs
@@ -1,8 +1,11 @@
 use cosmwasm_schema::cw_serde;
-use cosmwasm_std::{Binary, Empty, Timestamp};
+use cosmwasm_std::{
+    coin, Addr, BankMsg, Binary, Empty, Event, StdError, StdResult, Timestamp, Uint128,
+};
 use cw721_base::msg::{MintMsg, QueryMsg as Cw721QueryMsg};
 use cw_utils::Expiration;
 use sg721::RoyaltyInfoResponse;
+use sg_std::{Response, SubMsg, NATIVE_DENOM};
 
 #[cw_serde]
 pub enum ExecuteMsg<T, E> {
@@ -161,3 +164,38 @@ pub struct CollectionInfoResponse {
     pub start_trading_time: Option<Timestamp>,
     pub royalty_info: Option<RoyaltyInfoResponse>,
 }
+
+impl CollectionInfoResponse {
+    pub fn royalty_payout(
+        &self,
+        collection: Addr,
+        payment: Uint128,
+        protocol_fee: Uint128,
+        finders_fee: Option<Uint128>,
+        res: &mut Response,
+    ) -> StdResult<Uint128> {
+        if let Some(royalty_info) = self.royalty_info.as_ref() {
+            if royalty_info.share.is_zero() {
+                return Ok(Uint128::zero());
+            }
+            let royalty = coin((payment * royalty_info.share).u128(), NATIVE_DENOM);
+            if payment < (protocol_fee + finders_fee.unwrap_or(Uint128::zero()) + royalty.amount) {
+                return Err(StdError::generic_err("Fees exceed payment"));
+            }
+            res.messages.push(SubMsg::new(BankMsg::Send {
+                to_address: royalty_info.payment_address.to_string(),
+                amount: vec![royalty.clone()],
+            }));
+
+            let event = Event::new("royalty-payout")
+                .add_attribute("collection", collection.to_string())
+                .add_attribute("amount", royalty.to_string())
+                .add_attribute("recipient", royalty_info.payment_address.to_string());
+            res.events.push(event);
+
+            Ok(royalty.amount)
+        } else {
+            Ok(Uint128::zero())
+        }
+    }
+}
diff --git a/contracts/vending-minter/schema/config_response.json b/contracts/vending-minter/schema/config_response.json
index 9a9b45c3..7b69be08 100644
--- a/contracts/vending-minter/schema/config_response.json
+++ b/contracts/vending-minter/schema/config_response.json
@@ -20,6 +20,16 @@
     "base_token_uri": {
       "type": "string"
     },
+    "discount_price": {
+      "anyOf": [
+        {
+          "$ref": "#/definitions/Coin"
+        },
+        {
+          "type": "null"
+        }
+      ]
+    },
     "factory": {
       "type": "string"
     },
diff --git a/contracts/vending-minter/schema/execute_msg.json b/contracts/vending-minter/schema/execute_msg.json
index 6f8adbc2..a6044d1a 100644
--- a/contracts/vending-minter/schema/execute_msg.json
+++ b/contracts/vending-minter/schema/execute_msg.json
@@ -200,6 +200,42 @@
         }
       },
       "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "update_discount_price"
+      ],
+      "properties": {
+        "update_discount_price": {
+          "type": "object",
+          "required": [
+            "price"
+          ],
+          "properties": {
+            "price": {
+              "type": "integer",
+              "format": "uint128",
+              "minimum": 0.0
+            }
+          },
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "remove_discount_price"
+      ],
+      "properties": {
+        "remove_discount_price": {
+          "type": "object",
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
     }
   ],
   "definitions": {
diff --git a/contracts/vending-minter/schema/mint_price_response.json b/contracts/vending-minter/schema/mint_price_response.json
index 5df14b3f..e6857d00 100644
--- a/contracts/vending-minter/schema/mint_price_response.json
+++ b/contracts/vending-minter/schema/mint_price_response.json
@@ -14,6 +14,16 @@
     "current_price": {
       "$ref": "#/definitions/Coin"
     },
+    "discount_price": {
+      "anyOf": [
+        {
+          "$ref": "#/definitions/Coin"
+        },
+        {
+          "type": "null"
+        }
+      ]
+    },
     "public_price": {
       "$ref": "#/definitions/Coin"
     },
diff --git a/contracts/vending-minter/schema/minter_config_for__config_extension.json b/contracts/vending-minter/schema/minter_config_for__config_extension.json
index 925d853b..0556ef22 100644
--- a/contracts/vending-minter/schema/minter_config_for__config_extension.json
+++ b/contracts/vending-minter/schema/minter_config_for__config_extension.json
@@ -62,6 +62,16 @@
         "base_token_uri": {
           "type": "string"
         },
+        "discount_price": {
+          "anyOf": [
+            {
+              "$ref": "#/definitions/Coin"
+            },
+            {
+              "type": "null"
+            }
+          ]
+        },
         "num_tokens": {
           "type": "integer",
           "format": "uint32",
diff --git a/contracts/vending-minter/src/contract.rs b/contracts/vending-minter/src/contract.rs
index 9df6b0b3..411bf8f7 100644
--- a/contracts/vending-minter/src/contract.rs
+++ b/contracts/vending-minter/src/contract.rs
@@ -154,6 +154,7 @@ pub fn instantiate(
             per_address_limit: msg.init_msg.per_address_limit,
             whitelist: whitelist_addr,
             start_time: msg.init_msg.start_time,
+            discount_price: None,
         },
         mint_price: msg.init_msg.mint_price,
     };
@@ -230,7 +231,77 @@ pub fn execute(
         }
         ExecuteMsg::Shuffle {} => execute_shuffle(deps, env, info),
         ExecuteMsg::BurnRemaining {} => execute_burn_remaining(deps, env, info),
+        ExecuteMsg::UpdateDiscountPrice { price } => {
+            execute_update_discount_price(deps, env, info, price)
+        }
+        ExecuteMsg::RemoveDiscountPrice {} => execute_remove_discount_price(deps, info),
+    }
+}
+
+pub fn execute_update_discount_price(
+    deps: DepsMut,
+    env: Env,
+    info: MessageInfo,
+    price: u128,
+) -> Result<Response, ContractError> {
+    nonpayable(&info)?;
+    let mut config = CONFIG.load(deps.storage)?;
+    if info.sender != config.extension.admin {
+        return Err(ContractError::Unauthorized(
+            "Sender is not an admin".to_owned(),
+        ));
     }
+    if env.block.time < config.extension.start_time {
+        return Err(ContractError::BeforeMintStartTime {});
+    }
+
+    // discount price can't be greater than unit price
+    if price > config.mint_price.amount.u128() {
+        return Err(ContractError::UpdatedMintPriceTooHigh {
+            allowed: config.mint_price.amount.u128(),
+            updated: price,
+        });
+    }
+
+    let factory: ParamsResponse = deps
+        .querier
+        .query_wasm_smart(config.clone().factory, &Sg2QueryMsg::Params {})?;
+    let factory_params = factory.params;
+
+    // Check that the price is greater than the minimum
+    if factory_params.min_mint_price.amount.u128() > price {
+        return Err(ContractError::InsufficientMintPrice {
+            expected: factory_params.min_mint_price.amount.u128(),
+            got: price,
+        });
+    }
+
+    config.extension.discount_price = Some(coin(price, config.mint_price.denom.clone()));
+    CONFIG.save(deps.storage, &config)?;
+
+    Ok(Response::new()
+        .add_attribute("action", "update_discount_price")
+        .add_attribute("sender", info.sender)
+        .add_attribute("discount_price", price.to_string()))
+}
+
+pub fn execute_remove_discount_price(
+    deps: DepsMut,
+    info: MessageInfo,
+) -> Result<Response, ContractError> {
+    nonpayable(&info)?;
+    let mut config = CONFIG.load(deps.storage)?;
+    if info.sender != config.extension.admin {
+        return Err(ContractError::Unauthorized(
+            "Sender is not an admin".to_owned(),
+        ));
+    }
+    config.extension.discount_price = None;
+    CONFIG.save(deps.storage, &config)?;
+
+    Ok(Response::new()
+        .add_attribute("action", "remove_discount_price")
+        .add_attribute("sender", info.sender))
 }
 
 // Purge frees data after a mint is sold out
@@ -903,7 +974,8 @@ pub fn mint_price(deps: Deps, is_admin: bool) -> Result<Coin, StdError> {
     }
 
     if config.extension.whitelist.is_none() {
-        return Ok(config.mint_price);
+        let price = config.extension.discount_price.unwrap_or(config.mint_price);
+        return Ok(price);
     }
 
     let whitelist = config.extension.whitelist.unwrap();
@@ -915,7 +987,8 @@ pub fn mint_price(deps: Deps, is_admin: bool) -> Result<Coin, StdError> {
     if wl_config.is_active {
         Ok(wl_config.mint_price)
     } else {
-        Ok(config.mint_price)
+        let price = config.extension.discount_price.unwrap_or(config.mint_price);
+        Ok(price)
     }
 }
 
@@ -990,6 +1063,7 @@ fn query_config(deps: Deps) -> StdResult<ConfigResponse> {
         per_address_limit: config.extension.per_address_limit,
         whitelist: config.extension.whitelist.map(|w| w.to_string()),
         factory: config.factory.to_string(),
+        discount_price: config.extension.discount_price,
     })
 }
 
@@ -1043,11 +1117,13 @@ fn query_mint_price(deps: Deps) -> StdResult<MintPriceResponse> {
         factory_params.extension.airdrop_mint_price.amount.u128(),
         config.mint_price.denom,
     );
+    let discount_price = config.extension.discount_price;
     Ok(MintPriceResponse {
         public_price,
         airdrop_price,
         whitelist_price,
         current_price,
+        discount_price,
     })
 }
 
diff --git a/contracts/vending-minter/src/msg.rs b/contracts/vending-minter/src/msg.rs
index 2a26bc16..d4d9f035 100644
--- a/contracts/vending-minter/src/msg.rs
+++ b/contracts/vending-minter/src/msg.rs
@@ -33,6 +33,10 @@ pub enum ExecuteMsg {
     },
     Shuffle {},
     BurnRemaining {},
+    UpdateDiscountPrice {
+        price: u128,
+    },
+    RemoveDiscountPrice {},
 }
 
 #[cw_serde]
@@ -57,6 +61,7 @@ pub struct ConfigResponse {
     pub mint_price: Coin,
     pub whitelist: Option<String>,
     pub factory: String,
+    pub discount_price: Option<Coin>,
 }
 
 #[cw_serde]
@@ -75,6 +80,7 @@ pub struct MintPriceResponse {
     pub airdrop_price: Coin,
     pub whitelist_price: Option<Coin>,
     pub current_price: Coin,
+    pub discount_price: Option<Coin>,
 }
 
 #[cw_serde]
diff --git a/contracts/vending-minter/src/state.rs b/contracts/vending-minter/src/state.rs
index a2d720a0..04c98b3c 100644
--- a/contracts/vending-minter/src/state.rs
+++ b/contracts/vending-minter/src/state.rs
@@ -1,5 +1,5 @@
 use cosmwasm_schema::cw_serde;
-use cosmwasm_std::{Addr, Timestamp};
+use cosmwasm_std::{Addr, Coin, Timestamp};
 use cw_storage_plus::{Item, Map};
 use sg4::{MinterConfig, Status};
 
@@ -12,6 +12,7 @@ pub struct ConfigExtension {
     pub whitelist: Option<Addr>,
     pub start_time: Timestamp,
     pub per_address_limit: u32,
+    pub discount_price: Option<Coin>,
 }
 pub type Config = MinterConfig<ConfigExtension>;
 


Test patch:
diff --git a/test-suite/src/sg721_base/tests/integration_tests.rs b/test-suite/src/sg721_base/tests/integration_tests.rs
index 711a5f17..8bf8a651 100644
--- a/test-suite/src/sg721_base/tests/integration_tests.rs
+++ b/test-suite/src/sg721_base/tests/integration_tests.rs
@@ -74,7 +74,6 @@ mod tests {
         let cosmos_msg = factory_contract.call_with_funds(msg, creation_fee).unwrap();
 
         let res = app.execute(Addr::unchecked(ADMIN), cosmos_msg);
-        dbg!("{:?}", &res);
         assert!(res.is_ok());
 
         (app, Addr::unchecked("contract2"))
@@ -105,7 +104,6 @@ mod tests {
         let cosmos_msg = factory_contract.call_with_funds(msg, creation_fee).unwrap();
 
         let res = app.execute(Addr::unchecked(ADMIN), cosmos_msg);
-        dbg!("{:?}", &res);
         assert!(res.is_ok());
 
         (app, Addr::unchecked("contract2"))
@@ -248,6 +246,36 @@ mod tests {
         use super::*;
         use sg721_base::msg::{CollectionInfoResponse, QueryMsg};
 
+        #[test]
+        fn royalty_updates() {
+            let mut params = mock_collection_params();
+            params.info.external_link = None;
+            params.info.royalty_info = None;
+            let custom_create_minter_msg =
+                mock_create_minter_init_msg(params, mock_init_extension(None, None));
+            let (mut app, contract) = custom_proper_instantiate(custom_create_minter_msg);
+            let creator = Addr::unchecked("creator".to_string());
+
+            let royalty_info: Option<RoyaltyInfoResponse> = Some(RoyaltyInfoResponse {
+                payment_address: creator.to_string(),
+                share: Decimal::percent(11),
+            });
+            let res = app.execute_contract(
+                creator,
+                contract,
+                &Sg721ExecuteMsg::<Empty, Empty>::UpdateCollectionInfo {
+                    collection_info: UpdateCollectionInfoMsg {
+                        description: None,
+                        image: None,
+                        external_link: None,
+                        explicit_content: None,
+                        royalty_info: Some(royalty_info),
+                    },
+                },
+                &[],
+            );
+            assert!(res.is_err());
+        }
         #[test]
         fn update_collection_info() {
             // customize params so external_link is None
@@ -396,4 +424,200 @@ mod tests {
             assert!(res.is_err());
         }
     }
+
+    mod royalty_payout {
+        use super::*;
+
+        use crate::common_setup::setup_minter::vending_minter::mock_params::mock_create_minter_init_msg;
+        use cosmwasm_std::{Decimal, Response, Uint128};
+        use sg2::msg::CollectionParams;
+        use sg721::RoyaltyInfoResponse;
+        use sg721_base::msg::{CollectionInfoResponse, QueryMsg};
+
+        #[test]
+        fn standard_payout() {
+            let (app, contract) = proper_instantiate();
+
+            let res: CollectionInfoResponse = app
+                .wrap()
+                .query_wasm_smart(contract.clone(), &QueryMsg::CollectionInfo {})
+                .unwrap();
+
+            // payout 100stars, royalty share 10%, royalty payout 10stars
+            let payment = Uint128::from(100000000u128);
+            let royalty_share = res.clone().royalty_info.unwrap().share;
+            let royalty_payout = res
+                .royalty_payout(
+                    contract,
+                    payment,
+                    Uint128::from(10000000u128),
+                    None,
+                    &mut Response::default(),
+                )
+                .unwrap();
+            assert_eq!(royalty_payout, payment * royalty_share);
+        }
+
+        #[test]
+        fn payout_0_royalties() {
+            let init_msg = mock_init_extension(None, None);
+            let custom_collection_params = CollectionParams {
+                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()),
+                    start_trading_time: None,
+                    explicit_content: Some(false),
+                    royalty_info: Some(RoyaltyInfoResponse {
+                        payment_address: "creator".to_string(),
+                        share: Decimal::percent(0),
+                    }),
+                },
+                ..mock_collection_params()
+            };
+            let custom_create_minter_msg =
+                mock_create_minter_init_msg(custom_collection_params, init_msg);
+            let (app, contract) = custom_proper_instantiate(custom_create_minter_msg);
+
+            let res: CollectionInfoResponse = app
+                .wrap()
+                .query_wasm_smart(contract.clone(), &QueryMsg::CollectionInfo {})
+                .unwrap();
+
+            // payout 100stars, royalty share 0%, royalty payout 0stars
+            let payment = Uint128::from(100000000u128);
+            let royalty_payout = res
+                .royalty_payout(
+                    contract,
+                    payment,
+                    Uint128::from(10000000u128),
+                    None,
+                    &mut Response::default(),
+                )
+                .unwrap();
+            assert_eq!(royalty_payout, Uint128::zero());
+        }
+
+        #[test]
+        fn payout_too_much_royalties() {
+            let init_msg = mock_init_extension(None, None);
+            let custom_collection_params = CollectionParams {
+                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()),
+                    start_trading_time: None,
+                    explicit_content: Some(false),
+                    royalty_info: Some(RoyaltyInfoResponse {
+                        payment_address: "creator".to_string(),
+                        share: Decimal::percent(91),
+                    }),
+                },
+                ..mock_collection_params()
+            };
+            let custom_create_minter_msg =
+                mock_create_minter_init_msg(custom_collection_params, init_msg);
+            let (app, contract) = custom_proper_instantiate(custom_create_minter_msg);
+
+            let res: CollectionInfoResponse = app
+                .wrap()
+                .query_wasm_smart(contract.clone(), &QueryMsg::CollectionInfo {})
+                .unwrap();
+
+            // payout 100stars, royalty share 91%, royalty payout fails
+            // fees exceed payment
+            let payment = Uint128::from(100000000u128);
+            let res = res.royalty_payout(
+                contract,
+                payment,
+                Uint128::from(10000000u128),
+                None,
+                &mut Response::default(),
+            );
+            assert!(res.is_err());
+        }
+
+        #[test]
+        fn payout_odd_royalties() {
+            // uint * decimal::percent
+            let init_msg = mock_init_extension(None, None);
+            let custom_collection_params = CollectionParams {
+                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()),
+                    start_trading_time: None,
+                    explicit_content: Some(false),
+                    royalty_info: Some(RoyaltyInfoResponse {
+                        payment_address: "creator".to_string(),
+                        share: Decimal::percent(3),
+                    }),
+                },
+                ..mock_collection_params()
+            };
+            let custom_create_minter_msg =
+                mock_create_minter_init_msg(custom_collection_params, init_msg);
+            let (app, contract) = custom_proper_instantiate(custom_create_minter_msg);
+
+            let res: CollectionInfoResponse = app
+                .wrap()
+                .query_wasm_smart(contract.clone(), &QueryMsg::CollectionInfo {})
+                .unwrap();
+
+            // payout 100stars, royalty share 1%, royalty payout 10stars
+            let payment = Uint128::from(1111111111121111111u128);
+            let royalty_share = res.clone().royalty_info.unwrap().share;
+            let royalty_payout = res
+                .royalty_payout(
+                    contract,
+                    payment,
+                    Uint128::from(10000000u128),
+                    None,
+                    &mut Response::default(),
+                )
+                .unwrap();
+            assert_eq!(royalty_payout, payment * royalty_share);
+        }
+
+        #[test]
+        fn payout_royalties_none() {
+            let init_msg = mock_init_extension(None, None);
+            let custom_collection_params = CollectionParams {
+                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()),
+                    start_trading_time: None,
+                    explicit_content: Some(false),
+                    royalty_info: None,
+                },
+                ..mock_collection_params()
+            };
+            let custom_create_minter_msg =
+                mock_create_minter_init_msg(custom_collection_params, init_msg);
+            let (app, contract) = custom_proper_instantiate(custom_create_minter_msg);
+
+            let res: CollectionInfoResponse = app
+                .wrap()
+                .query_wasm_smart(contract.clone(), &QueryMsg::CollectionInfo {})
+                .unwrap();
+
+            // payout 100stars, royalty share none, royalty payout 0stars
+            let payment = Uint128::from(100000000u128);
+            let royalty_payout = res
+                .royalty_payout(
+                    contract,
+                    payment,
+                    Uint128::from(10000000u128),
+                    None,
+                    &mut Response::default(),
+                )
+                .unwrap();
+            assert_eq!(royalty_payout, Uint128::zero());
+        }
+    }
 }
diff --git a/test-suite/src/vending_minter/tests/mint_and_burn.rs b/test-suite/src/vending_minter/tests/mint_and_burn.rs
index aafde66e..e600bccd 100644
--- a/test-suite/src/vending_minter/tests/mint_and_burn.rs
+++ b/test-suite/src/vending_minter/tests/mint_and_burn.rs
@@ -1,3 +1,4 @@
+use crate::common_setup::setup_collection_whitelist::setup_whitelist_contract;
 use crate::common_setup::templates::{vending_minter_template, vending_minter_with_start_time};
 use cosmwasm_std::Coin;
 use cosmwasm_std::{coins, Timestamp, Uint128};
@@ -79,6 +80,144 @@ fn update_mint_price() {
     assert!(res.is_ok());
 }
 
+#[test]
+fn update_discount_mint_price() {
+    let vt = vending_minter_template(10);
+    let (mut router, creator, buyer) = (vt.router, vt.accts.creator, vt.accts.buyer);
+    let minter_addr = vt.collection_response_vec[0].minter.clone().unwrap();
+    let whitelist_addr = setup_whitelist_contract(&mut router, &creator, None);
+
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME - 10, None);
+    let set_whitelist_msg = ExecuteMsg::SetWhitelist {
+        whitelist: whitelist_addr.to_string(),
+    };
+    let _ = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &set_whitelist_msg,
+        &[],
+    );
+
+    // Update mint price higher
+    let update_msg = ExecuteMsg::UpdateMintPrice {
+        price: MINT_PRICE + 1,
+    };
+    let res = router.execute_contract(creator.clone(), minter_addr.clone(), &update_msg, &[]);
+    assert!(res.is_ok());
+
+    // can't update discount price before minting starts
+    let update_discount_msg = ExecuteMsg::UpdateDiscountPrice {
+        price: MINT_PRICE - 5,
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &update_discount_msg,
+        &[],
+    );
+    assert!(res.is_err());
+
+    // Set block forward, after start time. mint succeeds
+    setup_block_time(&mut router, GENESIS_MINT_START_TIME + 10_000_000, None);
+
+    // check mint price is updated
+    let res: vending_minter::msg::MintPriceResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::MintPrice {})
+        .unwrap();
+    assert_eq!(
+        res.current_price,
+        Coin {
+            denom: "ustars".to_string(),
+            amount: Uint128::new(MINT_PRICE + 1)
+        }
+    );
+    // no discount price yet
+    assert_eq!(res.discount_price, None);
+
+    // can't update discount below min price
+    let update_discount_msg = ExecuteMsg::UpdateDiscountPrice { price: 1 };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &update_discount_msg,
+        &[],
+    );
+    assert!(res.is_err());
+
+    // update discount price to MINT_PRICE - 5
+    let update_discount_msg = ExecuteMsg::UpdateDiscountPrice {
+        price: MINT_PRICE - 5,
+    };
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &update_discount_msg,
+        &[],
+    );
+    assert!(res.is_ok());
+
+    // check public price, mint price and discount price
+    let res: vending_minter::msg::MintPriceResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr.clone(), &QueryMsg::MintPrice {})
+        .unwrap();
+    assert_eq!(
+        res.public_price,
+        Coin {
+            denom: "ustars".to_string(),
+            amount: Uint128::new(MINT_PRICE + 1)
+        }
+    );
+    assert_eq!(
+        res.current_price,
+        Coin {
+            denom: "ustars".to_string(),
+            amount: Uint128::new(MINT_PRICE - 5)
+        }
+    );
+    assert_eq!(
+        res.discount_price,
+        Some(Coin {
+            denom: "ustars".to_string(),
+            amount: Uint128::new(MINT_PRICE - 5)
+        })
+    );
+
+    // Mint succeeds at discount price
+    let mint_msg = ExecuteMsg::Mint {};
+    let res = router.execute_contract(
+        buyer,
+        minter_addr.clone(),
+        &mint_msg,
+        &coins(MINT_PRICE - 5, NATIVE_DENOM),
+    );
+    assert!(res.is_ok());
+
+    // remove discount price
+    let remove_discount_msg = ExecuteMsg::RemoveDiscountPrice {};
+    let res = router.execute_contract(
+        creator.clone(),
+        minter_addr.clone(),
+        &remove_discount_msg,
+        &[],
+    );
+    assert!(res.is_ok());
+
+    // check price after remove discount price, should be mint price + 1
+    let res: vending_minter::msg::MintPriceResponse = router
+        .wrap()
+        .query_wasm_smart(minter_addr, &QueryMsg::MintPrice {})
+        .unwrap();
+    assert_eq!(
+        res.current_price,
+        Coin {
+            denom: "ustars".to_string(),
+            amount: Uint128::new(MINT_PRICE + 1)
+        }
+    );
+}
+
 #[test]
 fn burn_remaining() {
     let vt =