diff --git a/concordium-cis2/CHANGELOG.md b/concordium-cis2/CHANGELOG.md index cd441e72..19061b0f 100644 --- a/concordium-cis2/CHANGELOG.md +++ b/concordium-cis2/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased changes +- Added `Cis2Client` to the library. This can be used from one smart contract to call into other cis2 compatible smart contracts in a type safe way. + ## concordium-cis2 4.0.0 (2023-06-16) - Bump concordium-std to version 7. diff --git a/concordium-cis2/src/cis2_client.rs b/concordium-cis2/src/cis2_client.rs new file mode 100644 index 00000000..70f1e359 --- /dev/null +++ b/concordium-cis2/src/cis2_client.rs @@ -0,0 +1,686 @@ +//! CIS2 client is the intermediatory layer between any contract and +//! CIS2 compliant contract. +//! +//! # Description +//! It allows the contract to abstract away the logic of calling the +//! CIS2 contract for the following methods +//! - `supports_cis2` : Calls [`supports`](https://proposals.concordium.software/CIS/cis-0.html#supports) +//! - `operator_of` : Calls [`operatorOf`](https://proposals.concordium.software/CIS/cis-2.html#operatorof) +//! - `balance_of` : Calls [`balanceOf`](https://proposals.concordium.software/CIS/cis-2.html#balanceof) +//! - `transfer` : Calls [`transfer`](https://proposals.concordium.software/CIS/cis-2.html#transfer) +//! - `update_operator` : Calls [`updateOperator`](https://proposals.concordium.software/CIS/cis-2.html#updateoperator) + +use crate::*; +use concordium_std::*; + +const SUPPORTS_ENTRYPOINT_NAME: EntrypointName = EntrypointName::new_unchecked("supports"); +const OPERATOR_OF_ENTRYPOINT_NAME: EntrypointName = EntrypointName::new_unchecked("operatorOf"); +const BALANCE_OF_ENTRYPOINT_NAME: EntrypointName = EntrypointName::new_unchecked("balanceOf"); +const TRANSFER_ENTRYPOINT_NAME: EntrypointName = EntrypointName::new_unchecked("transfer"); +const UPDATE_OPERATOR_ENTRYPOINT_NAME: EntrypointName = + EntrypointName::new_unchecked("updateOperator"); + +pub type InvokeContractError = CallContractError>; + +/// Errors which can be returned by the `Cis2Client`. +#[derive(Debug)] +pub enum Cis2ClientError { + /// Invoking the contract returned the given error. + InvokeContractError(InvokeContractError), + /// The response from the contract could not be parsed. + ParseResult, + /// The response was not as expected, for example the response is an empty + /// vector for a single query. + InvalidResponse, +} + +impl Serial for Cis2ClientError { + fn serial(&self, out: &mut W) -> Result<(), W::Err> { + match self { + Cis2ClientError::InvokeContractError(e) => { + out.write_u8(2)?; + match e { + CallContractError::AmountTooLarge => out.write_u8(0), + CallContractError::MissingAccount => out.write_u8(1), + CallContractError::MissingContract => out.write_u8(2), + CallContractError::MissingEntrypoint => out.write_u8(3), + CallContractError::MessageFailed => out.write_u8(4), + CallContractError::LogicReject { + reason, + return_value, + } => { + out.write_u8(5)?; + reason.serial(out)?; + return_value.serial(out)?; + Ok(()) + } + CallContractError::Trap => out.write_u8(6), + } + } + Cis2ClientError::ParseResult => out.write_u8(0), + Cis2ClientError::InvalidResponse => out.write_u8(1), + } + } +} + +impl TryFrom> for Cis2ClientError { + type Error = Cis2ClientError; + + fn try_from(err: CallContractError) -> Result, Cis2ClientError> { + match err { + CallContractError::AmountTooLarge => { + Ok(Cis2ClientError::InvokeContractError(InvokeContractError::AmountTooLarge)) + } + CallContractError::MissingAccount => { + Ok(Cis2ClientError::InvokeContractError(InvokeContractError::MissingAccount)) + } + CallContractError::MissingContract => { + Ok(Cis2ClientError::InvokeContractError(InvokeContractError::MissingContract)) + } + CallContractError::MissingEntrypoint => { + Ok(Cis2ClientError::InvokeContractError(InvokeContractError::MissingEntrypoint)) + } + CallContractError::MessageFailed => { + Ok(Cis2ClientError::InvokeContractError(InvokeContractError::MessageFailed)) + } + CallContractError::LogicReject { + reason, + mut return_value, + } => Ok(Cis2ClientError::InvokeContractError(InvokeContractError::LogicReject { + reason, + return_value: Cis2Error::::deserial(&mut return_value)?, + })), + CallContractError::Trap => { + Ok(Cis2ClientError::InvokeContractError(InvokeContractError::Trap)) + } + } + } +} + +impl From for Cis2ClientError { + fn from(_: ParseError) -> Self { Cis2ClientError::ParseResult } +} + +/// Client for interacting with CIS2 compliant contracts. +/// +/// ## Examples +/// ```rust +/// use concordium_cis2::Cis2Client; +/// use concordium_std::ContractAddress; +/// let cis_contract_address = ContractAddress::new(0, 0); +/// Cis2Client::new(cis_contract_address); +/// ``` +pub struct Cis2Client { + contract: ContractAddress, +} + +impl Cis2Client { + pub fn new(contract: ContractAddress) -> Self { + Self { + contract, + } + } + + /// Calls the `supports` entrypoint of the CIS2 contract to check if the + /// given contract supports CIS2 standard. + /// If the contract supports CIS2 standard, it returns + /// `Ok(SupportResult::Support)`, else it returns + /// `Ok(SupportResult::NoSupport)`. If the contract supports CIS2 + /// standard by another contract, it returns + /// `Ok(SupportResult::SupportBy(Vec))`. If there is an + /// error, it returns `Err`. + /// + /// # Examples + /// ```rust + /// use concordium_cis2::*; + /// use concordium_std::{test_infrastructure::*, *}; + /// let mut host = TestHost::new((), TestStateBuilder::new()); + /// host.setup_mock_entrypoint( + /// ContractAddress::new(0, 0), + /// OwnedEntrypointName::new_unchecked("supports".to_string()), + /// MockFn::new_v1(|_, _, _, _| { + /// Ok((false, SupportsQueryResponse { + /// results: vec![SupportResult::Support], + /// })) + /// }), + /// ); + /// + /// let client = Cis2Client::new(ContractAddress::new(0, 0)); + /// let res: Result> = client.supports_cis2(&host); + /// assert!(res.is_ok()); + /// match res.unwrap() { + /// SupportResult::NoSupport => fail!(), + /// SupportResult::Support => (), + /// SupportResult::SupportBy(_) => fail!(), + /// } + /// ``` + pub fn supports_cis2( + &self, + host: &impl HasHost, + ) -> Result> { + let params = SupportsQueryParams { + queries: vec![CIS2_STANDARD_IDENTIFIER.to_owned()], + }; + let mut res: SupportsQueryResponse = + self.invoke_contract_read_only(host, SUPPORTS_ENTRYPOINT_NAME, ¶ms)?; + let res = res.results.pop().ok_or(Cis2ClientError::InvalidResponse)?; + + Ok(res) + } + + /// Calls the `operatorOf` entrypoint of the CIS2 contract to check if the + /// given owner is an operator of the given contract. If the owner is an + /// operator of the given contract, it returns `Ok(true)`, + /// else it returns `Ok(false)`. + /// If there is an error, it returns `Err`. + /// + /// # Examples + /// ```rust + /// use concordium_cis2::*; + /// use concordium_std::{test_infrastructure::*, *}; + /// + /// let mut host = TestHost::new((), TestStateBuilder::new()); + /// host.setup_mock_entrypoint( + /// ContractAddress::new(0, 0), + /// OwnedEntrypointName::new_unchecked("operatorOf".to_string()), + /// MockFn::new_v1(|_, _, _, _| { + /// Ok((false, OperatorOfQueryResponse { + /// 0: vec![true], + /// })) + /// }), + /// ); + /// + /// let client = Cis2Client::new(ContractAddress::new(0, 0)); + /// let res: Result> = client.operator_of( + /// &mut host, + /// Address::Account(AccountAddress([1; 32])), + /// Address::Contract(ContractAddress::new(1, 0)), + /// ); + /// + /// assert_eq!(res.unwrap(), true); + /// ``` + pub fn operator_of( + &self, + host: &impl HasHost, + owner: Address, + address: Address, + ) -> Result> { + let params = &OperatorOfQueryParams { + queries: vec![OperatorOfQuery { + owner, + address, + }], + }; + let mut res: OperatorOfQueryResponse = + self.invoke_contract_read_only(host, OPERATOR_OF_ENTRYPOINT_NAME, params)?; + let res = res.0.pop().ok_or(Cis2ClientError::InvalidResponse)?; + + Ok(res) + } + + /// Calls the `balanceOf` entrypoint of the CIS2 contract to get the balance + /// of the given owner for the given token. If the balance is returned, + /// it returns `Ok(balance)`, else it returns `Err`. + /// # Examples + /// ```rust + /// use concordium_cis2::*; + /// use concordium_std::{test_infrastructure::*, *}; + /// let mut host = TestHost::new((), TestStateBuilder::new()); + /// host.setup_mock_entrypoint( + /// ContractAddress::new(0, 0), + /// OwnedEntrypointName::new_unchecked("balanceOf".to_string()), + /// MockFn::new_v1(|_, _, _, _| { + /// Ok((false, BalanceOfQueryResponse::(vec![1.into()]))) + /// }), + /// ); + /// + /// let client = Cis2Client::new(ContractAddress::new(0, 0)); + /// let res: Result> = + /// client.balance_of(&host, TokenIdU8(1), Address::Account(AccountAddress([1; 32]))); + /// assert!(res.is_ok()); + /// assert_eq!(res.unwrap(), 1.into()); + /// ``` + pub fn balance_of( + &self, + host: &impl HasHost, + token_id: T, + address: Address, + ) -> Result> { + let params = BalanceOfQueryParams { + queries: vec![BalanceOfQuery { + token_id, + address, + }], + }; + + let mut res: BalanceOfQueryResponse = + self.invoke_contract_read_only(host, BALANCE_OF_ENTRYPOINT_NAME, ¶ms)?; + let res = res.0.pop().ok_or(Cis2ClientError::InvalidResponse)?; + + Ok(res) + } + + /// Calls the `transfer` entrypoint of the CIS2 contract to transfer the + /// given amount of tokens from the given owner to the given receiver. + /// If the transfer is successful and the state is modified, it returns + /// `Ok(true)`, else it returns `Ok(false)`. If there is an error, it + /// returns `Err`. + /// + /// # Examples + /// ```rust + /// use concordium_cis2::*; + /// use concordium_std::{test_infrastructure::*, *}; + /// let mut host = TestHost::new((), TestStateBuilder::new()); + /// host.setup_mock_entrypoint( + /// ContractAddress::new(0, 0), + /// OwnedEntrypointName::new_unchecked("transfer".to_string()), + /// MockFn::new_v1(|_, _, _, _| Ok((false, ()))), + /// ); + /// + /// let client = Cis2Client::new(ContractAddress::new(0, 0)); + /// let res: Result> = client.transfer(&mut host, Transfer { + /// amount: TokenAmountU8(1), + /// from: Address::Account(AccountAddress([1; 32])), + /// to: Receiver::Account(AccountAddress([2; 32])), + /// token_id: TokenIdU8(1), + /// data: AdditionalData::empty(), + /// }); + /// + /// assert!(res.is_ok()); + /// ``` + pub fn transfer( + &self, + host: &mut impl HasHost, + transfer: Transfer, + ) -> Result> { + let params = TransferParams(vec![transfer]); + let (state_modified, _): (bool, Option<()>) = + self.invoke_contract(host, TRANSFER_ENTRYPOINT_NAME, ¶ms)?; + + Ok(state_modified) + } + + /// Calls the `updateOperator` of the CIS2 contract. + /// If the update is successful and the state is modified, it returns + /// `Ok(true)`, else it returns `Ok(false)`. If there is an error, it + /// returns `Err`. + /// + /// # Examples + /// ```rust + /// use concordium_cis2::*; + /// use concordium_std::{test_infrastructure::*, *}; + /// let mut host = TestHost::new((), TestStateBuilder::new()); + /// host.setup_mock_entrypoint( + /// ContractAddress::new(0, 0), + /// OwnedEntrypointName::new_unchecked("updateOperator".to_string()), + /// MockFn::new_v1(|_, _, _, _| Ok((false, ()))), + /// ); + /// + /// let client = Cis2Client::new(ContractAddress::new(0, 0)); + /// let res: Result> = client.update_operator( + /// &mut host, + /// Address::Account(AccountAddress([1; 32])), + /// OperatorUpdate::Add, + /// ); + /// + /// assert!(res.is_ok()); + /// ``` + pub fn update_operator( + &self, + host: &mut impl HasHost, + operator: Address, + update: OperatorUpdate, + ) -> Result> { + let params = UpdateOperator { + operator, + update, + }; + let (state_modified, _): (bool, Option<()>) = + self.invoke_contract(host, UPDATE_OPERATOR_ENTRYPOINT_NAME, ¶ms)?; + + Ok(state_modified) + } + + fn invoke_contract_read_only( + &self, + host: &impl HasHost, + method: EntrypointName, + parameter: &P, + ) -> Result> { + let res = + host.invoke_contract_read_only(&self.contract, parameter, method, Amount::from_ccd(0)); + + let res = match res { + Ok(val) => val, + Err(err) => return Err(Cis2ClientError::::try_from(err)?), + }; + + let res = match res { + // Since the contract should return a response. If it doesn't, it is an error. + Some(mut res) => R::deserial(&mut res)?, + None => bail!(Cis2ClientError::InvalidResponse), + }; + + Ok(res) + } + + fn invoke_contract( + &self, + host: &mut impl HasHost, + method: EntrypointName, + parameter: &P, + ) -> Result<(bool, Option), Cis2ClientError> { + let res = host.invoke_contract(&self.contract, parameter, method, Amount::from_ccd(0)); + + let res = match res { + Ok(val) => { + let o = match val.1 { + Some(mut res) => Some(R::deserial(&mut res)?), + None => None, + }; + (val.0, o) + } + Err(err) => return Err(Cis2ClientError::::try_from(err)?), + }; + + Ok(res) + } +} + +#[cfg(test)] +mod test { + use crate::*; + use concordium_std::test_infrastructure::*; + + const INDEX: u64 = 0; + const SUBINDEX: u64 = 0; + type ContractTokenId = TokenIdU8; + type ContractTokenAmount = TokenAmountU8; + + #[test] + fn supports_cis2_test() { + let mut host = TestHost::new((), TestStateBuilder::new()); + fn mock_supports( + parameter: Parameter, + _a: Amount, + _a2: &mut Amount, + _s: &mut (), + ) -> Result<(bool, SupportsQueryResponse), CallContractError> + { + // Check that parameters are deserialized correctly. + let mut cursor = Cursor::new(parameter); + let params: Result = + SupportsQueryParams::deserial(&mut cursor); + assert!(params.is_ok()); + let params = params.unwrap(); + assert_eq!( + params.queries[0], + StandardIdentifierOwned::new_unchecked("CIS-2".to_owned()) + ); + + // Return a response with support. + Ok((false, SupportsQueryResponse { + results: vec![SupportResult::Support], + })) + } + + let cis_contract_address = ContractAddress::new(INDEX, SUBINDEX); + host.setup_mock_entrypoint( + cis_contract_address, + OwnedEntrypointName::new_unchecked("supports".to_string()), + MockFn::new_v1(mock_supports), + ); + + let client = Cis2Client::new(cis_contract_address); + let res: Result> = client.supports_cis2(&host); + assert!(res.is_ok()); + match res.unwrap() { + SupportResult::NoSupport => fail!(), + SupportResult::Support => (), + SupportResult::SupportBy(_) => fail!(), + } + } + + #[test] + fn supports_cis2_test_no_support() { + let mut host = TestHost::new((), TestStateBuilder::new()); + let cis_contract_address = ContractAddress::new(INDEX, SUBINDEX); + fn mock_supports( + _p: Parameter, + _a: Amount, + _a2: &mut Amount, + _s: &mut (), + ) -> Result<(bool, SupportsQueryResponse), CallContractError> + { + Ok((false, SupportsQueryResponse { + results: vec![SupportResult::NoSupport], + })) + } + + host.setup_mock_entrypoint( + cis_contract_address, + OwnedEntrypointName::new_unchecked("supports".to_string()), + MockFn::new_v1(mock_supports), + ); + + let client = Cis2Client::new(cis_contract_address); + let res: Result> = client.supports_cis2(&host); + assert!(res.is_ok()); + match res.unwrap() { + SupportResult::NoSupport => (), + SupportResult::Support => fail!(), + SupportResult::SupportBy(_) => fail!(), + } + } + + #[test] + fn supports_cis2_test_supported_by_other_contract() { + let mut host = TestHost::new((), TestStateBuilder::new()); + let cis_contract_address = ContractAddress::new(INDEX, SUBINDEX); + fn mock_supports( + _p: Parameter, + _a: Amount, + _a2: &mut Amount, + _s: &mut (), + ) -> Result<(bool, SupportsQueryResponse), CallContractError> + { + Ok((false, SupportsQueryResponse { + results: vec![SupportResult::SupportBy(vec![ContractAddress::new( + INDEX, + SUBINDEX + 1, + )])], + })) + } + + host.setup_mock_entrypoint( + cis_contract_address, + OwnedEntrypointName::new_unchecked("supports".to_string()), + MockFn::new_v1(mock_supports), + ); + + let client = Cis2Client::new(cis_contract_address); + let res: Result> = client.supports_cis2(&host); + match res.unwrap() { + SupportResult::NoSupport => fail!(), + SupportResult::Support => fail!(), + SupportResult::SupportBy(addresses) => { + assert_eq!(addresses.first(), Some(&ContractAddress::new(INDEX, SUBINDEX + 1))) + } + } + } + + #[test] + fn operator_of_test() { + let mut host = TestHost::new((), TestStateBuilder::new()); + let cis_contract_address = ContractAddress::new(INDEX, SUBINDEX); + let owner = Address::Account(AccountAddress([1; 32])); + let current_contract_address = Address::Contract(ContractAddress::new(INDEX + 1, SUBINDEX)); + fn mock_operator_of( + parameter: Parameter, + _a: Amount, + _a2: &mut Amount, + _s: &mut (), + ) -> Result<(bool, OperatorOfQueryResponse), CallContractError> + { + // Check that parameters are deserialized correctly. + let mut cursor = Cursor::new(parameter); + let params: Result = + OperatorOfQueryParams::deserial(&mut cursor); + match params { + Ok(params) => { + assert_eq!( + params.queries[0].address, + Address::Contract(ContractAddress::new(INDEX + 1, SUBINDEX)) + ); + assert_eq!(params.queries[0].owner, Address::Account(AccountAddress([1; 32]))); + } + Err(_) => fail!(), + }; + + // Return a response with operator true. + Ok((false, OperatorOfQueryResponse { + 0: vec![true], + })) + } + + host.setup_mock_entrypoint( + cis_contract_address, + OwnedEntrypointName::new_unchecked("operatorOf".to_string()), + MockFn::new_v1(mock_operator_of), + ); + + let client = Cis2Client::new(cis_contract_address); + let res: Result> = + client.operator_of(&mut host, owner, current_contract_address); + + assert_eq!(res.unwrap(), true); + } + + #[test] + fn balance_of_test() { + let mut host = TestHost::new((), TestStateBuilder::new()); + let cis_contract_address = ContractAddress::new(INDEX, SUBINDEX); + let owner = Address::Account(AccountAddress([1; 32])); + fn mock_balance_of( + parameter: Parameter, + _a: Amount, + _a2: &mut Amount, + _s: &mut (), + ) -> Result< + (bool, BalanceOfQueryResponse), + CallContractError>, + > { + // Check that parameters are deserialized correctly. + let mut cursor = Cursor::new(parameter); + let params: Result, ParseError> = + BalanceOfQueryParams::deserial(&mut cursor); + assert!(params.is_ok()); + let params = params.unwrap(); + assert_eq!(params.queries[0].token_id, TokenIdU8(1)); + assert_eq!(params.queries[0].address, Address::Account(AccountAddress([1; 32]))); + + // Return a balance of 1. + Ok((false, BalanceOfQueryResponse(vec![1.into()]))) + } + + host.setup_mock_entrypoint( + cis_contract_address, + OwnedEntrypointName::new_unchecked("balanceOf".to_string()), + MockFn::new_v1(mock_balance_of), + ); + + let client = Cis2Client::new(cis_contract_address); + let res: Result> = + client.balance_of(&host, TokenIdU8(1), owner); + + assert!(res.is_ok()); + let res: ContractTokenAmount = res.unwrap(); + assert_eq!(res, 1.into()); + } + + #[test] + fn transfer_test() { + let mut host = TestHost::new((), TestStateBuilder::new()); + let cis_contract_address = ContractAddress::new(INDEX, SUBINDEX); + let from = Address::Account(AccountAddress([1; 32])); + let to_account = AccountAddress([2; 32]); + let amount: ContractTokenAmount = 1.into(); + + fn mock_transfer( + parameter: Parameter, + _a: Amount, + _a2: &mut Amount, + _s: &mut (), + ) -> Result<(bool, ()), CallContractError<()>> { + // Check that parameters are deserialized correctly. + let mut cursor = Cursor::new(parameter); + let params: Result, ParseError> = + TransferParams::deserial(&mut cursor); + assert!(params.is_ok()); + let params = params.unwrap(); + assert_eq!(params.0[0].token_id, TokenIdU8(1)); + assert_eq!(params.0[0].to.address(), Address::Account(AccountAddress([2; 32]))); + assert_eq!(params.0[0].amount, 1.into()); + + // Return a successful transfer. + Ok((false, ())) + } + + host.setup_mock_entrypoint( + cis_contract_address, + OwnedEntrypointName::new_unchecked("transfer".to_string()), + MockFn::new_v1(mock_transfer), + ); + + let client = Cis2Client::new(cis_contract_address); + let res: Result> = client.transfer(&mut host, Transfer { + amount, + from, + to: Receiver::Account(to_account), + token_id: TokenIdU8(1), + data: AdditionalData::empty(), + }); + + assert!(res.is_ok()); + } + + #[test] + fn update_operator_test() { + fn mock_update_operator( + parameter: Parameter, + _a: Amount, + _a2: &mut Amount, + _s: &mut (), + ) -> Result<(bool, ()), CallContractError<()>> { + // Check that parameters are deserialized correctly. + let mut cursor = Cursor::new(parameter); + let params: Result = UpdateOperator::deserial(&mut cursor); + assert!(params.is_ok()); + let params = params.unwrap(); + assert_eq!(params.operator, Address::Account(AccountAddress([1; 32]))); + match params.update { + OperatorUpdate::Add => (), + OperatorUpdate::Remove => fail!(), + } + + // Return a successful update. + Ok((false, ())) + } + let mut host = TestHost::new((), TestStateBuilder::new()); + let cis_contract_address = ContractAddress::new(INDEX, SUBINDEX); + host.setup_mock_entrypoint( + cis_contract_address, + OwnedEntrypointName::new_unchecked("updateOperator".to_string()), + MockFn::new_v1(mock_update_operator), + ); + + let client = Cis2Client::new(cis_contract_address); + let res: Result> = client.update_operator( + &mut host, + Address::Account(AccountAddress([1; 32])), + OperatorUpdate::Add, + ); + + assert!(res.is_ok()); + } +} diff --git a/concordium-cis2/src/lib.rs b/concordium-cis2/src/lib.rs index ce40a8fe..bdbc7446 100644 --- a/concordium-cis2/src/lib.rs +++ b/concordium-cis2/src/lib.rs @@ -31,6 +31,10 @@ //! When `u256_amount` feature is enabled the type [`TokenAmountU256`] is defined //! and implements the [`IsTokenAmount`] interface. #![cfg_attr(not(feature = "std"), no_std)] + +mod cis2_client; +pub use cis2_client::{Cis2Client, Cis2ClientError}; + use concordium_std::{collections::BTreeMap, *}; // Re-export for backward compatibility. pub use concordium_std::MetadataUrl; @@ -862,7 +866,7 @@ impl schema::SchemaType for Cis2Event { } /// The different errors the contract can produce. -#[derive(Debug, PartialEq, Eq, SchemaType, Serial)] +#[derive(Debug, PartialEq, Eq, SchemaType, Serial, Deserial)] pub enum Cis2Error { /// Invalid token id (Error code: -42000001). InvalidTokenId,