Soroban Quest : Asset Interop
yuzurush
Posted on March 26, 2023
Hi there! Welcome to my "Soroban Quest" blog post series. Soroban Quest is a gamified educational course where you’ll learn Soroban (new smart contract platform by Stellar Foundation) and earn badge rewards!. In this series, i will explain steps to complete every soroban quest and help you understand more about soroban smart contract itself.
The 6th quest is called 'Asset Interop'. This quest will provide an example of how asset interoperability works in Soroban.
Joining The Quest
To join the quest and get a Quest Account, use this command:
sq play 6
And dont forget to fund the quest account right away.
Examining README.md
After examining README.md
we will need 2 account for this quest, our quest account act as Parent_Account
and the other account act as Child_Account
. To create account use Stellar Laboratory, and don't forget to fund it. Also the asset that we're going to use is native token(XLM/lumens).
The tasks for the 6th quest is :
- Deploy the
AllowanceContract
usingParent_Account
- Invoke the
init
function of theAllowanceContract
- Invoke the
incr_allow
function of the native token contract to allow yourAllowanceContract
to make proxy transfers from theParent_Account
to theChild_Account
- Invoke the
withdraw
function of theAllowanceContract
using either theChild_Account
orParent_Account
Native Token Contract ID :
d93f5c7bb0ebc4a9c8f727c5cebc4e41194d38257e1d0d910356b43bfc528813
The Contract Code
#![no_std]
use soroban_sdk::{contracterror, contractimpl, contracttype, Address, BytesN, Env};
mod token {
soroban_sdk::contractimport!(file = "./soroban_token_spec.wasm");
}
#[contracterror]
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord)]
#[repr(u32)]
pub enum Error {
ContractAlreadyInitialized = 1,
ContractNotInitialized = 2,
InvalidAuth = 3,
ChildAlreadyWithdrawn = 4,
InvalidInvoker = 5,
InvalidArguments = 6,
}
#[contracttype]
#[derive(Clone)]
pub enum StorageKey {
Parent, // Address
Child, // Address
TokenId, // BytesN<32>
Amount, // i128
Step, // u64
Latest, // u64
}
const SECONDS_IN_YEAR: u64 = 365 * 24 * 60 * 60; // = 31,536,000 seconds (fyi)
pub struct AllowanceContract;
pub trait AllowanceTrait {
fn init(
e: Env,
parent: Address, // the parent account giving the allowance
child: Address, // the child account receiving the allowance
token_id: BytesN<32>, // the id of the token being transferred as an allowance
amount: i128, // the total allowance amount given for the year
step: u64, // how frequently (in seconds) a withdrawal can be made
) -> Result<(), Error>;
fn withdraw(e: Env, invoker: Address) -> Result<(), Error>;
}
#[contractimpl]
impl AllowanceTrait for AllowanceContract {
fn init(
e: Env,
parent: Address,
child: Address,
token_id: BytesN<32>,
amount: i128,
step: u64,
) -> Result<(), Error> {
let token_key = StorageKey::TokenId;
if e.storage().has(&token_key) {
return Err(Error::ContractAlreadyInitialized);
}
parent.require_auth();
if step == 0 {
return Err(Error::InvalidArguments);
}
if (amount * step as i128) / SECONDS_IN_YEAR as i128 == 0 {
return Err(Error::InvalidArguments);
}
e.storage().set(&token_key, &token_id);
e.storage().set(&StorageKey::Parent, &parent);
e.storage().set(&StorageKey::Child, &child);
e.storage().set(&StorageKey::Amount, &amount);
e.storage().set(&StorageKey::Step, &step);
let current_ts = e.ledger().timestamp();
e.storage().set(&StorageKey::Latest, &(current_ts - step));
Ok(())
}
fn withdraw(e: Env, invoker: Address) -> Result<(), Error> {
let key = StorageKey::TokenId;
if !e.storage().has(&key) {
return Err(Error::ContractNotInitialized);
}
let child: Address = e.storage().get(&StorageKey::Child).unwrap().unwrap();
let parent: Address = e.storage().get(&StorageKey::Parent).unwrap().unwrap();
if invoker != child && invoker != parent {
return Err(Error::InvalidAuth);
}
invoker.require_auth();
let token_id: BytesN<32> = e.storage().get(&key).unwrap().unwrap();
let client = token::Client::new(&e, &token_id);
let step: u64 = e.storage().get(&StorageKey::Step).unwrap().unwrap();
let iterations = SECONDS_IN_YEAR / step;
let amount: i128 = e.storage().get(&StorageKey::Amount).unwrap().unwrap();
let withdraw_amount = amount / iterations as i128;
let latest: u64 = e.storage().get(&StorageKey::Latest).unwrap().unwrap();
if latest + step > e.ledger().timestamp() {
return Err(Error::ChildAlreadyWithdrawn);
}
client.xfer_from(
&e.current_contract_address(),
&parent,
&child,
&withdraw_amount,
);
let new_latest = latest + step;
e.storage().set(&StorageKey::Latest, &new_latest);
Ok(())
}
}
mod test;
The Allowance contract allows a "parent" account to set up an allowance for a "child" account. The parent specifies:
- The token being transferred (token ID)
- The total amount allowed for the year
- How frequently withdrawals can be made (in seconds)
The child can then withdraw their allowance in increments, as long as it's been at least the specified number of seconds since their last withdrawal.
The contract is located in lib.rs
and contains two functions:
- init() - Initialized the allowance details
- withdraw() - Transfers funds from the parent to the child
The init() function:
- Accepts the parent address, child address, token ID, total amount, and withdrawal frequency (step)
- Checks that the contract has not already been initialized
- Requires the parent address to authorize the initialization
- Checks that the step and total amount are valid (not 0 and would result in a non-zero annual amount)
- Stores the allowance details by calling env.storage().set()
- Returns Ok if successful This function sets up the initial allowance details and ensures the inputs are valid.
The withdraw() function:
- Accepts the invoker address (who is calling the function) and contract environment
- Checks that the contract has been initialized
- Retrieves the parent, child, and withdrawal details from storage
- Checks that the invoker is the parent or child
- Calculates the withdrawal amount and checks that the last withdrawal was at least step seconds ago
- Creates a token contract client and transfers the funds from the parent to the child
- Updates the last withdrawal time
- Returns Ok if successful
Building The Contract
To build the contract, use the following command:
cd quests/6-asset-interop
cargo build --target wasm32-unknown-unknown --release
This should output a .wasm file in the ../target directory:
../target/wasm32-unknown-unknown/release/soroban_asset_interop_contract.wasm
Deploying The Contract as Parent_Account
To deploy AllowanceContract
contract, use this following command :
soroban contract deploy --wasm /workspace/soroban-quest/quests/4-cross-contract/soroban_asset_interop_contract.wasm
Save the contract ID for later.
Initializing AllowanceContract
To invoke init
function from AllowanceContract
, use this command format:
soroban contract invoke --id <YourContracID> -- init --parent <ParentPublicKey> --child <ChildPublicKey> --token_id 'd93f5c7bb0ebc4a9c8f727c5cebc4e41194d38257e1d0d910356b43bfc528813' --amount <LumensAmount> --step <WithdrwarRateInSeconds>
With the AllowanceContract
already initialized, the Parent_Account
needs to authorize the contract to make proxy transfers on its behalf to the Child_Account
.
Allowing AllowanceContract
To authorize the contract to make proxy transfers, use this command format :
soroban contract invoke --id d93f5c7bb0ebc4a9c8f727c5cebc4e41194d38257e1d0d910356b43bfc528813 -- incr_allow --from <ParentPublicKey> --spender '{"object":{"address":{"contract":"'<AllowanceContractID>'"}}}' --amount <LumensAmount>
Withdrawing the Allowance
To withdraw the Allowance, use this command format :
soroban contract invoke --id <AllowanceContractID> -- withdraw --invoker <ParentorChildPublicKey>
Checking the Quest
We already completed every step to complete the quest and this is the last thing you need to do. Check your quest and claim your badge reward. To check use the following command :
sq check 6
Congratulations on completing all 6 Soroban quests! You've come a long way, and should feel very accomplished. Keep up the great work!
Posted on March 26, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.