DEV Community

Cover image for Soroban Quest - Asset Interop
Altuğ Bakan
Altuğ Bakan

Posted on

Soroban Quest - Asset Interop

So, we have tackled Custom Types on the last post on Soroban and learned a lot. Now, on this last quest, we will use our knowledge from all of the previous quests to dive into Asset Interop!

Understanding README.md

For the last time, let's check out the README.md file.

README

It is quite the quest! We will use Stellar's "Classic" Assets, Soroban Tokens, and even import some Lumens from Stellar network to Soroban network!

Setting Up Our Quest Account

At this point, you should be able to start playing pretty easy! If you need a reminder, we should use sq play 6, and fund our account to start playing the 6th and last quest.

Token Interface

On Soroban, tokens should implement the Token Interface if they want to interoperate between other contracts and protocols. Having a common interface for contracts help developers to support all types of tokens implementing the same interface without knowing their implementations.

The Token Interface is defined as

// The metadata used to initialize token (doesn't apply to contracts representing 
// 'classic' Stellar assets).
pub struct TokenMetadata {
    pub name: Bytes,
    pub symbol: Bytes,
    pub decimals: u32,
}

// Initializes a 'smart-only' token by setting its admin and metadata.
// Tokens that represent 'classic' Stellar assets don't need to call this, as
// their metadata is inherited from the existing assets.
fn init(env: Env, admin: Identifier, metadata: TokenMetadata);

// Functions that apply on for tokens representing classic assets.

// Moves the `amount` from classic asset balance to the token balance of `id`
// user.
// `id` must be a classic Stellar account (i.e. an account invoker or signature 
// signed by an account).
fn import(env: Env, id: Signature, nonce: BigInt, amount: i64);

// Moves the `amount` from token balance to the classic asset balance of `id`
// user.
// `id` must be a classic Stellar account (i.e. an account invoker or signature 
// signed by an account).
fn export(env: Env, id: Signature, nonce: BigInt, amount: i64);

// Admin interface -- these functions are privileged

// If "admin" is the administrator, burn "amount" from "from"
fn burn(e: Env, admin: Signature, nonce: BigInt, from: Identifier, amount: BigInt);

// If "admin" is the administrator, mint "amount" to "to"
fn mint(e: Env, admin: Signature, nonce: BigInt, to: Identifier, amount: BigInt);

// If "admin" is the administrator, set the administrator to "id"
fn set_admin(e: Env, admin: Signature, nonce: BigInt, new_admin: Identifier);

// If "admin" is the administrator, freeze "id"
fn freeze(e: Env, admin: Signature, nonce: BigInt, id: Identifier);

// If "admin" is the administrator, unfreeze "id"
fn unfreeze(e: Env, admin: Signature, nonce: BigInt, id: Identifier);

// Token Interface

// Get the allowance for "spender" to transfer from "from"
fn allowance(e: Env, from: Identifier, spender: Identifier) -> BigInt;

// Set the allowance to "amount" for "spender" to transfer from "from"
fn approve(e: Env, from: Signature, nonce: BigInt, spender: Identifier, amount: BigInt);

// Get the balance of "id"
fn balance(e: Env, id: Identifier) -> BigInt;

// Transfer "amount" from "from" to "to"
fn xfer(e: Env, from: Signature, nonce: BigInt, to: Identifier, amount: BigInt);

// Transfer "amount" from "from" to "to", consuming the allowance of "spender"
fn xfer_from(
    e: Env,
    spender: Signature,
    nonce: BigInt,
    from: Identifier,
    to: Identifier,
    amount: BigInt,
);

// Returns true if "id" is frozen
fn is_frozen(e: Env, id: Identifier) -> bool;

// Returns the current nonce for "id"
fn nonce(e: Env, id: Identifier) -> BigInt;

// Descriptive Interface

// Get the number of decimals used to represent amounts of this token
fn decimals(e: Env) -> u32;

// Get the name for this token
fn name(e: Env) -> Bytes;

// Get the symbol for this token
fn symbol(e: Env) -> Bytes;
Enter fullscreen mode Exit fullscreen mode

on the Soroban SDK. It uses some cryptographic tools and some helpful interfaces from the soroban-auth crate, which is a crate that contain libraries for user authentication on the Soroban network.

The token interface is quite large but it is not hard to understand, so I urge you to check it out yourself and see if you can make sense of it. We will inspect the parts we will use on the token interface in quite the detail when we need them.

