How to Write a Smart Contract on Sway with Transfer, Deposit, and Other Basic Functions

Requirements

  • forc: Follow this tutorial to install forc - https://fuellabs.github.io/fuels-ts/QUICKSTART.html
  • cargo & rust: The easiest way to install Cargo is to first install the current stable release of Rust by using rustup. Installing Rust using rustup will also install cargo. Run the following command to install Rust and Cargo:
url https://sh.rustup.rs -sSf | sh

Make sure you have the correct versions of forc and cargo installed. Follow the instructions here to install them.

Introduction

This tutorial builds upon the tutorial on how to issue your own token on the fuel testnet using Rust SDK. It shows you how to work with tokens in smart contracts by creating a MoneyBox smart contract and providing test coverage for it.

Create a Smart Contract

To create a Sway project skeleton, run the following command:

forc new money-box 

This will create the following file structure:
-src
— main.sw
.gitignore
Forc.toml

We will write the code for our contract in main.sw. Let’s start by declaring some functions.

// This decorator indicates that we are writing a contract.
// Other options include script and etc.
contract;

// Contract ABI (Application Binary Interface)
abi MoneyBox {
  // Function that allows the user to deposit money in the box
  fn deposit();

  // Function that allows the user to withdraw money
  fn withdraw(asset_id: ContractId, amount: u64);
}

// Implementation of the contract
impl MoneyBox for Contract {
  fn deposit() {}

  fn withdraw(asset_id: ContractId, amount: u64) {}
}

To store the state of the contract, we can use a storage entity that we declare before the contract ABI.

storage {
  // Map to store deposits
  deposits: StorageMap<(Address, ContractId), u64> = StorageMap {},
}

Great, it looks like you have added logic to your functions and defined types for them. Here is a revised version of the code with added explanations:
Code you can find here.

contract;
use std::{
    auth::{
        AuthError,
        msg_sender,
    },
    call_frames::{
        msg_asset_id,
    },
    context::{
        msg_amount,
    },
    revert::require,
    token::transfer_to_address
};

storage {
    deposits: StorageMap<(Address, ContractId), u64> = StorageMap {},
}

enum Error {
    InsufficientBalance: (),
}
abi MoneyBox {
		//it's required to provide this decorator that shows what kind of 
		// permission this function has to storage entity
    #[storage(write, read)]
    fn deposit();

    #[storage(write, read)]
    fn witdraw(asset_id: ContractId, amount: u64);

    #[storage(read)]
    fn balance(address: Address, asset_id: ContractId) -> u64;
}

fn get_msg_sender_address_or_panic() -> Address {
    let sender: Result<Identity, AuthError> = msg_sender();
    if let Identity::Address(address) = sender.unwrap() {
        address
    } else {
        revert(0);
    }
}

//util function that returns user balance value
#[storage(read)]
fn balance_internal(address: Address, asset_id: ContractId) -> u64 {
    let key = (address, asset_id);
    storage.deposits.get(key)
}

impl MoneyBox for Contract {
    #[storage(write, read)]
    fn deposit() {
//amount of token attached to payment
        let amount = msg_amount();
//asset id of token attached to payment
        let asset_id = msg_asset_id();
        let address = get_msg_sender_address_or_panic();

        let key = (address, asset_id);
//total amount that will be increased in case there is already some user deposit
        let amount = amount + storage.deposits.get(key);
        storage.deposits.insert(key, amount);
    }

//function that returns users' balance and can be called 
    #[storage(read)]
    fn balance(address: Address, asset_id: ContractId) -> u64 {
//inside it uses our util function that is declared above
        balance_internal(address, asset_id)
    }

//function for token withdraw
    #[storage(write, read)]
    fn withdraw(asset_id: ContractId, amount: u64) {
//user address 
        let address = get_msg_sender_address_or_panic();
//user balance before  withdrawal
        let balance = balance_internal(address, asset_id);
//check that the required amount is less or equal to user balance otherwise 
// it will throw an error
        require(balance >= amount, Error::InsufficientBalance);

//function that sends money back to the user if check is passed
        transfer_to_address(amount, asset_id, address);

        let amount_after = balance - amount;
        let key = (address, asset_id);
        if amount_after > 0 {
            storage.deposits.insert(key, amount_after);
        } else{
            storage.deposits.insert(key, 0);
        }
    }
}

The above code is for a smart contract that can be used to store and manage funds on the Fuel blockchain. The contract has three functions:

  1. deposit: This function is used to deposit tokens into the contract. When called, it gets the amount and asset ID of the tokens attached to the current message, gets the address of the sender of the current message, and then deposits the tokens into the contract by adding the amount to the user’s existing balance.
  2. withdraw: This function is used to withdraw tokens from the contract. When called, it gets the user’s address, checks that the user has enough balance to cover the withdrawal, and then transfers the specified amount of the specified asset to the user’s address. If the withdrawal is successful, it updates the user’s balance in the contract.
  3. balance: This function is used to get the balance of a user in the contract. It takes an address and an asset ID as inputs and returns the balance of the specified user and asset.

