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.
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;
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};
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");
}
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>;
}
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(())
}
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 thenetwork_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 thetimestamp
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(())
}
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,
});
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 theParent_Account
- Invoke the
init
function of theAllowanceContract
-
import
some XLM into theParent_Account
from their "Classic" Stellar account -
approve
theAllowanceContract
to make proxy transfers from theParent_Account
to theChild_Account
- Invoke the
withdraw
function of theAllowanceContract
with either theChild_Account
orParent_Account
-
export
some XLM from theChild_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
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
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()}")
Note these values somewhere to not forget them as we will need those later.
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()}")
Note the XLM token's contract ID, which should be 71c670db8b9d9dd3fa17d83bd98e4a9814f926121972774bd419fa402fe01dc7
, somewhere since we will use it later.
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]
We should get a success reply if our arguments are correct. Note that the allowance is in stroop
s, and a stroop
is a 10 millionth of a Lumen.
I allowed the Child account to withdraw 500 Lumens yearly, once every week (60 * 60 * 24 * 7 = 604800).
import
ing 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);
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
nonce
sLet'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]
Note that the amount is again in stroop
s, meaning that we have to add seven 0s to convert them to 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;
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())
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]"}}}]}}'
We will get a reply indicating our balance in stroop
s.
approve
ing 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);
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]
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.
withdraw
ing 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]
We should see a success denoting the withdrawal has succeeded.
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]"}}}]}}'
We should see a non-zero value for the Child account's balance! This value is also in stroop
s and will change with the arguments you have supplied to the init
function.
export
ing 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);
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]
Note that we can only export a maximum of the amount of Lumens that we have.
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!
Latest comments (1)
Hope you enjoyed this post!
If you want to support my work, you can send me some XLM at
GB2M73QXDA7NTXEAQ4T2EGBE7IYMZVUX4GZEOFQKRWJEMC2AVHRYL55V
Thanks for all your support!