Skip to main content
info

The term "custom token" has been deprecated in favor of "contract token". View the conversation in the Stellar Developer Discord.

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 contract 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 three 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 contract 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 to declare a trait that complies with the SEP-41 token interface.

pub trait TokenInterface {
/// Returns the allowance for `spender` to transfer from `from`.
///
/// The amount returned is the amount that spender is allowed to transfer
/// out of from's balance. When the spender transfers amounts, the allowance
/// will be reduced by the amount transferred.
///
/// # Arguments
///
/// * `from` - The address holding the balance of tokens to be drawn from.
/// * `spender` - The address spending the tokens held by `from`.
fn allowance(env: Env, from: Address, spender: Address) -> i128;

/// Set the allowance by `amount` for `spender` to transfer/burn from
/// `from`.
///
/// The amount set is the amount that spender is approved to transfer out of
/// from's balance. The spender will be allowed to transfer amounts, and
/// when an amount is transferred the allowance will be reduced by the
/// amount transferred.
///
/// # Arguments
///
/// * `from` - The address holding the balance of tokens to be drawn from.
/// * `spender` - The address being authorized to spend the tokens held by
/// `from`.
/// * `amount` - The tokens to be made available to `spender`.
/// * `live_until_ledger` - The ledger number where this allowance expires. Cannot
/// be less than the current ledger number unless the amount is being set to 0.
/// An expired entry (where live_until_ledger < the current ledger number)
/// should be treated as a 0 amount allowance.
///
/// # Events
///
/// Emits an event with topics `["approve", from: Address,
/// spender: Address], data = [amount: i128, live_until_ledger: u32]`
fn approve(env: Env, from: Address, spender: Address, amount: i128, live_until_ledger: u32);

/// Returns the balance of `id`.
///
/// # Arguments
///
/// * `id` - The address for which a balance is being queried. If the
/// address has no existing balance, returns 0.
fn balance(env: Env, id: Address) -> i128;

/// Transfer `amount` from `from` to `to`.
///
/// # Arguments
///
/// * `from` - The address holding the balance of tokens which will be
/// withdrawn from.
/// * `to` - The address which will receive the transferred tokens.
/// * `amount` - The amount of tokens to be transferred.
///
/// # Events
///
/// Emits an event with:
/// * topics `["transfer", from: Address, to: Address]`
/// * data `{ to_muxed_id: Option<u64>, amount: i128 }: Map`
///
/// Legacy implementations may emit an event with:
/// * topics `["transfer", from: Address, to: Address]`
/// * data `amount: i128`
fn transfer(env: Env, from: Address, to: MuxedAddress, amount: i128);

/// Transfer `amount` from `from` to `to`, consuming the allowance that
/// `spender` has on `from`'s balance. Authorized by spender
/// (`spender.require_auth()`).
///
/// The spender will be allowed to transfer the amount from from's balance
/// if the amount is less than or equal to the allowance that the spender
/// has on the from's balance. The spender's allowance on from's balance
/// will be reduced by the amount.
///
/// # Arguments
///
/// * `spender` - The address authorizing the transfer, and having its
/// allowance consumed during the transfer.
/// * `from` - The address holding the balance of tokens which will be
/// withdrawn from.
/// * `to` - The address which will receive the transferred tokens.
/// * `amount` - The amount of tokens to be transferred.
///
/// # Events
///
/// Emits an event with topics `["transfer", from: Address, to: Address],
/// data = amount: i128`
fn transfer_from(env: Env, spender: Address, from: Address, to: Address, amount: i128);

/// Burn `amount` from `from`.
///
/// Reduces from's balance by the amount, without transferring the balance
/// to another holder's balance.
///
/// # Arguments
///
/// * `from` - The address holding the balance of tokens which will be
/// burned from.
/// * `amount` - The amount of tokens to be burned.
///
/// # Events
///
/// Emits an event with topics `["burn", from: Address], data = amount:
/// i128`
fn burn(env: Env, from: Address, amount: i128);

/// Burn `amount` from `from`, consuming the allowance of `spender`.
///
/// Reduces from's balance by the amount, without transferring the balance
/// to another holder's balance.
///
/// The spender will be allowed to burn the amount from from's balance, if
/// the amount is less than or equal to the allowance that the spender has
/// on the from's balance. The spender's allowance on from's balance will be
/// reduced by the amount.
///
/// # Arguments
///
/// * `spender` - The address authorizing the burn, and having its allowance
/// consumed during the burn.
/// * `from` - The address holding the balance of tokens which will be
/// burned from.
/// * `amount` - The amount of tokens to be burned.
///
/// # Events
///
/// Emits an event with topics `["burn", from: Address], data = amount:
/// i128`
fn burn_from(env: Env, spender: Address, from: Address, amount: i128);

/// Returns the number of decimals used to represent amounts of this token.
///
/// # Panics
///
/// If the contract has not yet been initialized.
fn decimals(env: Env) -> u32;

/// Returns the name for this token.
///
/// # Panics
///
/// If the contract has not yet been initialized.
fn name(env: Env) -> String;

/// Returns the symbol for this token.
///
/// # Panics
///
/// If the contract has not yet been initialized.
fn symbol(env: Env) -> String;
}
CAUTION WHEN MODIFYING ALLOWANCES

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.