The contract also has an Error enum with a single variant, InsufficientBalance, which is used to revert execution when a user tries to withdraw more tokens than they have available. The get_msg_sender_address_or_panic function is a helper function that returns the address of the sender of the current message, or reverts execution if the sender is not an address. The balance_internal function is a helper function that gets the balance of a user in the contract.

To build and deploy this contract, you can run the following command in the root of your project:

forc build && forc deploy

This command will compile the contract and deploy it to the blockchain. If the deployment is successful, you will see output similar to the following:

Adding core
    Adding std git+https://github.com/fuellabs/sway?tag=v0.32.2#b9996f13463c324e256014935c053c334b880ab5
   Created new lock file at /Users/lidia/projects/fuel/wallet/money-box/Forc.lock
  Compiled library "core".
  Compiled library "std".
  Compiled contract "money-box".
  Bytecode size is 5076 bytes.
  Compiled library "core".
  Compiled library "std".
  Compiled contract "money-box".
  Bytecode size is 5076 bytes.
Contract id: 0x3d87339d2a1a426fb813fd4f66ab74dc2f0e0aa9209a3569e204d4865c08e28f

The generated ABIs for the contract will be stored in the out folder.

It is important to note that in order to deploy the contract, you will need to have a local node running or specify the URL of a testnet node.

Test cover

To create a testing environment for your project, run the following command:

cargo generate --init fuellabs/sway templates/sway-test-rs --name money_box --force

This will generate a new tests folder, along with Cargo.toml and Forc.lock files. The output will look like this:

⚠️   Favorite `fuellabs/sway` not found in config, using it as a git repository: https://github.com/fuellabs/sway.git
🔧   Destination: /Users/lidia/projects/fuel/wallet/money-box ...
🔧   project-name: money-box ...
🔧   Generating template ...
[1/3]   Done: Cargo.toml                                                                                 
[2/3]   Done: tests/harness.rs                                                                           
[3/3]   Done: tests                                                                                      🔧   Moving generated files into: `/Users/lidia/projects/fuel/wallet/money-box`...
✨   Done! New project created /Users/lidia/projects/fuel/wallet/money-box

To better organize your tests, you can create a few subfolders in the tests folder:

  • local_test - for tests that run on a local node
  • testnet_tests - for tests that run on a testnet node
  • utils - for helpful functions
  • artefacts - for contract ABIs

Each of these folders should have a mod.rs file and as an entry point. The harness.rs file serves as the entry point for the tests folder, and includes all necessary imports.

Let’s add the ABIs for the Token contract to the artefacts folder. You can find an example here, or you can build it from a standard token smart contract available here.

In the utils folder, create a file called local_tests_utils.rs where you can implement functions for deploying the token contract, creating wallets, and other helpful functions to keep the file with the test minimal. Code source you can find here.

use crate::utils::number_utils::parse_units;
use fuels::prelude::*;
use rand::prelude::Rng;

abigen!(MoneyBox, "out/debug/money-box-abi.json");

abigen!(
    TokenContract,
    "tests/artefacts/token/token_contract-abi.json"
);

pub mod wallet_abi_calls {
    use fuels::contract::call_response::FuelCallResponse;

    use super::*;

    pub async fn balance(
        contract: &MoneyBox, addresss: Address, asset: ContractId
    ) -> Result<FuelCallResponse<u64>, Error> {
        contract.methods().balance(addresss,asset).simulate().await
    }
}

pub struct DeployTokenConfig {
    pub name: String,
    pub symbol: String,
    pub decimals: u8,
    pub mint_amount: u64,
}

// Initializes a wallet and returns an unwrapped instance
pub async fn init_wallet() -> WalletUnlocked {
    let mut wallets = launch_custom_provider_and_get_wallets(
        WalletsConfig::new(
            Some(1),             /* Single wallet */
            Some(1),             /* Single coin (UTXO) */
            Some(1_000_000_000), /* Amount per coin */
        ),
        None,
        None,
    )
    .await;
    wallets.pop().unwrap()
}

