diff --git a/concordium-cis2/CHANGELOG.md b/concordium-cis2/CHANGELOG.md index 7a873d30d..092f91703 100644 --- a/concordium-cis2/CHANGELOG.md +++ b/concordium-cis2/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## Unreleased changes +- Add `Clone` trait for `StandardIdentifierOwned`. - Add specific parameter type `OnReceivingCis2DataParams` for a contract function which receives CIS2 tokens with a specific type D for the AdditionalData. - Add `Deserial` trait for `OnReceivingCis2DataParams`. - Add `Serial` trait for `OnReceivingCis2DataParams`. diff --git a/concordium-cis2/src/lib.rs b/concordium-cis2/src/lib.rs index 68188b543..2db2c00b9 100644 --- a/concordium-cis2/src/lib.rs +++ b/concordium-cis2/src/lib.rs @@ -1336,7 +1336,7 @@ impl<'a> StandardIdentifier<'a> { /// Consists of a string of ASCII characters up to a length of 255. /// /// See [StandardIdentifier] for the borrowed version. -#[derive(Debug, Serialize, PartialEq, Eq, SchemaType)] +#[derive(Debug, Serialize, PartialEq, Eq, SchemaType, Clone)] #[concordium(transparent)] pub struct StandardIdentifierOwned { #[concordium(size_length = 1)] diff --git a/examples/cis2-multi/src/lib.rs b/examples/cis2-multi/src/lib.rs index 4b00bde3c..62ac6b663 100644 --- a/examples/cis2-multi/src/lib.rs +++ b/examples/cis2-multi/src/lib.rs @@ -6,15 +6,10 @@ //! types each identified by a token ID. A token type is then globally //! identified by the contract address together with the token ID. //! -//! In this example the contract is initialized with no tokens, and tokens can -//! be minted through a `mint` contract function, which can be called by anyone. -//! The `mint` function airdrops the `MINT_AIRDROP` amount of tokens to a -//! specified `owner` address in the input parameter. No functionality to burn -//! token is defined in this example. -//! //! Note: The word 'address' refers to either an account address or a //! contract address. //! +//! ## CIS3 standard (sponsored transactions): //! This contract implements the Concordium Token Standard CIS2. In addition, it //! implements the CIS3 standard which includes features for sponsored //! transactions. @@ -30,21 +25,10 @@ //! service provider submits the transaction on behalf of the user and pays the //! transaction fee to execute the transaction on-chain. //! -//! As follows from the CIS2 specification, the contract has a `transfer` -//! function for transferring an amount of a specific token type from one -//! address to another address. An address can enable and disable one or more -//! addresses as operators with the `updateOperator` function. An operator of -//! an address is allowed to transfer any tokens owned by this address. //! As follows from the CIS3 specification, the contract has a `permit` -//! function. It is the sponsored counterpart to the `transfer/updateOperator` -//! function and can be executed by anyone on behalf of an account given a -//! signed message. -//! -//! This contract also contains an example of a function to be called when -//! receiving tokens. In which case the contract will forward the tokens to -//! the contract owner. -//! This function is not very useful and is only there to showcase a simple -//! implementation of a token receive hook. +//! function. It is the sponsored counterpart to the +//! `transfer/updateOperator/mint/burn` function and can be executed by anyone +//! on behalf of an account given a signed message. //! //! Concordium supports natively multi-sig accounts. Each account address on //! Concordium is controlled by one or several credential(s) (real-world @@ -62,7 +46,75 @@ //! (that have exactly one credential and exactly one public key for that //! credential), the signaturesMaps/publicKeyMaps in this contract, will have //! only one value at key 0 in the inner and outer maps. - +//! +//! ## `Transfer` and `updateOperator` functions: +//! As follows from the CIS2 specification, the contract has a `transfer` +//! function for transferring an amount of a specific token type from one +//! address to another address. An address can enable and disable one or more +//! addresses as operators with the `updateOperator` function. An operator of +//! an address is allowed to transfer any tokens owned by this address. +//! +//! ## `Mint` and `burn` functions: +//! In this example, the contract is initialized with no tokens, and tokens can +//! be minted through a `mint` contract function, which can be called by anyone +//! (unprotected function). The `mint` function airdrops the `MINT_AIRDROP` +//! amount of tokens to a specified `owner` address in the input parameter. +//! ATTENTION: You most likely want to add your custom access control mechanism +//! to the `mint` function and `permit` function. +//! +//! A token owner (or any of its operator addresses) can burn some of the token +//! owner's tokens by invoking the `burn` function. +//! +//! ## `onReceivingCIS2` functions: +//! This contract also contains an example of a function to be called when +//! receiving tokens. In which case the contract will forward the tokens to +//! the contract owner. +//! This function is not very useful and is only there to showcase a simple +//! implementation of a token receive hook. +//! +//! ## Grant and Revoke roles: +//! The contract has access control roles. The admin can grant or revoke all +//! other roles. The available roles are ADMIN (can grant/revoke roles), +//! UPGRADER (can upgrade the contract), BLACKLISTER (can blacklist addresses), +//! and PAUSER (can pause the contract). +//! +//! ## Blacklist: +//! This contract includes a blacklist. The account with `BLACKLISTER` role can +//! add/remove addresses to/from that list. Blacklisted addresses can not +//! transfer their tokens, receive new tokens, or burn their tokens. +//! +//! ## Pausing: +//! This contract can be paused by an address with the PAUSER role. +//! `Mint,burn,updateOperator,transfer` entrypoints cannot be called when the +//! contract is paused. +//! +//! ## Native upgradability: +//! This contract can be upgraded. The contract +//! has a function to upgrade the smart contract instance to a new module and +//! call optionally a migration function after the upgrade. To use this example, +//! deploy `contract-version1` and then upgrade the smart contract instance to +//! `contract-version2` by invoking the `upgrade` function with the below JSON +//! inputParameter: +//! +//! ```json +//! { +//! "migrate": { +//! "Some": [ +//! [ +//! "migration", +//! "" +//! ] +//! ] +//! }, +//! "module": "" +//! } +//! ``` +//! +//! This initial contract (`contract-version1`) has no migration function. +//! The next version of the contract (`contract-version2`), could have a +//! migration function added to e.g. change the shape of the smart contract +//! state from `contract-version1` to `contract-version2`. +//! https://github.com/Concordium/concordium-rust-smart-contracts/blob/main/examples/smart-contract-upgrade/contract-version2/src/lib.rs #![cfg_attr(not(feature = "std"), no_std)] use concordium_cis2::*; use concordium_std::{collections::BTreeMap, EntrypointName, *}; @@ -75,13 +127,33 @@ const SUPPORTS_STANDARDS: [StandardIdentifier<'static>; 2] = const SUPPORTS_PERMIT_ENTRYPOINTS: [EntrypointName; 2] = [EntrypointName::new_unchecked("updateOperator"), EntrypointName::new_unchecked("transfer")]; -/// Tag for the CIS3 Nonce event. -pub const NONCE_EVENT_TAG: u8 = u8::MAX - 5; +/// Event tags. +pub const UPDATE_BLACKLIST_EVENT_TAG: u8 = 0; +pub const GRANT_ROLE_EVENT_TAG: u8 = 1; +pub const REVOKE_ROLE_EVENT_TAG: u8 = 2; +pub const NONCE_EVENT_TAG: u8 = 250; + +const TRANSFER_ENTRYPOINT: EntrypointName<'_> = EntrypointName::new_unchecked("transfer"); +const UPDATE_OPERATOR_ENTRYPOINT: EntrypointName<'_> = + EntrypointName::new_unchecked("updateOperator"); +const MINT_ENTRYPOINT: EntrypointName<'_> = EntrypointName::new_unchecked("mint"); +const BURN_ENTRYPOINT: EntrypointName<'_> = EntrypointName::new_unchecked("burn"); /// Tagged events to be serialized for the event log. #[derive(Debug, Serial, Deserial, PartialEq, Eq)] #[concordium(repr(u8))] pub enum Event { + /// The event is logged whenever an address is added or removed from the + /// blacklist. + #[concordium(tag = 0)] + UpdateBlacklist(UpdateBlacklistEvent), + /// The event tracks when a new role is granted to an address. + #[concordium(tag = 1)] + GrantRole(GrantRoleEvent), + /// The event tracks when a role is revoked from an address. + #[concordium(tag = 2)] + RevokeRole(RevokeRoleEvent), + /// Cis3 event. /// The event tracks the nonce used by the signer of the `PermitMessage` /// whenever the `permit` function is invoked. #[concordium(tag = 250)] @@ -101,6 +173,34 @@ pub struct NonceEvent { pub nonce: u64, } +/// The UpdateBlacklistEvent is logged when an address is added to or removed +/// from the blacklist. +#[derive(Debug, Serialize, SchemaType, PartialEq, Eq)] +pub struct UpdateBlacklistEvent { + /// The update to the address. + pub update: BlacklistUpdate, + /// The address which is added/removed from the blacklist. + pub address: Address, +} + +/// The GrantRoleEvent is logged when a new role is granted to an address. +#[derive(Serialize, SchemaType, Debug, PartialEq, Eq)] +pub struct GrantRoleEvent { + /// The address that has been its role granted. + pub address: Address, + /// The role that was granted to the above address. + pub role: Roles, +} + +/// The RevokeRoleEvent is logged when a role is revoked from an address. +#[derive(Serialize, SchemaType, Debug, PartialEq, Eq)] +pub struct RevokeRoleEvent { + /// Address that has been its role revoked. + pub address: Address, + /// The role that was revoked from the above address. + pub role: Roles, +} + // Implementing a custom schemaType for the `Event` struct containing all // CIS2/CIS3 events. This custom implementation flattens the fields to avoid one // level of nesting. Deriving the schemaType would result in e.g.: {"Nonce": @@ -119,6 +219,36 @@ impl schema::SchemaType for Event { ]), ), ); + event_map.insert( + GRANT_ROLE_EVENT_TAG, + ( + "GrantRole".to_string(), + schema::Fields::Named(vec![ + (String::from("address"), Address::get_type()), + (String::from("role"), Roles::get_type()), + ]), + ), + ); + event_map.insert( + REVOKE_ROLE_EVENT_TAG, + ( + "RevokeRole".to_string(), + schema::Fields::Named(vec![ + (String::from("address"), Address::get_type()), + (String::from("role"), Roles::get_type()), + ]), + ), + ); + event_map.insert( + UPDATE_BLACKLIST_EVENT_TAG, + ( + "UpdateBlacklist".to_string(), + schema::Fields::Named(vec![ + (String::from("update"), BlacklistUpdate::get_type()), + (String::from("address"), Address::get_type()), + ]), + ), + ); event_map.insert( TRANSFER_EVENT_TAG, ( @@ -200,49 +330,18 @@ pub struct MintParams { pub token_id: ContractTokenId, } -/// The state for each address. -#[derive(Serial, DeserialWithState, Deletable)] -#[concordium(state_parameter = "S")] -struct AddressState { - /// The amount of tokens owned by this address. - balances: StateMap, - /// The addresses which are currently enabled as operators for this address. - operators: StateSet, -} - -impl AddressState { - fn empty(state_builder: &mut StateBuilder) -> Self { - AddressState { - balances: state_builder.new_map(), - operators: state_builder.new_set(), - } - } +/// The parameter for the contract function `burn` which burns a number +/// of tokens from the owner's address. +#[derive(Serialize, SchemaType)] +pub struct BurnParams { + /// Owner of the tokens. + pub owner: Address, + /// The amount of tokens to burn. + pub amount: ContractTokenAmount, + /// The token_id of the tokens to be burned. + pub token_id: ContractTokenId, } -/// The contract state, -/// -/// Note: The specification does not specify how to structure the contract state -/// and this could be structured in a more space-efficient way. -#[derive(Serial, DeserialWithState)] -#[concordium(state_parameter = "S")] -struct State { - /// The state of addresses. - state: StateMap, S>, - /// All of the token IDs. - tokens: StateMap, - /// Map with contract addresses providing implementations of additional - /// standards. - implementors: StateMap, S>, - /// A registry to link an account to its next nonce. The nonce is used to - /// prevent replay attacks of the signed message. The nonce is increased - /// sequentially every time a signed message (corresponding to the - /// account) is successfully executed in the `permit` function. This - /// mapping keeps track of the next nonce that needs to be used by the - /// account to generate a signature. - nonces_registry: StateMap, - /// The amount of tokens airdropped when the mint function is invoked. - mint_airdrop: TokenAmountU64, -} /// The parameter type for the contract function `supportsPermit`. #[derive(Debug, Serialize, SchemaType)] pub struct SupportsPermitQueryParams { @@ -295,9 +394,124 @@ pub struct PermitParam { #[derive(Serialize)] pub struct PermitParamPartial { /// Signature/s. The CIS3 standard supports multi-sig accounts. - signature: AccountSignatures, + pub signature: AccountSignatures, /// Account that created the above signature. - signer: AccountAddress, + pub signer: AccountAddress, +} + +/// The parameter type for the contract function `setPaused`. +#[derive(Serialize, SchemaType)] +#[repr(transparent)] +pub struct SetPausedParams { + /// Specifies if contract is paused. + pub paused: bool, +} + +/// The parameter type for the contract function `upgrade`. +/// Takes the new module and optionally an entrypoint to call in the new module +/// after triggering the upgrade. The upgrade is reverted if the entrypoint +/// fails. This is useful for doing migration in the same transaction triggering +/// the upgrade. +#[derive(Serialize, SchemaType)] +pub struct UpgradeParams { + /// The new module reference. + pub module: ModuleReference, + /// Optional entrypoint to call in the new module after upgrade. + pub migrate: Option<(OwnedEntrypointName, OwnedParameter)>, +} + +/// The parameter for the contract function `grantRole` which grants a role to +/// an address. +#[derive(Serialize, SchemaType)] +pub struct GrantRoleParams { + /// The address that has been its role granted. + pub address: Address, + /// The role that has been granted to the above address. + pub role: Roles, +} + +/// The parameter for the contract function `revokeRole` which revokes a role +/// from an address. +#[derive(Serialize, SchemaType)] +pub struct RevokeRoleParams { + /// The address that has been its role revoked. + pub address: Address, + /// The role that has been revoked from the above address. + pub role: Roles, +} + +/// A struct containing a set of roles granted to an address. +#[derive(Serial, DeserialWithState, Deletable)] +#[concordium(state_parameter = "S")] +struct AddressRoleState { + /// Set of roles. + roles: StateSet, +} + +/// Enum of available roles in this contract. +#[derive(Serialize, PartialEq, Eq, Reject, SchemaType, Clone, Copy, Debug)] +pub enum Roles { + /// Admin role. + ADMIN, + /// Upgrader role. + UPGRADER, + /// Blacklister role. + BLACKLISTER, + /// Pauser role. + PAUSER, + // /// Minter role. (comment in if you want a MINTER role) + // MINTER, +} + +/// The state for each address. +#[derive(Serial, DeserialWithState, Deletable)] +#[concordium(state_parameter = "S")] +struct AddressState { + /// The amount of tokens owned by this address. + balances: StateMap, + /// The addresses which are currently enabled as operators for this address. + operators: StateSet, +} + +impl AddressState { + fn empty(state_builder: &mut StateBuilder) -> Self { + AddressState { + balances: state_builder.new_map(), + operators: state_builder.new_set(), + } + } +} + +/// The contract state, +/// +/// Note: The specification does not specify how to structure the contract state +/// and this could be structured in a more space-efficient way. +#[derive(Serial, DeserialWithState)] +#[concordium(state_parameter = "S")] +struct State { + /// The state of addresses. + state: StateMap, S>, + /// All of the token IDs. + tokens: StateMap, + /// A map with contract addresses providing implementations of additional + /// standards. + implementors: StateMap, S>, + /// A registry to link an account to its next nonce. The nonce is used to + /// prevent replay attacks of the signed message. The nonce is increased + /// sequentially every time a signed message (corresponding to the + /// account) is successfully executed in the `permit` function. This + /// mapping keeps track of the next nonce that needs to be used by the + /// account to generate a signature. + nonces_registry: StateMap, + /// Set of addresses that are not allowed to receive new tokens, sent + /// their tokens, or burn their tokens. + blacklist: StateSet, + /// The amount of tokens airdropped when the mint function is invoked. + mint_airdrop: ContractTokenAmount, + /// Specifies if the contract is paused. + paused: bool, + /// A map containing all roles granted to addresses. + roles: StateMap, S>, } /// The different errors the contract can produce. @@ -333,12 +547,42 @@ pub enum CustomContractError { WrongEntryPoint, // -12 /// Failed signature verification: Signature is expired. Expired, // -13 + /// Token owner address is blacklisted. + Blacklisted, // -14 + /// Account address has no canonical address. + NoCanonicalAddress, // -15 + /// Upgrade failed because the new module does not exist. + FailedUpgradeMissingModule, // -16 + /// Upgrade failed because the new module does not contain a contract with a + /// matching name. + FailedUpgradeMissingContract, // -17 + /// Upgrade failed because the smart contract version of the module is not + /// supported. + FailedUpgradeUnsupportedModuleVersion, // -18 + /// Contract is paused. + Paused, // -19 + /// Failed to revoke role because it was not granted in the first place. + RoleWasNotGranted, // -20 + /// Failed to grant role because it was granted already in the first place. + RoleWasAlreadyGranted, // -21 } pub type ContractError = Cis2Error; pub type ContractResult = Result; +/// Mapping errors related to contract upgrades to CustomContractError. +impl From for CustomContractError { + #[inline(always)] + fn from(ue: UpgradeError) -> Self { + match ue { + UpgradeError::MissingModule => Self::FailedUpgradeMissingModule, + UpgradeError::MissingContract => Self::FailedUpgradeMissingContract, + UpgradeError::UnsupportedModuleVersion => Self::FailedUpgradeUnsupportedModuleVersion, + } + } +} + /// Mapping account signature error to CustomContractError impl From for CustomContractError { fn from(e: CheckAccountSignatureError) -> Self { @@ -371,13 +615,16 @@ impl From for ContractError { impl State { /// Construct a state with no tokens - fn empty(state_builder: &mut StateBuilder, mint_airdrop: TokenAmountU64) -> Self { + fn empty(state_builder: &mut StateBuilder, mint_airdrop: ContractTokenAmount) -> Self { State { state: state_builder.new_map(), tokens: state_builder.new_map(), implementors: state_builder.new_map(), nonces_registry: state_builder.new_map(), mint_airdrop, + blacklist: state_builder.new_set(), + paused: false, + roles: state_builder.new_map(), } } @@ -390,7 +637,7 @@ impl State { token_id: &ContractTokenId, metadata_url: &MetadataUrl, owner: &Address, - mint_airdrop: TokenAmountU64, + mint_airdrop: ContractTokenAmount, state_builder: &mut StateBuilder, ) -> MetadataUrl { let token_metadata = self.tokens.get(token_id).map(|x| x.to_owned()); @@ -410,6 +657,25 @@ impl State { } } + /// Burns an amount of tokens from a given owner address. + fn burn( + &mut self, + token_id: &ContractTokenId, + amount: ContractTokenAmount, + owner: &Address, + ) -> ContractResult<()> { + ensure!(self.contains_token(token_id), ContractError::InvalidTokenId); + + let mut owner_state = + self.state.entry(*owner).occupied_or(ContractError::InsufficientFunds)?; + + let mut owner_balance = owner_state.balances.entry(*token_id).or_insert(0.into()); + ensure!(*owner_balance >= amount, ContractError::InsufficientFunds); + *owner_balance -= amount; + + Ok(()) + } + /// Check that the token ID currently exists in this contract. #[inline(always)] fn contains_token(&self, token_id: &ContractTokenId) -> bool { @@ -499,6 +765,14 @@ impl State { }); } + /// Update the state adding a new address to the blacklist. + /// Succeeds even if the `address` is already in the blacklist. + fn add_blacklist(&mut self, address: Address) { self.blacklist.insert(address); } + + /// Update the state removing an address from the blacklist. + /// Succeeds even if the `address` is not in the list. + fn remove_blacklist(&mut self, address: &Address) { self.blacklist.remove(address); } + /// Check if state contains any implementors for a given standard. fn have_implementors(&self, std_id: &StandardIdentifierOwned) -> SupportResult { if let Some(addresses) = self.implementors.get(std_id) { @@ -516,6 +790,46 @@ impl State { ) { self.implementors.insert(std_id, implementors); } + + /// Grant role to an address. + fn grant_role(&mut self, account: &Address, role: Roles, state_builder: &mut StateBuilder) { + self.roles.entry(*account).or_insert_with(|| AddressRoleState { + roles: state_builder.new_set(), + }); + + self.roles.entry(*account).and_modify(|entry| { + entry.roles.insert(role); + }); + } + + /// Revoke role from an address. + fn revoke_role(&mut self, account: &Address, role: Roles) { + self.roles.entry(*account).and_modify(|entry| { + entry.roles.remove(&role); + }); + } + + /// Check if an address has an role. + fn has_role(&self, account: &Address, role: Roles) -> bool { + return match self.roles.get(account) { + None => false, + Some(roles) => roles.roles.contains(&role), + }; + } +} + +/// Convert the address into its canonical account address (in case it is an +/// account address). Account addresses on Concordium have account aliases. We +/// call the alias 0 for every account the canonical account address. +/// https://developer.concordium.software/en/mainnet/net/references/transactions.html#account-aliases +fn get_canonical_address(address: Address) -> ContractResult
{ + let canonical_address = match address { + Address::Account(account) => { + Address::Account(account.get_alias(0).ok_or(CustomContractError::NoCanonicalAddress)?) + } + Address::Contract(contract) => Address::Contract(contract), + }; + Ok(canonical_address) } // Contract functions @@ -523,15 +837,32 @@ impl State { /// Initialize contract instance with no token types. #[init( contract = "cis2_multi", + parameter = "ContractTokenAmount", event = "Cis2Event", - parameter = "TokenAmountU64" + enable_logger )] -fn contract_init(ctx: &InitContext, state_builder: &mut StateBuilder) -> InitResult { +fn contract_init( + ctx: &InitContext, + state_builder: &mut StateBuilder, + logger: &mut impl HasLogger, +) -> InitResult { // Parse the parameter. - let mint_airdrop: TokenAmountU64 = ctx.parameter_cursor().get()?; + let mint_airdrop: ContractTokenAmount = ctx.parameter_cursor().get()?; // Construct the initial contract state. - Ok(State::empty(state_builder, mint_airdrop)) + let mut state = State::empty(state_builder, mint_airdrop); + + // Get the instantiater of this contract instance. + let invoker = Address::Account(ctx.init_origin()); + + // Grant ADMIN role. + state.grant_role(&invoker, Roles::ADMIN, state_builder); + logger.log(&Event::GrantRole(GrantRoleEvent { + address: invoker, + role: Roles::ADMIN, + }))?; + + Ok(state) } #[derive(Serialize, SchemaType, PartialEq, Eq, Debug)] @@ -542,8 +873,14 @@ pub struct ViewAddressState { #[derive(Serialize, SchemaType, PartialEq, Eq)] pub struct ViewState { - pub state: Vec<(Address, ViewAddressState)>, - pub tokens: Vec, + pub state: Vec<(Address, ViewAddressState)>, + pub tokens: Vec, + pub nonces_registry: Vec<(AccountAddress, u64)>, + pub blacklist: Vec
, + pub roles: Vec<(Address, Vec)>, + pub mint_airdrop: ContractTokenAmount, + pub paused: bool, + pub implementors: Vec<(StandardIdentifierOwned, Vec)>, } /// View function for testing. This reports on the entire state of the contract @@ -552,36 +889,111 @@ pub struct ViewState { fn contract_view(_ctx: &ReceiveContext, host: &Host) -> ReceiveResult { let state = host.state(); - let mut inner_state = Vec::new(); - for (k, a_state) in state.state.iter() { - let mut balances = Vec::new(); - let mut operators = Vec::new(); - for (token_id, amount) in a_state.balances.iter() { - balances.push((*token_id, *amount)); - } - for o in a_state.operators.iter() { - operators.push(*o); - } - - inner_state.push((*k, ViewAddressState { - balances, - operators, - })); - } - let mut tokens = Vec::new(); - for v in state.tokens.iter() { - tokens.push(*v.0); - } + let contract_state = state + .state + .iter() + .map(|(key, value)| { + let mut balances = Vec::new(); + let mut operators = Vec::new(); + for (token_id, amount) in value.balances.iter() { + balances.push((*token_id, *amount)); + } + for operator in value.operators.iter() { + operators.push(*operator); + } + (*key, ViewAddressState { + balances, + operators, + }) + }) + .collect(); + + let tokens = state.tokens.iter().map(|a| *a.0).collect(); + let nonces_registry = state.nonces_registry.iter().map(|(a, b)| (*a, *b)).collect(); + let blacklist = state.blacklist.iter().map(|a| *a).collect(); + let roles: Vec<(Address, Vec)> = state + .roles + .iter() + .map(|(key, value)| { + let mut roles_vec = Vec::new(); + for role in value.roles.iter() { + roles_vec.push(*role); + } + (*key, roles_vec) + }) + .collect(); + + let implementors: Vec<(StandardIdentifierOwned, Vec)> = state + .implementors + .iter() + .map(|(key, value)| { + let mut implementors = Vec::new(); + for test in value.iter() { + implementors.push(*test); + } + + ((*key).clone(), implementors) + }) + .collect(); Ok(ViewState { - state: inner_state, + state: contract_state, tokens, + nonces_registry, + blacklist, + roles, + implementors, + mint_airdrop: host.state().mint_airdrop, + paused: host.state().paused, }) } +/// Internal `mint/permit` helper function. Invokes the `mint` +/// function of the state. Logs a `Mint` event. +/// The function assumes that the mint is authorized. +fn mint( + params: MintParams, + host: &mut Host, + logger: &mut impl HasLogger, +) -> ContractResult<()> { + let is_blacklisted = host.state().blacklist.contains(&get_canonical_address(params.owner)?); + + // Check token owner is not blacklisted. + ensure!(!is_blacklisted, CustomContractError::Blacklisted.into()); + + // Check that contract is not paused. + ensure!(!host.state().paused, CustomContractError::Paused.into()); + + let (state, builder) = host.state_and_builder(); + + // Mint the token in the state. + let token_metadata = state.mint( + ¶ms.token_id, + ¶ms.metadata_url, + ¶ms.owner, + state.mint_airdrop, + builder, + ); + + // Event for minted token. + logger.log(&Cis2Event::Mint(MintEvent { + token_id: params.token_id, + amount: state.mint_airdrop, + owner: params.owner, + }))?; + + // Metadata URL for the token. + logger.log(&Cis2Event::TokenMetadata::<_, ContractTokenAmount>(TokenMetadataEvent { + token_id: params.token_id, + metadata_url: token_metadata, + }))?; + + Ok(()) +} + /// Mint/Airdrops the fixed amount of `MINT_AIRDROP` of new tokens to the /// `owner` address. ATTENTION: Can be called by anyone. You should add your -/// custom access control to this function. +/// custom access control to this function and the permit function. /// /// Logs a `Mint` and a `TokenMetadata` event for each token. /// The metadata_url in the input parameter of the token is ignored except for @@ -589,6 +1001,8 @@ fn contract_view(_ctx: &ReceiveContext, host: &Host) -> ReceiveResult, + logger: &mut impl HasLogger, +) -> ContractResult<()> { + // Check that contract is not paused. + ensure!(!host.state().paused, CustomContractError::Paused.into()); + + let is_blacklisted = host.state().blacklist.contains(&get_canonical_address(params.owner)?); + + // Check token owner is not blacklisted. + ensure!(!is_blacklisted, CustomContractError::Blacklisted.into()); + + // Burn the token in the state. + host.state_mut().burn(¶ms.token_id, params.amount, ¶ms.owner)?; + + // Event for burned tokens. + logger.log(&Cis2Event::Burn(BurnEvent { token_id: params.token_id, - amount: state.mint_airdrop, + amount: params.amount, owner: params.owner, }))?; - // Metadata URL for the token. - logger.log(&Cis2Event::TokenMetadata::<_, ContractTokenAmount>(TokenMetadataEvent { - token_id: params.token_id, - metadata_url: token_metadata, - }))?; + Ok(()) +} + +/// Burns an amount of tokens from the +/// `owner` address. +/// +/// Logs a `Burn` event for each token. +/// +/// It rejects if: +/// - Fails to parse parameter. +/// - The sender is not the token owner or an operator of the token owner. +/// - The owner is blacklisted. +/// - The contract is paused. +/// - The token owner owns an insufficient token amount to burn from. +/// - Fails to log Burn event. +#[receive( + contract = "cis2_multi", + name = "burn", + parameter = "BurnParams", + error = "ContractError", + enable_logger, + mutable +)] +fn contract_burn( + ctx: &ReceiveContext, + host: &mut Host, + logger: &mut impl HasLogger, +) -> ContractResult<()> { + // Parse the parameter. + let params: BurnParams = ctx.parameter_cursor().get()?; + + // Get the sender who invoked this contract function. + let sender = ctx.sender(); + + // Authenticate the sender for the token burns. + ensure!( + params.owner == sender || host.state().is_operator(&sender, ¶ms.owner), + ContractError::Unauthorized + ); + + burn(params, host, logger)?; + Ok(()) } @@ -642,9 +1116,25 @@ fn transfer( host: &mut Host, logger: &mut impl HasLogger, ) -> ContractResult<()> { + let to_address = transfer.to.address(); + + // Check token receiver is not blacklisted. + ensure!( + !host.state().blacklist.contains(&get_canonical_address(to_address)?), + CustomContractError::Blacklisted.into() + ); + + // Check token owner is not blacklisted. + ensure!( + !host.state().blacklist.contains(&get_canonical_address(transfer.from)?), + CustomContractError::Blacklisted.into() + ); + + // Check that contract is not paused. + ensure!(!host.state().paused, CustomContractError::Paused.into()); + let (state, builder) = host.state_and_builder(); - let to_address = transfer.to.address(); // Update the contract state state.transfer(&transfer.token_id, transfer.amount, &transfer.from, &to_address, builder)?; @@ -677,6 +1167,8 @@ fn transfer( /// /// It rejects if: /// - It fails to parse the parameter. +/// - Either the from or to addresses are blacklisted. +/// - The contract is paused. /// - Any of the transfers fail to be executed, which could be if: /// - The `token_id` does not exist. /// - The sender is not the owner of the token, or an operator for this @@ -843,41 +1335,72 @@ fn contract_permit( host.check_account_signature(param.signer, ¶m.signature, &message_hash)?; ensure!(valid_signature, CustomContractError::WrongSignature.into()); - if message.entry_point.as_entrypoint_name() == EntrypointName::new_unchecked("transfer") { - // Transfer the tokens. - - let TransferParams(transfers): TransferParameter = from_bytes(&message.payload)?; + match message.entry_point.as_entrypoint_name() { + TRANSFER_ENTRYPOINT => { + // Transfer the tokens. + let TransferParams(transfers): TransferParameter = from_bytes(&message.payload)?; + + for transfer_entry in transfers { + // Authenticate the signer for this transfer + ensure!( + transfer_entry.from.matches_account(¶m.signer) + || host + .state() + .is_operator(&Address::from(param.signer), &transfer_entry.from), + ContractError::Unauthorized + ); + + transfer(transfer_entry, host, logger)? + } + } + UPDATE_OPERATOR_ENTRYPOINT => { + // Update the operator. + let UpdateOperatorParams(updates): UpdateOperatorParams = from_bytes(&message.payload)?; + + let (state, builder) = host.state_and_builder(); + + for update in updates { + update_operator( + update.update, + concordium_std::Address::Account(param.signer), + update.operator, + state, + builder, + logger, + )?; + } + } + MINT_ENTRYPOINT => { + // Mint tokens. + // ATTENTION: Can be called by anyone. You should add your + // custom access control here. + let params: MintParams = from_bytes(&message.payload)?; + + // // Check that only the MINTER can authorize to mint. (comment in if you want + // // only the MINTER role having the authorization to mint) + // ensure!( + // host.state().has_role(&Address::from(param.signer), Roles::MINTER), + // ContractError::Unauthorized + // ); + + mint(params, host, logger)?; + } + BURN_ENTRYPOINT => { + // Burn tokens. + let params: BurnParams = from_bytes(&message.payload)?; - for transfer_entry in transfers { - // Authenticate the signer for this transfer + // Authenticate the sender for the token burns. ensure!( - transfer_entry.from.matches_account(¶m.signer) - || host.state().is_operator(&Address::from(param.signer), &transfer_entry.from), + params.owner.matches_account(¶m.signer) + || host.state().is_operator(&Address::from(param.signer), ¶ms.owner), ContractError::Unauthorized ); - transfer(transfer_entry, host, logger)? + burn(params, host, logger)?; } - } else if message.entry_point.as_entrypoint_name() - == EntrypointName::new_unchecked("updateOperator") - { - // Update the operator. - let UpdateOperatorParams(updates): UpdateOperatorParams = from_bytes(&message.payload)?; - - let (state, builder) = host.state_and_builder(); - - for update in updates { - update_operator( - update.update, - concordium_std::Address::Account(param.signer), - update.operator, - state, - builder, - logger, - )?; + _ => { + bail!(CustomContractError::WrongEntryPoint.into()) } - } else { - bail!(CustomContractError::WrongEntryPoint.into()) } // Log the nonce event. @@ -901,6 +1424,9 @@ fn update_operator( builder: &mut StateBuilder, logger: &mut impl HasLogger, ) -> ContractResult<()> { + // Check that contract is not paused. + ensure!(!state.paused, CustomContractError::Paused.into()); + // Update the operator in the state. match update { OperatorUpdate::Add => state.add_operator(&sender, &operator, builder), @@ -1015,6 +1541,40 @@ fn contract_operator_of( Ok(result) } +/// The parameter type for the contract functions `isBlacklisted`. +#[derive(Debug, Serialize, SchemaType)] +#[concordium(transparent)] +pub struct VecOfAddresses { + /// List of queries. + #[concordium(size_length = 2)] + pub queries: Vec
, +} + +/// Takes a list of queries. Each query contains an address which is checked if +/// that address is blacklisted. +/// +/// It rejects if: +/// - It fails to parse the parameter. +#[receive( + contract = "cis2_multi", + name = "isBlacklisted", + parameter = "VecOfAddresses", + return_value = "Vec", + error = "ContractError" +)] +fn contract_is_blacklisted(ctx: &ReceiveContext, host: &Host) -> ContractResult> { + // Parse the parameter. + let params: VecOfAddresses = ctx.parameter_cursor().get()?; + // Build the response. + let mut response = Vec::with_capacity(params.queries.len()); + for address in params.queries { + // Query the state if address is blacklisted. + let is_blacklisted = host.state().blacklist.contains(&get_canonical_address(address)?); + response.push(is_blacklisted); + } + Ok(response) +} + /// Response type for the function `publicKeyOf`. #[derive(Debug, Serialize, SchemaType)] #[concordium(transparent)] @@ -1280,3 +1840,237 @@ fn contract_set_implementor(ctx: &ReceiveContext, host: &mut Host) -> Con host.state_mut().set_implementors(params.id, params.implementors); Ok(()) } + +/// The update to an address with respect to the blacklist. +#[derive(Debug, Serialize, Clone, Copy, SchemaType, PartialEq, Eq)] +pub enum BlacklistUpdate { + /// Remove from blacklist. + Remove, + /// Add to blacklist. + Add, +} + +/// A single update of an address with respect to the blacklist. +#[derive(Debug, Serialize, Clone, SchemaType, PartialEq, Eq)] +pub struct UpdateBlacklist { + /// The update for this address. + pub update: BlacklistUpdate, + /// The address which is either added to or removed from the blacklist. + pub address: Address, +} + +/// The parameter type for the contract function `updateBlacklist`. +#[derive(Debug, Serialize, Clone, SchemaType)] +#[concordium(transparent)] +pub struct UpdateBlacklistParams(#[concordium(size_length = 2)] pub Vec); + +/// Add addresses or remove addresses from the blacklist. +/// Logs an `UpdateBlacklist` event. +/// +/// It rejects if: +/// - Sender is not the BLACKLISTER of the contract instance. +/// - It fails to parse the parameter. +/// - Fails to log event. +#[receive( + contract = "cis2_multi", + name = "updateBlacklist", + parameter = "UpdateBlacklistParams", + error = "ContractError", + enable_logger, + mutable +)] +fn contract_update_blacklist( + ctx: &ReceiveContext, + host: &mut Host, + logger: &mut impl HasLogger, +) -> ContractResult<()> { + // Get the sender who invoked this contract function. + let sender = ctx.sender(); + + // Check that only the BLACKLISTER is authorized to blacklist an address. + ensure!(host.state().has_role(&sender, Roles::BLACKLISTER), ContractError::Unauthorized); + + // Parse the parameter. + let UpdateBlacklistParams(params) = ctx.parameter_cursor().get()?; + + for param in params { + let canonical_address = get_canonical_address(param.address)?; + + // Add/remove address from the blacklist. + match param.update { + BlacklistUpdate::Add => host.state_mut().add_blacklist(canonical_address), + BlacklistUpdate::Remove => host.state_mut().remove_blacklist(&canonical_address), + } + + // Log the update blacklist event. + logger.log(&Event::UpdateBlacklist(UpdateBlacklistEvent { + address: canonical_address, + update: param.update, + }))?; + } + + Ok(()) +} + +/// Upgrade this smart contract instance to a new module and call optionally a +/// migration function after the upgrade. +/// +/// It rejects if: +/// - Sender is not the UPGRADER of the contract instance. +/// - It fails to parse the parameter. +/// - If the ugrade fails. +/// - If the migration invoke fails. +/// +/// This function is marked as `low_level`. This is **necessary** since the +/// high-level mutable functions store the state of the contract at the end of +/// execution. This conflicts with migration since the shape of the state +/// **might** be changed by the migration function. If the state is then written +/// by this function it would overwrite the state stored by the migration +/// function. +#[receive( + contract = "cis2_multi", + name = "upgrade", + parameter = "UpgradeParams", + error = "CustomContractError", + low_level +)] +fn contract_upgrade(ctx: &ReceiveContext, host: &mut LowLevelHost) -> ContractResult<()> { + // Read the top-level contract state. + let state: State = host.state().read_root()?; + + // Get the sender who invoked this contract function. + let sender = ctx.sender(); + + // Check that only the UPGRADER is authorized to upgrade the contract. + ensure!(state.has_role(&sender, Roles::UPGRADER), ContractError::Unauthorized); + // Parse the parameter. + let params: UpgradeParams = ctx.parameter_cursor().get()?; + // Trigger the upgrade. + host.upgrade(params.module)?; + // Call the migration function if provided. + if let Some((func, parameters)) = params.migrate { + host.invoke_contract_raw( + &ctx.self_address(), + parameters.as_parameter(), + func.as_entrypoint_name(), + Amount::zero(), + )?; + } + Ok(()) +} + +/// Pause/Unpause this smart contract instance by the PAUSER. The transfer, +/// updateOperator, mint, and burn functions cannot be +/// executed when the contract is paused. +/// +/// It rejects if: +/// - Sender is not the PAUSER of the contract instance. +/// - It fails to parse the parameter. +#[receive( + contract = "cis2_multi", + name = "setPaused", + parameter = "SetPausedParams", + error = "CustomContractError", + mutable +)] +fn contract_set_paused(ctx: &ReceiveContext, host: &mut Host) -> ContractResult<()> { + // Get the sender who invoked this contract function. + let sender = ctx.sender(); + + // Check that only the PAUSER is authorized to pause the contract. + ensure!(host.state().has_role(&sender, Roles::PAUSER), ContractError::Unauthorized); + + // Parse the parameter. + let params: SetPausedParams = ctx.parameter_cursor().get()?; + + // Update the paused variable. + host.state_mut().paused = params.paused; + + Ok(()) +} + +/// Add role to an address. +/// +/// It rejects if: +/// - It fails to parse the parameter. +/// - Sender is not the ADMIN of the contract instance. +/// - The `address` is already holding the specified role to be granted. +#[receive( + contract = "cis2_multi", + name = "grantRole", + parameter = "GrantRoleParams", + enable_logger, + mutable +)] +fn contract_grant_role( + ctx: &ReceiveContext, + host: &mut Host, + logger: &mut impl HasLogger, +) -> ContractResult<()> { + // Parse the parameter. + let params: GrantRoleParams = ctx.parameter_cursor().get()?; + + let (state, state_builder) = host.state_and_builder(); + + // Get the sender who invoked this contract function. + let sender = ctx.sender(); + // Check that only the ADMIN is authorized to grant roles. + ensure!(state.has_role(&sender, Roles::ADMIN), ContractError::Unauthorized); + + // Check that the `address` had previously not hold the specified role. + ensure!( + !state.has_role(¶ms.address, params.role), + CustomContractError::RoleWasAlreadyGranted.into() + ); + + // Grant role. + state.grant_role(¶ms.address, params.role, state_builder); + logger.log(&Event::GrantRole(GrantRoleEvent { + address: params.address, + role: params.role, + }))?; + Ok(()) +} + +/// Revoke role from an address. +/// +/// It rejects if: +/// - It fails to parse the parameter. +/// - Sender is not the ADMIN of the contract instance. +/// - The `address` is not holding the specified role to be revoked. +#[receive( + contract = "cis2_multi", + name = "revokeRole", + parameter = "RevokeRoleParams", + enable_logger, + mutable +)] +fn contract_revoke_role( + ctx: &ReceiveContext, + host: &mut Host, + logger: &mut impl HasLogger, +) -> ContractResult<()> { + // Parse the parameter. + let params: RevokeRoleParams = ctx.parameter_cursor().get()?; + + let (state, _) = host.state_and_builder(); + + // Get the sender who invoked this contract function. + let sender = ctx.sender(); + // Check that only the ADMIN is authorized to revoke roles. + ensure!(state.has_role(&sender, Roles::ADMIN), ContractError::Unauthorized); + + // Check that the `address` had previously hold the specified role. + ensure!( + state.has_role(¶ms.address, params.role), + CustomContractError::RoleWasNotGranted.into() + ); + + // Revoke role. + state.revoke_role(¶ms.address, params.role); + logger.log(&Event::RevokeRole(RevokeRoleEvent { + address: params.address, + role: params.role, + }))?; + Ok(()) +} diff --git a/examples/cis2-multi/tests/tests.rs b/examples/cis2-multi/tests/tests.rs index 6ea494597..eb54eb31e 100644 --- a/examples/cis2-multi/tests/tests.rs +++ b/examples/cis2-multi/tests/tests.rs @@ -11,8 +11,18 @@ use concordium_std::{ const ALICE: AccountAddress = AccountAddress([0; 32]); const ALICE_ADDR: Address = Address::Account(ALICE); const BOB: AccountAddress = AccountAddress([1; 32]); +const BOB_CANONICAL: AccountAddress = AccountAddress([ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, +]); +const BOB_CANONICAL_ADDR: Address = Address::Account(BOB_CANONICAL); const BOB_ADDR: Address = Address::Account(BOB); -const NON_EXISTING_ACCOUNT: AccountAddress = AccountAddress([2u8; 32]); +const UPGRADER: AccountAddress = AccountAddress([2; 32]); +const UPGRADER_ADDR: Address = Address::Account(UPGRADER); +const BLACKLISTER: AccountAddress = AccountAddress([3; 32]); +const BLACKLISTER_ADDR: Address = Address::Account(BLACKLISTER); +const PAUSER: AccountAddress = AccountAddress([4; 32]); +const PAUSER_ADDR: Address = Address::Account(PAUSER); +const NON_EXISTING_ACCOUNT: AccountAddress = AccountAddress([99u8; 32]); /// Token IDs. const TOKEN_0: ContractTokenId = TokenIdU8(2); @@ -34,7 +44,8 @@ const DUMMY_SIGNATURE: SignatureEd25519 = SignatureEd25519([ /// the appropriate events are logged. #[test] fn test_minting() { - let (chain, _keypairs, contract_address, update) = initialize_contract_with_alice_tokens(); + let (chain, _keypairs, contract_address, update, _module_reference) = + initialize_contract_with_alice_tokens(); // Invoke the view entrypoint and check that the tokens are owned by Alice. let invoke = chain @@ -79,7 +90,8 @@ fn test_minting() { /// Test regular transfer where sender is the owner. #[test] fn test_account_transfer() { - let (mut chain, _keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); // Transfer one token from Alice to Bob. let transfer_params = TransferParams::from(vec![concordium_cis2::Transfer { @@ -140,7 +152,8 @@ fn test_account_transfer() { /// Then add Bob as an operator for Alice. #[test] fn test_add_operator() { - let (mut chain, _keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); // Add Bob as an operator for Alice. let params = UpdateOperatorParams(vec![UpdateOperator { @@ -176,7 +189,7 @@ fn test_add_operator() { }], }; - // Invoke the operatorOf view entrypoint and check that Bob is an operator for + // Invoke the operatorOf entrypoint and check that Bob is an operator for // Alice. let invoke = chain .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { @@ -185,7 +198,7 @@ fn test_add_operator() { address: contract_address, message: OwnedParameter::from_serial(&query_params).expect("OperatorOf params"), }) - .expect("Invoke view"); + .expect("Invoke opeatorOf"); let rv: OperatorOfQueryResponse = invoke.parse_return_value().expect("OperatorOf return value"); assert_eq!(rv, OperatorOfQueryResponse(vec![true])); @@ -196,7 +209,8 @@ fn test_add_operator() { /// himself. #[test] fn test_unauthorized_sender() { - let (mut chain, _keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); // Construct a transfer of `TOKEN_0` from Alice to Bob, which will be submitted // by Bob. @@ -226,7 +240,8 @@ fn test_unauthorized_sender() { /// Test that an operator can make a transfer. #[test] fn test_operator_can_transfer() { - let (mut chain, _keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); // Add Bob as an operator for Alice. let params = UpdateOperatorParams(vec![UpdateOperator { @@ -283,11 +298,114 @@ fn test_operator_can_transfer() { ]); } +/// Test permit mint function. The signature is generated in the test +/// case. ALICE mints tokens to her account. +#[test] +fn test_permit_mint() { + let (mut chain, keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); + + // Check balances in state. + let balance_of_alice_and_bob = get_balances(&chain, contract_address); + + assert_eq!(balance_of_alice_and_bob.0, [TokenAmountU64(100), TokenAmountU64(0)]); + + // Create input parameters for the `mint` function. + let payload = MintParams { + owner: ALICE_ADDR, + metadata_url: MetadataUrl { + url: "https://some.example/token/2A".to_string(), + hash: None, + }, + token_id: TOKEN_1, + }; + + let update = + permit(&mut chain, contract_address, to_bytes(&payload), "mint".to_string(), keypairs); + + // Check that the correct events occurred. + let events = update + .events() + .flat_map(|(_addr, events)| events.iter().map(|e| e.parse().expect("Deserialize event"))) + .collect::>(); + + assert_eq!(events, [ + Event::Cis2Event(Cis2Event::Mint(MintEvent { + token_id: TOKEN_1, + amount: TokenAmountU64(100), + owner: ALICE_ADDR, + })), + Event::Cis2Event(Cis2Event::TokenMetadata(TokenMetadataEvent { + token_id: TOKEN_1, + metadata_url: MetadataUrl { + url: "https://some.example/token/2A".to_string(), + hash: None, + }, + })), + Event::Nonce(NonceEvent { + account: ALICE, + nonce: 0, + }) + ]); + + // Check balances in state. + let balance_of_alice_and_bob = get_balances(&chain, contract_address); + + assert_eq!(balance_of_alice_and_bob.0, [TokenAmountU64(200), TokenAmountU64(0)]); +} + +/// Test permit burn function. The signature is generated in the test +/// case. ALICE burns tokens from her account. +#[test] +fn test_permit_burn() { + let (mut chain, keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); + + // Check balances in state. + let balance_of_alice_and_bob = get_balances(&chain, contract_address); + + assert_eq!(balance_of_alice_and_bob.0, [TokenAmountU64(100), TokenAmountU64(0)]); + + // Create input parameters for the `burn` function. + let payload = BurnParams { + owner: ALICE_ADDR, + amount: TokenAmountU64(1), + token_id: TOKEN_1, + }; + + let update = + permit(&mut chain, contract_address, to_bytes(&payload), "burn".to_string(), keypairs); + + // Check that the correct events occurred. + let events = update + .events() + .flat_map(|(_addr, events)| events.iter().map(|e| e.parse().expect("Deserialize event"))) + .collect::>(); + + assert_eq!(events, [ + Event::Cis2Event(Cis2Event::Burn(BurnEvent { + token_id: TOKEN_1, + amount: TokenAmountU64(1), + owner: ALICE_ADDR, + })), + Event::Nonce(NonceEvent { + account: ALICE, + nonce: 0, + }) + ]); + + // Check balances in state. + let balance_of_alice_and_bob = get_balances(&chain, contract_address); + + assert_eq!(balance_of_alice_and_bob.0, [TokenAmountU64(99), TokenAmountU64(0)]); +} + /// Test permit update operator function. The signature is generated in the test /// case. ALICE adds BOB as an operator. #[test] -fn test_inside_signature_permit_update_operator() { - let (mut chain, keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); +fn test_permit_update_operator() { + let (mut chain, keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); // Check operator in state let bob_is_operator_of_alice = operator_of(&chain, contract_address); @@ -301,61 +419,13 @@ fn test_inside_signature_permit_update_operator() { }; let payload = UpdateOperatorParams(vec![update_operator]); - // The `viewMessageHash` function uses the same input parameter `PermitParam` as - // the `permit` function. The `PermitParam` type includes a `signature` and - // a `signer`. Because these two values (`signature` and `signer`) are not - // read in the `viewMessageHash` function, any value can be used and we choose - // to use `DUMMY_SIGNATURE` and `ALICE` in the test case below. - let signature_map = BTreeMap::from([(0u8, CredentialSignatures { - sigs: BTreeMap::from([(0u8, concordium_std::Signature::Ed25519(DUMMY_SIGNATURE))]), - })]); - - let mut permit_update_operator_param = PermitParam { - signature: AccountSignatures { - sigs: signature_map, - }, - signer: ALICE, - message: PermitMessage { - timestamp: Timestamp::from_timestamp_millis(10_000_000_000), - contract_address: ContractAddress::new(0, 0), - entry_point: OwnedEntrypointName::new_unchecked("updateOperator".into()), - nonce: 0, - payload: to_bytes(&payload), - }, - }; - - // Get the message hash to be signed. - let invoke = chain - .contract_invoke(BOB, BOB_ADDR, Energy::from(10000), UpdateContractPayload { - amount: Amount::zero(), - address: contract_address, - receive_name: OwnedReceiveName::new_unchecked("cis2_multi.viewMessageHash".to_string()), - message: OwnedParameter::from_serial(&permit_update_operator_param) - .expect("Should be a valid inut parameter"), - }) - .expect("Should be able to query viewMessageHash"); - - let message_hash: HashSha2256 = - from_bytes(&invoke.return_value).expect("Should return a valid result"); - - permit_update_operator_param.signature = keypairs.sign_message(&to_bytes(&message_hash)); - - // Update operator with the permit function. - let update = chain - .contract_update( - Signer::with_one_key(), - BOB, - Address::Account(BOB), - Energy::from(10000), - UpdateContractPayload { - amount: Amount::zero(), - address: contract_address, - receive_name: OwnedReceiveName::new_unchecked("cis2_multi.permit".to_string()), - message: OwnedParameter::from_serial(&permit_update_operator_param) - .expect("Should be a valid inut parameter"), - }, - ) - .expect("Should be able to update operator with permit"); + let update = permit( + &mut chain, + contract_address, + to_bytes(&payload), + "updateOperator".to_string(), + keypairs, + ); // Check that the correct events occurred. let events = update @@ -384,8 +454,9 @@ fn test_inside_signature_permit_update_operator() { /// Test permit transfer function. The signature is generated in the test case. /// TOKEN_1 is transferred from Alice to Bob. #[test] -fn test_inside_signature_permit_transfer() { - let (mut chain, keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); +fn test_permit_transfer() { + let (mut chain, keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); // Check balances in state. let balance_of_alice_and_bob = get_balances(&chain, contract_address); @@ -402,61 +473,8 @@ fn test_inside_signature_permit_transfer() { }; let payload = TransferParams::from(vec![transfer]); - // The `viewMessageHash` function uses the same input parameter `PermitParam` as - // the `permit` function. The `PermitParam` type includes a `signature` and - // a `signer`. Because these two values (`signature` and `signer`) are not - // read in the `viewMessageHash` function, any value can be used and we choose - // to use `DUMMY_SIGNATURE` and `ALICE` in the test case below. - let signature_map = BTreeMap::from([(0u8, CredentialSignatures { - sigs: BTreeMap::from([(0u8, concordium_std::Signature::Ed25519(DUMMY_SIGNATURE))]), - })]); - - let mut permit_transfer_param = PermitParam { - signature: AccountSignatures { - sigs: signature_map, - }, - signer: ALICE, - message: PermitMessage { - timestamp: Timestamp::from_timestamp_millis(10_000_000_000), - contract_address: ContractAddress::new(0, 0), - entry_point: OwnedEntrypointName::new_unchecked("transfer".into()), - nonce: 0, - payload: to_bytes(&payload), - }, - }; - - // Get the message hash to be signed. - let invoke = chain - .contract_invoke(BOB, BOB_ADDR, Energy::from(10000), UpdateContractPayload { - amount: Amount::zero(), - address: contract_address, - receive_name: OwnedReceiveName::new_unchecked("cis2_multi.viewMessageHash".to_string()), - message: OwnedParameter::from_serial(&permit_transfer_param) - .expect("Should be a valid inut parameter"), - }) - .expect("Should be able to query viewMessageHash"); - - let message_hash: HashSha2256 = - from_bytes(&invoke.return_value).expect("Should return a valid result"); - - permit_transfer_param.signature = keypairs.sign_message(&to_bytes(&message_hash)); - - // Transfer token with the permit function. - let update = chain - .contract_update( - Signer::with_one_key(), - BOB, - BOB_ADDR, - Energy::from(10000), - UpdateContractPayload { - amount: Amount::zero(), - address: contract_address, - receive_name: OwnedReceiveName::new_unchecked("cis2_multi.permit".to_string()), - message: OwnedParameter::from_serial(&permit_transfer_param) - .expect("Should be a valid inut parameter"), - }, - ) - .expect("Should be able to transfer token with permit"); + let update = + permit(&mut chain, contract_address, to_bytes(&payload), "transfer".to_string(), keypairs); // Check that the correct events occurred. let events = update @@ -489,7 +507,8 @@ fn test_inside_signature_permit_transfer() { /// transaction. We check that the nonce of `NON_EXISTING_ACCOUNT` is 0. #[test] fn test_nonce_of_query() { - let (mut chain, keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); + let (mut chain, keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); // To increase the nonce of `ALICE's` account, we invoke the // `update_permit` function with a valid signature from ALICE account. @@ -601,7 +620,8 @@ fn test_nonce_of_query() { /// not exist on chain. #[test] fn test_public_key_of_query() { - let (chain, keypairs, contract_address, _update) = initialize_contract_with_alice_tokens(); + let (chain, keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); let public_key_of_query_vector = VecOfAccountAddresses { queries: vec![ALICE, NON_EXISTING_ACCOUNT], @@ -635,68 +655,654 @@ fn test_public_key_of_query() { assert!(public_keys_of.0[1].is_none(), "Non existing account should have no public keys"); } -/// Check if Bob is an operator of Alice. -fn operator_of(chain: &Chain, contract_address: ContractAddress) -> OperatorOfQueryResponse { - let operator_of_params = OperatorOfQueryParams { - queries: vec![OperatorOfQuery { - address: BOB_ADDR, - owner: ALICE_ADDR, - }], - }; +/// Test burning tokens. +#[test] +fn test_burning_tokens() { + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); - // Check operator in state - let invoke = chain - .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { - amount: Amount::zero(), - receive_name: OwnedReceiveName::new_unchecked("cis2_multi.operatorOf".to_string()), - address: contract_address, - message: OwnedParameter::from_serial(&operator_of_params) - .expect("OperatorOf params"), - }) - .expect("Invoke operatorOf"); - let rv: OperatorOfQueryResponse = invoke.parse_return_value().expect("OperatorOf return value"); - rv -} + // Create input parameters to burn one of Alice's tokens. + let burn_params = BurnParams { + owner: ALICE_ADDR, + amount: TokenAmountU64(1), + token_id: TOKEN_1, + }; -/// Get the `TOKEN_1` balances for Alice and Bob. -fn get_balances( - chain: &Chain, - contract_address: ContractAddress, -) -> ContractBalanceOfQueryResponse { - let balance_of_params = ContractBalanceOfQueryParams { - queries: vec![ - BalanceOfQuery { - token_id: TOKEN_1, - address: ALICE_ADDR, - }, - BalanceOfQuery { - token_id: TOKEN_1, - address: BOB_ADDR, + // Burn one of Alice's tokens. + let update = chain + .contract_update( + Signer::with_one_key(), + ALICE, + ALICE_ADDR, + Energy::from(10000), + UpdateContractPayload { + amount: Amount::zero(), + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.burn".to_string()), + message: OwnedParameter::from_serial(&burn_params) + .expect("Should be a valid inut parameter"), }, - ], - }; + ) + .expect("Should be able to burn tokens"); - let invoke = chain - .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { - amount: Amount::zero(), - receive_name: OwnedReceiveName::new_unchecked("cis2_multi.balanceOf".to_string()), - address: contract_address, - message: OwnedParameter::from_serial(&balance_of_params) - .expect("BalanceOf params"), - }) - .expect("Invoke balanceOf"); - let rv: ContractBalanceOfQueryResponse = - invoke.parse_return_value().expect("BalanceOf return value"); - rv -} + // Check that the event is logged. + let events = update.events().flat_map(|(_addr, events)| events); -/// Helper function that sets up the contract with two types of tokens minted to -/// Alice. She has 100 of `TOKEN_0` and 100 of `TOKEN_1`. -/// Alice's account is created with keys. -/// Hence, Alice's account signature can be checked in the test cases. -fn initialize_contract_with_alice_tokens( -) -> (Chain, AccountKeys, ContractAddress, ContractInvokeSuccess) { - let (mut chain, keypairs, contract_address) = initialize_chain_and_contract(); + let events: Vec> = + events.map(|e| e.parse().expect("Deserialize event")).collect(); + + assert_eq!(events, [Cis2Event::Burn(BurnEvent { + owner: ALICE_ADDR, + amount: TokenAmountU64(1), + token_id: TOKEN_1, + })]); + + // Check balances in state. + let balance_of_alice_and_bob = get_balances(&chain, contract_address); + + assert_eq!(balance_of_alice_and_bob.0, [TokenAmountU64(99), TokenAmountU64(0)]); +} + +/// Test adding and removing from blacklist works. +#[test] +fn test_adding_to_blacklist() { + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); + + // Check that Alice and Bob are not blacklisted. + let rv: Vec = is_blacklisted(&chain, contract_address); + assert_eq!(rv, [false, false]); + + // Create input parameters to add Bob to the blacklist. + let update_blacklist = UpdateBlacklist { + update: BlacklistUpdate::Add, + address: BOB_ADDR, + }; + let update_blacklist_params = UpdateBlacklistParams(vec![update_blacklist]); + + // Update blacklist. + let update = chain + .contract_update( + Signer::with_one_key(), + BLACKLISTER, + BLACKLISTER_ADDR, + Energy::from(10000), + UpdateContractPayload { + amount: Amount::zero(), + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked( + "cis2_multi.updateBlacklist".to_string(), + ), + message: OwnedParameter::from_serial(&update_blacklist_params) + .expect("Update blacklist params"), + }, + ) + .expect("Should be able to update blacklist"); + + // Check that the correct events occurred. + let events = update + .events() + .flat_map(|(_addr, events)| events.iter().map(|e| e.parse().expect("Deserialize event"))) + .collect::>(); + + assert_eq!(events, [Event::UpdateBlacklist(UpdateBlacklistEvent { + address: BOB_CANONICAL_ADDR, + update: BlacklistUpdate::Add, + })]); + + // Check that Alice is not blacklisted and Bob is blacklisted. + let rv: Vec = is_blacklisted(&chain, contract_address); + assert_eq!(rv, [false, true]); + + // Create input parameters to remove Bob from the blacklist. + let update_blacklist = UpdateBlacklist { + update: BlacklistUpdate::Remove, + address: BOB_ADDR, + }; + let update_blacklist_params = UpdateBlacklistParams(vec![update_blacklist]); + + // Update blacklist. + let update = chain + .contract_update( + Signer::with_one_key(), + BLACKLISTER, + BLACKLISTER_ADDR, + Energy::from(10000), + UpdateContractPayload { + amount: Amount::zero(), + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked( + "cis2_multi.updateBlacklist".to_string(), + ), + message: OwnedParameter::from_serial(&update_blacklist_params) + .expect("Update blacklist params"), + }, + ) + .expect("Should be able to update blacklist"); + + // Check that the correct events occurred. + let events = update + .events() + .flat_map(|(_addr, events)| events.iter().map(|e| e.parse().expect("Deserialize event"))) + .collect::>(); + + assert_eq!(events, [Event::UpdateBlacklist(UpdateBlacklistEvent { + address: BOB_CANONICAL_ADDR, + update: BlacklistUpdate::Remove, + })]); + + // Check that Alice and Bob are not blacklisted. + let rv: Vec = is_blacklisted(&chain, contract_address); + assert_eq!(rv, [false, false]); +} + +/// Test blacklisted address cannot receive tokens, send tokens, or burn tokens. +#[test] +fn test_token_balance_of_blacklisted_address_can_not_change() { + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); + + // Send some tokens to Bob. + let transfer_params = TransferParams::from(vec![concordium_cis2::Transfer { + from: ALICE_ADDR, + to: Receiver::Account(BOB), + token_id: TOKEN_0, + amount: TokenAmountU64(1), + data: AdditionalData::empty(), + }]); + + let _update = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.transfer".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&transfer_params).expect("Transfer params"), + }) + .expect("Transfer tokens"); + + // Create input parameters to add Bob to the blacklist. + let update_blacklist = UpdateBlacklist { + update: BlacklistUpdate::Add, + address: BOB_ADDR, + }; + let update_blacklist_params = UpdateBlacklistParams(vec![update_blacklist]); + + // Update blacklist with BOB's address. + let _update = chain + .contract_update( + Signer::with_one_key(), + BLACKLISTER, + BLACKLISTER_ADDR, + Energy::from(10000), + UpdateContractPayload { + amount: Amount::zero(), + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked( + "cis2_multi.updateBlacklist".to_string(), + ), + message: OwnedParameter::from_serial(&update_blacklist_params) + .expect("Update blacklist params"), + }, + ) + .expect("Should be able to update blacklist"); + + // Bob cannot receive tokens via `transfer`. + let transfer_params = TransferParams::from(vec![concordium_cis2::Transfer { + from: ALICE_ADDR, + to: Receiver::Account(BOB), + token_id: TOKEN_0, + amount: TokenAmountU64(1), + data: AdditionalData::empty(), + }]); + + let update = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.transfer".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&transfer_params).expect("Transfer params"), + }) + .expect_err("Transfer tokens"); + + // Check that the correct error is returned. + let rv: ContractError = update.parse_return_value().expect("ContractError return value"); + assert_eq!(rv, CustomContractError::Blacklisted.into()); + + // Bob cannot transfer tokens via `transfer`. + let transfer_params = TransferParams::from(vec![concordium_cis2::Transfer { + from: BOB_ADDR, + to: Receiver::Account(ALICE), + token_id: TOKEN_0, + amount: TokenAmountU64(1), + data: AdditionalData::empty(), + }]); + + let update = chain + .contract_update(SIGNER, BOB, BOB_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.transfer".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&transfer_params).expect("Transfer params"), + }) + .expect_err("Transfer tokens"); + + // Check that the correct error is returned. + let rv: ContractError = update.parse_return_value().expect("ContractError return value"); + assert_eq!(rv, CustomContractError::Blacklisted.into()); + + // Bob cannot mint tokens to its address. + let mint_params = MintParams { + owner: BOB_ADDR, + token_id: TOKEN_0, + metadata_url: MetadataUrl { + url: "https://some.example/token/02".to_string(), + hash: None, + }, + }; + + let update = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.mint".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&mint_params).expect("Mint params"), + }) + .expect_err("Mint tokens"); + + // Check that the correct error is returned. + let rv: ContractError = update.parse_return_value().expect("ContractError return value"); + assert_eq!(rv, CustomContractError::Blacklisted.into()); + + // Bob cannot burn tokens from its address. + let burn_params = BurnParams { + owner: BOB_ADDR, + amount: TokenAmountU64(1), + token_id: TOKEN_1, + }; + + // Burn one of Alice's tokens. + let update = chain + .contract_update( + Signer::with_one_key(), + BOB, + BOB_ADDR, + Energy::from(10000), + UpdateContractPayload { + amount: Amount::zero(), + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.burn".to_string()), + message: OwnedParameter::from_serial(&burn_params) + .expect("Should be a valid inut parameter"), + }, + ) + .expect_err("Burn tokens"); + + // Check that the correct error is returned. + let rv: ContractError = update.parse_return_value().expect("ContractError return value"); + assert_eq!(rv, CustomContractError::Blacklisted.into()); +} + +/// Upgrade the contract to itself without invoking a migration function. +#[test] +fn test_upgrade_without_migration_function() { + let (mut chain, _keypairs, contract_address, _update, module_reference) = + initialize_contract_with_alice_tokens(); + + let input_parameter = UpgradeParams { + module: module_reference, + migrate: None, + }; + + // Upgrade `contract_version1` to `contract_version2`. + let update = chain.contract_update( + Signer::with_one_key(), + UPGRADER, + UPGRADER_ADDR, + Energy::from(10000), + UpdateContractPayload { + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.upgrade".into()), + message: OwnedParameter::from_serial(&input_parameter) + .expect("`UpgradeParams` should be a valid inut parameter"), + amount: Amount::from_ccd(0), + }, + ); + + assert!( + !update.expect("Upgrade should succeed").state_changed, + "State should not be changed because no `migration` function was called" + ); + + // Invoke the view entrypoint and check that the state of the contract can be + // read. + let invoke = chain + .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.view".to_string()), + address: contract_address, + message: OwnedParameter::empty(), + }) + .expect("Invoke view"); + + // Check that the tokens (as set up in the + // `initialize_contract_with_alice_tokens` function) are owned by Alice. + let rv: ViewState = invoke.parse_return_value().expect("ViewState return value"); + assert_eq!(rv.tokens[..], [TOKEN_0, TOKEN_1]); + assert_eq!(rv.state, vec![(ALICE_ADDR, ViewAddressState { + balances: vec![(TOKEN_0, 100.into()), (TOKEN_1, 100.into())], + operators: Vec::new(), + })]); +} + +/// Test that the pause/unpause entrypoints correctly sets the pause value in +/// the state. +#[test] +fn test_pause_functionality() { + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); + + // Pause the contract. + chain + .contract_update(SIGNER, PAUSER, PAUSER_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.setPaused".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&true).expect("Pause params"), + }) + .expect("Pause"); + + // Check that the contract is now paused. + assert_eq!(invoke_view(&mut chain, contract_address).paused, true); + + // Unpause the contract. + chain + .contract_update(SIGNER, PAUSER, PAUSER_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.setPaused".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&false).expect("Unpause params"), + }) + .expect("Unpause"); + // Check that the contract is now unpaused. + assert_eq!(invoke_view(&mut chain, contract_address).paused, false); +} + +/// Test that only the PAUSER can pause/unpause the contract. +#[test] +fn test_pause_unpause_unauthorized() { + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); + + // Pause the contract as Bob, who is not the PAUSER. + let update = chain + .contract_update(SIGNER, BOB, BOB_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.setPaused".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&true).expect("Pause params"), + }) + .expect_err("Pause"); + + // Check that the correct error is returned. + let rv: ContractError = update.parse_return_value().expect("ContractError return value"); + assert_eq!(rv, ContractError::Unauthorized); +} + +/// Test that one can NOT call non-admin state-mutative functions (burn, +/// mint, transfer, updateOperator) when the contract is paused. +#[test] +fn test_no_execution_of_state_mutative_functions_when_paused() { + let (mut chain, _keypairs, contract_address, _update, _module_reference) = + initialize_contract_with_alice_tokens(); + + // Pause the contract. + chain + .contract_update(SIGNER, PAUSER, PAUSER_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.setPaused".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&true).expect("Pause params"), + }) + .expect("Pause"); + + // Try to transfer 1 token from Alice to Bob. + let transfer_params = TransferParams::from(vec![concordium_cis2::Transfer { + from: ALICE_ADDR, + to: Receiver::Account(BOB), + token_id: TOKEN_0, + amount: TokenAmountU64(1), + data: AdditionalData::empty(), + }]); + let update_transfer = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.transfer".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&transfer_params).expect("Transfer params"), + }) + .expect_err("Transfer tokens"); + assert_contract_paused_error(&update_transfer); + + // Try to add Bob as an operator for Alice. + let params = UpdateOperatorParams(vec![UpdateOperator { + update: OperatorUpdate::Add, + operator: BOB_ADDR, + }]); + let update_operator = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.updateOperator".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(¶ms).expect("UpdateOperator params"), + }) + .expect_err("Update operator"); + assert_contract_paused_error(&update_operator); + + // Try to mint tokens. + let params = MintParams { + owner: ALICE_ADDR, + metadata_url: MetadataUrl { + url: "https://some.example/token/02".to_string(), + hash: None, + }, + token_id: TOKEN_0, + }; + + let update_operator = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.mint".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(¶ms).expect("Mint params"), + }) + .expect_err("Update operator"); + assert_contract_paused_error(&update_operator); + + // Try to burn tokens. + let params = BurnParams { + owner: ALICE_ADDR, + amount: TokenAmountU64(1), + token_id: TOKEN_0, + }; + + let update_operator = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.burn".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(¶ms).expect("Burn params"), + }) + .expect_err("Update operator"); + assert_contract_paused_error(&update_operator); +} + +/// Check that the returned error is `ContractPaused`. +fn assert_contract_paused_error(update: &ContractInvokeError) { + let rv: ContractError = update.parse_return_value().expect("ContractError return value"); + assert_eq!(rv, ContractError::Custom(CustomContractError::Paused)); +} + +/// Get the result of the view entrypoint. +fn invoke_view(chain: &mut Chain, contract_address: ContractAddress) -> ViewState { + let invoke = chain + .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.view".to_string()), + address: contract_address, + message: OwnedParameter::empty(), + }) + .expect("Invoke view"); + invoke.parse_return_value().expect("Return value") +} + +/// Execute a permit function invoke +fn permit( + chain: &mut Chain, + contract_address: ContractAddress, + payload: Vec, + entrypoint_name: String, + keypairs: AccountKeys, +) -> ContractInvokeSuccess { + // The `viewMessageHash` function uses the same input parameter `PermitParam` as + // the `permit` function. The `PermitParam` type includes a `signature` and + // a `signer`. Because these two values (`signature` and `signer`) are not + // read in the `viewMessageHash` function, any value can be used and we choose + // to use `DUMMY_SIGNATURE` and `ALICE` in the test case below. + let signature_map = BTreeMap::from([(0u8, CredentialSignatures { + sigs: BTreeMap::from([(0u8, concordium_std::Signature::Ed25519(DUMMY_SIGNATURE))]), + })]); + + let mut param = PermitParam { + signature: AccountSignatures { + sigs: signature_map, + }, + signer: ALICE, + message: PermitMessage { + timestamp: Timestamp::from_timestamp_millis(10_000_000_000), + contract_address: ContractAddress::new(0, 0), + entry_point: OwnedEntrypointName::new_unchecked(entrypoint_name), + nonce: 0, + payload, + }, + }; + + // Get the message hash to be signed. + let invoke = chain + .contract_invoke(BOB, BOB_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.viewMessageHash".to_string()), + message: OwnedParameter::from_serial(¶m) + .expect("Should be a valid inut parameter"), + }) + .expect("Should be able to query viewMessageHash"); + + let message_hash: HashSha2256 = + from_bytes(&invoke.return_value).expect("Should return a valid result"); + + param.signature = keypairs.sign_message(&to_bytes(&message_hash)); + + // Execute permit function. + chain + .contract_update( + Signer::with_one_key(), + BOB, + BOB_ADDR, + Energy::from(10000), + UpdateContractPayload { + amount: Amount::zero(), + address: contract_address, + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.permit".to_string()), + message: OwnedParameter::from_serial(¶m) + .expect("Should be a valid inut parameter"), + }, + ) + .expect("Should be able to exit permit token with permit") +} + +/// Check if Bob is an operator of Alice. +fn operator_of(chain: &Chain, contract_address: ContractAddress) -> OperatorOfQueryResponse { + let operator_of_params = OperatorOfQueryParams { + queries: vec![OperatorOfQuery { + address: BOB_ADDR, + owner: ALICE_ADDR, + }], + }; + + // Check operator in state + let invoke = chain + .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.operatorOf".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&operator_of_params) + .expect("OperatorOf params"), + }) + .expect("Invoke operatorOf"); + let rv: OperatorOfQueryResponse = invoke.parse_return_value().expect("OperatorOf return value"); + rv +} + +/// Check if Alice and Bob are blacklisted. +fn is_blacklisted(chain: &Chain, contract_address: ContractAddress) -> Vec { + let addresses_query_vector = VecOfAddresses { + queries: vec![ALICE_ADDR, BOB_ADDR], + }; + + // Invoke the isBlacklisted entrypoint. + let invoke = chain + .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.isBlacklisted".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&addresses_query_vector) + .expect("Should be a valid inut parameter"), + }) + .expect("Invoke isBlacklisted"); + + let rv: Vec = invoke.parse_return_value().expect("Vec return value"); + rv +} + +/// Get the `TOKEN_1` balances for Alice and Bob. +fn get_balances( + chain: &Chain, + contract_address: ContractAddress, +) -> ContractBalanceOfQueryResponse { + let balance_of_params = ContractBalanceOfQueryParams { + queries: vec![ + BalanceOfQuery { + token_id: TOKEN_1, + address: ALICE_ADDR, + }, + BalanceOfQuery { + token_id: TOKEN_1, + address: BOB_ADDR, + }, + ], + }; + + let invoke = chain + .contract_invoke(ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.balanceOf".to_string()), + address: contract_address, + message: OwnedParameter::from_serial(&balance_of_params) + .expect("BalanceOf params"), + }) + .expect("Invoke balanceOf"); + let rv: ContractBalanceOfQueryResponse = + invoke.parse_return_value().expect("BalanceOf return value"); + rv +} + +/// Helper function that sets up the contract with two types of tokens minted to +/// Alice. She has 100 of `TOKEN_0` and 100 of `TOKEN_1`. +/// Alice's account is created with keys. +/// Hence, Alice's account signature can be checked in the test cases. +fn initialize_contract_with_alice_tokens( +) -> (Chain, AccountKeys, ContractAddress, ContractInvokeSuccess, ModuleReference) { + let (mut chain, keypairs, contract_address, module_reference) = initialize_chain_and_contract(); let mint_params = MintParams { owner: ALICE_ADDR, @@ -736,15 +1342,14 @@ fn initialize_contract_with_alice_tokens( }) .expect("Mint tokens"); - (chain, keypairs, contract_address, update) + (chain, keypairs, contract_address, update, module_reference) } /// Setup chain and contract. -/// -/// Also creates the two accounts, Alice and Bob. -/// -/// Alice is the owner of the contract. -fn initialize_chain_and_contract() -> (Chain, AccountKeys, ContractAddress) { +/// The function creates the five accounts: ALICE, BOB, UPGRADER, PAUSER, and +/// BLACKLISTER. The function grants ALICE the ADMIN role, the UPGRADER the +/// UPGRADE role, and the BLACKLISTER the BLACKLISTE role. +fn initialize_chain_and_contract() -> (Chain, AccountKeys, ContractAddress, ModuleReference) { let mut chain = Chain::new(); let rng = &mut rand::thread_rng(); @@ -760,6 +1365,9 @@ fn initialize_chain_and_contract() -> (Chain, AccountKeys, ContractAddress) { // Create some accounts on the chain. chain.create_account(Account::new_with_keys(ALICE, balance, (&keypairs).into())); chain.create_account(Account::new(BOB, ACC_INITIAL_BALANCE)); + chain.create_account(Account::new(UPGRADER, ACC_INITIAL_BALANCE)); + chain.create_account(Account::new(BLACKLISTER, ACC_INITIAL_BALANCE)); + chain.create_account(Account::new(PAUSER, ACC_INITIAL_BALANCE)); // Load and deploy the module. let module = module_load_v1("concordium-out/module.wasm.v1").expect("Module exists"); @@ -775,5 +1383,53 @@ fn initialize_chain_and_contract() -> (Chain, AccountKeys, ContractAddress) { }) .expect("Initialize contract"); - (chain, keypairs, init.contract_address) + // Grant UPGRADER role + let grant_role_params = GrantRoleParams { + address: UPGRADER_ADDR, + role: Roles::UPGRADER, + }; + + let _update = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.grantRole".to_string()), + address: init.contract_address, + message: OwnedParameter::from_serial(&grant_role_params) + .expect("GrantRole params"), + }) + .expect("UPGRADER should be granted role"); + + // Grant PAUSER role + let grant_role_params = GrantRoleParams { + address: PAUSER_ADDR, + role: Roles::PAUSER, + }; + + let _update = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.grantRole".to_string()), + address: init.contract_address, + message: OwnedParameter::from_serial(&grant_role_params) + .expect("GrantRole params"), + }) + .expect("PAUSER should be granted role"); + + // Grant BLACKLISTER role + let grant_role_params = GrantRoleParams { + address: BLACKLISTER_ADDR, + role: Roles::BLACKLISTER, + }; + + let _update = chain + .contract_update(SIGNER, ALICE, ALICE_ADDR, Energy::from(10000), UpdateContractPayload { + amount: Amount::zero(), + receive_name: OwnedReceiveName::new_unchecked("cis2_multi.grantRole".to_string()), + address: init.contract_address, + message: OwnedParameter::from_serial(&grant_role_params) + .expect("GrantRole params"), + }) + .expect("BLACKLISTER should be granted role"); + + (chain, keypairs, init.contract_address, deployment.module_reference) }