Contract Interactions with Concordium-Rust-SDK

The tutorial demonstrates how to interact with smart contracts on Concordium blockchain using Rust-SDK, covering deployment, token minting, metadata checking, and contract function invocation.

Step 1: Setting Up the Client Project

Let’s continue with the client development. First, you will create a new instance of your deployed contract with the module reference you have. Follow the steps below:

  1. Create a new directory for the client project using Cargo:

cargo new cis2-rust-sdk-minting
  1. Open the "Cargo.toml" file and add the required dependencies for the project, including SDK, web client components, command-line and error handlers, serialization, and time libraries:

[dependencies]
concordium-rust-sdk = "2"
tokio = { version = "1", features = ["full"] }
warp = "0.3"
log = "0.4.11"
env_logger = "0.9"
clap = { version = "4", features = ["derive"] }
anyhow = "1.0"
chrono = "0.4.19"
thiserror = "1"
structopt = "0.3.26"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
strum = "0.24"
strum_macros = "0.24"
  1. In the "main.rs" file, import the necessary libraries and define the required enums and structs, including:

    • A command-line argument parser (clap) for obtaining parameters.

    • Structopt for parsing the input parameters as a struct.

    • The path library to manipulate file paths, for example, the path of our wallet file.

    • Several internal Concordium Rust SDK functions for making transactions, serialization, managing accounts, and more.

use crate::clap::AppSettings;
use anyhow::Context;
use concordium_rust_sdk::{
    common::{self, types::TransactionTime, SerdeDeserialize, SerdeSerialize},
    smart_contracts::{
        common as concordium_std,
        common::Amount,
        types::{OwnedContractName, OwnedReceiveName},
    },
    types::{
        smart_contracts::{ModuleReference, OwnedParameter, WasmModule},
        transactions::{send, BlockItem, InitContractPayload, UpdateContractPayload},
        AccountInfo, ContractAddress, WalletAccount,
    },
    v2,
};
use std::path::PathBuf;
use structopt::*;

use strum_macros::EnumString;

Step 2: Defining Actions Enum

  1. Define an enum called Action to represent different actions, such as deploying a module, initializing a contract, or interacting with the contract using a schema. You'll specify the following transaction types:

    • Deploy Action: The module's path.

    • Init Action: The deployed module reference.

    • WithSchema Action: The transaction parameter for minting, transferring, or viewing the contract state.

    Note that all will be invokable with the schema file.

  2. Implement the StructOpt trait for the Action enum to parse command-line arguments.

The code for defining the Actions Enum is provided below:

#[derive(StructOpt)]
enum Action {
    #[structopt(about = "Deploy the module")]
    Deploy {
        #[structopt(long = "module", help = "Path to the contract module.")]
        module_path: PathBuf,
    },
    #[structopt(about = "Initialize the CIS-2 NFT contract")]
    Init {
        #[structopt(
            long,
            help = "The module reference used for initializing the contract instance."
        )]
        module_ref: ModuleReference,
    },
    #[structopt(
        about = "Update the contract and set the provided  using JSON parameters and a \
                 schema."
    )]
    WithSchema {
        #[structopt(long, help = "Path of the JSON parameter.")]
        parameter: PathBuf,
        #[structopt(long, help = "Path to the schema.")]
        schema: PathBuf,
        #[structopt(long, help = "The contract to update.")]
        address: ContractAddress,
        #[structopt(long, help = "Transaction Type")]
        transaction_type_: TransactionType,
    },
}

Step 3: Handling Transaction Types

  1. Define an enum called TransactionType to specify different transaction types, such as minting, transferring, token metadata, or viewing.

  2. Implement the StructOpt and EnumString traits for the TransactionType enum to parse command-line arguments.

The code below handles the transaction types:

#[derive(StructOpt, EnumString)]
enum TransactionType {
    #[structopt(about = "Mint")]
    Mint,
    #[structopt(about = "Transfer")]
    Transfer,
    #[structopt(about = "TokenMetadata")]
    TokenMetadata,
    #[structopt(about = "View")]
    View,
}

Step 4: Handling Transaction Results