// Returns an instance of the money box contract
pub async fn get_money_box_instance(wallet: &WalletUnlocked) -> MoneyBox {
    let id = Contract::deploy(
        "./out/debug/money-box.bin",
        &wallet,
        TxParameters::default(),
        StorageConfiguration::default(),
    )
    .await
    .unwrap();

    MoneyBox::new(id, wallet.clone())
}
// Returns an instance of the token contract that we will use as a test USDC token
// we will use it as test USDC token
    wallet: &WalletUnlocked,
    deploy_config: &DeployTokenConfig,
) -> TokenContract {
    let mut name = deploy_config.name.clone();
    let mut symbol = deploy_config.symbol.clone();
    let decimals = deploy_config.decimals;

    let mut rng = rand::thread_rng();
    let salt = rng.gen::<[u8; 32]>();

    let id = Contract::deploy_with_parameters(
        "./tests/artefacts/token/token_contract.bin",
        &wallet,
        TxParameters::default(),
        StorageConfiguration::default(),
        Salt::from(salt),
    )
    .await
    .unwrap();

    let instance = TokenContract::new(id, wallet.clone());
    let methods = instance.methods();

    let mint_amount = parse_units(deploy_config.mint_amount, decimals);
    name.push_str(" ".repeat(32 - deploy_config.name.len()).as_str());
    symbol.push_str(" ".repeat(8 - deploy_config.symbol.len()).as_str());

    let config: token_contract_mod::Config = token_contract_mod::Config {
        name: fuels::core::types::SizedAsciiString::<32>::new(name).unwrap(),
        symbol: fuels::core::types::SizedAsciiString::<8>::new(symbol).unwrap(),
        decimals,
    };

    let _res = methods
        .initialize(config, mint_amount, Address::from(wallet.address()))
        .call()
        .await;
    let _res = methods.mint().append_variable_outputs(1).call().await;

    instance
}

//util fun that prints wallet balances
pub async fn print_balances(wallet: &WalletUnlocked) {
    let balances = wallet.get_balances().await.unwrap();
    println!("{:#?}\n", balances);
}

To install the rand package, run the following command:

cargo add rand

To test our contract, we will create a file called main_test.rs in the local_tests folder. First, we will import the necessary dependencies and create the main test function. Then, we will initialize a wallet and deploy a token contract, which we will use as our USDC token. Next, we will get an instance of the money box contract and perform a series of operations: deposit 100 USDC, withdraw 100 USDC, deposit 50 USDC, withdraw 50 USDC, and finally check the balance. We will assert that the balance is correct after each operation. At the end of the function, we will print the final balance. Whole file you can find here

//requited imports
use fuels::{
    prelude::CallParameters,
    tx::{Address, AssetId, ContractId},
};

use crate::utils::{
    local_tests_utils::*,
    number_utils::{format_units, parse_units},
};

//main test call
#[tokio::test]
async fn main_test() {
    //--------------- CREATE WALLET ---------------
    let wallet = init_wallet().await;
    let address = Address::from(wallet.address());
    println!("Wallet address {address}\n");

//token deployment with mint
    //--------------- DEPLOY TOKEN ---------------
    let usdc_config = DeployTokenConfig {
        name: String::from("USD Coin"),
        symbol: String::from("USDC"),
        decimals: 6,
        mint_amount: 10000,
    };
 ....

To run all tests in your project, use the following command:

cargo test

or may use a button to run test within the IDE.

This will compile and run all the tests in your project. If all tests are written correctly, you will see the following output:

Compiling project v0.1.0 (/Users/lidia/projects/fuel/wallet/money-box)
    Finished test [unoptimized + debuginfo] target(s) in 4.93s
     Running tests/harness.rs (target/debug/deps/integration_tests-f1fda32fb5b57c2b)

running 1 test
Wallet address 09c0b2d1a486c439a87bcba6b46a7a1a23f3897cc83a94521a96da5c23bc58db

{
    "0x0acff46c3d40e873f8e00f3f59e4860ef549494b1b3005e3685d736bd9da3dd4": 10000000000,
    "0x0000000000000000000000000000000000000000000000000000000000000000": 1000000000,
}

âś… first deposit for 100 USDC is done and total balance is 100 USDC
âś… first withdraw for 100 USDC is done and total balance is 0 USDC
âś… second deposit for 50 USDC is done and total balance is 50 USDC
âś… third deposit for 150 USDC is done and total balance is 200 USDC
âś… second withdraw for 200 USDC is done and total balance is 185 USDC
test local_tests::main_test::main_test ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 1 filtered out; finished in 0.98s

Conclusion

In summary, this tutorial demonstrated how to create a basic smart contract with deposit and transfer functionality, as well as set up a test environment to ensure the contract’s proper operation. By following these steps, you can efficiently develop and manage your own smart contracts on the Fuel network. Additionally, the provided resources can assist you in further exploring and expanding upon the concepts covered in this tutorial.

Resources

https://github.com/sway-gang/money-box

11 Likes

thx a lot,very informative