DEV Community

Sanskar Jaiswal
Sanskar Jaiswal

Posted on

Build a Calorie Tracker Dapp with Soroban and React.js

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:

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
Enter fullscreen mode Exit fullscreen mode

Now go ahead and install the CLI:

cargo install --locked --version 20.0.1 soroban-cli
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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};
Enter fullscreen mode Exit fullscreen mode

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),
}
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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 is Op::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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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 
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Just like before we have to do some folder restructuring:

mv calorie-tracker-client/* .
rmdir calorie-tracker-client
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/",
});
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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>
</>
Enter fullscreen mode Exit fullscreen mode

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:

Good luck on your dapp building journey! ❤️

Top comments (0)