Examining the Contract

Once again, let's check out the lib.rs file. The source code for this contract is quite large, and pretty complex. Let's check out some important parts of the source file.

use soroban_auth::{Identifier, Signature};
Enter fullscreen mode Exit fullscreen mode

On this contract, we are dealing with user authentication, so we need the soroban_auth crate to add this functionality to our contract. This crate helps us get more details about the input arguments of some functions that the Token Interface use.

mod token {
    soroban_sdk::contractimport!(file = "./soroban_token_spec.wasm");
}
Enter fullscreen mode Exit fullscreen mode

This mod statement imports the soroban_token_spec.wasm file to make the compiler infer the types and structs from the Token Interface. This is useful as we did not have to copy and paste the source code of the token interface of the contract to a new file and import it.

Allowance Trait

We have a trait in our code, which defines the AllowanceTrait interface. The contracts implementing the AllowanceTrait should implement two functions, init and withdraw, to successfully compile.

pub trait AllowanceTrait {
    fn init(
        e: Env,
        child: AccountId,
        token_id: BytesN<32>,
        amount: BigInt,
        step: u64,
    ) -> Result<(), Error>;

    fn withdraw(e: Env) -> Result<(), Error>;
}
Enter fullscreen mode Exit fullscreen mode

The init function has four external arguments. The child argument sets the "Child" account that can withdraw some money from the "Parent" account. token_id arguments is the ID of the token that the Child account can withdraw, amount is how much of the said token that Child account withdraw for a year, and step is how frequent the child account can withdraw, in seconds.

The withdraw function does not have an external argument and returns success or a custom Error if the the withdrawal fails.

We can see that our AllowanceContract implements the AllowanceTrait trait, so it should implement those two functions in addition to whatever it might implement. Let's check out the implementations of the init and withdraw functions so that we have a good understanding about what each of them do.

init Function

The init function is defined as

