Repository: public-awesome/launchpad
Pull request #461 (2022-11-14 04:48:53)

Multiple Operators for Collections Whitelist
For the Levana project, we need the collection whitelist to have multiple operators/admins such that the airdrop contract will be able to add members to the whitelist. 

This PR implements multiple PRs following the example from marketplace. The other way to implement multiple admins would be to copy the example in cw-plus. 


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 2f64a052..37fac6b2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2105,12 +2105,14 @@ version = "0.21.1"
 dependencies = [
  "cosmwasm-schema",
  "cosmwasm-std",
+ "cw-multi-test",
  "cw-storage-plus",
  "cw-utils",
  "cw2",
  "rust_decimal",
  "schemars",
  "serde",
+ "sg-multi-test 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg-std 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "sg1 0.21.1 (registry+https://github.com/rust-lang/crates.io-index)",
  "thiserror",
diff --git a/contracts/sg-eth-airdrop/src/helpers/build_msg.rs b/contracts/sg-eth-airdrop/src/helpers/build_msg.rs
index 51670120..77187ef1 100644
--- a/contracts/sg-eth-airdrop/src/helpers/build_msg.rs
+++ b/contracts/sg-eth-airdrop/src/helpers/build_msg.rs
@@ -4,7 +4,7 @@ use crate::msg::InstantiateMsg;
 use cosmwasm_std::{coins, Addr, BankMsg};
 use cosmwasm_std::{to_binary, DepsMut, Env, MessageInfo, StdResult, WasmMsg};
 use sg_std::{CosmosMsg, StargazeMsgWrapper, SubMsg};
-use sg_whitelist::helpers::CollectionWhitelistContract;
+use sg_whitelist::interface::CollectionWhitelistContract;
 use sg_whitelist::msg::AddMembersMsg;
 use sg_whitelist::msg::ExecuteMsg as CollectionWhitelistExecuteMsg;
 use whitelist_generic::helpers::WhitelistGenericContract;
diff --git a/contracts/whitelist/Cargo.toml b/contracts/whitelist/Cargo.toml
index 5d6f35a8..435f8ec5 100644
--- a/contracts/whitelist/Cargo.toml
+++ b/contracts/whitelist/Cargo.toml
@@ -47,3 +47,5 @@ thiserror       = { workspace = true }
 
 [dev-dependencies]
 cosmwasm-schema = { workspace = true }
+sg-multi-test = { workspace = true }
+cw-multi-test = { workspace = true }
diff --git a/contracts/whitelist/examples/schema.rs b/contracts/whitelist/examples/schema.rs
index 61747daf..a38e1842 100644
--- a/contracts/whitelist/examples/schema.rs
+++ b/contracts/whitelist/examples/schema.rs
@@ -4,8 +4,8 @@ use std::fs::create_dir_all;
 use cosmwasm_schema::{export_schema, remove_schemas, schema_for};
 
 use sg_whitelist::msg::{
-    ConfigResponse, ExecuteMsg, HasEndedResponse, HasMemberResponse, HasStartedResponse,
-    InstantiateMsg, IsActiveResponse, MembersResponse, QueryMsg,
+    AdminListResponse, ConfigResponse, ExecuteMsg, HasEndedResponse, HasMemberResponse,
+    HasStartedResponse, InstantiateMsg, IsActiveResponse, MembersResponse, QueryMsg,
 };
 use sg_whitelist::state::Config;
 
@@ -25,4 +25,5 @@ fn main() {
     export_schema(&schema_for!(IsActiveResponse), &out_dir);
     export_schema(&schema_for!(MembersResponse), &out_dir);
     export_schema(&schema_for!(QueryMsg), &out_dir);
+    export_schema(&schema_for!(AdminListResponse), &out_dir);
 }
diff --git a/contracts/whitelist/schema/admin_list_response.json b/contracts/whitelist/schema/admin_list_response.json
new file mode 100644
index 00000000..3b04e955
--- /dev/null
+++ b/contracts/whitelist/schema/admin_list_response.json
@@ -0,0 +1,21 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "title": "AdminListResponse",
+  "type": "object",
+  "required": [
+    "admins",
+    "mutable"
+  ],
+  "properties": {
+    "admins": {
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
+    "mutable": {
+      "type": "boolean"
+    }
+  },
+  "additionalProperties": false
+}
diff --git a/contracts/whitelist/schema/config.json b/contracts/whitelist/schema/config.json
index 0bc8ad94..0bf69711 100644
--- a/contracts/whitelist/schema/config.json
+++ b/contracts/whitelist/schema/config.json
@@ -3,7 +3,6 @@
   "title": "Config",
   "type": "object",
   "required": [
-    "admin",
     "end_time",
     "member_limit",
     "mint_price",
@@ -12,9 +11,6 @@
     "start_time"
   ],
   "properties": {
-    "admin": {
-      "$ref": "#/definitions/Addr"
-    },
     "end_time": {
       "$ref": "#/definitions/Timestamp"
     },
@@ -42,10 +38,6 @@
   },
   "additionalProperties": false,
   "definitions": {
-    "Addr": {
-      "description": "A human readable address.\n\nIn Cosmos, this is typically bech32 encoded. But for multi-chain smart contracts no assumptions should be made other than being UTF-8 encoded and of reasonable length.\n\nThis type represents a validated address. It can be created in the following ways 1. Use `Addr::unchecked(input)` 2. Use `let checked: Addr = deps.api.addr_validate(input)?` 3. Use `let checked: Addr = deps.api.addr_humanize(canonical_addr)?` 4. Deserialize from JSON. This must only be done from JSON that was validated before such as a contract's state. `Addr` must not be used in messages sent by the user because this would result in unvalidated instances.\n\nThis type is immutable. If you really need to mutate it (Really? Are you sure?), create a mutable copy using `let mut mutable = Addr::to_string()` and operate on that `String` instance.",
-      "type": "string"
-    },
     "Coin": {
       "type": "object",
       "required": [
diff --git a/contracts/whitelist/schema/execute_msg.json b/contracts/whitelist/schema/execute_msg.json
index 2dd80f74..4d95a597 100644
--- a/contracts/whitelist/schema/execute_msg.json
+++ b/contracts/whitelist/schema/execute_msg.json
@@ -77,6 +77,43 @@
         }
       },
       "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "update_admins"
+      ],
+      "properties": {
+        "update_admins": {
+          "type": "object",
+          "required": [
+            "admins"
+          ],
+          "properties": {
+            "admins": {
+              "type": "array",
+              "items": {
+                "type": "string"
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "freeze"
+      ],
+      "properties": {
+        "freeze": {
+          "type": "object",
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
     }
   ],
   "definitions": {
diff --git a/contracts/whitelist/schema/instantiate_msg.json b/contracts/whitelist/schema/instantiate_msg.json
index 45d83666..830e6cff 100644
--- a/contracts/whitelist/schema/instantiate_msg.json
+++ b/contracts/whitelist/schema/instantiate_msg.json
@@ -3,6 +3,8 @@
   "title": "InstantiateMsg",
   "type": "object",
   "required": [
+    "admins",
+    "admins_mutable",
     "end_time",
     "member_limit",
     "members",
@@ -11,6 +13,15 @@
     "start_time"
   ],
   "properties": {
+    "admins": {
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
+    "admins_mutable": {
+      "type": "boolean"
+    },
     "end_time": {
       "$ref": "#/definitions/Timestamp"
     },
diff --git a/contracts/whitelist/schema/query_msg.json b/contracts/whitelist/schema/query_msg.json
index a086ed56..72b50bcd 100644
--- a/contracts/whitelist/schema/query_msg.json
+++ b/contracts/whitelist/schema/query_msg.json
@@ -103,6 +103,502 @@
         }
       },
       "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "admin_list"
+      ],
+      "properties": {
+        "admin_list": {
+          "type": "object",
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
+    },
+    {
+      "type": "object",
+      "required": [
+        "can_execute"
+      ],
+      "properties": {
+        "can_execute": {
+          "type": "object",
+          "required": [
+            "msg",
+            "sender"
+          ],
+          "properties": {
+            "msg": {
+              "$ref": "#/definitions/CosmosMsg_for_Empty"
+            },
+            "sender": {
+              "type": "string"
+            }
+          },
+          "additionalProperties": false
+        }
+      },
+      "additionalProperties": false
+    }
+  ],
+  "definitions": {
+    "BankMsg": {
+      "description": "The message types of the bank module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "Sends native tokens from the contract to the given address.\n\nThis is translated to a [MsgSend](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/bank/v1beta1/tx.proto#L19-L28). `from_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "send"
+          ],
+          "properties": {
+            "send": {
+              "type": "object",
+              "required": [
+                "amount",
+                "to_address"
+              ],
+              "properties": {
+                "amount": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "to_address": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This will burn the given coins from the contract's account. There is no Cosmos SDK message that performs this, but it can be done by calling the bank keeper. Important if a contract controls significant token supply that must be retired.",
+          "type": "object",
+          "required": [
+            "burn"
+          ],
+          "properties": {
+            "burn": {
+              "type": "object",
+              "required": [
+                "amount"
+              ],
+              "properties": {
+                "amount": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Binary": {
+      "description": "Binary is a wrapper around Vec<u8> to add base64 de/serialization with serde. It also adds some helper methods to help encode inline.\n\nThis is only needed as serde-json-{core,wasm} has a horrible encoding for Vec<u8>. See also <https://github.com/CosmWasm/cosmwasm/blob/main/docs/MESSAGE_TYPES.md>.",
+      "type": "string"
+    },
+    "Coin": {
+      "type": "object",
+      "required": [
+        "amount",
+        "denom"
+      ],
+      "properties": {
+        "amount": {
+          "$ref": "#/definitions/Uint128"
+        },
+        "denom": {
+          "type": "string"
+        }
+      }
+    },
+    "CosmosMsg_for_Empty": {
+      "oneOf": [
+        {
+          "type": "object",
+          "required": [
+            "bank"
+          ],
+          "properties": {
+            "bank": {
+              "$ref": "#/definitions/BankMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "custom"
+          ],
+          "properties": {
+            "custom": {
+              "$ref": "#/definitions/Empty"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "staking"
+          ],
+          "properties": {
+            "staking": {
+              "$ref": "#/definitions/StakingMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "distribution"
+          ],
+          "properties": {
+            "distribution": {
+              "$ref": "#/definitions/DistributionMsg"
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "type": "object",
+          "required": [
+            "wasm"
+          ],
+          "properties": {
+            "wasm": {
+              "$ref": "#/definitions/WasmMsg"
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "DistributionMsg": {
+      "description": "The message types of the distribution module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "This is translated to a [MsgSetWithdrawAddress](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L29-L37). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "set_withdraw_address"
+          ],
+          "properties": {
+            "set_withdraw_address": {
+              "type": "object",
+              "required": [
+                "address"
+              ],
+              "properties": {
+                "address": {
+                  "description": "The `withdraw_address`",
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [[MsgWithdrawDelegatorReward](https://github.com/cosmos/cosmos-sdk/blob/v0.42.4/proto/cosmos/distribution/v1beta1/tx.proto#L42-L50). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "withdraw_delegator_reward"
+          ],
+          "properties": {
+            "withdraw_delegator_reward": {
+              "type": "object",
+              "required": [
+                "validator"
+              ],
+              "properties": {
+                "validator": {
+                  "description": "The `validator_address`",
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Empty": {
+      "description": "An empty struct that serves as a placeholder in different places, such as contracts that don't set a custom message.\n\nIt is designed to be expressable in correct JSON and JSON Schema but contains no meaningful data. Previously we used enums without cases, but those cannot represented as valid JSON Schema (https://github.com/CosmWasm/cosmwasm/issues/451)",
+      "type": "object"
+    },
+    "StakingMsg": {
+      "description": "The message types of the staking module.\n\nSee https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto",
+      "oneOf": [
+        {
+          "description": "This is translated to a [MsgDelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L81-L90). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "delegate"
+          ],
+          "properties": {
+            "delegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [MsgUndelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L112-L121). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "undelegate"
+          ],
+          "properties": {
+            "undelegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "This is translated to a [MsgBeginRedelegate](https://github.com/cosmos/cosmos-sdk/blob/v0.40.0/proto/cosmos/staking/v1beta1/tx.proto#L95-L105). `delegator_address` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "redelegate"
+          ],
+          "properties": {
+            "redelegate": {
+              "type": "object",
+              "required": [
+                "amount",
+                "dst_validator",
+                "src_validator"
+              ],
+              "properties": {
+                "amount": {
+                  "$ref": "#/definitions/Coin"
+                },
+                "dst_validator": {
+                  "type": "string"
+                },
+                "src_validator": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
+    },
+    "Uint128": {
+      "description": "A thin wrapper around u128 that is using strings for JSON encoding/decoding, such that the full u128 range can be used for clients that convert JSON numbers to floats, like JavaScript and jq.\n\n# Examples\n\nUse `from` to create instances of this and `u128` to get the value out:\n\n``` # use cosmwasm_std::Uint128; let a = Uint128::from(123u128); assert_eq!(a.u128(), 123);\n\nlet b = Uint128::from(42u64); assert_eq!(b.u128(), 42);\n\nlet c = Uint128::from(70u32); assert_eq!(c.u128(), 70); ```",
+      "type": "string"
+    },
+    "WasmMsg": {
+      "description": "The message types of the wasm module.\n\nSee https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto",
+      "oneOf": [
+        {
+          "description": "Dispatches a call to another contract at a known address (with known ABI).\n\nThis is translated to a [MsgExecuteContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L68-L78). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "execute"
+          ],
+          "properties": {
+            "execute": {
+              "type": "object",
+              "required": [
+                "contract_addr",
+                "funds",
+                "msg"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                },
+                "funds": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "msg": {
+                  "description": "msg is the json-encoded ExecuteMsg struct (as raw Binary)",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Instantiates a new contracts from previously uploaded Wasm code.\n\nThis is translated to a [MsgInstantiateContract](https://github.com/CosmWasm/wasmd/blob/v0.16.0-alpha1/x/wasm/internal/types/tx.proto#L47-L61). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "instantiate"
+          ],
+          "properties": {
+            "instantiate": {
+              "type": "object",
+              "required": [
+                "code_id",
+                "funds",
+                "label",
+                "msg"
+              ],
+              "properties": {
+                "admin": {
+                  "type": [
+                    "string",
+                    "null"
+                  ]
+                },
+                "code_id": {
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                },
+                "funds": {
+                  "type": "array",
+                  "items": {
+                    "$ref": "#/definitions/Coin"
+                  }
+                },
+                "label": {
+                  "description": "A human-readbale label for the contract",
+                  "type": "string"
+                },
+                "msg": {
+                  "description": "msg is the JSON-encoded InstantiateMsg struct (as raw Binary)",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Migrates a given contracts to use new wasm code. Passes a MigrateMsg to allow us to customize behavior.\n\nOnly the contract admin (as defined in wasmd), if any, is able to make this call.\n\nThis is translated to a [MsgMigrateContract](https://github.com/CosmWasm/wasmd/blob/v0.14.0/x/wasm/internal/types/tx.proto#L86-L96). `sender` is automatically filled with the current contract's address.",
+          "type": "object",
+          "required": [
+            "migrate"
+          ],
+          "properties": {
+            "migrate": {
+              "type": "object",
+              "required": [
+                "contract_addr",
+                "msg",
+                "new_code_id"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                },
+                "msg": {
+                  "description": "msg is the json-encoded MigrateMsg struct that will be passed to the new code",
+                  "allOf": [
+                    {
+                      "$ref": "#/definitions/Binary"
+                    }
+                  ]
+                },
+                "new_code_id": {
+                  "description": "the code_id of the new logic to place in the given contract",
+                  "type": "integer",
+                  "format": "uint64",
+                  "minimum": 0.0
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Sets a new admin (for migrate) on the given contract. Fails if this contract is not currently admin of the target contract.",
+          "type": "object",
+          "required": [
+            "update_admin"
+          ],
+          "properties": {
+            "update_admin": {
+              "type": "object",
+              "required": [
+                "admin",
+                "contract_addr"
+              ],
+              "properties": {
+                "admin": {
+                  "type": "string"
+                },
+                "contract_addr": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        },
+        {
+          "description": "Clears the admin on the given contract, so no more migration possible. Fails if this contract is not currently admin of the target contract.",
+          "type": "object",
+          "required": [
+            "clear_admin"
+          ],
+          "properties": {
+            "clear_admin": {
+              "type": "object",
+              "required": [
+                "contract_addr"
+              ],
+              "properties": {
+                "contract_addr": {
+                  "type": "string"
+                }
+              }
+            }
+          },
+          "additionalProperties": false
+        }
+      ]
     }
-  ]
+  }
 }
diff --git a/contracts/whitelist/src/admin.rs b/contracts/whitelist/src/admin.rs
new file mode 100644
index 00000000..cbb9d9b3
--- /dev/null
+++ b/contracts/whitelist/src/admin.rs
@@ -0,0 +1,67 @@
+use cosmwasm_std::{Addr, Deps, DepsMut, Env, MessageInfo, StdResult};
+use sg_std::Response;
+
+use crate::{
+    msg::{AdminListResponse, CanExecuteResponse},
+    state::ADMIN_LIST,
+    validators::map_validate,
+    ContractError,
+};
+
+pub fn execute_update_admins(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+    admins: Vec<String>,
+) -> Result<Response, ContractError> {
+    let mut cfg = ADMIN_LIST.load(deps.storage)?;
+    if !cfg.can_modify(info.sender.as_ref()) {
+        Err(ContractError::Unauthorized {})
+    } else {
+        cfg.admins = map_validate(deps.api, &admins)?;
+        ADMIN_LIST.save(deps.storage, &cfg)?;
+
+        let res = Response::new().add_attribute("action", "update_admins");
+        Ok(res)
+    }
+}
+
+pub fn can_execute(deps: &DepsMut, sender: Addr) -> Result<Addr, ContractError> {
+    let cfg = ADMIN_LIST.load(deps.storage)?;
+    let can = cfg.is_admin(&sender);
+    if !can {
+        return Err(ContractError::Unauthorized {});
+    }
+    Ok(sender)
+}
+
+pub fn execute_freeze(
+    deps: DepsMut,
+    _env: Env,
+    info: MessageInfo,
+) -> Result<Response, ContractError> {
+    let mut cfg = ADMIN_LIST.load(deps.storage)?;
+    if !cfg.can_modify(info.sender.as_ref()) {
+        Err(ContractError::Unauthorized {})
+    } else {
+        cfg.mutable = false;
+        ADMIN_LIST.save(deps.storage, &cfg)?;
+
+        let res = Response::new().add_attribute("action", "freeze");
+        Ok(res)
+    }
+}
+
+pub fn query_admin_list(deps: Deps) -> StdResult<AdminListResponse> {
+    let cfg = ADMIN_LIST.load(deps.storage)?;
+    Ok(AdminListResponse {
+        admins: cfg.admins.into_iter().map(|a| a.into()).collect(),
+        mutable: cfg.mutable,
+    })
+}
+
+pub fn query_can_execute(deps: Deps, sender: &str) -> StdResult<CanExecuteResponse> {
+    let cfg = ADMIN_LIST.load(deps.storage)?;
+    let can = cfg.is_admin(deps.api.addr_validate(sender)?);
+    Ok(CanExecuteResponse { can_execute: can })
+}
diff --git a/contracts/whitelist/src/contract.rs b/contracts/whitelist/src/contract.rs
index 9b71c8c2..61fa70eb 100644
--- a/contracts/whitelist/src/contract.rs
+++ b/contracts/whitelist/src/contract.rs
@@ -1,10 +1,14 @@
+use crate::admin::{
+    can_execute, execute_freeze, execute_update_admins, query_admin_list, query_can_execute,
+};
 use crate::error::ContractError;
 use crate::msg::{
     AddMembersMsg, ConfigResponse, ExecuteMsg, HasEndedResponse, HasMemberResponse,
     HasStartedResponse, InstantiateMsg, IsActiveResponse, MembersResponse, QueryMsg,
     RemoveMembersMsg,
 };
-use crate::state::{Config, CONFIG, WHITELIST};
+use crate::state::{AdminList, Config, ADMIN_LIST, CONFIG, WHITELIST};
+use crate::validators::map_validate;
 #[cfg(not(feature = "library"))]
 use cosmwasm_std::entry_point;
 use cosmwasm_std::{to_binary, Binary, Deps, DepsMut, Env, MessageInfo, StdResult};
@@ -22,10 +26,10 @@ const CONTRACT_NAME: &str = "crates.io:sg-whitelist";
 const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION");
 
 // contract governance params
-const MAX_MEMBERS: u32 = 5000;
-const PRICE_PER_1000_MEMBERS: u128 = 100_000_000;
-const MIN_MINT_PRICE: u128 = 25_000_000;
-const MAX_PER_ADDRESS_LIMIT: u32 = 30;
+pub const MAX_MEMBERS: u32 = 5000;
+pub const PRICE_PER_1000_MEMBERS: u128 = 100_000_000;
+pub const MIN_MINT_PRICE: u128 = 25_000_000;
+pub const MAX_PER_ADDRESS_LIMIT: u32 = 30;
 
 // queries
 const PAGINATION_DEFAULT_LIMIT: u32 = 25;
@@ -91,7 +95,6 @@ pub fn instantiate(
     msg.members.dedup();
 
     let config = Config {
-        admin: info.sender.clone(),
         start_time: msg.start_time,
         end_time: msg.end_time,
         num_members: msg.members.len() as u32,
@@ -101,6 +104,12 @@ pub fn instantiate(
     };
     CONFIG.save(deps.storage, &config)?;
 
+    let admin_config = AdminList {
+        admins: map_validate(deps.api, &msg.admins)?,
+        mutable: msg.admins_mutable,
+    };
+    ADMIN_LIST.save(deps.storage, &admin_config)?;
+
     if msg.start_time > msg.end_time {
         return Err(ContractError::InvalidStartTime(
             msg.start_time,
@@ -163,6 +172,8 @@ pub fn execute(
         ExecuteMsg::IncreaseMemberLimit(member_limit) => {
             execute_increase_member_limit(deps, info, member_limit)
         }
+        ExecuteMsg::UpdateAdmins { admins } => execute_update_admins(deps, env, info, admins),
+        ExecuteMsg::Freeze {} => execute_freeze(deps, env, info),
     }
 }
 
@@ -173,9 +184,7 @@ pub fn execute_update_start_time(
     start_time: Timestamp,
 ) -> Result<Response, ContractError> {
     let mut config = CONFIG.load(deps.storage)?;
-    if info.sender != config.admin {
-        return Err(ContractError::Unauthorized {});
-    }
+    can_execute(&deps, info.sender.clone())?;
 
     // don't allow updating start time if whitelist is active
     if env.block.time >= config.start_time {
@@ -208,9 +217,7 @@ pub fn execute_update_end_time(
     end_time: Timestamp,
 ) -> Result<Response, ContractError> {
     let mut config = CONFIG.load(deps.storage)?;
-    if info.sender != config.admin {
-        return Err(ContractError::Unauthorized {});
-    }
+    can_execute(&deps, info.sender.clone())?;
 
     // don't allow updating end time if whitelist is active
     if env.block.time >= config.start_time {
@@ -236,10 +243,7 @@ pub fn execute_add_members(
     mut msg: AddMembersMsg,
 ) -> Result<Response, ContractError> {
     let mut config = CONFIG.load(deps.storage)?;
-    if info.sender != config.admin {
-        return Err(ContractError::Unauthorized {});
-    }
-
+    can_execute(&deps, info.sender.clone())?;
     // remove duplicate members
     msg.to_add.sort_unstable();
     msg.to_add.dedup();
@@ -273,9 +277,7 @@ pub fn execute_remove_members(
     msg: RemoveMembersMsg,
 ) -> Result<Response, ContractError> {
     let mut config = CONFIG.load(deps.storage)?;
-    if info.sender != config.admin {
-        return Err(ContractError::Unauthorized {});
-    }
+    can_execute(&deps, info.sender.clone())?;
 
     if env.block.time >= config.start_time {
         return Err(ContractError::AlreadyStarted {});
@@ -303,9 +305,7 @@ pub fn execute_update_per_address_limit(
     per_address_limit: u32,
 ) -> Result<Response, ContractError> {
     let mut config = CONFIG.load(deps.storage)?;
-    if info.sender != config.admin {
-        return Err(ContractError::Unauthorized {});
-    }
+    can_execute(&deps, info.sender)?;
 
     if per_address_limit > MAX_PER_ADDRESS_LIMIT {
         return Err(ContractError::InvalidPerAddressLimit {
@@ -336,7 +336,7 @@ pub fn execute_increase_member_limit(
         });
     }
 
-    // if new limit crosses 1,000 members, requires upgrade fee. Otherwise, free upgrade.
+    // if new limit crosses 1,000 members, requires upgrade fee. Otherwise,  upgrade.
     let old_limit = Decimal::new(config.member_limit.into(), 3).ceil();
     let new_limit = Decimal::new(member_limit.into(), 3).ceil();
     let upgrade_fee: u128 = if new_limit > old_limit {
@@ -376,6 +376,8 @@ pub fn query(deps: Deps, env: Env, msg: QueryMsg) -> StdResult<Binary> {
         QueryMsg::IsActive {} => to_binary(&query_is_active(deps, env)?),
         QueryMsg::HasMember { member } => to_binary(&query_has_member(deps, member)?),
         QueryMsg::Config {} => to_binary(&query_config(deps, env)?),
+        QueryMsg::AdminList {} => to_binary(&query_admin_list(deps)?),
+        QueryMsg::CanExecute { sender, .. } => to_binary(&query_can_execute(deps, &sender)?),
     }
 }
 
@@ -400,7 +402,7 @@ fn query_is_active(deps: Deps, env: Env) -> StdResult<IsActiveResponse> {
     })
 }
 
-fn query_members(
+pub fn query_members(
     deps: Deps,
     start_after: Option<String>,
     limit: Option<u32>,
@@ -419,7 +421,7 @@ fn query_members(
     Ok(MembersResponse { members })
 }
 
-fn query_has_member(deps: Deps, member: String) -> StdResult<HasMemberResponse> {
+pub fn query_has_member(deps: Deps, member: String) -> StdResult<HasMemberResponse> {
     let addr = deps.api.addr_validate(&member)?;
 
     Ok(HasMemberResponse {
@@ -427,7 +429,7 @@ fn query_has_member(deps: Deps, member: String) -> StdResult<HasMemberResponse>
     })
 }
 
-fn query_config(deps: Deps, env: Env) -> StdResult<ConfigResponse> {
+pub fn query_config(deps: Deps, env: Env) -> StdResult<ConfigResponse> {
     let config = CONFIG.load(deps.storage)?;
     Ok(ConfigResponse {
         num_members: config.num_members,
@@ -439,359 +441,3 @@ fn query_config(deps: Deps, env: Env) -> StdResult<ConfigResponse> {
         is_active: (env.block.time >= config.start_time) && (env.block.time < config.end_time),
     })
 }
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-    use cosmwasm_std::{
-        coin,
-        testing::{mock_dependencies, mock_env, mock_info},
-    };
-    use sg_std::NATIVE_DENOM;
-
-    const ADMIN: &str = "admin";
-    const UNIT_AMOUNT: u128 = 100_000_000;
-
-    const GENESIS_START_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME);
-    const END_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME + 1000);
-
-    fn setup_contract(deps: DepsMut) {
-        let msg = InstantiateMsg {
-            members: vec!["adsfsa".to_string()],
-            start_time: GENESIS_START_TIME,
-            end_time: END_TIME,
-            mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
-            per_address_limit: 1,
-            member_limit: 1000,
-        };
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let res = instantiate(deps, mock_env(), info, msg).unwrap();
-        assert_eq!(2, res.messages.len());
-    }
-
-    #[test]
-    fn proper_initialization() {
-        let mut deps = mock_dependencies();
-        setup_contract(deps.as_mut());
-    }
-
-    #[test]
-    fn improper_initialization() {
-        let mut deps = mock_dependencies();
-        let msg = InstantiateMsg {
-            members: vec!["adsfsa".to_string()],
-            start_time: END_TIME,
-            end_time: END_TIME,
-            mint_price: coin(1, NATIVE_DENOM),
-            per_address_limit: 1,
-            member_limit: 1000,
-        };
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-    }
-
-    #[test]
-    fn improper_initialization_invalid_denom() {
-        let mut deps = mock_dependencies();
-        let msg = InstantiateMsg {
-            members: vec!["adsfsa".to_string()],
-            start_time: END_TIME,
-            end_time: END_TIME,
-            mint_price: coin(UNIT_AMOUNT, "not_ustars"),
-            per_address_limit: 1,
-            member_limit: 1000,
-        };
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let err = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-        assert_eq!(err.to_string(), "InvalidDenom: not_ustars");
-    }
-
-    #[test]
-    fn improper_initialization_invalid_creation_fee() {
-        let mut deps = mock_dependencies();
-        let msg = InstantiateMsg {
-            members: vec!["adsfsa".to_string()],
-            start_time: END_TIME,
-            end_time: END_TIME,
-            mint_price: coin(UNIT_AMOUNT, "ustars"),
-            per_address_limit: 1,
-            member_limit: 3000,
-        };
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let err = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-        assert_eq!(
-            err.to_string(),
-            "IncorrectCreationFee 100000000 < 300000000"
-        );
-    }
-
-    #[test]
-    fn improper_initialization_dedup() {
-        let mut deps = mock_dependencies();
-        let msg = InstantiateMsg {
-            members: vec![
-                "adsfsa".to_string(),
-                "adsfsa".to_string(),
-                "adsfsa".to_string(),
-            ],
-            start_time: END_TIME,
-            end_time: END_TIME,
-            mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
-            per_address_limit: 1,
-            member_limit: 1000,
-        };
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
-        let res = query_config(deps.as_ref(), mock_env()).unwrap();
-        assert_eq!(1, res.num_members);
-    }
-
-    #[test]
-    fn check_start_time_after_end_time() {
-        let msg = InstantiateMsg {
-            members: vec!["adsfsa".to_string()],
-            start_time: END_TIME,
-            end_time: GENESIS_START_TIME,
-            mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
-            per_address_limit: 1,
-            member_limit: 1000,
-        };
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let mut deps = mock_dependencies();
-        instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-    }
-
-    #[test]
-    fn update_start_time() {
-        let mut deps = mock_dependencies();
-        setup_contract(deps.as_mut());
-
-        let msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME - 100));
-        let info = mock_info(ADMIN, &[]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
-        assert_eq!(res.attributes.len(), 3);
-        let res = query_config(deps.as_ref(), mock_env()).unwrap();
-        assert_eq!(res.start_time, GENESIS_START_TIME);
-    }
-
-    #[test]
-    fn update_end_time() {
-        let mut deps = mock_dependencies();
-        setup_contract(deps.as_mut());
-
-        let msg = ExecuteMsg::UpdateEndTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME + 100));
-        let info = mock_info(ADMIN, &[]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
-        assert_eq!(res.attributes.len(), 3);
-
-        let msg = ExecuteMsg::UpdateEndTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME - 100));
-        let info = mock_info(ADMIN, &[]);
-        execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-    }
-
-    #[test]
-    fn update_members() {
-        let mut deps = mock_dependencies();
-        setup_contract(deps.as_mut());
-
-        // dedupe addrs
-        let add_msg = AddMembersMsg {
-            to_add: vec!["adsfsa1".to_string(), "adsfsa1".to_string()],
-        };
-        let msg = ExecuteMsg::AddMembers(add_msg);
-        let info = mock_info(ADMIN, &[]);
-        let res = execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap();
-        assert_eq!(res.attributes.len(), 2);
-        let res = query_members(deps.as_ref(), None, None).unwrap();
-        assert_eq!(res.members.len(), 2);
-
-        execute(deps.as_mut(), mock_env(), info.clone(), msg).unwrap_err();
-
-        let remove_msg = RemoveMembersMsg {
-            to_remove: vec!["adsfsa1".to_string()],
-        };
-        let msg = ExecuteMsg::RemoveMembers(remove_msg);
-        let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
-        assert_eq!(res.attributes.len(), 2);
-        let res = query_members(deps.as_ref(), None, None).unwrap();
-        assert_eq!(res.members.len(), 1);
-    }
-
-    #[test]
-    fn too_many_members_check() {
-        let mut deps = mock_dependencies();
-        setup_contract(deps.as_mut());
-
-        let mut members = vec![];
-        for i in 0..MAX_MEMBERS {
-            members.push(format!("adsfsa{}", i));
-        }
-
-        let inner_msg = AddMembersMsg { to_add: members };
-        let msg = ExecuteMsg::AddMembers(inner_msg);
-        let info = mock_info(ADMIN, &[]);
-        let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-        assert_eq!(
-            ContractError::MembersExceeded {
-                expected: 1000,
-                actual: 1000
-            }
-            .to_string(),
-            err.to_string()
-        );
-    }
-
-    #[test]
-    fn update_per_address_limit() {
-        let mut deps = mock_dependencies();
-        setup_contract(deps.as_mut());
-
-        let per_address_limit: u32 = 50;
-        let msg = ExecuteMsg::UpdatePerAddressLimit(per_address_limit);
-        let info = mock_info(ADMIN, &[]);
-        // let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
-        // let wl_config: ConfigResponse = query_config(deps.as_ref(), mock_env()).unwrap();
-        let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-        assert_eq!(
-            ContractError::InvalidPerAddressLimit {
-                max: MAX_PER_ADDRESS_LIMIT.to_string(),
-                got: per_address_limit.to_string(),
-            }
-            .to_string(),
-            err.to_string()
-        );
-
-        let per_address_limit: u32 = 2;
-        let msg = ExecuteMsg::UpdatePerAddressLimit(per_address_limit);
-        let info = mock_info(ADMIN, &[]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
-        assert_eq!(res.attributes.len(), 2);
-        let wl_config: ConfigResponse = query_config(deps.as_ref(), mock_env()).unwrap();
-        assert_eq!(wl_config.per_address_limit, per_address_limit);
-    }
-    #[test]
-    fn query_members_pagination() {
-        let mut deps = mock_dependencies();
-        let mut members = vec![];
-        for i in 0..150 {
-            members.push(format!("stars1{}", i));
-        }
-        let msg = InstantiateMsg {
-            members: members.clone(),
-            start_time: END_TIME,
-            end_time: END_TIME,
-            mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
-            per_address_limit: 1,
-            member_limit: 1000,
-        };
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
-        assert_eq!(2, res.messages.len());
-
-        let mut all_elements: Vec<String> = vec![];
-
-        // enforcing a min
-        let res = query_members(deps.as_ref(), None, None).unwrap();
-        assert_eq!(res.members.len(), 25);
-
-        // enforcing a max
-        let res = query_members(deps.as_ref(), None, Some(125)).unwrap();
-        assert_eq!(res.members.len(), 100);
-
-        // first fetch
-        let res = query_members(deps.as_ref(), None, Some(50)).unwrap();
-        assert_eq!(res.members.len(), 50);
-        all_elements.append(&mut res.members.clone());
-
-        // second
-        let res = query_members(
-            deps.as_ref(),
-            Some(res.members[res.members.len() - 1].clone()),
-            Some(50),
-        )
-        .unwrap();
-        assert_eq!(res.members.len(), 50);
-        all_elements.append(&mut res.members.clone());
-
-        // third
-        let res = query_members(
-            deps.as_ref(),
-            Some(res.members[res.members.len() - 1].clone()),
-            Some(50),
-        )
-        .unwrap();
-        all_elements.append(&mut res.members.clone());
-        assert_eq!(res.members.len(), 50);
-
-        // check fetched items
-        assert_eq!(all_elements.len(), 150);
-        members.sort();
-        all_elements.sort();
-        assert_eq!(members, all_elements);
-    }
-
-    #[test]
-    fn increase_member_limit() {
-        let mut deps = mock_dependencies();
-        setup_contract(deps.as_mut());
-        let res = query_config(deps.as_ref(), mock_env()).unwrap();
-        assert_eq!(1000, res.member_limit);
-
-        // needs upgrade fee
-        let msg = ExecuteMsg::IncreaseMemberLimit(1001);
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg);
-        assert!(res.is_ok());
-
-        // 0 upgrade fee
-        let msg = ExecuteMsg::IncreaseMemberLimit(1002);
-        let info = mock_info(ADMIN, &[coin(0, "ustars")]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg);
-        assert!(res.is_ok());
-
-        // 0 upgrade fee, fails when including a fee
-        // don't allow updating to the same number of memebers
-        let msg = ExecuteMsg::IncreaseMemberLimit(1002);
-        let info = mock_info(ADMIN, &[coin(1, "ustars")]);
-        execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-
-        // 0 upgrade fee, fails when including a fee
-        let msg = ExecuteMsg::IncreaseMemberLimit(1003);
-        let info = mock_info(ADMIN, &[coin(1, "ustars")]);
-        let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-        assert_eq!(err.to_string(), "IncorrectCreationFee 1 < 0");
-
-        // 0 upgrade fee
-        let msg = ExecuteMsg::IncreaseMemberLimit(1502);
-        let info = mock_info(ADMIN, &[coin(0, "ustars")]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg);
-        assert!(res.is_ok());
-
-        // 0 upgrade fee
-        let msg = ExecuteMsg::IncreaseMemberLimit(2000);
-        let info = mock_info(ADMIN, &[coin(0, "ustars")]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg);
-        assert!(res.is_ok());
-
-        // needs upgrade fee
-        let msg = ExecuteMsg::IncreaseMemberLimit(2002);
-        let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg);
-        assert!(res.is_ok());
-
-        // needs upgrade fee
-        let msg = ExecuteMsg::IncreaseMemberLimit(4002);
-        let info = mock_info(ADMIN, &[coin(200_000_000, "ustars")]);
-        let res = execute(deps.as_mut(), mock_env(), info, msg);
-        assert!(res.is_ok());
-
-        // over MAX_MEMBERS, Invalid member limit
-        let msg = ExecuteMsg::IncreaseMemberLimit(6000);
-        let info = mock_info(ADMIN, &[coin(400_000_000, "ustars")]);
-        let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
-        assert_eq!(
-            err.to_string(),
-            "Invalid member limit. min: 4002, max: 5000, got: 6000"
-        );
-    }
-}
diff --git a/contracts/whitelist/src/error.rs b/contracts/whitelist/src/error.rs
index 01062619..4959574d 100644
--- a/contracts/whitelist/src/error.rs
+++ b/contracts/whitelist/src/error.rs
@@ -52,4 +52,7 @@ pub enum ContractError {
 
     #[error("{0}")]
     PaymentError(#[from] PaymentError),
+
+    #[error("UnauthorizedAdmin")]
+    UnauthorizedAdmin {},
 }
diff --git a/contracts/whitelist/src/helpers.rs b/contracts/whitelist/src/helpers/interface.rs
similarity index 100%
rename from contracts/whitelist/src/helpers.rs
rename to contracts/whitelist/src/helpers/interface.rs
diff --git a/contracts/whitelist/src/helpers/validators.rs b/contracts/whitelist/src/helpers/validators.rs
new file mode 100644
index 00000000..b5ffe298
--- /dev/null
+++ b/contracts/whitelist/src/helpers/validators.rs
@@ -0,0 +1,5 @@
+use cosmwasm_std::{Addr, Api, StdResult};
+
+pub fn map_validate(api: &dyn Api, admins: &[String]) -> StdResult<Vec<Addr>> {
+    admins.iter().map(|addr| api.addr_validate(addr)).collect()
+}
diff --git a/contracts/whitelist/src/lib.rs b/contracts/whitelist/src/lib.rs
index 0f47974b..95aa31f3 100644
--- a/contracts/whitelist/src/lib.rs
+++ b/contracts/whitelist/src/lib.rs
@@ -1,6 +1,17 @@
 pub mod contract;
 mod error;
-pub mod helpers;
+#[path = "./tests/integration_tests.rs"]
+#[cfg(test)]
+pub mod integration_tests;
 pub mod msg;
 pub mod state;
+#[path = "./tests/unit_tests.rs"]
+#[cfg(test)]
+pub mod unit_tests;
+
+pub mod admin;
+#[path = "./helpers/interface.rs"]
+pub mod interface;
+#[path = "./helpers/validators.rs"]
+pub mod validators;
 pub use crate::error::ContractError;
diff --git a/contracts/whitelist/src/msg.rs b/contracts/whitelist/src/msg.rs
index 03cfb731..15c78b7b 100644
--- a/contracts/whitelist/src/msg.rs
+++ b/contracts/whitelist/src/msg.rs
@@ -1,5 +1,5 @@
 use cosmwasm_schema::cw_serde;
-use cosmwasm_std::{Coin, Timestamp};
+use cosmwasm_std::{Coin, CosmosMsg, Empty, Timestamp};
 
 #[cw_serde]
 pub struct InstantiateMsg {
@@ -9,6 +9,8 @@ pub struct InstantiateMsg {
     pub mint_price: Coin,
     pub per_address_limit: u32,
     pub member_limit: u32,
+    pub admins: Vec<String>,
+    pub admins_mutable: bool,
 }
 
 #[cw_serde]
@@ -19,6 +21,14 @@ pub enum ExecuteMsg {
     RemoveMembers(RemoveMembersMsg),
     UpdatePerAddressLimit(u32),
     IncreaseMemberLimit(u32),
+    UpdateAdmins { admins: Vec<String> },
+    Freeze {},
+}
+
+#[cw_serde]
+pub struct AdminListResponse {
+    pub admins: Vec<String>,
+    pub mutable: bool,
 }
 
 #[cw_serde]
@@ -44,6 +54,13 @@ pub enum QueryMsg {
         member: String,
     },
     Config {},
+
+    AdminList {},
+
+    CanExecute {
+        sender: String,
+        msg: CosmosMsg<Empty>,
+    },
 }
 
 #[cw_serde]
@@ -86,3 +103,16 @@ pub struct ConfigResponse {
     pub mint_price: Coin,
     pub is_active: bool,
 }
+
+#[cw_serde]
+pub enum SudoMsg {
+    /// Add a new operator
+    AddOperator { operator: String },
+    /// Remove operator
+    RemoveOperator { operator: String },
+}
+
+#[cw_serde]
+pub struct CanExecuteResponse {
+    pub can_execute: bool,
+}
diff --git a/contracts/whitelist/src/state.rs b/contracts/whitelist/src/state.rs
index 495289e9..3f430aa9 100644
--- a/contracts/whitelist/src/state.rs
+++ b/contracts/whitelist/src/state.rs
@@ -4,7 +4,6 @@ use cw_storage_plus::{Item, Map};
 
 #[cw_serde]
 pub struct Config {
-    pub admin: Addr,
     pub start_time: Timestamp,
     pub end_time: Timestamp,
     pub num_members: u32,
@@ -13,5 +12,24 @@ pub struct Config {
     pub member_limit: u32,
 }
 
+#[cw_serde]
+pub struct AdminList {
+    pub admins: Vec<Addr>,
+    pub mutable: bool,
+}
+
+impl AdminList {
+    pub fn is_admin(&self, addr: impl AsRef<str>) -> bool {
+        let addr = addr.as_ref();
+        self.admins.iter().any(|a| a.as_ref() == addr)
+    }
+
+    pub fn can_modify(&self, addr: &str) -> bool {
+        self.mutable && self.is_admin(addr)
+    }
+}
+
+pub const ADMIN_LIST: Item<AdminList> = Item::new("admin_list");
+
 pub const CONFIG: Item<Config> = Item::new("config");
 pub const WHITELIST: Map<Addr, bool> = Map::new("wl");


Test patch:
diff --git a/contracts/sg-eth-airdrop/src/testing/setup/setup_collection_whitelist.rs b/contracts/sg-eth-airdrop/src/testing/setup/setup_collection_whitelist.rs
index e14c7971..351eb7be 100644
--- a/contracts/sg-eth-airdrop/src/testing/setup/setup_collection_whitelist.rs
+++ b/contracts/sg-eth-airdrop/src/testing/setup/setup_collection_whitelist.rs
@@ -18,6 +18,8 @@ pub fn setup_whitelist_contract(router: &mut StargazeApp, creator: &Addr) -> Add
         mint_price: coin(WHITELIST_AMOUNT, NATIVE_DENOM),
         per_address_limit: WL_PER_ADDRESS_LIMIT,
         member_limit: 1000,
+        admins: vec![creator.to_string()],
+        admins_mutable: true,
     };
     router
         .instantiate_contract(
diff --git a/contracts/vending-minter/src/integration_tests.rs b/contracts/vending-minter/src/integration_tests.rs
index 3ef1f7f9..80750056 100644
--- a/contracts/vending-minter/src/integration_tests.rs
+++ b/contracts/vending-minter/src/integration_tests.rs
@@ -111,6 +111,8 @@ fn setup_whitelist_contract(router: &mut StargazeApp, creator: &Addr) -> Addr {
         mint_price: coin(WHITELIST_AMOUNT, NATIVE_DENOM),
         per_address_limit: WL_PER_ADDRESS_LIMIT,
         member_limit: 1000,
+        admins: vec![creator.to_string()],
+        admins_mutable: true,
     };
     router
         .instantiate_contract(
diff --git a/contracts/whitelist/src/tests/integration_tests.rs b/contracts/whitelist/src/tests/integration_tests.rs
new file mode 100644
index 00000000..356339c1
--- /dev/null
+++ b/contracts/whitelist/src/tests/integration_tests.rs
@@ -0,0 +1,270 @@
+use cosmwasm_std::{coin, coins, Addr, Timestamp};
+use cw_multi_test::{BankSudo, Contract, ContractWrapper, Executor, SudoMsg as CWSudoMsg};
+use sg_multi_test::StargazeApp;
+use sg_std::{StargazeMsgWrapper, GENESIS_MINT_START_TIME, NATIVE_DENOM};
+
+use crate::{
+    msg::{AddMembersMsg, ExecuteMsg, InstantiateMsg, MembersResponse, QueryMsg, RemoveMembersMsg},
+    state::AdminList,
+};
+
+const COLLECTION_WHITELIST_ADDR: &str = "contract0";
+const ADMIN: &str = "admin";
+const SECOND_ADMIN: &str = "second_admin";
+const UNIT_AMOUNT: u128 = 100_000_000;
+
+const GENESIS_START_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME);
+const END_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME + 1000);
+
+pub fn custom_mock_app() -> StargazeApp {
+    StargazeApp::default()
+}
+
+pub fn collection_whitelist() -> Box<dyn Contract<StargazeMsgWrapper>> {
+    let contract = ContractWrapper::new(
+        crate::contract::execute,
+        crate::contract::instantiate,
+        crate::contract::query,
+    );
+    Box::new(contract)
+}
+
+fn instantiate_contract(admin_account: &str, app: &mut StargazeApp) {
+    let admin = Addr::unchecked(admin_account);
+    let funds_amount = 100000000;
+
+    let initial_members = vec!["member0".to_string()];
+    let msg = InstantiateMsg {
+        members: initial_members,
+        start_time: GENESIS_START_TIME,
+        end_time: END_TIME,
+        mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
+        per_address_limit: 1,
+        member_limit: 1000,
+        admins: vec![ADMIN.to_string(), SECOND_ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    app.sudo(CWSudoMsg::Bank({
+        BankSudo::Mint {
+            to_address: admin.to_string(),
+            amount: coins(funds_amount, NATIVE_DENOM),
+        }
+    }))
+    .map_err(|err| println!("{:?}", err))
+    .ok();
+
+    let collection_id = app.store_code(collection_whitelist());
+    let _ = app.instantiate_contract(
+        collection_id,
+        admin,
+        &msg,
+        &coins(funds_amount, NATIVE_DENOM),
+        "collection_whitelist".to_string(),
+        None,
+    );
+}
+
+fn add_members_with_specified_admin(admin: &str, members: Vec<String>, app: &mut StargazeApp) {
+    let admin_addr = Addr::unchecked(admin);
+    let collection_whitelist_contract = Addr::unchecked("contract0");
+    let initial_members = vec!["member0".to_string()];
+
+    let query_msg = QueryMsg::Members {
+        start_after: None,
+        limit: None,
+    };
+    let expected_result = MembersResponse {
+        members: initial_members.clone(),
+    };
+    let query_result: MembersResponse = app
+        .wrap()
+        .query_wasm_smart(collection_whitelist_contract.clone(), &query_msg)
+        .unwrap();
+    assert_eq!(query_result, expected_result);
+
+    let add_msg = AddMembersMsg {
+        to_add: members.clone(),
+    };
+    let msg = ExecuteMsg::AddMembers(add_msg);
+    let res = app.execute_contract(admin_addr, collection_whitelist_contract.clone(), &msg, &[]);
+    assert_eq!(res.unwrap().events.len(), 2);
+
+    let query_msg = QueryMsg::Members {
+        start_after: None,
+        limit: None,
+    };
+    let expected_result = MembersResponse {
+        members: [initial_members, members].concat(),
+    };
+    let query_result: MembersResponse = app
+        .wrap()
+        .query_wasm_smart(collection_whitelist_contract, &query_msg)
+        .unwrap();
+    assert_eq!(query_result, expected_result);
+}
+
+fn remove_members_with_specified_admin(admin: &str, members: Vec<String>, app: &mut StargazeApp) {
+    let admin_addr = Addr::unchecked(admin);
+    let collection_whitelist_contract = Addr::unchecked("contract0");
+    let initial_members = vec![
+        "member0".to_string(),
+        "member1".to_string(),
+        "member2".to_string(),
+    ];
+
+    let query_msg = QueryMsg::Members {
+        start_after: None,
+        limit: None,
+    };
+    let expected_result = MembersResponse {
+        members: initial_members,
+    };
+    let query_result: MembersResponse = app
+        .wrap()
+        .query_wasm_smart(collection_whitelist_contract.clone(), &query_msg)
+        .unwrap();
+    assert_eq!(query_result, expected_result);
+
+    let remove_msg = RemoveMembersMsg { to_remove: members };
+    let msg = ExecuteMsg::RemoveMembers(remove_msg);
+    let res = app.execute_contract(admin_addr, collection_whitelist_contract.clone(), &msg, &[]);
+    assert_eq!(res.unwrap().events.len(), 2);
+
+    let query_msg = QueryMsg::Members {
+        start_after: None,
+        limit: None,
+    };
+    let expected_result = MembersResponse {
+        members: vec!["member0".to_string()],
+    };
+    let query_result: MembersResponse = app
+        .wrap()
+        .query_wasm_smart(collection_whitelist_contract, &query_msg)
+        .unwrap();
+    assert_eq!(query_result, expected_result);
+}
+
+fn add_members_blocked(admin: &str, members: Vec<String>, app: &mut StargazeApp) {
+    let admin_addr = Addr::unchecked(admin);
+    let collection_whitelist_contract = Addr::unchecked("contract0");
+    let initial_members = vec!["member0".to_string()];
+
+    let query_msg = QueryMsg::Members {
+        start_after: None,
+        limit: None,
+    };
+    let expected_result = MembersResponse {
+        members: initial_members,
+    };
+    let query_result: MembersResponse = app
+        .wrap()
+        .query_wasm_smart(collection_whitelist_contract.clone(), &query_msg)
+        .unwrap();
+    assert_eq!(query_result, expected_result);
+
+    let add_msg = AddMembersMsg { to_add: members };
+    let msg = ExecuteMsg::AddMembers(add_msg);
+    let res = app.execute_contract(admin_addr, collection_whitelist_contract.clone(), &msg, &[]);
+    assert_eq!(res.unwrap_err().root_cause().to_string(), "Unauthorized");
+
+    let query_result: MembersResponse = app
+        .wrap()
+        .query_wasm_smart(collection_whitelist_contract, &query_msg)
+        .unwrap();
+    assert_eq!(query_result, expected_result);
+}
+
+#[test]
+fn test_instantiate() {
+    let mut app = custom_mock_app();
+    let admin_account = "admin";
+    instantiate_contract(admin_account, &mut app);
+}
+
+#[test]
+fn test_add_admin() {
+    let mut app = custom_mock_app();
+    instantiate_contract(ADMIN, &mut app);
+    let collection_whitelist_addr = Addr::unchecked(COLLECTION_WHITELIST_ADDR);
+
+    let new_admin: &str = "new_admin";
+    let update_admins_message = ExecuteMsg::UpdateAdmins {
+        admins: vec![ADMIN.to_string(), new_admin.to_string()],
+    };
+    let _ = app.execute_contract(
+        Addr::unchecked(ADMIN),
+        Addr::unchecked(collection_whitelist_addr),
+        &update_admins_message,
+        &[],
+    );
+    let members = vec!["member1".to_string(), "member2".to_string()];
+
+    add_members_with_specified_admin(new_admin, members.clone(), &mut app);
+    remove_members_with_specified_admin(new_admin, members, &mut app);
+}
+
+#[test]
+fn test_remove_admin() {
+    let mut app = custom_mock_app();
+    instantiate_contract(ADMIN, &mut app);
+    let collection_whitelist_addr = Addr::unchecked(COLLECTION_WHITELIST_ADDR);
+
+    let update_admin_message = ExecuteMsg::UpdateAdmins {
+        admins: vec![ADMIN.to_string()],
+    };
+
+    let _ = app.execute_contract(
+        Addr::unchecked(ADMIN),
+        Addr::unchecked(collection_whitelist_addr),
+        &update_admin_message,
+        &[],
+    );
+
+    let members = vec!["member1".to_string()];
+    add_members_blocked(SECOND_ADMIN, members, &mut app);
+}
+
+#[test]
+fn test_query_admin_list() {
+    let mut app = custom_mock_app();
+    instantiate_contract(ADMIN, &mut app);
+    let collection_whitelist_contract = Addr::unchecked(COLLECTION_WHITELIST_ADDR);
+
+    let query_msg = QueryMsg::AdminList {};
+    let query_result: AdminList = app
+        .wrap()
+        .query_wasm_smart(collection_whitelist_contract, &query_msg)
+        .unwrap();
+    let expected_result = AdminList {
+        admins: vec![Addr::unchecked("admin"), Addr::unchecked("second_admin")],
+        mutable: true,
+    };
+    assert_eq!(query_result, expected_result);
+}
+
+#[test]
+fn test_freeze_admins() {
+    let mut app = custom_mock_app();
+    instantiate_contract(ADMIN, &mut app);
+    let collection_whitelist_addr = Addr::unchecked(COLLECTION_WHITELIST_ADDR);
+
+    let freeze_admins_msg = ExecuteMsg::Freeze {};
+    let _ = app.execute_contract(
+        Addr::unchecked(ADMIN),
+        Addr::unchecked(collection_whitelist_addr.clone()),
+        &freeze_admins_msg,
+        &[],
+    );
+
+    let update_admin_message = ExecuteMsg::UpdateAdmins {
+        admins: vec![ADMIN.to_string()],
+    };
+
+    let res = app.execute_contract(
+        Addr::unchecked(ADMIN),
+        Addr::unchecked(collection_whitelist_addr),
+        &update_admin_message,
+        &[],
+    );
+    assert_eq!(res.unwrap_err().root_cause().to_string(), "Unauthorized");
+}
diff --git a/contracts/whitelist/src/tests/unit_tests.rs b/contracts/whitelist/src/tests/unit_tests.rs
new file mode 100644
index 00000000..1aa77571
--- /dev/null
+++ b/contracts/whitelist/src/tests/unit_tests.rs
@@ -0,0 +1,423 @@
+use crate::contract::{
+    execute, instantiate, query_config, query_members, MAX_MEMBERS, MAX_PER_ADDRESS_LIMIT,
+};
+use crate::error::ContractError;
+use crate::msg::{AddMembersMsg, ConfigResponse, ExecuteMsg, InstantiateMsg, RemoveMembersMsg};
+use cosmwasm_std::DepsMut;
+use cosmwasm_std::Timestamp;
+use sg_std::{GENESIS_MINT_START_TIME, NATIVE_DENOM};
+
+use cosmwasm_std::{
+    coin,
+    testing::{mock_dependencies, mock_env, mock_info},
+};
+
+const ADMIN: &str = "admin";
+const NOT_ADMIN: &str = "not_admin";
+const SECOND_ADMIN: &str = "second_admin";
+const UNIT_AMOUNT: u128 = 100_000_000;
+
+const GENESIS_START_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME);
+const END_TIME: Timestamp = Timestamp::from_nanos(GENESIS_MINT_START_TIME + 1000);
+
+fn setup_contract(deps: DepsMut) {
+    let msg = InstantiateMsg {
+        members: vec!["adsfsa".to_string()],
+        start_time: GENESIS_START_TIME,
+        end_time: END_TIME,
+        mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
+        per_address_limit: 1,
+        member_limit: 1000,
+        admins: vec![ADMIN.to_string(), SECOND_ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let res = instantiate(deps, mock_env(), info, msg).unwrap();
+    assert_eq!(2, res.messages.len());
+}
+
+#[test]
+fn proper_initialization() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+}
+
+#[test]
+fn improper_initialization() {
+    let mut deps = mock_dependencies();
+    let msg = InstantiateMsg {
+        members: vec!["adsfsa".to_string()],
+        start_time: END_TIME,
+        end_time: END_TIME,
+        mint_price: coin(1, NATIVE_DENOM),
+        per_address_limit: 1,
+        member_limit: 1000,
+        admins: vec![ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+}
+
+#[test]
+fn improper_initialization_invalid_denom() {
+    let mut deps = mock_dependencies();
+    let msg = InstantiateMsg {
+        members: vec!["adsfsa".to_string()],
+        start_time: END_TIME,
+        end_time: END_TIME,
+        mint_price: coin(UNIT_AMOUNT, "not_ustars"),
+        per_address_limit: 1,
+        member_limit: 1000,
+        admins: vec![ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let err = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+    assert_eq!(err.to_string(), "InvalidDenom: not_ustars");
+}
+
+#[test]
+fn improper_initialization_invalid_creation_fee() {
+    let mut deps = mock_dependencies();
+    let msg = InstantiateMsg {
+        members: vec!["adsfsa".to_string()],
+        start_time: END_TIME,
+        end_time: END_TIME,
+        mint_price: coin(UNIT_AMOUNT, "ustars"),
+        per_address_limit: 1,
+        member_limit: 3000,
+        admins: vec![ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let err = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+    assert_eq!(
+        err.to_string(),
+        "IncorrectCreationFee 100000000 < 300000000"
+    );
+}
+
+#[test]
+fn improper_initialization_dedup() {
+    let mut deps = mock_dependencies();
+    let msg = InstantiateMsg {
+        members: vec![
+            "adsfsa".to_string(),
+            "adsfsa".to_string(),
+            "adsfsa".to_string(),
+        ],
+        start_time: END_TIME,
+        end_time: END_TIME,
+        mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
+        per_address_limit: 1,
+        member_limit: 1000,
+        admins: vec![ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let _res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
+    let res = query_config(deps.as_ref(), mock_env()).unwrap();
+    assert_eq!(1, res.num_members);
+}
+
+#[test]
+fn check_start_time_after_end_time() {
+    let msg = InstantiateMsg {
+        members: vec!["adsfsa".to_string()],
+        start_time: END_TIME,
+        end_time: GENESIS_START_TIME,
+        mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
+        per_address_limit: 1,
+        member_limit: 1000,
+        admins: vec![ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let mut deps = mock_dependencies();
+    instantiate(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+}
+
+#[test]
+fn update_start_time() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+
+    let msg = ExecuteMsg::UpdateStartTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME - 100));
+    let info = mock_info(ADMIN, &[]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
+    assert_eq!(res.attributes.len(), 3);
+    let res = query_config(deps.as_ref(), mock_env()).unwrap();
+    assert_eq!(res.start_time, GENESIS_START_TIME);
+}
+
+#[test]
+fn update_end_time() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+
+    let msg = ExecuteMsg::UpdateEndTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME + 100));
+    let info = mock_info(ADMIN, &[]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
+    assert_eq!(res.attributes.len(), 3);
+
+    let msg = ExecuteMsg::UpdateEndTime(Timestamp::from_nanos(GENESIS_MINT_START_TIME - 100));
+    let info = mock_info(ADMIN, &[]);
+    execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+}
+
+#[test]
+fn update_members() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+
+    // dedupe addrs
+    let add_msg = AddMembersMsg {
+        to_add: vec!["adsfsa1".to_string(), "adsfsa1".to_string()],
+    };
+    let msg = ExecuteMsg::AddMembers(add_msg);
+    let info = mock_info(ADMIN, &[]);
+    let res = execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap();
+    assert_eq!(res.attributes.len(), 2);
+    let res = query_members(deps.as_ref(), None, None).unwrap();
+    assert_eq!(res.members.len(), 2);
+
+    execute(deps.as_mut(), mock_env(), info.clone(), msg).unwrap_err();
+
+    let remove_msg = RemoveMembersMsg {
+        to_remove: vec!["adsfsa1".to_string()],
+    };
+    let msg = ExecuteMsg::RemoveMembers(remove_msg);
+    let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
+    assert_eq!(res.attributes.len(), 2);
+    let res = query_members(deps.as_ref(), None, None).unwrap();
+    assert_eq!(res.members.len(), 1);
+}
+
+#[test]
+fn too_many_members_check() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+
+    let mut members = vec![];
+    for i in 0..MAX_MEMBERS {
+        members.push(format!("adsfsa{}", i));
+    }
+
+    let inner_msg = AddMembersMsg { to_add: members };
+    let msg = ExecuteMsg::AddMembers(inner_msg);
+    let info = mock_info(ADMIN, &[]);
+    let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+    assert_eq!(
+        ContractError::MembersExceeded {
+            expected: 1000,
+            actual: 1000
+        }
+        .to_string(),
+        err.to_string()
+    );
+}
+
+#[test]
+fn update_per_address_limit() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+
+    let per_address_limit: u32 = 50;
+    let msg = ExecuteMsg::UpdatePerAddressLimit(per_address_limit);
+    let info = mock_info(ADMIN, &[]);
+    // let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
+    // let wl_config: ConfigResponse = query_config(deps.as_ref(), mock_env()).unwrap();
+    let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+    assert_eq!(
+        ContractError::InvalidPerAddressLimit {
+            max: MAX_PER_ADDRESS_LIMIT.to_string(),
+            got: per_address_limit.to_string(),
+        }
+        .to_string(),
+        err.to_string()
+    );
+
+    let per_address_limit: u32 = 2;
+    let msg = ExecuteMsg::UpdatePerAddressLimit(per_address_limit);
+    let info = mock_info(ADMIN, &[]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
+    assert_eq!(res.attributes.len(), 2);
+    let wl_config: ConfigResponse = query_config(deps.as_ref(), mock_env()).unwrap();
+    assert_eq!(wl_config.per_address_limit, per_address_limit);
+}
+#[test]
+fn query_members_pagination() {
+    let mut deps = mock_dependencies();
+    let mut members = vec![];
+    for i in 0..150 {
+        members.push(format!("stars1{}", i));
+    }
+    let msg = InstantiateMsg {
+        members: members.clone(),
+        start_time: END_TIME,
+        end_time: END_TIME,
+        mint_price: coin(UNIT_AMOUNT, NATIVE_DENOM),
+        per_address_limit: 1,
+        member_limit: 1000,
+        admins: vec![ADMIN.to_string()],
+        admins_mutable: true,
+    };
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let res = instantiate(deps.as_mut(), mock_env(), info, msg).unwrap();
+    assert_eq!(2, res.messages.len());
+
+    let mut all_elements: Vec<String> = vec![];
+
+    // enforcing a min
+    let res = query_members(deps.as_ref(), None, None).unwrap();
+    assert_eq!(res.members.len(), 25);
+
+    // enforcing a max
+    let res = query_members(deps.as_ref(), None, Some(125)).unwrap();
+    assert_eq!(res.members.len(), 100);
+
+    // first fetch
+    let res = query_members(deps.as_ref(), None, Some(50)).unwrap();
+    assert_eq!(res.members.len(), 50);
+    all_elements.append(&mut res.members.clone());
+
+    // second
+    let res = query_members(
+        deps.as_ref(),
+        Some(res.members[res.members.len() - 1].clone()),
+        Some(50),
+    )
+    .unwrap();
+    assert_eq!(res.members.len(), 50);
+    all_elements.append(&mut res.members.clone());
+
+    // third
+    let res = query_members(
+        deps.as_ref(),
+        Some(res.members[res.members.len() - 1].clone()),
+        Some(50),
+    )
+    .unwrap();
+    all_elements.append(&mut res.members.clone());
+    assert_eq!(res.members.len(), 50);
+
+    // check fetched items
+    assert_eq!(all_elements.len(), 150);
+    members.sort();
+    all_elements.sort();
+    assert_eq!(members, all_elements);
+}
+
+#[test]
+fn increase_member_limit() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+    let res = query_config(deps.as_ref(), mock_env()).unwrap();
+    assert_eq!(1000, res.member_limit);
+
+    // needs upgrade fee
+    let msg = ExecuteMsg::IncreaseMemberLimit(1001);
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg);
+    assert!(res.is_ok());
+
+    // 0 upgrade fee
+    let msg = ExecuteMsg::IncreaseMemberLimit(1002);
+    let info = mock_info(ADMIN, &[coin(0, "ustars")]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg);
+    assert!(res.is_ok());
+
+    // 0 upgrade fee, fails when including a fee
+    // don't allow updating to the same number of memebers
+    let msg = ExecuteMsg::IncreaseMemberLimit(1002);
+    let info = mock_info(ADMIN, &[coin(1, "ustars")]);
+    execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+
+    // 0 upgrade fee, fails when including a fee
+    let msg = ExecuteMsg::IncreaseMemberLimit(1003);
+    let info = mock_info(ADMIN, &[coin(1, "ustars")]);
+    let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+    assert_eq!(err.to_string(), "IncorrectCreationFee 1 < 0");
+
+    // 0 upgrade fee
+    let msg = ExecuteMsg::IncreaseMemberLimit(1502);
+    let info = mock_info(ADMIN, &[coin(0, "ustars")]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg);
+    assert!(res.is_ok());
+
+    // 0 upgrade fee
+    let msg = ExecuteMsg::IncreaseMemberLimit(2000);
+    let info = mock_info(ADMIN, &[coin(0, "ustars")]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg);
+    assert!(res.is_ok());
+
+    // needs upgrade fee
+    let msg = ExecuteMsg::IncreaseMemberLimit(2002);
+    let info = mock_info(ADMIN, &[coin(100_000_000, "ustars")]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg);
+    assert!(res.is_ok());
+
+    // needs upgrade fee
+    let msg = ExecuteMsg::IncreaseMemberLimit(4002);
+    let info = mock_info(ADMIN, &[coin(200_000_000, "ustars")]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg);
+    assert!(res.is_ok());
+
+    // over MAX_MEMBERS, Invalid member limit
+    let msg = ExecuteMsg::IncreaseMemberLimit(6000);
+    let info = mock_info(ADMIN, &[coin(400_000_000, "ustars")]);
+    let err = execute(deps.as_mut(), mock_env(), info, msg).unwrap_err();
+    assert_eq!(
+        err.to_string(),
+        "Invalid member limit. min: 4002, max: 5000, got: 6000"
+    );
+}
+
+#[test]
+fn cant_update_members_non_admin() {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+
+    // dedupe addrs
+    let add_msg = AddMembersMsg {
+        to_add: vec!["adsfsa1".to_string(), "adsfsa1".to_string()],
+    };
+    let msg = ExecuteMsg::AddMembers(add_msg);
+    let info = mock_info(NOT_ADMIN, &[]);
+    let res = execute(deps.as_mut(), mock_env(), info, msg);
+
+    assert_eq!(res.unwrap_err().to_string(), "Unauthorized")
+}
+
+fn add_members_with_specified_admin(admin: &str) {
+    let mut deps = mock_dependencies();
+    setup_contract(deps.as_mut());
+
+    // dedupe addrs
+    let add_msg = AddMembersMsg {
+        to_add: vec!["adsfsa1".to_string(), "adsfsa1".to_string()],
+    };
+    let msg = ExecuteMsg::AddMembers(add_msg);
+    let info = mock_info(admin, &[]);
+    let res = execute(deps.as_mut(), mock_env(), info.clone(), msg.clone()).unwrap();
+    assert_eq!(res.attributes.len(), 2);
+    let res = query_members(deps.as_ref(), None, None).unwrap();
+    assert_eq!(res.members.len(), 2);
+
+    execute(deps.as_mut(), mock_env(), info.clone(), msg).unwrap_err();
+
+    let remove_msg = RemoveMembersMsg {
+        to_remove: vec!["adsfsa1".to_string()],
+    };
+    let msg = ExecuteMsg::RemoveMembers(remove_msg);
+    let res = execute(deps.as_mut(), mock_env(), info, msg).unwrap();
+    assert_eq!(res.attributes.len(), 2);
+    let res = query_members(deps.as_ref(), None, None).unwrap();
+    assert_eq!(res.members.len(), 1);
+}
+
+#[test]
+fn second_admin_can_add_members() {
+    add_members_with_specified_admin(SECOND_ADMIN);
+}