Cowchain Farm: a Dapp built with Soroban and Flutter
Hasto
Posted on August 27, 2023
In this article, I will show you the steps in creating Cowchain Farm, a smart contract app built using Soroban on Stellar blockchain and Flutter as the front-end framework.
You can find the source code for the Dapp on the GitHub link below:
You watch the Web Demo & Code Walkthrough video on the Youtube link below:
You can also try the live application on Cowchain Farm.
Ensure that you already have a Stellar FUTURENET account and have the Freighter extension installed and enabled in your browser.
Also, make sure the Experimental Mode is enabled in the Freighter extension settings.
Install Rust and Soroban CLI
First, we have to install Rust. You can follow the steps to install Rust in the following article:
- Install Rust - Rust Programming Language
- Walkthrough: Installing Rust on Windows
- How To Install Rust on Ubuntu 20.04
Next we install Soroban CLI:
cargo install --locked --version 20.0.0-rc1 soroban-cli
OPTIONAL
If you want to write the smart contract using Rust nightly channel, you can install it using:
rustup toolchain install nightly
Then use the nightly channel by default with:
rustup default nightly
Setup New Rust Project
Create a new rust project, then open it with your favorite IDE:
cargo new cowchain-farm-soroban --lib
Before we start writing a smart contract, there are 2 things we must do first on the new rust project:
- Create a new file named
rust-toolchain.toml
, which contains:
[toolchain]
channel = "stable" # this value depends on your Rust channel
targets = ["wasm32-unknown-unknown"]
components = ["rustc", "cargo", "rustfmt", "clippy", "rust-src"]
- Setup your
Cargo.toml
file with:
[package]
name = "cowchain_farm"
version = "0.1.0"
edition = "2021"
authors = <Your Info Here>
[lib]
crate-type = ["cdylib"]
[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true
[dependencies]
soroban-sdk = "20.0.0-rc1"
[dev_dependencies]
soroban-sdk = { version = "20.0.0-rc1", features = ["testutils"] }
And now we are ready to start writing our smart contracts using Soroban.
Contract Enums
There are 3 enums that we will use in our smart contract:
- Status
- DataKey
- CowBreed
These enums will be used for administrative purposes, result status of function invocation, and identification of the types of cow in the contract.
For that, let's create enums.rs
file in the src
directory and use the code contained in this GitHub repository file as its contents.
Contract Custom Types
In our smart contract, instead of using the rust Option type, we will use Soroban custom types as the result of a function invocation.
All function invocations, whether successful or unsuccessful, return a custom types.
Custom types are also used for cow data and cow feeding statistics.
There are 7 custom types that we will use in our smart contract:
- CowData
- CowStatus
- BuyCowResult
- SellCowResult
- CowFeedingStats
- GetAllCowResult
- CowAppraisalResult
Let's create types.rs
file in the src
directory and use the code contained in this GitHub repository file as its contents.
Contract Constants
There are several types of constants that we use in this contract, including:
- Amount of ledgers for a certain period
- Cow prices
- Reward or fine's multiplier
- Feeding time limits
Constant of Ledger's amount for a certain period
We can use the ledger as a time reference because ledgers have an average closing time of between 5 and 6 seconds.
Therefore, we use the ledger sequence to measure time in this smart contract.
For example, assuming the closing time of the ledger is 5 seconds, then within 24 hours, there will be an accumulation of 17280 ledgers.
Thus, we can use 17280 as the time limit, which is equal to 24 hours.
Following are the time constants we use in this smart contract:
pub const LEDGER_AMOUNT_IN_24_HOURS: u32 = 17280;
pub const LEDGER_AMOUNT_IN_3_DAYS: u32 = 51840;
pub const LEDGER_AMOUNT_IN_1_WEEK: u32 = 120960;
pub const LEDGER_AMOUNT_IN_1_MONTH: u32 = 483840;
Constant of Cow Prices
This is the price constant for each Cow breed in the XLM unit.
The constants used are:
pub const JERSEY_PRICE: i128 = 1000;
pub const LIMOUSIN_PRICE: i128 = 1000;
pub const HALLIKAR_PRICE: i128 = 1000;
pub const HEREFORD_PRICE: i128 = 5000;
pub const HOLSTEIN_PRICE: i128 = 15000;
pub const SIMMENTAL_PRICE: i128 = 15000;
Constant for Reward or Fine's Multiplier
In this smart contract, we will get rewards or fines every time we feed the cows.
The criteria for these rewards or fines are:
- On-time feeding, a reward of 0.5% of the cow's purchase price
- Late feeding, a reward of 0.25% of the cow's purchase price
- Forget feeding, a fine of 1% of the cow's purchase price
However, since Soroban doesn't enable floating-point arithmetic, we must convert the reward or fine from the floating-point number to a fixed-point number format.
Our smallest number is 0.25. Therefore, we have to multiply all numbers by 100 so there are no more floating-point numbers.
Thus, we will get the rewards or fines constants as follows:
pub const ON_TIME_REWARD: i128 = 50; // 0.5%
pub const LATE_REWARD: i128 = 25; // 0.25%
pub const FORGET_FINE: i128 = 100; // 1%
pub const PRECISION_100_PERCENT: i128 = 10_000; // 100%
Constant of Feeding Time Limits
In the paragraph above, we have mentioned several criteria, such as On-time, Late, and Forget feeding.
Here, we will discuss the time limit for each of these criteria. The time limit measurement starts from when the cow was first purchased or since the last time the cow was fed.
Full stomach, ledger 0 - 4320
On-time feeding, ledger 4320- 8640
Late feeding, ledger 8640 - 12960
Forget feeding, ledger 12960 - 12960
note: 4320 ledger is equal to 6 hours
Based on the time limit range above, the constants used are:
pub const WELL_FED: u32 = 4320;
pub const ON_TIME_FEED: u32 = 8640;
pub const LATE_FEED: u32 = 12960;
Create constants.rs
file in the src
directory and use all the constants codes above as its contents.
You also can use the code contained in this GitHub repository file as the content for constants.rs
file.
Contract Trait (Interface)
In writing Soroban smart contracts, you don't have to use traits, this is optional.
However, using traits will make it easier for us to read what functions are in our smart contract.
The functions that we will implement are the following:
- init
- upgrade
- bump_instance
- health_check
- buy_cow
- sell_cow
- feed_the_cow
- get_all_cow
- cow_appraisal
Let's create interface.rs
file in the src
directory and use the code contained in this GitHub repository file as its contents.
Contract Implementation
In the lib.rs
file, let's use the following code as the initial code before implementing the functions in traits.
#![no_std]
use soroban_sdk::{contract, contractimpl, token, Address, BytesN, Env, String, Symbol, Vec};
use crate::constants::*;
use crate::enums::*;
use crate::interface::*;
use crate::types::*;
mod constants;
mod enums;
mod interface;
mod types;
#[contract]
pub struct CowContract;
#[contractimpl]
impl CowContractTrait for CowContract {
// implement CowContractTrait items in interface.rs file here
}
You can find the complete implementation of the smart contract function in the GitHub repository lib.rs
file.
I will explain in depth what happens in each smart contract function.
init
fn init(env: Env, admin: Address, native_token: Address, message: String) -> Status;
This is the first function that we should call after deploying a contract.
The purpose of this function is to store Admin and Native Token addresses to be used in other functions and extend the contract's storage instance lifetime to the next 50 weeks.
This function requires a password to ensure the identity of the contract initiator, and this is so that no one other than us can initiate it.
The use of this password can be replaced by using a deployer contract. But for this time, I chose to use a password.
Let's see what happened here:
Checking initialization password
The function will check the message/password given by the invoker.
If the message does not match the one in the contract, the function will return the TryAgain
status enum.
let internal_password = String::from_slice(&env, "password");
if message.ne(&internal_password) {
return Status::TryAgain;
}
Checking contract initialization
Checks whether the contract is initialized.
If the contract is initialized, then the function returns the AlreadyInitialized
status enum.
let is_admin_exist = env.storage().instance().has(&DataKey::Admin);
if is_admin_exist {
return Status::AlreadyInitialized;
}
Checking admin authorization
Check whether the admin address provided has authorization.
It will throw an error if the admin address has no authorization.
admin.require_auth();
Saving the admin address, native token address, and initialization ledger sequence to instance storage
env.storage().instance().set(&DataKey::Admin, &admin);
env.storage()
.instance()
.set(&DataKey::NativeToken, &native_token);
env.storage()
.instance()
.set(&DataKey::InitializedLedger, &env.ledger().sequence());
Bump instance storage lifetime to 50 weeks and return Ok
env.storage().instance().bump(LEDGER_AMOUNT_IN_50_WEEKS);
Status::Ok
upgrade
fn upgrade(env: Env, new_wasm_hash: BytesN<32>) -> Status;
So, each time we deploy a contract, we will get a different contract address.
In some cases, this has the potential to cause problems. Because every time we deploy a contract for a new version, we have to migrate user data from the old contract address to the new one.
This is where the upgrade function comes in handy.
We can deploy the latest contract version using the old contract address.
Let's see what happened here:
Checking contract initialization
Check the contract initialization by checking the Admin address key in the instance storage.
Returns the NotInitialized
status enum if the Admin address key does not exist.
let is_admin_exist = env.storage().instance().has(&DataKey::Admin);
if !is_admin_exist {
return Status::NotInitialized;
}
Load the Admin address and check for its authorization
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
admin.require_auth();
Updating the current contract using the supplied WASH Hash and return Upgraded
env.deployer().update_current_contract_wasm(new_wasm_hash);
Status::Upgraded
bump_instance
fn bump_instance(env: Env, ledger_amount: u32) -> Status;
Bump instance helps extend the storage instance lifetime on our contract.
Let's see what happened here:
Checking contract initialization
Check the contract initialization by checking the Admin address key in the instance storage.
Returns the NotInitialized
status enum if the Admin address key does not exist.
let is_admin_exist = env.storage().instance().has(&DataKey::Admin);
if !is_admin_exist {
return Status::NotInitialized;
}
Load the Admin address and check for its authorization
let admin: Address = env.storage().instance().get(&DataKey::Admin).unwrap();
admin.require_auth();
Bump the instance storage using the supplied Ledger amount and return Bumped
env.storage().instance().bump(ledger_amount);
Status::Bumped
health_check
fn health_check(env: Env) -> CowStatus;
As the name implies, this function determines if our contract is still alive or has expired.
This function will return CowStatus custom types if the smart contract is alive.
CowStatus {
status: Status::Ok,
ledger: env.ledger().sequence(),
}
buy_cow
fn buy_cow(
env: Env,
user: Address,
cow_name: Symbol,
cow_id: String,
cow_breed: CowBreed,
) -> BuyCowResult;
The buy cow function requires authorization from the user account.
This is because the user will transfer XLM tokens from the user account to the contract account to purchase the cow.
Before making any transaction, this function will do all necessary checks.
Starting from checking whether the contract has been initiated,
Or does the user have enough balance to make a purchase?
If all checks are passed, the contract will start carrying out activities such as token transfer, storing user data in persistent storage, and storing cow data in temporary storage.
Finally, the function will return the BuyCowResult struct to the function invoker.
Let's see what happened here:
Check for user authorization
user.require_auth();
Checking contract initialization
Check the contract initialization by checking the Native token address key in the instance storage.
let is_native_token_exist = env.storage().instance().has(&DataKey::NativeToken);
if !is_native_token_exist {
return BuyCowResult::default(env, Status::NotInitialized);
}
Create native token Client
let native_token: Address = env.storage().instance().get(&DataKey::NativeToken).unwrap();
let native_token_client = token::Client::new(&env, &native_token);
Check user balance
Check whether the user has enough balance to purchase the cow.
let user_native_token_balance: i128 = native_token_client.balance(&user);
let minimum_user_balance: i128 = 15_000_000;
let cow_price_in_stroops: i128 = get_cow_base_price_in_stroops(&cow_breed);
let user_balance_after_tx: i128 =
user_native_token_balance - minimum_user_balance - cow_price_in_stroops.clone();
if user_balance_after_tx <= 0 {
return BuyCowResult::default(env, Status::InsufficientFund);
}
Transfer funds from the user to the supplier
native_token_client.transfer(
&user,
&env.current_contract_address(),
&cow_price_in_stroops,
);
Creating a new cow data
let new_cow_data = CowData {
id: cow_id.clone(),
name: cow_name,
breed: cow_breed,
born_ledger: env.ledger().sequence(),
last_fed_ledger: env.ledger().sequence(),
feeding_stats: CowFeedingStats::default(),
};
Check ownership data
Check whether the user already has cow ownership data.
The new cow data will be added to the existing ownership list if the owner already has a cow.
let mut cow_ownership_list: Vec<String> = Vec::new(&env);
let is_owner_exist = env.storage().persistent().has(&user);
if is_owner_exist {
let ownership_data: Vec<String> = env.storage().persistent().get(&user).unwrap();
cow_ownership_list.append(&ownership_data);
}
cow_ownership_list.push_back(cow_id.clone());
Saving the ownership to persistent storage and the cow data to temporary storage
env.storage().persistent().set(&user, &cow_ownership_list);
env.storage()
.persistent()
.bump(&user, LEDGER_AMOUNT_IN_1_WEEK);
env.storage().temporary().set(&cow_id, &new_cow_data);
env.storage()
.temporary()
.bump(&cow_id, LEDGER_AMOUNT_IN_24_HOURS);
Return BuyCowResult custom types
BuyCowResult {
status: Status::Ok,
cow_data: new_cow_data,
ownership: cow_ownership_list,
}
sell_cow
fn sell_cow(env: Env, user: Address, cow_id: String) -> SellCowResult;
Like the previous function, the sell cow function requires user account authorization.
This function also performs all necessary checks.
Especially checking whether the cow to be sold is still alive? and if the cow is old enough to be sold.
After all checks have been passed, this function will delete the cow ID from the list of user ownership in persistent storage. And also delete the cow data in the temporary storage.
Finally, the function will return a SellCowResult struct to the invoker.
Let's see what happened here:
Check for user authorization
user.require_auth();
Checking contract initialization
Check the contract initialization by checking the Native token address key in the instance storage.
let is_native_token_exist = env.storage().instance().has(&DataKey::NativeToken);
if !is_native_token_exist {
return SellCowResult::default(env, Status::NotInitialized);
}
Checking if cow still alive
let is_cow_alive = env.storage().temporary().has(&cow_id);
if !is_cow_alive {
return SellCowResult::default(env, Status::NotFound);
}
Checking if user data still exist
let is_ownership_exist = env.storage().persistent().has(&user);
if !is_ownership_exist {
return SellCowResult::default(env, Status::MissingOwnership);
}
Checking for Cow's age
The cow to be sold must be at least 3 days old.
let cow_data: CowData = env.storage().temporary().get(&cow_id).unwrap();
let current_ledger: u32 = env.ledger().sequence();
let cow_age: u32 = current_ledger - cow_data.born_ledger;
if cow_age < LEDGER_AMOUNT_IN_3_DAYS {
return SellCowResult::default(env, Status::Underage);
}
Get cow selling price
let cow_base_price: i128 = get_cow_base_price_in_stroops(&cow_data.breed);
let cow_selling_price = get_cow_appraisal_price(&cow_data, cow_base_price);
Create native token Client
let native_token: Address = env.storage().instance().get(&DataKey::NativeToken).unwrap();
let native_token_client = token::Client::new(&env, &native_token);
Check current contract balance
let contract_native_token_balance: i128 = native_token_client.balance(&env.current_contract_address());
if contract_native_token_balance < cow_selling_price {
return SellCowResult::default(env, Status::InsufficientFund);
}
Transfer funds from the supplier to the user
native_token_client.transfer(&env.current_contract_address(), &user, &cow_selling_price);
Update ownership to delete data on cows that have been sold
let mut cow_ownership_list: Vec<String> = env.storage().persistent().get(&user).unwrap();
let index = cow_ownership_list.first_index_of(&cow_id).unwrap();
cow_ownership_list.remove_unchecked(index);
Saving the updated ownership to persistent storage and remove the cow data from temporary storage
env.storage().persistent().set(&user, &cow_ownership_list);
env.storage()
.persistent()
.bump(&user, LEDGER_AMOUNT_IN_1_WEEK);
env.storage().temporary().remove(&cow_id);
Return SellCowResult custom types
SellCowResult{
status: Status::Ok,
ownership: cow_ownership_list,
}
feed_the_cow
fn feed_the_cow(env: Env, user: Address, cow_id: String) -> CowStatus;
Feed the Cow is a function to update statistical data on cow’s feeding activity and also to extend the life of cow data in temporary storage.
This statistical data will then be used to estimate the cow's selling price.
Let's see what happened here:
Checking if cow still alive
let is_cow_alive = env.storage().temporary().has(&cow_id);
if !is_cow_alive {
return CowStatus::new(env, Status::NotFound);
}
Checking if user data still exist
let is_ownership_exist = env.storage().persistent().has(&user);
if !is_ownership_exist {
return CowStatus::new(env, Status::MissingOwnership);
}
Get current cow data from temporary storage
let mut cow_data: CowData = env.storage().temporary().get(&cow_id).unwrap();
Check feeding distance
Check whether the distance between the last feeding time and the current one is larger than 4320 ledgers or 6 hours.
let current_ledger: u32 = env.ledger().sequence();
let last_fed_ledger: u32 = cow_data.last_fed_ledger;
let feed_distance: u32 = current_ledger - last_fed_ledger;
if feed_distance <= WELL_FED {
return CowStatus::new(env, Status::FullStomach);
}
Calculate feeding performance
Find out whether the feeding time is on time, late, or forgotten.
let mut on_time = cow_data.feeding_stats.on_time;
let mut late = cow_data.feeding_stats.late;
let mut forget = cow_data.feeding_stats.forget;
if feed_distance > WELL_FED && feed_distance <= ON_TIME_FEED {
on_time = on_time + 1;
}
if feed_distance > ON_TIME_FEED && feed_distance <= LATE_FEED {
late = late + 1;
}
if feed_distance > LATE_FEED {
forget = forget + 1;
}
Update cow data with the new stats
cow_data.last_fed_ledger = env.ledger().sequence();
cow_data.feeding_stats = CowFeedingStats {
on_time,
late,
forget,
};
Save the updated cow data to temporary storage and bump user persistent storage
env.storage().temporary().set(&cow_id, &cow_data);
env.storage()
.temporary()
.bump(&cow_id, LEDGER_AMOUNT_IN_24_HOURS);
env.storage()
.persistent()
.bump(&user, LEDGER_AMOUNT_IN_1_WEEK);
Return CowStatus custom types
CowStatus {
status: Status::Ok,
ledger: cow_data.last_fed_ledger,
}
get_all_cow
fn get_all_cow(env: Env, user: Address) -> GetAllCowResult;
Get all cow is a function to get all cow data owned by a user. This function is usually called when the user login to the website.
Let's see what happened here:
Check for user authorization
user.require_auth();
Checking if user data still exist
let is_ownership_exist = env.storage().persistent().has(&user);
if !is_ownership_exist {
return GetAllCowResult {
status: Status::Fail,
data: Vec::new(&env),
};
}
Get user ownership data
let ownership_data: Vec<String> = env.storage().persistent().get(&user).unwrap();
Iterate the ownership data to get all user cow data
let mut cow_data_list: Vec<CowData> = Vec::new(&env);
for cow_id in ownership_data {
let is_cow_alive = env.storage().temporary().has(&cow_id);
if !is_cow_alive {
continue;
}
let cow_data: CowData = env.storage().temporary().get(&cow_id).unwrap();
cow_data_list.push_back(cow_data);
}
Return GetAllCowResult custom types
GetAllCowResult {
status: Status::Ok,
data: cow_data_list,
}
cow_appraisal
fn cow_appraisal(env: Env, cow_id: String) -> CowAppraisalResult;
When we do sales activities, before calling the sell cow function, the function that is called first is cow appraisal.
This is because we need the user's approval regarding the cow's selling price.
This function will estimate the price based on the feeding performance of the cows.
A cow that is constantly fed on time will increase its price.
While cows that are late or even forget to be fed, the price is more difficult to increase and even tends to decrease.
Let's see what happened here:
Checking if cow still alive
let is_cow_alive = env.storage().temporary().has(&cow_id);
if !is_cow_alive {
return CowAppraisalResult::default(Status::NotFound);
}
Get cow appraisal price
let cow_data: CowData = env.storage().temporary().get(&cow_id).unwrap();
let cow_base_price: i128 = get_cow_base_price_in_stroops(&cow_data.breed);
let cow_price_appraisal = get_cow_appraisal_price(&cow_data, cow_base_price);
Return CowAppraisalResult custom types
CowAppraisalResult {
status: Status::Ok,
price: cow_price_appraisal,
}
Contract Internal Function
There are two functions that are part of this smart contract but not exposed to the public.
The two functions are:
- get_cow_base_price_in_stroops
- get_cow_appraisal_price
get_cow_base_price_in_stroops
fn get_cow_base_price_in_stroops(breed: &CowBreed) -> i128 {
let cow_price_in_native_token = match breed {
CowBreed::Jersey => JERSEY_PRICE,
CowBreed::Limousin => LIMOUSIN_PRICE,
CowBreed::Hallikar => HALLIKAR_PRICE,
CowBreed::Hereford => HEREFORD_PRICE,
CowBreed::Holstein => HOLSTEIN_PRICE,
CowBreed::Simmental => SIMMENTAL_PRICE,
};
cow_price_in_native_token * 10_000_000
}
This function retrieves cow price data from a constant based on the cow's breed.
The price retrieved from the constant is in XLM units.
This function will then convert the price into units of stroops before returning the results to the invoker.
This conversion is necessary because tokens in Soroban use the smallest unit, namely stroops.
get_cow_appraisal_price
fn get_cow_appraisal_price(cow_data: &CowData, cow_base_price: i128) -> i128 {
let on_time_rewards: i128 = (cow_data.feeding_stats.on_time as i128) * ON_TIME_REWARD;
let late_rewards: i128 = (cow_data.feeding_stats.late as i128) * LATE_REWARD;
let forget_fines: i128 = (cow_data.feeding_stats.forget as i128) * FORGET_FINE;
let mut rewards_fines_multiplier: i128 = on_time_rewards + late_rewards - forget_fines;
if rewards_fines_multiplier < -PRECISION_100_PERCENT {
rewards_fines_multiplier = -PRECISION_100_PERCENT;
}
let rewards_or_fines: i128 =
(cow_base_price * rewards_fines_multiplier) / PRECISION_100_PERCENT;
cow_base_price + rewards_or_fines
}
This function calculates the cow appraisal price using a fixed point number.
First, the function will calculate the multiplier factor for rewards or fines based on cow feeding stats.
After that, the function will ensure that the multiplier cannot be negative.
Finally, the multiplier factor will be multiplied by the base price of the cow, then the unit will be changed to stroops and added to the cow's base price so that the cow's appraisal price is obtained.
Build and Deploy Smart Contract to Stellar FUTURENET
Build contract:
cargo build --target wasm32-unknown-unknown --release
Deploy contract to FUTURENET:
soroban contract deploy \
--wasm target/wasm32-unknown-unknown/release/cowchain-farm-soroban.wasm \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022'
After the deployment is complete, you will receive a Contract Address. Save that address to be used in calling the contract functions.
The form of Contract Address will be similar to CB7UCV29SYKUFRZNEIMKVW5XKSJCGTMBCSJFN5OJ2SSXBTPRXO42XGT8
.
Calling Smart Contracts using Soroban CLI
Example for calling health_check
, a function without argument:
soroban contract invoke \
--id CB7UCV29SYKUFRZNEIMKVW5XKSJCGTMBCSJFN5OJ2SSXBTPRXO42XGT8 \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022' \
--fee 12345678 \
-- \
health_check
Example for calling bump_instance
, a function with argument:
soroban contract invoke \
--id CB7UCV29SYKUFRZNEIMKVW5XKSJCGTMBCSJFN5OJ2SSXBTPRXO42XGT8 \
--rpc-url https://rpc-futurenet.stellar.org:443 \
--network-passphrase 'Test SDF Future Network ; October 2022' \
--fee 12345678 \
-- \
bump_instance \
--ledger_amount 1234
For more details, you can read the README.md
file in the Cowchain Farm Soroban GitHub repository to discover the prerequisites for calling smart contract functions, and how to call them using Soroban CLI.
What's Next? 🚀
Now that we've unraveled the intricacies of this extensive Soroban smart contract code, you might be wondering, 'What's the next step?'
Well, armed with this newfound knowledge, you're poised to take your blockchain journey to the next level.
Keep coding, and remember: each line of code you write is a step toward transforming ideas into reality.
Happy coding and Happy building with Soroban!
Posted on August 27, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.