In this article, we will learn how to build a Dapp which tracks calories. We'll use Soroban, a smart contract platform built on Rust and running on the Stellar network. We'll use React.js for the frontend.
You can get a demo of what we are going to build here: https://calorie-tracker-iota.vercel.app/
Getting started
Lets start with installing all our prerequisite dependencies:
Rust
We are going to write our smart contract in Rust. Rust offers memory safety and amazing concurrency primitives, making it a very nice language to work with blockchains and smart contracts. You can install Rust by following the instructions here:
- Linux, macOS: https://www.rust-lang.org/tools/install
- Windows https://forge.rust-lang.org/infra/other-installation-methods.html#other-ways-to-install-rustup
If you don't know Rust, the Rust Programming Language book is an excellent way to learn the language. Check it out: https://doc.rust-lang.org/book/.
Soroban
Now that we have Rust installed, we can install the Soroban CLI. The Soroban CLI lets us build, deploy and invoke our contracts along with a plethora of other things.
Since the contracts are compiled to WASM binaries and deployed as such, we first need to configure the Rust toolchain's target:
rustup target add wasm32-unknown-unknown
Now go ahead and install the CLI:
cargo install --locked --version 20.0.1 soroban-cli
Building our contract
Our smart contract will provide three methods that can be invoked:
- add: To add calories for a particular date
- subtract: To subtract calories for a particular date
- get: To get the calories for a particular date range
Lets start a new project:
cargo new --lib calorie-tracker
Now
Now we'll do some project reorganization. Since we might want to add more contracts in the future, it'd make sense to organize the contracts in one directory:
mkdir -p contracts/calorie-tracker
mv Cargo.toml contracts/calorie-tracker
mv src contracts/calorie-tracker
Now modify contracts/calorie-tracker/Cargo.toml
to look like this:
[package]
name = "calorie-tracker"
version = "0.1.0"
edition = "2021"
[dependencies]
soroban-sdk = "20.0.0"
chrono = "0.4.31"
[dev_dependencies]
soroban-sdk = { version = "20.0.0" }
[lib]
crate-type = ["cdylib"]
and Cargo.toml
to look like:
[workspace]
resolver = "2"
members = [
"contracts/*",
]
[workspace.dependencies]
soroban-sdk = "20.0.0"
[profile.release]
opt-level = "z"
overflow-checks = true
debug = 0
strip = "symbols"
debug-assertions = false
panic = "abort"
codegen-units = 1
lto = true
[profile.release-with-logs]
inherits = "release"
debug-assertions = true
With this out of the way, we can actually focus on writing some code!
First lets import some useful things from the soroban_sdk
crate. This crate contains utilities and constructs that make it simple to create smart contracts:
#![no_std]
use soroban_sdk::{contract, contractimpl, contracttype, vec, Address, Env, Map, String, Vec};
Notice that we specify that we don't want to include the std
inbuilt crate since this will be built into a WASM binary at the end.
Now lets define the enum that will contain the user's info:
#[contracttype]
pub enum DataKey {
Counter(Address),
}
Address is a universal Soroban identifier that may represent a Stellar account, a contract or an 'account contract'. For more info see the docs: https://soroban.stellar.org/docs/basic-tutorials/auth
Next, we'll define an enum that specifies the kind of operation that the user wants to perform:
#[contracttype]
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Op {
Add,
Subtract,
}
Since the user can either add or subtract calories, we have two variants, one for each operation.
Let's define our contract type now:
#[contract]
pub struct CalorieTracker;
The #[contract]
attribute designates the CalorieTracker
struct as the type to which contract functions are associated. This implies that the struct will have contract functions implemented for it.
Now lets implement those functions:
#[contractimpl]
impl CalorieTracker {
// Adds the provided calories to the user's calorie count.
pub fn add(env: Env, user: Address, calories: u32, date: String) -> i32 {
user.require_auth();
let key = DataKey::Counter(user.clone());
let val: Option<Map<String, Vec<(u32, Op)>>> = env.storage().persistent().get(&key);
let mut total_calories: i32 = 0;
if let Some(mut calorie_records) = val {
let res: Option<Vec<(u32, Op)>> = calorie_records.get(date.clone());
if let Some(mut calorie_vals) = res {
calorie_vals.push_back((calories, Op::Add));
calorie_vals.iter().for_each(|val| {
if val.1 == Op::Add {
total_calories += val.0 as i32;
} else {
total_calories -= val.0 as i32;
}
});
calorie_records.set(date, calorie_vals);
env.storage().persistent().set(&key, &calorie_records);
} else {
let calorie_vals = vec![&env, (calories, Op::Add)];
calorie_records.set(date, calorie_vals);
env.storage().persistent().set(&key, &calorie_records);
total_calories = calories as i32;
}
} else {
let mut calorie_records = Map::new(&env);
let calorie_vals = vec![&env, (calories, Op::Add)];
calorie_records.set(date, calorie_vals);
env.storage().persistent().set(&key, &calorie_records);
total_calories = calories as i32;
}
total_calories
}
}
Lets unpack the above method. We accept an env
variable that acts like a service discovery mechanism containing all facilities available to the contract. You can read more about it here: https://soroban.stellar.org/docs/fundamentals-and-concepts/environment-concepts
The second argument is the user
, which contains the information about the user. user.require_auth()
tells the contract that the user needs to be authenticated for this method to execute.
The third and fourth argument pertain to our actual business logic, calories
and date
.
Since we need to keep track of each user's calorie information, we need some sort of persistent storage.
We can use the storage provided to the contract by env.storage().persistent()
. Each user's information is sorted in separate maps of type: Map<String, Vec<u32, Op>>
. The key contains the date for which the calories are being added for. The value is a vector where each element contains two things:
- The calorie count
- The operation.
Since we're implementing the
add
method, the operation here isOp::Add
.
We first get the user's map, then insert the specified calorie count along with the operation for the provided date. If the user's map is not found we create one and make sure to store it along with the provided date and calorie count.
The method returns the resultant total calorie count for the provided date. To figure that out, we loop through the array containing the calorie count along with the operation for that date and then simply add or subtract each calorie count according to the related operation.
Voila! We have ourseleves a contract!
The subtract
function looks very similar to this, so I'll leave that to you as an exercise.
Here is the definition to help you get started:
pub fn subtract(env: Env, user: Address, calories: u32, date: String) -> i32
Now lets implement our get
method:
pub fn get(env: Env, user: Address, dates: Vec<String>) -> Map<String, i32> {
user.require_auth();
let key = DataKey::Counter(user.clone());
let mut total_calories: Map<String, i32> = Map::new(&env);
let val: Option<Map<String, Vec<(u32, Op)>>> = env.storage().persistent().get(&key);
if let Some(calorie_records) = val {
for date in dates {
let mut calories: i32 = 0;
let res: Option<Vec<(u32, Op)>> = calorie_records.get(date.clone());
if let Some(calorie_vals) = res {
calorie_vals.iter().for_each(|val| {
if val.1 == Op::Add {
calories += val.0 as i32;
} else {
calories -= val.0 as i32;
}
});
}
total_calories.set(date.clone(), calories);
}
}
total_calories
}
As you can see the major difference here is that we are accepting a list of dates. Then we get the user's calorie activity for each date, go through them adding or subtracting them to a total and then returning a map with the date as the key and the total calorie count as the value.
You can check out the entire contract here: https://github.com/aryan9600/calorie-tracker/tree/main/contracts/calorie-tracker/lib.rs
Now that we have our code ready, lets build and deploy it:
# Add details about the network that this contract will be deployed on
soroban config network add --global futurenet \
--rpc-url https://rpc-futurenet.stellar.org \
--network-passphrase "Test SDF Future Network ; October 2022"
# Create a identity to use to deploy the contract on the network
soroban config identity generate --global <username>
address=$(soroban config identity address alice)
# Fund the account with some test tokens
soroban config identity fund $address --network futurenet
# Build the contract; generates a wasm file
soroban contract build
# Install the wasm file on the ledger
wasm_hash=$(soroban contract install \
--network futurenet \
--source <username> \
--wasm target/wasm32-unknown-unknown/release/calorie-tracker.wasm)
# Deploy the contract on the network
soroban contract deploy \
--wasm-hash $wasm_hash \
--source <username> \
--network futurenet \
> .soroban/calorie-tracker-id
Lets put it into action!
soroban contract invoke --id $(cat .soroban/calorie-tracker-id) \
--source <username> \
--network futurenet \
-- \
add --user $address --calories 10 --date "2023-12-08"
soroban contract invoke --id $(cat .soroban/calorie-tracker-id) \
--source <username> \
--network futurenet \
-- \
subtract --user $address --calories 5 --date "2023-12-08"
Building the client app
Now that our contract is ready and deployed, lets build a decentralized application so that other users can invoke it. Lets initialize a new React.js project:
npx create-react-app calorie-tracker-client
Just like before we have to do some folder restructuring:
mv calorie-tracker-client/* .
rmdir calorie-tracker-client
Next we'll need to generate the Typescript bindings for the contract so that we can call our contract functions from the frontend:
soroban contract bindings typescript \
--network futurenet \
--contract-id $(cat .soroban/calorie-tracker-id) \
--output-dir node_modules/calorie-tracker-client
Add the following command also as a postinstall
script in your package.json
:
"postinstall" "soroban contract bindings typescript --network testnet --contract-id $(cat .soroban/hello-id) --output-dir node_modules/hello-soroban-client"
We also need to add a wallet so that we can sign the transaction. The best one right now is Freigher. Install its extension in your browser and then add its API package as a dependency:
npm install @stellar/freighter-api
npm run postinstall
Now open src/App.tsx
and add the following code:
const tracker = new Contract({
contractId: networks.futurenet.contractId,
networkPassphrase: networks.futurenet.networkPassphrase,
rpcUrl: "https://rpc-futurenet.stellar.org/",
});
This creates a client through which we can call our contract functions. Next we'll create a function that calls Freigher's API to get the user's public key:
let addressLookup = (async () => {
if (await isConnected()) return getPublicKey()
})();
let address: string;
const addressObject = {
address: '',
displayName: '',
};
const addressToHistoricObject = (address: string) => {
addressObject.address = address;
addressObject.displayName = `${address.slice(0, 4)}...${address.slice(-4)}`;
return addressObject
};
export function useAccount(): typeof addressObject | null {
const [, setLoading] = useState(address === undefined);
useEffect(() => {
if (address !== undefined) return;
addressLookup
.then(user => { if (user) address = user })
.finally(() => { setLoading(false) });
}, []);
if (address) return addressToHistoricObject(address);
return null;
};
Now lets build the main React component that will call the method:
return (
<>
<div className="container">
<h2> Select date </h2>
<DatePicker
showIcon
selected={inputDate}
onChange={(date) => setInputDate(date)}
wrapperClassName={"customDatePickerWidth"}
className={"customDatePickerWidth"}
/>
<h2> Add calories </h2>
<input
type="number"
name="add"
min="0"
value={caloriesToAdd}
onChange={(e) => setCaloriesToAdd(Number(e.target.value))}
/>
{isAddLoading ? (
<div className="spinner"></div> // Spinner element
) : (
<button
onClick={() => {
setIsAddLoading(true);
tracker.add({
user: account.address,
calories: Number(caloriesToAdd),
date: inputDate.toISOString().slice(0, 10)
}).then(tx => {
tx.signAndSend().then(val => {
setDailyCalories(val.result);
setModalMsg('Calories added!');
setModalOpen(true);
setIsAddLoading(false);
}).catch(error => {
console.error("error sending tx: ", error);
setIsAddLoading(false);
})
}).catch(error => {
console.error("Error updating calories:", error);
setIsAddLoading(false);
})
}}
disabled={isAddLoading}
>
Submit
</button>
)}
</div>
</>
We have a date picker that lets the user selects the date for which they want to add the calories. Then we have a number input field which will contain the calorie count. Upon clicking on the Submit button, the contract is called with the user's public key, date and the calorie count. We let the user know that their calorie record has been stored and show the total number of calories for that date in a modal.
For the full client code, check: https://github.com/aryan9600/calorie-tracker/tree/main/src
Conclusion
Now that we've seen how we can write Soroban smart contracts in Rust and then integrate them with React, you know the basics of how to build a dapp. There are several other resources, you can refer to build dapps and smart contracts:
- https://soroban.stellar.org/dapps
- https://dev.to/hastodev/cowchain-farm-a-dapp-built-with-soroban-and-flutter-2me0
Good luck on your dapp building journey! ❤️
Top comments (0)