CAP: 0046-06 (formerly 0054)
Title: Smart Contract Standardized Asset
Working Group:
Owner: Jonathan Jove <@jonjove>
Authors: Jonathan Jove <@jonjove>, Siddharth Suresh <@sisuresh>,
Consulted: Nicolas Barry <@monsieurnicolas>, Leigh McCulloch <@leighmcculloch>, Tomer Weller <@tomerweller>
Status: Draft
Created: 2022-05-31
Discussion: TBD
Protocol version: TBD
Allow contracts to interoperate with Stellar assets.
A fungible asset is a fundamental concept on blockchains. Fungible assets can conceptually be divided into two pieces: standard functionality enabling push and pull transfers, and varying functionality around asset administration. Most blockchain ecosystems have very little innovation in the space of fungible assets, with developers often relying on open source implementations such as OpenZeppelin.
Rather than rely on an open source implementation, developers should have access to a native contract which fulfils typical needs. This does not prevent developers from implementing their own fungible asset if the contract does not meet their needs. But the efficiency gained from a native implementation should reduce fees sufficiently to encourage most developers to choose the native implementation when it is suitable.
The native implementation should serve as a compatibility layer for classic assets. This ensures that any contract which can interoperate with new smart assets can also interoperate with classic assets, and vice versa.
The advantage of a native implementation is that the semantics are known to the protocol. This allows additional enhancements like a dedicated fee lane for simple asset transfers, or the potential to use the asset in a high-throughput parallel exchange like SPEEDEX.
This CAP is aligned with the following Stellar Network Goals:
- The Stellar Network should make it easy for developers of Stellar projects to create highly usable products
- The Stellar Network should enable cross-border payments, i.e. payments via exchange of assets, throughout the globe, enabling users to make payments between assets in a manner that is fast, cheap, and highly usable.
This proposal introduces a native contract implementation for tokens which is
suitable for both smart tokens and wrapped classic tokens. The interface tries
to follow an ERC-20 model, although functions perform their own authorization
more like EIP-2612 permit
. Identifiers in these tokens can be contracts,
ed25519 public keys, or Stellar accounts.
See the XDR diffs in the Soroban overview CAP, specifically those covering new envelope types.
The following types are used throughtout this proposal. The comment above each
type describes the way in which the type is serialized as an SCVal
.
/******************************************************************************\
*
* Data Structures
*
\******************************************************************************/
pub struct Ed25519Signature {
pub public_key: BytesN<32>,
pub signature: BytesN<64>,
}
pub struct AccountSignatures {
pub account_id: AccountId,
pub signatures: Vec,
}
pub enum Signature {
Invoker,
Ed25519(Ed25519Signature),
Account(AccountSignatures),
}
pub enum Identifier {
Contract(BytesN<32>),
Ed25519(BytesN<32>),
Account(AccountId),
}
// Byte arrays and strings can be passed to contracts and stores using the Bytes type.
// BytesN is fixed size, while Bytes is variable
pub struct Bytes(EnvVal<Host, Object>);
pub struct BytesN<const N: usize>(EnvVal<Host, Object>);
Signatures for this contract are over the following SignaturePayload
type
pub struct SignaturePayloadV0 {
pub network: Bytes,
pub contract: BytesN<32>,
pub name: Symbol,
pub args: Vec,
}
pub enum SignaturePayload {
V0(SignaturePayloadV0),
}
/******************************************************************************\
*
* Initialization Interface
*
\******************************************************************************/
// Initializes a token contract that does not wrap an asset on the classic side.
// Should be used on a contract created with create_token_from_contract.
// Sets admin, decimals, name, symbol, and the clawback flag.
fn init(admin: Identifier, metadata: TokenMetadata, can_clawback: bool) -> Result<(), Error>;
The descriptive interface provides information about the representation of the token.
/******************************************************************************\
*
* Descriptive Interface
*
\******************************************************************************/
// Get the number of decimals used to represent amounts of this token
fn decimals() -> Result<u32, Error>;
// Get the name for this token
fn name() -> Result<Bytes, Error>;
// Get the symbol for this token
fn symbol() -> Result<Bytes, Error>;
TODO: Specify u128 in CAP-0046
The token interface provides capabilities analogous to those of ERC-20 tokens.
/******************************************************************************\
*
* Token Interface
*
\******************************************************************************/
// Get the allowance for "spender" to transfer from "from"
fn allowance(from: Identifier, spender: Identifier) -> Result<u128, Error>;
// Verify from and nonce. Then increase the allowance by "amount" for "spender" to transfer from "from".
// If an allowance does not exist, set allowance to "amount".
fn increase_allowance(from: Signature, nonce: BigInt, spender: Identifier, amount: u128) -> Result<(), Error>;
// Verify from and nonce. Then decrease the allowance by "amount" for "spender" to transfer from "from".
// If amount is greater than the existing allowance, set the allowance to 0.
fn decrease_allowance(from: Signature, nonce: BigInt, spender: Identifier, amount: u128) -> Result<(), Error>;
// Get the balance of "id"
fn balance_of(id: Identifier) -> Result<u128, Error>;
// Verify from and nonce. Then transfer "amount" from "from" to "to"
fn xfer(from: Signature, nonce: BigInt, to: Identifier, amount: u128) -> Result<(), Error>;
// Verify spender and nonce. Then transfer "amount" from "from" to "to", consuming the allowance of "spender"
fn xfer_from(spender: Signature, nonce: BigInt, from: Identifier, to: Identifier, amount: u128) -> Result<(), Error>;
The admin interface provides the ability to control supply and some simple compliance functionality.
/******************************************************************************\
*
* Admin Interface
*
\******************************************************************************/
// If "admin" is the administrator and clawback is not disabled, clawback "amount" from "from".
// The "amount" is burned. This function can be used on deauthorized balances.
fn clawback(admin: Signature, nonce: BigInt, from: Identifier, amount: u128) -> Result<(), Error>;
// If "admin" is the administrator, clear the authorized flag for "id"
fn deauthorize(admin: Signature, nonce: BigInt, id: Identifier) -> Result<(), Error>;
// If "admin" is the administrator, set the authorized flag for "id". Without authorization,
// "id" will not be able to use it's balance.
fn authorize(admin: Signature, nonce: BigInt, id: Identifier) -> Result<(), Error>;
// Returns true if the authorized flag is set
fn is_authorized(id: Identifier) -> Result<bool, Error>;
// If "admin" is the administrator, mint "amount" to "to"
fn mint(admin: Signature, nonce: BigInt, to: Identifier, amount: u128) -> Result<(), Error>;
// If "admin" is the administrator, set the administrator to "id"
fn set_admin(admin: Signature, nonce: BigInt, new_admin: Identifier) -> Result<(), Error>;
// If "admin" is the administrator, disable the clawback function for this contract
fn disable_clawback(admin: Signature, nonce: BigInt) -> Result<(), Error>;
// Returns the current admin. If admin is not set, returns a zero ed25519 key
fn admin() -> Result<Identifier, Error>;
The wrapper interface is only provided for classic assets, allowing assets to flow between classic and smart.
/******************************************************************************\
*
* Wrapper Interface
*
\******************************************************************************/
// Move "amount" from "id" on classic to "id" on smart
fn import(id: Signature, nonce: BigInt, amount: i64) -> Result<(), Error>;
// Move "amount" from "id" on smart to "id" on classic
fn export(id: Signature, nonce: BigInt, amount: i64) -> Result<(), Error>;
In order to guarantee uniqueness of wrapper contracts, create_token_from_asset
produces a deterministic contract identifier that does not depend on the creator
or any salt. This is achieved by introducing
ENVELOPE_TYPE_CONTRACT_ID_FROM_ASSET
in
CAP-0046-02.
The wrapper contracts should be initialized atomically with contract creation so
the right asset is passed in. create_token_from_asset
should call the init_asset
function specified below after the contract is created.
Note that both functions below are not exposed for contracts to call. create_token_from_asset
can only be called through InvokeHostFunctionOp
specified in CAP-0046-04.
// Creates a contract that wraps a classic Stellar asset. The asset parameter should be
// the binary representation of the XDR Asset being wrapped. This will also call init_asset on the
// newly created contract to prevent the predetermined contract id for this asset from being taken.
fn create_token_from_asset(asset: Object) -> Result<Object, Error>;
// This is a built-in token contract function, but only
// intended to be called by create_token_from_asset.
// init_asset will initialize a contract for a wrapped classic asset
// (Native, AlphaNum4, or AlphaNum12). It will fail if the contractID
// of this contract does not match the expected contractID for this asset
// returned by Host::get_contract_id_from_asset. This function should only be
// called by the create_token_from_asset host function for this reason.
//
// No admin will be set for the Native token, so any function that checks the admin
// (clawback, disable_clawback, deauthorize, authorize, mint, set_admin) will always fail
fn init_asset(asset_bytes: Bytes) -> Result<(), Error>;
// From CAP-0046-02 - Instantiates a contract with the source referring to the built-in token.
// Returns the newly created contractID.
fn create_token_from_contract(salt: Object) -> Result<Object, Error>;
Uniqueness does not apply to non-wrapper token contracts, so this function uses the creating contract and a salt.
Balances will be authorized by default.
From the perspective of a contract, smart assets and classic assets have exactly identical semantics. This makes contract design and implementation easier by reducing the number of edge cases to consider and tests to write.
The main goal of this proposal is to create a standard asset with predictable behavior. This preserves the ability to deliver certain future enhancements such as a dedicated fee-lane for payments. If payments have arbitrary pre / post hooks then any arbitrarily expensive program could get embedded into the payment lane, significantly reducing the feasibility of such a concept.
This proposal supports several signature mechanisms to acknowledge the reality
that account-based multisig is a fundamental aspect of the Stellar ecosystem.
I anticipate many pure contract users will favor a single Ed25519 key because
fees will be lower, so we provide this option as well. Contracts cannot sign, so
they need a separate authorization mechanism too (achieved with
get_invoking_contract
).
This is an extremely useful design point that makes it possible to couple multiple signed messages. Consider the interface described in "Ecosystem Support: Wrap-Then-Do Contract". This interface can receive multiple signed messages. But an attacker could observe this on the network, then submit the signed messages separately. By adding the caller to the signed message, it allows the caller to perform additional consistency checks. For example, the calling contract could receive a signature over a message containing the coupled signed messages.
Unlike the existing Stellar protocol, which uses simple flags for compliance controls, this proposal delegates all decisions regarding administrative functionality to an administrator. The administrator can be a contract, a simple Ed25519 key, or an existing Stellar account.
This design decision does not break compatibility with Stellar assets. If an existing holder does not want to accept the new terms, they simply elect not to wrap their asset. The ability to do this without breaking compatibility is one of the big advantages of using wrappers.
If people want an analog to the existing compliance semantics, we can provide a reference administrator contract that implements them.
The wrapper interface is only provided for classic assets. Smart assets do not have a wrapper interface because those administered by contracts would lose control over the wrapped assets. Smart asset issuers can always deploy their own wrapper interface should they need it.
Total supply is confusing for classic assets because they can exist in wrapped
and unwrapped form. Total supply is also high contention when minting and
burning. Tokens that need to track total supply can do so by having the
administrator contract update it when calling mint
and clawback
.
Unlike earlier variants of this proposal (see CAP-0048 and CAP-0049), this proposal does not implicitly deploy a wrapper for every classic asset. However, anyone can deploy the wrapper for any asset. The main advantage to this approach is that we no longer need to special case the identifiers for these wrappers. We still desire that these wrappers are unique, so they will be constructed differently even though they have the same format.
Uniqueness is not a concern for smart tokens, so their identifiers are constructed using the typical creator and salt approach.
The ability to call the clawback
function is set by the entity that calls
init
for non-classic tokens. For classic tokens, clawback
will be enabled
only if AUTH_CLAWBACK_ENABLED_FLAG
is set on the issuer. If the issuer account
is missing, clawback
will be disabled on initialization because anyone could
create the missing issuer account without any flags. Instead of checking for
AUTH_CLAWBACK_ENABLED_FLAG
, we could just have clawback
enabled by default,
and allow the issuer to clear it. I don't think this is a great idea because I
think we'll see most issuers not clear the flag even if they don't require
clawback, making users hesitant to use classic assets on Soroban.
Note that clawback
can be disabled using disable_clawback
, but it can not be
enabled. This matches the clawback behavior on Stellar classic.
The options considered were u63 (contained in an ScVal), u64, u128, u256, and an
arbitrary precision big integer. Big integer is unnecessary since u256 is more
than enough to represent any reasonable balance. This is made more obvious when
looking at other chains and what they use (Solana uses u64, Near uses u128, and
ERC-20 uses u256). Note that a u63 is more efficient than u64 in Soroban because
the u63 can be contained in a 64 bit ScVal
, while u64 will need be to an
ScObject
, which only exists in the host and needs to be referenced by the guest.
Now the question is what should it be instead? u63 is the most efficient with u64 close behind, but not the most flexible if you use more than a couple decimal places. For example, a u64 balance with 9 decimal places would have a max value of 18,446,744,073.709551615. Stellar classic uses a signed 64 bit integer (which would be a u63 in Soroban) with seven decimal places, leading to a max value of 922,337,203,685.4775807. These values might be sufficient in most cases, but there are some edge cases where these limits are too small. For example, the US Treasury has a cash balance of $636 billion. This barely works in the Stellar case, but isn’t far off from reaching the max. It works in the variable decimals case if you give up some decimals. There are other currency and asset examples that exceed these limits as well. Instead of forcing issuers to compromise on decimals, the best option is to use u128 balances.
u128 gives users more flexibilty than Stellar classic without compromising too much in terms of performance. u256 gives us even more flexibility and easy compatibility with ERC-20, but u128 has a max value that shold work with almost every use case even with 18 decimal places (340,282,366,920,938,463,463.374607431768211456). The performance implications of using u128 instead of u63 or u64 should be measured.
This proposal does not include an approve
function like the one specified in EIP-20. This was done to mitigate the allowance vulnerability specified in this google doc. TLDR: If user A specified an allowance of 10 for B, then tries to reduce it to 5, B could spend the allowance before the reduction is accepted on the ledger, and then have an additional 5 to spend. Instead of reducing the allowance to 5, the user actually increased it to 15!
Instead of approve
, this proposal specifies two functions to deal with allowances - increase_allowance
and decrease_allowance
.
This proposal does not include a burn
or burnFrom
function that allows a
user to burn a balance that it holds. This functionality can be imitated by just
sending the balance to the zero ed25519 identifier. The alternative would be to
implement burn
and burnFrom
similar to what the OpenZeppelinERC-20
implementation does.
One concern about the use of wrapped assets is that it will degrade the user experience by requiring users of classic assets to submit additional transactions: first wrap, then do. This can be avoided relatively easily by implementing a generic wrap-then-do contract. A wrap-then-do contract might have the following functions
fn do_then_unwrap(sig: KeyedAccountAuthorization, token: u256,
id: AccountAuthorization, amount: u128, contract: u256,
symbol: SCSymbool, parameters: SCVec) {
// check that "sig" contains medium threshold signatures over
// Message::V0(MessageV0 {
// nonce: nonce_of(Identifier::Account(sig.publicKey)),
// thisContract: get_contract_id(),
// symbol: "do_then_unwrap",
// parameters: (token, id, amount, contract, symbol, symbol,
// parameters),
// caller: Caller::Transaction,
// })
// otherwise trap
let keyedID = KeyedAccountAuthorization {
publicKey: sig.publicKey,
authorization: id;
};
call(contract, symbol, parameters);
call(token, "unwrap", (keyedID, amount));
}
// signatures in "id" should be over messages that specify this contract as the
// "caller"
// signatures in elements of "parameters" should be over messages that specify
// this contract as the "caller"
fn wrap_then_do(sig: KeyedAccountAuthorization, token: u256,
id: AccountAuthorization, amount: u128, contract: u256,
symbol: SCSymbool, parameters: SCVec) {
// check that "sig" contains medium threshold signatures over
// Message::V0(MessageV0 {
// nonce: nonce_of(Identifier::Account(sig.publicKey)),
// thisContract: get_contract_id(),
// symbol: "wrap_then_do",
// parameters: (token, id, amount, contract, symbol, symbol,
// parameters),
// caller: Caller::Transaction,
// })
// otherwise trap
let keyedID = KeyedAccountAuthorization {
publicKey: sig.publicKey,
authorization: id;
};
call(token, "wrap", (keyedID, amount));
call(contract, symbol, parameters);
}
One disadvantage of this design is the fact that token functionality must be
separated into different contracts. For example, a liquidity pool share token
will typically have mint
and burn
functions which can be called by any user.
This is not possible because there is no way to extend a contract, so the only
functions available will be those specified above. Instead, the additional
functions will need to be provided in a separate contract.
This proposal is completely backwards compatible. It describes a new interface to existing behavior that is accessible from smart contracts, but it does not modify the existing behavior.
This proposal will lead to more ledger entries for tracking token state, increasing the total ledger size.
This proposal does not introduce any security concerns.
None yet.
None yet.