diff --git a/Cargo.lock b/Cargo.lock index b1e7d67d1..c8f75bde0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -972,6 +972,7 @@ dependencies = [ "cw-denom 2.5.0", "cw-multi-test", "cw-ownable", + "cw-paginate-storage 2.5.0", "cw-storage-plus 1.2.0", "cw-utils 1.0.3", "cw-vesting", diff --git a/contracts/external/cw-payroll-factory/Cargo.toml b/contracts/external/cw-payroll-factory/Cargo.toml index 9b89969a5..10728ef9f 100644 --- a/contracts/external/cw-payroll-factory/Cargo.toml +++ b/contracts/external/cw-payroll-factory/Cargo.toml @@ -1,5 +1,5 @@ [package] -name ="cw-payroll-factory" +name = "cw-payroll-factory" authors = ["Jake Hartnell"] description = "A CosmWasm factory contract for instantiating a payroll contract." edition = { workspace = true } @@ -27,6 +27,7 @@ cw20 = { workspace = true } thiserror = { workspace = true } cw-vesting = { workspace = true, features = ["library"] } cw-utils = { workspace = true } +cw-paginate-storage = { workspace = true } [dev-dependencies] cw-multi-test = { workspace = true } diff --git a/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json index d9eb0eb32..29c570424 100644 --- a/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json +++ b/contracts/external/cw-payroll-factory/schema/cw-payroll-factory.json @@ -10,6 +10,15 @@ "vesting_code_id" ], "properties": { + "instantiate_allowlist": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, "owner": { "type": [ "string", @@ -91,6 +100,40 @@ }, "additionalProperties": false }, + { + "description": "Callable only by the current owner. Updates the addresses that are allowed to instantiate vesting contracts.", + "type": "object", + "required": [ + "update_instantiate_allowlist" + ], + "properties": { + "update_instantiate_allowlist": { + "type": "object", + "properties": { + "to_add": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "to_remove": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, { "description": "Update the contract's ownership. The `action` to be provided can be either to propose transferring ownership to an account, accept a pending ownership transfer, or renounce the ownership permanently.", "type": "object", @@ -636,6 +679,36 @@ } }, "additionalProperties": false + }, + { + "description": "Returns the allowlist Addresses allowed to instantiate vesting contracts", + "type": "object", + "required": [ + "instantiate_allowlist" + ], + "properties": { + "instantiate_allowlist": { + "type": "object", + "properties": { + "limit": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + }, + "start_after": { + "type": [ + "string", + "null" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } ] }, @@ -649,6 +722,23 @@ "format": "uint64", "minimum": 0.0 }, + "instantiate_allowlist": { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Nullable_Array_of_Addr", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Addr" + }, + "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" + } + } + }, "list_vesting_contracts": { "$schema": "http://json-schema.org/draft-07/schema#", "title": "Array_of_VestingContract", diff --git a/contracts/external/cw-payroll-factory/src/contract.rs b/contracts/external/cw-payroll-factory/src/contract.rs index ff033174d..64e921c12 100644 --- a/contracts/external/cw-payroll-factory/src/contract.rs +++ b/contracts/external/cw-payroll-factory/src/contract.rs @@ -1,8 +1,8 @@ #[cfg(not(feature = "library"))] use cosmwasm_std::entry_point; use cosmwasm_std::{ - from_json, to_json_binary, Binary, CosmosMsg, Deps, DepsMut, Env, MessageInfo, Order, Reply, - Response, StdResult, SubMsg, WasmMsg, + from_json, to_json_binary, Binary, CosmosMsg, Deps, DepsMut, Empty, Env, MessageInfo, Order, + Reply, Response, StdResult, SubMsg, WasmMsg, }; use cosmwasm_std::{Addr, Coin}; @@ -19,8 +19,11 @@ use cw_vesting::msg::{ use cw_vesting::vesting::Vest; use crate::error::ContractError; -use crate::msg::{ExecuteMsg, InstantiateMsg, QueryMsg, ReceiveMsg}; -use crate::state::{vesting_contracts, VestingContract, TMP_INSTANTIATOR_INFO, VESTING_CODE_ID}; +use crate::msg::{ExecuteMsg, InstantiateMsg, MigrateMsg, QueryMsg, ReceiveMsg}; +use crate::state::{ + vesting_contracts, VestingContract, INSTANTIATE_ALLOWLIST, TMP_INSTANTIATOR_INFO, + VESTING_CODE_ID, +}; pub(crate) const CONTRACT_NAME: &str = "crates.io:cw-payroll-factory"; pub(crate) const CONTRACT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -31,16 +34,32 @@ pub const MAX_LIMIT: u32 = 50; #[cfg_attr(not(feature = "library"), entry_point)] pub fn instantiate( deps: DepsMut, - _env: Env, + env: Env, info: MessageInfo, msg: InstantiateMsg, ) -> Result { - cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; + let ownership = cw_ownable::initialize_owner(deps.storage, deps.api, msg.owner.as_deref())?; set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; VESTING_CODE_ID.save(deps.storage, &msg.vesting_code_id)?; + + let mut msgs = vec![]; + + if let Some(allowlist) = msg.instantiate_allowlist { + msgs.push(CosmosMsg::Wasm(WasmMsg::Execute { + contract_addr: env.contract.address.to_string(), + msg: to_json_binary(&ExecuteMsg::UpdateInstantiateAllowlist { + to_add: Some(allowlist), + to_remove: None, + })?, + funds: vec![], + })) + } + Ok(Response::new() + .add_messages(msgs) .add_attribute("method", "instantiate") - .add_attribute("creator", info.sender)) + .add_attribute("creator", info.sender) + .add_attributes(ownership.into_attributes())) } #[cfg_attr(not(feature = "library"), entry_point)] @@ -60,9 +79,54 @@ pub fn execute( ExecuteMsg::UpdateCodeId { vesting_code_id } => { execute_update_code_id(deps, info, vesting_code_id) } + ExecuteMsg::UpdateInstantiateAllowlist { to_add, to_remove } => { + execute_set_instantiate_allowlist(deps, env, info, to_add, to_remove) + } } } +pub fn execute_set_instantiate_allowlist( + deps: DepsMut, + env: Env, + info: MessageInfo, + to_add: Option>, + to_remove: Option>, +) -> Result { + if info.sender != env.contract.address { + cw_ownable::assert_owner(deps.storage, &info.sender)?; + } + + // Add new addresses + if let Some(add_list) = to_add.as_ref() { + for addr_str in add_list { + let addr = deps.api.addr_validate(addr_str)?; + + if !INSTANTIATE_ALLOWLIST.has(deps.storage, &addr) { + INSTANTIATE_ALLOWLIST.save(deps.storage, &addr, &Empty {})?; + } + } + } + + // Remove addresses + if let Some(remove_list) = to_remove.as_ref() { + for addr_str in remove_list { + let addr = deps.api.addr_validate(addr_str)?; + INSTANTIATE_ALLOWLIST.remove(deps.storage, &addr); + } + } + + Ok(Response::new() + .add_attribute("action", "set_instantiate_allowlist") + .add_attribute( + "added", + to_add.map_or_else(|| "none".to_string(), |v| v.join(", ")), + ) + .add_attribute( + "removed", + to_remove.map_or_else(|| "none".to_string(), |v| v.join(", ")), + )) +} + pub fn execute_receive_cw20( _env: Env, deps: DepsMut, @@ -121,15 +185,14 @@ pub fn instantiate_contract( instantiate_msg: PayrollInstantiateMsg, label: String, ) -> Result { - // Check sender is contract owner if set - let ownership = cw_ownable::get_ownership(deps.storage)?; - if ownership - .owner - .as_ref() - .map_or(false, |owner| *owner != sender) - { - return Err(ContractError::Unauthorized {}); - } + // Check sender is contract owner if set - or an allowlisted address + cw_ownable::assert_owner(deps.storage, &sender).or_else(|e| { + if INSTANTIATE_ALLOWLIST.has(deps.storage, &sender) { + Ok(()) + } else { + Err(ContractError::Ownable(e)) + } + })?; let code_id = VESTING_CODE_ID.load(deps.storage)?; @@ -291,6 +354,19 @@ pub fn query(deps: Deps, _env: Env, msg: QueryMsg) -> StdResult { } QueryMsg::Ownership {} => to_json_binary(&cw_ownable::get_ownership(deps.storage)?), QueryMsg::CodeId {} => to_json_binary(&VESTING_CODE_ID.load(deps.storage)?), + QueryMsg::InstantiateAllowlist { start_after, limit } => { + let start_after = start_after + .map(|x| deps.api.addr_validate(&x)) + .transpose()?; + + to_json_binary(&cw_paginate_storage::paginate_map( + deps, + &INSTANTIATE_ALLOWLIST, + start_after.as_ref(), + limit, + Order::Ascending, + )?) + } } } @@ -346,3 +422,9 @@ pub fn reply(deps: DepsMut, _env: Env, msg: Reply) -> Result Err(ContractError::UnknownReplyId { id: msg.id }), } } + +#[cfg_attr(not(feature = "library"), entry_point)] +pub fn migrate(deps: DepsMut, _env: Env, _msg: MigrateMsg) -> Result { + set_contract_version(deps.storage, CONTRACT_NAME, CONTRACT_VERSION)?; + Ok(Response::default()) +} diff --git a/contracts/external/cw-payroll-factory/src/error.rs b/contracts/external/cw-payroll-factory/src/error.rs index e251a2a9b..b31eb93ff 100644 --- a/contracts/external/cw-payroll-factory/src/error.rs +++ b/contracts/external/cw-payroll-factory/src/error.rs @@ -14,9 +14,6 @@ pub enum ContractError { #[error("{0}")] PaymentError(#[from] PaymentError), - #[error("Unauthorized")] - Unauthorized {}, - #[error("{0}")] ParseReplyError(#[from] ParseReplyError), diff --git a/contracts/external/cw-payroll-factory/src/msg.rs b/contracts/external/cw-payroll-factory/src/msg.rs index eacc5357e..48864168d 100644 --- a/contracts/external/cw-payroll-factory/src/msg.rs +++ b/contracts/external/cw-payroll-factory/src/msg.rs @@ -7,6 +7,7 @@ use cw_vesting::msg::InstantiateMsg as PayrollInstantiateMsg; pub struct InstantiateMsg { pub owner: Option, pub vesting_code_id: u64, + pub instantiate_allowlist: Option>, } #[cw_ownable_execute] @@ -23,6 +24,12 @@ pub enum ExecuteMsg { /// Callable only by the current owner. Updates the code ID used /// while instantiating vesting contracts. UpdateCodeId { vesting_code_id: u64 }, + + /// Callable only by the current owner. Updates the addresses that are allowed to instantiate vesting contracts. + UpdateInstantiateAllowlist { + to_add: Option>, + to_remove: Option>, + }, } // Receiver setup @@ -85,4 +92,17 @@ pub enum QueryMsg { /// Returns the code ID currently being used to instantiate vesting contracts. #[returns(::std::primitive::u64)] CodeId {}, + + /// Returns the allowlist + /// Addresses allowed to instantiate vesting contracts + #[returns(Option>)] + InstantiateAllowlist { + start_after: Option, + limit: Option, + }, +} + +#[cw_serde] +pub enum MigrateMsg { + FromCompatible {}, } diff --git a/contracts/external/cw-payroll-factory/src/state.rs b/contracts/external/cw-payroll-factory/src/state.rs index c65514faa..e5d6e5820 100644 --- a/contracts/external/cw-payroll-factory/src/state.rs +++ b/contracts/external/cw-payroll-factory/src/state.rs @@ -1,10 +1,11 @@ use cosmwasm_schema::cw_serde; -use cosmwasm_std::Addr; -use cw_storage_plus::{Index, IndexList, IndexedMap, Item, MultiIndex}; +use cosmwasm_std::{Addr, Empty}; +use cw_storage_plus::{Index, IndexList, IndexedMap, Item, Map, MultiIndex}; /// Temporarily holds the address of the instantiator for use in submessages pub const TMP_INSTANTIATOR_INFO: Item = Item::new("tmp_instantiator_info"); pub const VESTING_CODE_ID: Item = Item::new("pci"); +pub const INSTANTIATE_ALLOWLIST: Map<&Addr, Empty> = Map::new("instantiate_allowlist"); #[cw_serde] pub struct VestingContract { diff --git a/contracts/external/cw-payroll-factory/src/tests.rs b/contracts/external/cw-payroll-factory/src/tests.rs index 1feffd463..a2f0745cc 100644 --- a/contracts/external/cw-payroll-factory/src/tests.rs +++ b/contracts/external/cw-payroll-factory/src/tests.rs @@ -57,6 +57,7 @@ pub fn test_instantiate_native_payroll_contract() { let instantiate = InstantiateMsg { owner: Some(ALICE.to_string()), vesting_code_id: cw_vesting_code_id, + instantiate_allowlist: None, }; let factory_addr = app .instantiate_contract( @@ -124,7 +125,10 @@ pub fn test_instantiate_native_payroll_contract() { .unwrap_err() .downcast() .unwrap(); - assert_eq!(err, ContractError::Unauthorized {}); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); // Get the payroll address from the instantiate event let instantiate_event = &res.events[2]; @@ -240,6 +244,7 @@ pub fn test_instantiate_cw20_payroll_contract() { let instantiate = InstantiateMsg { owner: Some(ALICE.to_string()), vesting_code_id: cw_vesting_code_id, + instantiate_allowlist: None, }; let factory_addr = app .instantiate_contract( @@ -369,6 +374,7 @@ fn test_instantiate_wrong_ownership_native() { let instantiate = InstantiateMsg { owner: Some(ALICE.to_string()), vesting_code_id: cw_vesting_code_id, + instantiate_allowlist: None, }; let factory_addr = app .instantiate_contract( @@ -407,7 +413,10 @@ fn test_instantiate_wrong_ownership_native() { .unwrap(); // Can't instantiate if you are not the owner. - assert_eq!(err, ContractError::Unauthorized {}); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); } #[test] @@ -421,6 +430,7 @@ fn test_update_vesting_code_id() { let instantiate = InstantiateMsg { owner: Some(ALICE.to_string()), vesting_code_id: cw_vesting_code_id, + instantiate_allowlist: None, }; let factory_addr = app .instantiate_contract( @@ -539,6 +549,7 @@ pub fn test_inconsistent_cw20_amount() { let instantiate = InstantiateMsg { owner: Some(ALICE.to_string()), vesting_code_id: cw_vesting_code_id, + instantiate_allowlist: None, }; let factory_addr = app .instantiate_contract( @@ -598,3 +609,141 @@ pub fn test_inconsistent_cw20_amount() { } ); } + +#[test] +pub fn test_instantiate_allowlist() { + let mut app = App::default(); + let code_id = app.store_code(factory_contract()); + let cw_vesting_code_id = app.store_code(cw_vesting_contract()); + + // Define allowlist + let allowlist = vec![BOB.to_string(), "charlie".to_string()]; + + // Instantiate factory with Alice as owner and Bob and Charlie in the allowlist + let instantiate = InstantiateMsg { + owner: Some(ALICE.to_string()), + vesting_code_id: cw_vesting_code_id, + instantiate_allowlist: Some(allowlist.clone()), + }; + let factory_addr = app + .instantiate_contract( + code_id, + Addr::unchecked("CREATOR"), + &instantiate, + &[], + "cw-admin-factory", + None, + ) + .unwrap(); + + // Mint tokens for testing + for address in &[ALICE, BOB, "charlie", "dave"] { + app.sudo(SudoMsg::Bank({ + BankSudo::Mint { + to_address: address.to_string(), + amount: coins(INITIAL_BALANCE, NATIVE_DENOM), + } + })) + .unwrap(); + } + + let amount = Uint128::new(1000000); + let unchecked_denom = UncheckedDenom::Native(NATIVE_DENOM.to_string()); + + let instantiate_payroll_msg = ExecuteMsg::InstantiateNativePayrollContract { + instantiate_msg: PayrollInstantiateMsg { + owner: None, + recipient: "recipient".to_string(), + title: "title".to_string(), + description: Some("desc".to_string()), + total: amount, + denom: unchecked_denom.clone(), + schedule: Schedule::SaturatingLinear, + vesting_duration_seconds: 200, + unbonding_duration_seconds: 2592000, // 30 days + start_time: None, + }, + label: "Payroll".to_string(), + }; + + // Test: Alice (owner) can instantiate + app.execute_contract( + Addr::unchecked(ALICE), + factory_addr.clone(), + &instantiate_payroll_msg, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap(); + + // Test: Bob (in allowlist) can instantiate + app.execute_contract( + Addr::unchecked(BOB), + factory_addr.clone(), + &instantiate_payroll_msg, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap(); + + // Test: Charlie (in allowlist) can instantiate + app.execute_contract( + Addr::unchecked("charlie"), + factory_addr.clone(), + &instantiate_payroll_msg, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap(); + + // Test: Dave (not in allowlist) cannot instantiate + let err: ContractError = app + .execute_contract( + Addr::unchecked("dave"), + factory_addr.clone(), + &instantiate_payroll_msg, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); + + // Test: Update allowlist + let update_msg = ExecuteMsg::UpdateInstantiateAllowlist { + to_add: Some(vec!["dave".to_string()]), + to_remove: Some(vec!["charlie".to_string()]), + }; + app.execute_contract( + Addr::unchecked(ALICE), + factory_addr.clone(), + &update_msg, + &[], + ) + .unwrap(); + + // Test: Dave (now in allowlist) can instantiate + app.execute_contract( + Addr::unchecked("dave"), + factory_addr.clone(), + &instantiate_payroll_msg, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap(); + + // Test: Charlie (removed from allowlist) cannot instantiate + let err: ContractError = app + .execute_contract( + Addr::unchecked("charlie"), + factory_addr, + &instantiate_payroll_msg, + &coins(amount.u128(), NATIVE_DENOM), + ) + .unwrap_err() + .downcast() + .unwrap(); + assert_eq!( + err, + ContractError::Ownable(cw_ownable::OwnershipError::NotOwner) + ); +}