Define an enum called TransactionResult to handle different types of transaction results. Note the following:

  • TransactionResult enum will be used to handle errors and different results returned from each match statement.

  • State-changing transactions such as init_contract, deploy_contract, and update_contract will be treated differently from tokenMetadata() and view() functions, as they do not cause state changes.

  • The function should be used to call view functions without causing state changes, which returns a specific type.

  • Having a parent enum like TransactionResult helps in returning consistent types while allowing individual manipulation for each type of transaction result.

Use the following code to handle the Transaction Results:

#[derive(Debug)]
enum TransactionResult {
    StateChanging(AccountTransaction<EncodedPayload>),
    None,
}

Step 5: Configuring Application Parameters

  1. Define a struct called App to represent application parameters, including node connection endpoint, account keys path, and the action to perform.

  2. Implement the StructOpt trait for the App struct to parse command-line arguments.

Note the following:

  • You will be configuring the base settings, including the node setup.

  • Since you will deploy this contract to the testnet, the default provided by Concordium will be used, which is testnet.node.concordium.com.

  • Additionally, you will need your key file path (exported from the wallet) and the action.

  • All of these parameters should be configurable from the terminal as input parameters.

Use the code below to configure your application parameters:

/// Node connection, key path and the action input struct
#[derive(StructOpt)]
struct App {
    #[structopt(
        long = "node",
        help = "GRPC interface of the node.",
        default_value = "http://node.testnet.concordium.com:20000"
    )]
    endpoint: v2::Endpoint,
    #[structopt(long = "account", help = "Path to the account key file.")]
    keys_path: PathBuf,
    #[structopt(subcommand, help = "The action you want to perform.")]
    action: Action,
}

Step 6: Implementing the Main Function

  1. Implement the main() function as an asynchronous function using tokio::main. It is a multi-threaded runtime capable of handling multiple requests simultaneously.

  2. Inside the main() function, parse command-line arguments using the App struct and perform necessary setup tasks, such as establishing a connection with the Concordium node and loading account keys.

Please note the following:

  • The main() function reads inputs from the terminal and establishes a connection with Concordium by creating a client.

  • You'll upload your key file by providing its path.

  • You'll retrieve the nonce of the last finalized block to obtain the full list of onboarded accounts.

  • You'll check the action type to determine whether this will be a Deploy, Init, or WithSchema transaction using a match or if-else statement. (In Rust, there is no switch-case statement.)

Let's begin coding with Deploy and Init first, then proceed with WithSchema:

#[tokio::main(flavor = "multi_thread")]
async fn main() -> anyhow::Result<()> {
    use base64::{engine::general_purpose, Engine as _};
    let app = {
        let app = App::clap().global_setting(AppSettings::ColoredHelp);
        let matches = app.get_matches();
        App::from_clap(&matches)
    };

    let mut client = v2::Client::new(app.endpoint)
        .await
        .context("Cannot connect.")?;

    // load account keys and sender address from a file
    let keys: WalletAccount =
        WalletAccount::from_json_file(app.keys_path).context("Could not read the keys file.")?;

    // Get the initial nonce at the last finalized block.
    let acc_info: AccountInfo = client
        .get_account_info(&keys.address.into(), &v2::BlockIdentifier::Best)
        .await?
        .response;

    let nonce = acc_info.account_nonce;
    // set expiry to now + 5min
    let expiry: TransactionTime =
        TransactionTime::from_seconds((chrono::Utc::now().timestamp() + 300) as u64);

Step 7: Deploying Contract

  1. To deploy the contract and perform all other transactions, use the send() wrapper from the concordium-rust-sdk within the transactions library. Then, proceed as follows:

    • Retrieve the compiled WebAssembly (wasm) smart contract module.

    • Deserialize it, then invoke the deploy_module() function from the same library.

  2. To organize your directory, create a folder named nft-params and copy both the exported wallet file and the module from the concordium-out directory into it:

let tx = match app.action {
        Action::Deploy { module_path } => {
            let contents = std::fs::read(module_path).context("Could not read contract module.")?;
            let payload: WasmModule =
                common::Deserial::deserial(&mut std::io::Cursor::new(contents))?;
            TransactionResult::StateChanging(send::deploy_module(
                &keys,
                keys.address,
                nonce,
                expiry,
                payload,
            ))
        }

This is how your "main.rs" file would look like:

  1. Build your file, then run the executable in the target/debug folder with the following command:

cargo build
cd target/debug
./cis2-rust-sdk-minting --account ../../nft-params/wallet.export deploy --module ../../nft-params/module.wasm.v1

This would be the result of the command:

Now, you have successfully deployed your smart contract!

Step 8: Initializing Contract

  1. Create a new instance of the deployed contract and note the following:

    • In the first step, verify whether the action is "Init."

    • Add an empty "OwnedParam" since your smart contract's init function doesn't require an input parameter.

    • There is no amount required for this function as a payment.

    • The "init" function itself necessitates the module reference that you obtained in the previous step.

    • Invoke the "init_contract()" function from the send wrapper of the transactions library.

Action::Init {
        module_ref: mod_ref,
    } => {
        let param = OwnedParameter::empty();
        //                 .expect("Known to not exceed parameter size limit.");
        let payload = InitContractPayload {
            amount: Amount::zero(),
            mod_ref,
            init_name: OwnedContractName::new_unchecked(
                "init_rust_sdk_minting_tutorial".to_string(),
            ),
            param,
        };
        TransactionResult::StateChanging(send::init_contract(
            &keys,
            keys.address,
            nonce,
            expiry,
            payload,
            10000u64.into(),
        ))
}
  1. Run the following command:

./cis2-rust-sdk-minting --account ../../nft-params/wallet.export init --module-ref <YOUR-MODULE-REFERENCE> 

This will be the result of running the command:

In the following sections, you will use the schema file either while changing the state with transfer(), mint() functions or print return values in the form of JSON from the contract.

Step 9: Using Schema in View and State-Changing Functions

You will need the schema file when calling the mint() and transfer() functions, as well as any view function for printing, including tokenMetadata() and view().

Note the following:

  • You are required to read and load the schema from the .bs64 output file.

  • Copy and paste the schema from "concordium-out" to the "nft-params" folder for convenience.

  • The base64 encoding does not include padding, so decode it accordingly.

  • The TransactionType enum helps you distinguish between transactions because each requires different parameters, invokes different functions, and utilizes different schema parts.

  • Due to the potential for a mismatch in return types in the match statement, after every transaction, the return type is TransactionResult.

  • Depending on the transaction, it will return either TransactionResult::StateChanging (if it's a mint or transfer) or TransactionResult::None (if it's a view function).

Action::WithSchema {
    parameter,
    schema,
    address,
    transaction_type_,
} => {
    let parameter: serde_json::Value = serde_json::from_slice(
        &std::fs::read(parameter.unwrap()).context("Unable to read parameter file.")?,
    )
    .context("Unable to parse parameter JSON.")?;

    let schemab64 = std::fs::read(schema).context("Unable to read the schema file.")?;
    let schema_source = general_purpose::STANDARD_NO_PAD.decode(schemab64);

    let schema = concordium_std::from_bytes::<concordium_std::schema::VersionedModuleSchema>(
        &schema_source?,
    )?;
    // schema_global = schema;
    match transaction_type_ {
        TransactionType::Mint => {
            let param_schema =
                schema.get_receive_param_schema("rust_sdk_minting_tutorial", "mint")?;
            let serialized_parameter = param_schema.serial_value(&parameter)?;
            let message = OwnedParameter::try_from(serialized_parameter).unwrap();
            let payload = UpdateContractPayload {
                amount: Amount::zero(),
                address,
                receive_name: OwnedReceiveName::new_unchecked(
                    "rust_sdk_minting_tutorial.mint".to_string(),
                ),
                message,
            };

            TransactionResult::StateChanging(send::update_contract(
                &keys,
                keys.address,
                nonce,
                expiry,
                payload,
                10000u64.into(),
            ))
        }
        //// Transfer Transaction which changes the state
        TransactionType::Transfer => {
            let param_schema =
                schema.get_receive_param_schema("rust_sdk_minting_tutorial", "transfer")?;
            let serialized_parameter = param_schema.serial_value(&parameter)?;
            let message = OwnedParameter::try_from(serialized_parameter).unwrap();
            let payload = UpdateContractPayload {
                amount: Amount::zero(),
                address,
                receive_name: OwnedReceiveName::new_unchecked(
                    "rust_sdk_minting_tutorial.transfer".to_string(),
                ),
                message,
            };
            //// call update contract with the payload
            TransactionResult::StateChanging(send::update_contract(
                &keys,
                keys.address,
                nonce,
                expiry,
                payload,
                10000u64.into(),
            ))
        }
        /// Token Metadata function with no state change
        TransactionType::TokenMetadata => {
            let param_schema = schema
                .get_receive_param_schema("rust_sdk_minting_tutorial", "tokenMetadata")?;
            let rv_schema = schema.get_receive_return_value_schema(
                "rust_sdk_minting_tutorial",
                "tokenMetadata",
            )?;

            let serialized_parameter = param_schema.serial_value(&parameter)?;
            let context = ContractContext {
                invoker: None, //Account(AccountAddress),
                contract: address,
                amount: Amount::zero(),
                method: OwnedReceiveName::new_unchecked(
                    "rust_sdk_minting_tutorial.tokenMetadata".to_string(),
                ),
                parameter: OwnedParameter::try_from(serialized_parameter).unwrap(), //Default::default(),
                energy: 1000000.into(),
            };
            // invoke instance
            let info = client
                .invoke_instance(&BlockIdentifier::Best, &context)
                .await?;

            match info.response {
                    concordium_rust_sdk::types::smart_contracts::InvokeContractResult::Success { return_value, .. } => {
                        let bytes: concordium_rust_sdk::types::smart_contracts::ReturnValue = return_value.unwrap();
                        // deserialize and print return value
                        println!( "{}",rv_schema.to_json_string_pretty(&bytes.value)?);//jsonxf::pretty_print(&param_schema.to_json_string_pretty(&bytes.value)?).unwrap());
                    }
                    _ => {
                        println!("Could'nt succesfully invoke the instance. Check the parameters.")
                    }
                }
            TransactionResult::None

            // info
        }
        TransactionType::View => {
            let rv_schema = schema
                .get_receive_return_value_schema("rust_sdk_minting_tutorial", "view")?;

            let context = ContractContext {
                invoker: None, //Account(AccountAddress),
                contract: address,
                amount: Amount::zero(),
                method: OwnedReceiveName::new_unchecked(
                    "rust_sdk_minting_tutorial.view".to_string(),
                ),
                parameter: Default::default(),
                energy: 1000000.into(),
            };
            // invoke instance
            let info = client
                .invoke_instance(&BlockIdentifier::Best, &context)
                .await?;

            match info.response {
                    concordium_rust_sdk::types::smart_contracts::InvokeContractResult::Success { return_value, .. } => {
                        let bytes: concordium_rust_sdk::types::smart_contracts::ReturnValue = return_value.unwrap();
                        // deserialize and print return value
                        println!( "{}",rv_schema.to_json_string_pretty(&bytes.value)?);//jsonxf::pretty_print(&param_schema.to_json_string_pretty(&bytes.value)?).unwrap());
                    }
                    _ => {
                        println!("Could'nt succesfully invoke the instance. Check the parameters.")
                    }
                }
            TransactionResult::None

            // info
        }
    }
} 

Step 10: Handling Transaction Results

Finally, you'll implement one last match statement with TransactionResult for the transaction output. This section will handle the following tasks:

  1. Displaying transaction details, including:

    • Module reference during deployment

    • Contract address upon initialization

    • Rejection reason, if applicable, by referencing BlockSummaryDetails

  2. Handling the return values of view functions discussed earlier. These values are managed within this final match statement for a smooth program exit.

Use the following code to handle the Transaction Results:

match tx {
    TransactionResult::StateChanging(result) => {
        let item = BlockItem::AccountTransaction(result);
        // submit the transaction to the chain
        let transaction_hash = client.send_block_item(&item).await?;
        println!(
            "Transaction {} submitted (nonce = {}).",
            transaction_hash, nonce,
        );
        let (bh, bs) = client.wait_until_finalized(&transaction_hash).await?;
        println!("Transaction finalized in block {}.", bh);

        match bs.details {
            BlockItemSummaryDetails::AccountTransaction(ad) => {
                match ad.effects {
                    AccountTransactionEffects::ModuleDeployed { module_ref } => {
                        println!("module ref is {}", module_ref);
                    }
                    AccountTransactionEffects::ContractInitialized { data } => {
                        println!("Contract address is {}", data.address);
                    }
                    AccountTransactionEffects::None {
                        transaction_type,
                        reject_reason,
                    } => {
                        println!("The Rejection Outcome is {:#?}", reject_reason);
                    }
                    _ => (),
                };
            }
            BlockItemSummaryDetails::AccountCreation(_) => (),
            BlockItemSummaryDetails::Update(_) => {
                println!("Transaction finalized in block {:?}.", bs.details);
                ()
            }
        };
    }
     TransactionResult::None => {
        println!("No state changes, already printed, gracefully exiting.");
    }
}

Step 11: Minting Tokens

Now, you can call the mint() function from your new instance. You can follow the instructions from the developer portal for the complete minting tutorial.

To mint the tokens, use the following:

  1. Create a JSON file named "nft-params.json" in the "nft-params" folder. This file will contain parameters for minting tokens, such as the owner's account address and token IDs.

{
    "owner": {
        "Account": ["YOUR-ACCOUNT-ADDRESS"]
    },
    "tokens": ["TOKEN-ID"]
}
  1. If needed, use the --help keyword to check the available parameters:

  1. Invoke the with-schema command to mint tokens. This command requires the contract address, parameters, schema, and transaction type. The transaction type should be specified using the --transaction-type flag (e.g., Mint).

Note the following:

  • The program supports multiple transaction types such as mint, transfer, view, burn, etc.

  • Another enum called TransactionType is added to specify the type of transaction. This enum is necessary while starting the program using the command line.

  • You are expected to provide JSON parameters and a schema file, both of which will be read from the provided path.

For further details, use the --help option.

Use the command below to invoke the mint() function.

./cis2-rust-sdk-minting --account ../../nft-params/wallet.export with-schema --address "<INDEX,SUBINDEX>" --parameter ../../nft-params/nft-params.json --schema ../../nft-params/module-schema.bs64 --transaction-type Mint

Upon successful execution, you have minted your first token using Concordium Rust-SDK:

Step 12: Checking Token Metadata

Let’s check our token’s metadata URL by invoking the tokenMetadata() function of cis2-nft, which requires a token_id. To do this, use the following:

  1. Create a JSON file named "token-id.json" in the "nft-params" folder. This file should contain token IDs for which you want to check metadata.

[
    "TOKEN-ID",
    "TOKEN-ID"
]
  1. Invoke the with-schema command to check token metadata. This command requires the contract address, parameters, schema, and transaction type (TokenMetadata). Run the following command:

./cis2-rust-sdk-minting --account ../../nft-params/wallet.export with-schema --address "<INDEX,SUBINDEX>" --parameter ../../nft-params/token-id.json --schema ../../nft-params/module-schema.bs64 --transaction-type TokenMetadata
  1. This command will invoke the tokenMetadata() function of cis2-nft and provide metadata for the specified token IDs:

Step 13: Invoking the View Function

  1. Invoke the with-schema command to call the view() function, which returns the current state of the contract instance. Run the following command:

./cis2-rust-sdk-minting --account ../../nft-params/wallet.export with-schema --address "<INDEX,SUBINDEX>" --parameter ../../nft-params/token-id.json --schema ../../nft-params/module-schema.bs64 --transaction-type View
  1. Although the view() function does not necessarily require a parameter to be invoked. The program expects a parameter. You can use the same token-id.json file to display the state.

  2. Upon successful execution, the following will be displayed in your terminal:

Congrats! You have successfully completed the Concordium NFT Minting Tutorial with Rust-SDK! The full code can be found in this GitHub repository.

Last updated