Events: SEP-41 Tokens vs. Stellar Asset Contracts

The event shapes in the interface above are the ones a SEP-41 contract token emits. A Stellar Asset Contract — the built-in contract every Stellar asset has — emits the same event names with one extra topic: the asset's SEP-11 identifier, always last.

Code written against the shapes above will therefore mis-parse an event emitted by a Stellar Asset Contract, reading the asset identifier as a missing field or ignoring it entirely.

CAP-67 is the normative source for the Stellar Asset Contract shapes below, and for the unification of classic events described further down.

EventSEP-41 contract tokenStellar Asset Contract
approve["approve", from, spender]["approve", from, spender, sep0011_asset]
transfer["transfer", from, to]["transfer", from, to, sep0011_asset]
burn["burn", from]["burn", from, sep0011_asset]
mintnot part of this interface["mint", to, sep0011_asset]
clawbacknot part of this interface["clawback", from, sep0011_asset]
set_authorizednot part of this interface["set_authorized", id, sep0011_asset]
set_adminnot part of this interface["set_admin", admin, sep0011_asset]

On the Stellar Asset Contract side, the extra topic does not change the data payload: amount: i128 for transfer, mint, burn, and clawback; [amount: i128, live_until_ledger: u32] for approve; authorize: bool for set_authorized; and new_admin: Address for set_admin. When the destination of a Stellar Asset Contract transfer or mint carries a multiplexing id, the data becomes { amount: i128, to_muxed_id }: Map instead — see Monitoring Payments as event stream for a worked example.

A SEP-41 contract token keeps whatever payload the interface above documents for it, so do not carry these payload shapes back across the table.

A Stellar Asset Contract transfer does not always emit a transfer event

When the asset issuer is one side of the transfer, the contract emits a different event: mint if from is the issuer, burn if to is the issuer — and the burn drops any multiplexing id. You only get a transfer event when neither side is the issuer, or when both are. See Interacting with classic Stellar assets.

Telling a classic operation from a contract invocation

Since protocol 23, classic operations emit these same events, published under the asset's Stellar Asset Contract address whether or not that contract has been deployed — see Tracking the movement of value.

Not all of them, though. approve and set_admin are never emitted by a classic operation, because no classic operation is equivalent to them: allowances and contract administration exist only on the contract side. So those two always come from a contract invocation, and the rest — transfer, mint, burn, clawback, and set_authorized — can come from either path.

The two paths produce byte-identical events, so nothing inside an event distinguishes them. To tell them apart, join the event back to its operation using txHash and operationIndex, then read the operation's type. Note that this separates a classic operation from a contract invocation, not a direct call to the asset contract from one made by another contract on a user's behalf — both of those are InvokeHostFunction operations emitting the same event.

If you would rather not do that join yourself, the Token Transfer Processor normalizes both paths into a single stream of typed events.

One event in the unified set has no counterpart here: fee is transaction-level, carries only two topics and no asset identifier, and is never emitted by a contract function.

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.