fn init(
        e: Env,
        child: AccountId,
        token_id: BytesN<32>,
        amount: BigInt,
        step: u64,
    ) -> Result<(), Error> {
        let token_key = DataKey::TokenId;
        if e.data().has(token_key.clone()) {
            return Err(Error::ContractAlreadyInitialized);
        }

        if step == 0 {
            return Err(Error::InvalidArguments);
        }

        if (&amount * step) / SECONDS_IN_YEAR == 0 {
            return Err(Error::InvalidArguments);
        }

        e.data().set(token_key, token_id);
        e.data()
            .set(DataKey::Parent, to_account(e.invoker()).unwrap());
        e.data().set(DataKey::Child, child);
        e.data().set(DataKey::Amount, amount);
        e.data().set(DataKey::Step, step);

        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

The contract has to be initialized only once. The first thing that the init function does is checking this case. If the environment already has a TokenId defined, which can be only done on the init function, it means that the contract has already been initialized, and the init function returns an Error.

After a sanity check, the contract validates the numerical arguments. It first checks if the step value is not 0, as division by 0 on calculations would give an error, and you can't really withdraw every 0 seconds. After that, it checks if (&amount * step) / SECONDS_IN_YEAR is not zero. Since we are supplying an amount that is for a whole year, we can calculate how much the child account can withdraw at a time using the calculation given.

Then, the function sets up some data on the environment for later use. Lastly, the contract sets up the time of the initial withdraw to a value in the past, so that the Child can withdraw at any time starting now.

Ledger Data

We have utilized the contract data quite much, but how about some network-wide values that do not depend on any contract? We can read the values from the Stellar Ledger using the ledger method of the environment.

The Ledger has information about the protocol_version, sequence, timestamp of the closing time, and the network_passphrase available for all contracts to read. This information is useful as we can create a contract that unlocks at a future time, which is implemented using the timestamp value of the Ledger. Check out the docs on soroban_sdk for more information.

withdraw Function

The withdraw function is implemented as

fn withdraw(e: Env) -> Result<(), Error> {
        let key = DataKey::TokenId;
        if !e.data().has(key.clone()) {
            return Err(Error::ContractNotInitialized);
        }

        let token_id: BytesN<32> = e.data().get(key).unwrap().unwrap();
        let client = token::Client::new(&e, token_id);

        match e.invoker() {
            Address::Account(id) => id,
            _ => return Err(Error::InvalidInvoker),
        };

        let step: u64 = e.data().get(DataKey::Step).unwrap().unwrap();
        let iterations = SECONDS_IN_YEAR / step;
        let amount: BigInt = e.data().get(DataKey::Amount).unwrap().unwrap();
        let withdraw_amount = amount / iterations;

        let latest: u64 = e.data().get(DataKey::Latest).unwrap().unwrap();
        if latest + step > e.ledger().timestamp() {
            return Err(Error::ChildAlreadyWithdrawn);
        }

        client.xfer_from(
            &Signature::Invoker,
            &BigInt::zero(&e),
            &Identifier::Account(e.data().get(DataKey::Parent).unwrap().unwrap()),
            &Identifier::Account(child),
            &withdraw_amount,
        );

        let new_latest = latest + step;
        e.data().set(DataKey::Latest, new_latest);

        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

The withdraw function also starts with a sanity check, this time it requires the contract to be initialized, as it needs to know the parameters on what will it work on.

After the check, it creates a client which, if you remember, is an interface for the function to interact with another contract. This time, the interface is any contract that implements the Token Interface.

After that, it checks the caller is an EOA and not another contract. This is not required, but used for our purposes.

Next, it does some calculations like how much to withdraw at a time, who is the Child account, and such using the stored data on the environment. It also checks if the latest withdraw is before the allowed interval, and returns an error otherwise.

Finally, it sends the required amount to the Child account using the xfer_from function of the token contract. This function transfers tokens from the Parent account to the Child account. To accomplish this task, the Parent account should allow the use of its tokens by this contract. We will accomplish that task later on the quest.

Examining the Test

The test.rs file has a test using the USD Coin contract to create an allowance for the Child account, approving the contract and so on. Please inspect the test functions to see if you can make sense of them. One thing to note here is the use of

env.ledger().set(LedgerInfo {
        timestamp: 1669726145,
        protocol_version: 1,
        sequence_number: 10,
        network_passphrase: Default::default(),
        base_reserve: 10,
    });
Enter fullscreen mode Exit fullscreen mode

function to set the state of the Soroban Ledger for testing purposes. Since the functions depend on the timestamps of the Ledger, we need a way of testing instead of waiting the required amount of time!

Again, we can use cargo test to test the validity of the tests.

Python Scripts

You will see a folder named py-scripts on the quest folder that contains some Python scripts. These scripts are supplied by overcat, and are ways to interact with the Soroban network using the Stellar Python SDK. We won't be using these scripts, but feel free to check them out if you are interested with interacting with Soroban using Python.

Solving the Quest

So, our tasks for the quest are

  • Deploy the AllowanceContract as the Parent_Account
  • Invoke the init function of the AllowanceContract
  • import some XLM into the Parent_Account from their "Classic" Stellar account
  • approve the AllowanceContract to make proxy transfers from the Parent_Account to the Child_Account
  • Invoke the withdraw function of the AllowanceContract with either the Child_Account or Parent_Account
  • export some XLM from the Child_Account to their "Classic" Stellar account

Let's start with them.

Deploying the Contract

As usual, we can build and deploy the contract using

cargo build --target wasm32-unknown-unknown --release
soroban deploy --wasm target/wasm32-unknown-unknown/release/soroban_asset_interop_contract.wasm
Enter fullscreen mode Exit fullscreen mode

Contract Deployment

Note your contract ID somewhere, as we will need it for the next steps of the quest.

Invoking the init Function

At this point, we are used to the custom types that we have to supply to the soroban CLI, so invoking the init function shouldn't be that hard. The init function has the arguments of type AccountId, BytesN<32>, BigInt, and u64.

We have worked with the all those types except BigInt, which is not any different than supplying a u64 variable. The only difference is that the BigInt type can be a quite large number, even larger than the u64 variable.

We need to create a Child account before invoking the init function, as we need to supply the value as an argument.

The stellar_sdk on Python has all the functionality we need to create an account and view its public key in hexadecimal. Note that for this Quest, we need some additional Soroban compatibility on stellar_sdk, so we need to install a specific version of the stellar_sdk. We might already have a version of stellar_sdk, so use the following command to change your version of the stellar_sdk to a Soroban enabled one.

pip install git+https://github.com/StellarCN/py-stellar-base.git@soroban --upgrade --force-reinstall
Enter fullscreen mode Exit fullscreen mode

To create the Child account and get its public key in hexadecimal, we will use

from stellar_sdk import Keypair

child = Keypair.random()

print(f"Public Key: {child.public_key}")
print(f"Private Key: {child.secret}")
print(f"Public Key in Hex: {child.raw_public_key().hex()}")
Enter fullscreen mode Exit fullscreen mode

Note these values somewhere to not forget them as we will need those later.

Image description

We also need to find the contract ID of the native Stellar token on Soroban network. Since the contract IDs of imported tokens are deterministic, we can use the same calculation to determine the address. We will use

import hashlib
from stellar_sdk import Asset, xdr

asset = Asset.native()

data = xdr.HashIDPreimage(
    xdr.EnvelopeType.ENVELOPE_TYPE_CONTRACT_ID_FROM_ASSET,
    from_asset=asset.to_xdr_object(),
)

print(f"Native Token ID: {hashlib.sha256(data.to_xdr_bytes()).hexdigest()}")
Enter fullscreen mode Exit fullscreen mode

Note the XLM token's contract ID, which should be 71c670db8b9d9dd3fa17d83bd98e4a9814f926121972774bd419fa402fe01dc7, somewhere since we will use it later.

XLM Contract ID

Now that we have all the information we need, Let's invoke the init function of our contract.

soroban invoke \
--id [your contract id] \
--fn init \
--arg '{"object":{"accountId":{"publicKeyTypeEd25519":"[public key of your child account in hexadecimal]"}}}' \
--arg [the XLM token contract address] \
--arg [the allowance that you would like to give in stroops] \
--arg [the withdrawal rate in seconds]
Enter fullscreen mode Exit fullscreen mode

We should get a success reply if our arguments are correct. Note that the allowance is in stroops, and a stroop is a 10 millionth of a Lumen.

Invoking Init Function

I allowed the Child account to withdraw 500 Lumens yearly, once every week (60 * 60 * 24 * 7 = 604800).

importing XLM to Parent Account

Now, we have to import some "Classic" Lumens from the Stellar network to Soroban network, so that the Child account can withdraw them. We will use the import function of the token contract, as it implements the Token Interface that we discussed above. As a reminder, the definition of the import function is

fn import(env: Env, id: Signature, nonce: BigInt, amount: i64);
Enter fullscreen mode Exit fullscreen mode

We have a new Signature type that we need to construct as an argument. The Signature type is a proof indicating the signer is giving permission to the function invoker the required permissions. Since we will import for ourselves, we can use the "Invoker" argument to tell the contract that we are doing it for ourselves. Since we are the invoker of this function, the contract knows that we possess the control of the account's secret key, and allows us to accomplish whatever we would like to do.

The nonce parameter is used for signature validation. Since we are not supplying a signature, we can just pass 0.

Signature nonces

Let's say we have approved a third party to take 100 tokens from us by giving them a signature indicating the approval. What keeps them from just using the signature again and again to take 100s of tokens from us? This is called a "Signature Replay Attack", and should be migitated with making signatures "one use only".

Adding a nonce parameter to the message we are signing, assuming the contract has the correct implementation, will prevent Signature Replay Attacks, as now the contract will check if the supplied nonce is one higher than the previous.

So, we can call the import function on the XLM Token Contract using

soroban invoke \
--id 71c670db8b9d9dd3fa17d83bd98e4a9814f926121972774bd419fa402fe01dc7 \
--fn import \
--arg '{"object":{"vec":[{"symbol":"Invoker"}]}}' \
--arg 0 \
--arg [amount in stroops]
Enter fullscreen mode Exit fullscreen mode

Note that the amount is again in stroops, meaning that we have to add seven 0s to convert them to Lumens.

Importing Lumens

We can go a step further and check out the amount of Lumens we have using the balance function on the XLM Token Contract. The definition of the balance function is

fn balance(e: Env, id: Identifier) -> BigInt;
Enter fullscreen mode Exit fullscreen mode

The Identifier type is a type that indicates if the ID argument is from an EOA or a Contract. Since we are an EOA with a Public and Private key pair, we should use the Account identifier on our argument. If you forgot how to calculate your account ID in hexadecimal using Python, here's a reminder:

from stellar_sdk import Keypair

print(Keypair.from_public_key("[your public key]").raw_public_key().hex())
Enter fullscreen mode Exit fullscreen mode

Let's invoke the balance function of the XLM Token Contract using

soroban invoke \
--id 71c670db8b9d9dd3fa17d83bd98e4a9814f926121972774bd419fa402fe01dc7 \
--fn balance \
--arg '{"object":{"vec":[{"symbol":"Account"},{"object":{"accountId":{"publicKeyTypeEd25519":"[your public key in hexadecimal]"}}}]}}'
Enter fullscreen mode Exit fullscreen mode

We will get a reply indicating our balance in stroops.

Parent Account's Balance

approveing the AllowanceContract

We now should permit the AllowanceContract to use our Lumens. Note that we should not permit the Child account instead, as they should only be able to withdraw using our contract.

The approve function's definition is

fn approve(e: Env, from: Signature, nonce: BigInt, spender: Identifier, amount: BigInt);
Enter fullscreen mode Exit fullscreen mode

This time the Identifier will be of type contract, which will have the contract ID of our deployed contract as an input. Since we already know how to construct the other arguments, we can call the approve function using

soroban invoke \
--id 71c670db8b9d9dd3fa17d83bd98e4a9814f926121972774bd419fa402fe01dc7 \
--fn approve \
--arg '{"object":{"vec":[{"symbol":"Invoker"}]}}' \
--arg 0 \
--arg '{"object":{"vec":[{"symbol":"Contract"},{"object":{"bytes":"[your contract id]"}}]}}' \
--arg [amount to approve]
Enter fullscreen mode Exit fullscreen mode

If you approve less than a withdraw function would take at a time, it will fail. We can approve the same amount of tokens we have allowed.

Approval

withdrawing to the Child Account

After setting up the contract and giving out permissions, we now can invoke the withdraw function in our contract using either the child account or our account. Note that anyone can call the withdraw function, but the funds will only go to the Child account. For a difference, let's invoke the contract from the Child account. We just need to supply the Child account's Secret Key to soroban CLI to invoke it from the Child account.

soroban invoke \
--id [your contract id] \
--fn withdraw \
--secret-key [child account's secret key]
Enter fullscreen mode Exit fullscreen mode

We should see a success denoting the withdrawal has succeeded.

Withdraw Function

Now, let's check out the balance of the child account to see if the withdrawal actually worked.

soroban invoke \
--id 71c670db8b9d9dd3fa17d83bd98e4a9814f926121972774bd419fa402fe01dc7 \
--fn balance \
--arg '{"object":{"vec":[{"symbol":"Account"},{"object":{"accountId":{"publicKeyTypeEd25519":"[child account's public key in hexadecimal]"}}}]}}'
Enter fullscreen mode Exit fullscreen mode

We should see a non-zero value for the Child account's balance! This value is also in stroops and will change with the arguments you have supplied to the init function.

Child Account's Balance

exporting Lumens Back

Since the Child account has some Lumens now, we can export them back to the Stellar network using the export command of the XLM Token Contract. The export function is defined as

fn export(env: Env, id: Signature, nonce: BigInt, amount: i64);
Enter fullscreen mode Exit fullscreen mode

Similar to the import function, we will build these arguments as JSON objects, and invoke the export function from the Child account.

soroban invoke \
--id 71c670db8b9d9dd3fa17d83bd98e4a9814f926121972774bd419fa402fe01dc7 \
--fn export \
--arg '{"object":{"vec":[{"symbol":"Invoker"}]}}' \
--arg 0 \
--arg 100000 \
--secret-key [child account's secret key]
Enter fullscreen mode Exit fullscreen mode

Note that we can only export a maximum of the amount of Lumens that we have.

Exporting Lumens

Success! Use sq check 6 to claim your reward 🎉!

Finishing Up

This was the last quest of this series, and definitely was the hardest one to finish. You should be proud of yourself! We have accomplished many feats using the Soroban network, and learned a lot about blockchain design, cryptography primitives, and programming in general.

I hope that you have enjoyed these posts, and learned some new information. The Stellar Quest series is always an incredible place to learn, so keep going on! Solve the Learn Quests, the Side Quests, and read the documentation.

I hope to see you again for the next quest. See you around!

Oldest comments (1)

Collapse
 
altug profile image
Altuğ Bakan

Hope you enjoyed this post!

If you want to support my work, you can send me some XLM at

GB2M73QXDA7NTXEAQ4T2EGBE7IYMZVUX4GZEOFQKRWJEMC2AVHRYL55V
Stellar Wallet

Thanks for all your support!