Token Interface
Token contracts, including the Stellar Asset Contract and example token implementations expose the following common interface.
Tokens deployed on Soroban can implement any interface they choose, however, they should satisfy the following interface to be interoperable with contracts built to support Soroban's built-in tokens.
Note, that in the specific cases the interface doesn't have to be fully implemented. For example, the custom token may not implement the administrative interface compatible with the Stellar Asset Contract - it won't stop it from being usable in the contracts that only perform the regular user operations (transfers, allowances, balances etc.).
Compatibility Requirements
For any given contract function, there are 3 requirements that should be consistent with the interface described here:
- Function interface (name and arguments) - if not consistent, then the users simply won't be able to use the function at all. This is the hard requirement.
- Authorization - the users have to authorize the token function calls with all the arguments of the invocation (see the interface comments). If this is inconsistent, then the custom token may have issues with getting the correct signatures from the users and may also confuse the wallet software.
- Events - the token has to emit the events in the specified format. If inconsistent, then the token may not be handled correctly by the downstream systems such as block explorers.
Code
The interface below uses the Rust soroban-sdk.
pub trait Contract {
// --------------------------------------------------------------------------------
// Admin interface – privileged functions.
// --------------------------------------------------------------------------------
//
// All the admin functions have to be authorized by the admin with all input
// arguments, i.e. they have to call `admin.require_auth()`.
/// Clawback "amount" from "from" account. "amount" is burned.
/// Emit event with topics = ["clawback", admin: Address, to: Address], data = [amount: i128]
fn clawback(
env: soroban_sdk::Env,
from: Address,
amount: i128,
);
/// Mints "amount" to "to".
/// Emit event with topics = ["mint", admin: Address, to: Address], data = [amount: i128]
fn mint(
env: soroban_sdk::Env,
to: Address,
amount: i128,
);
/// Sets the administrator to the specified address "new_admin".
/// Emit event with topics = ["set_admin", admin: Address], data = [new_admin: Address]
fn set_admin(
env: soroban_sdk::Env,
new_admin: Address,
);
/// Sets whether the account is authorized to use its balance.
/// If "authorized" is true, "id" should be able to use its balance.
/// Emit event with topics = ["set_authorized", id: Address], data = [authorize: bool]
fn set_authorized(
env: soroban_sdk::Env,
id: Address,
authorize: bool,
);
// --------------------------------------------------------------------------------
// Token interface
// --------------------------------------------------------------------------------
//
// All the functions here have to be authorized by the token spender
// (usually named `from` here) using all the input arguments, i.e. they have
// to call `from.require_auth()`.
/// Set the allowance by "amount" for "spender" to transfer/burn from "from".
/// "expiration_ledger" is the ledger number where this allowance expires. It cannot
/// be less than the current ledger number unless the amount is being set to 0.
/// An expired entry (where "expiration_ledger" < the current ledger number)
/// should be treated as a 0 amount allowance.
/// Emit event with topics = ["approve", from: Address, spender: Address], data = [amount: i128, expiration_ledger: u32]
fn approve(
env: soroban_sdk::Env,
from: Address,
spender: Address,
amount: i128,
expiration_ledger: u32,
);
/// Transfer "amount" from "from" to "to".
/// Emit event with topics = ["transfer", from: Address, to: Address], data = [amount: i128]
fn transfer(
env: soroban_sdk::Env,
from: Address,
to: Address,
amount: i128,
);
/// Transfer "amount" from "from" to "to", consuming the allowance of "spender".
/// Authorized by spender (`spender.require_auth()`).
/// Emit event with topics = ["transfer", from: Address, to: Address], data = [amount: i128]
fn transfer_from(
env: soroban_sdk::Env,
spender: Address,
from: Address,
to: Address,
amount: i128,
);
/// Burn "amount" from "from".
/// Emit event with topics = ["burn", from: Address], data = [amount: i128]
fn burn(
env: soroban_sdk::Env,
from: Address,
amount: i128,
);
/// Burn "amount" from "from", consuming the allowance of "spender".
/// Emit event with topics = ["burn", from: Address], data = [amount: i128]
fn burn_from(
env: soroban_sdk::Env,
spender: Address,
from: Address,
amount: i128,
);
// --------------------------------------------------------------------------------
// Read-only Token interface
// --------------------------------------------------------------------------------
//
// The functions here don't need any authorization and don't emit any
// events.
/// Get the balance of "id".
fn balance(env: soroban_sdk::Env, id: Address) -> i128;
/// Get the spendable balance of "id". This will return the same value as balance()
/// unless this is called on the Stellar Asset Contract, in which case this can
/// be less due to reserves/liabilities.
fn spendable_balance(env: soroban_sdk::Env id: Address) -> i128;
// Returns true if "id" is authorized to use its balance.
fn authorized(env: soroban_sdk::Env, id: Address) -> bool;
/// Get the allowance for "spender" to transfer from "from".
fn allowance(
env: soroban_sdk::Env,
from: Address,
spender: Address,
) -> i128;
// --------------------------------------------------------------------------------
// Descriptive Interface
// --------------------------------------------------------------------------------
// Get the number of decimals used to represent amounts of this token.
fn decimals(env: soroban_sdk::Env) -> u32;
// Get the name for this token.
fn name(env: soroban_sdk::Env) -> soroban_sdk::Bytes;
// Get the symbol for this token.
fn symbol(env: soroban_sdk::Env) -> soroban_sdk::Bytes;
}
The approve
function overwrites the previous value with amount
, so it is
possible for the previous allowance to be spent in an earlier transaction before
amount
is written in a later transaction. The result of this is that spender
can spend more than intended. This issue can be avoided by first setting the
allowance to 0, verifying that the spender didn't spend any portion of the
previous allowance, and then setting the allowance to the new desired amount.
You can read more about this issue here -
https://github.com/ethereum/EIPs/issues/20#issuecomment-263524729.
Metadata
Another requirement for complying with the token interface is to write the
standard metadata (decimal
, name
, and symbol
) for the token in a specific
format. This format allows users to directly read constant data from the ledger
instead of invoking a Wasm function. The token
example
demonstrates how to use the Rust
soroban-token-sdk
to write the metadata, and we strongly encourage token implementations to follow
this approach.
Handling Failure Conditions
In the token interface, there are several instances where function calls can fail due to various reasons such as lack of proper authorization, insufficient allowance or balance, etc. To handle these failure conditions, it is important to specify the expected behavior when such situations arise.
Its important to note the that the token interface not only incorporates the authorization concept for matching asset authorization in Stellar Classic, but it also utilizes the Soroban authorization mechanism. So, if you try to make a token call and it fails, it could be because of either token authorization processes.
To provide more context, when you use the token interface, there is a function called authorized
that returns "true" if an address has token authorization.
More details on Authorization can be found here.
For the functions in the token interface, trapping should be used as the standard way to handle failure conditions since the interface is not designed to return error codes. This means that when a function encounters an error, it will halt execution and revert any state changes that occurred during the function call.
Failure Conditions
Here is a list of basic failure conditions and their expected behavior for functions in the token interface:
Admin functions:
- If the admin did not authorize the call, the function should trap.
- If the admin attempts to perform an invalid action (e.g., minting a negative amount), the function should trap.
Token functions:
- If the caller is not authorized to perform the action (e.g., transferring tokens without proper authorization), the function should trap.
- If the action would result in an invalid state (e.g., transferring more tokens than available in the balance or allowance), the function should trap.
Example: Handling Insufficient Allowance in burn_from
function
In the burn_from
function, the token contract should check whether the spender has enough allowance to burn the specified amount of tokens from the from
address. If the allowance is insufficient, the function should trap, halting execution and reverting any state changes.
Here's an example of how the burn_from
function can be modified to handle this failure condition:
fn burn_from(
env: soroban_sdk::Env,
spender: Address,
from: Address,
amount: i128,
) {
// Check if the spender has enough allowance
let current_allowance = allowance(env, from, spender);
if current_allowance < amount {
// Trap if the allowance is insufficient
panic!("Insufficient allowance");
}
// Proceed with burning tokens
// ...
}
By clearly outlining how to handle failures and incorporating the right error management techniques in the token interface, we can make token contracts stronger and safer.