diff --git a/docs/build/guides/conventions/upgrading-contracts.mdx b/docs/build/guides/conventions/upgrading-contracts.mdx index 79e7958d6..93f37a870 100644 --- a/docs/build/guides/conventions/upgrading-contracts.mdx +++ b/docs/build/guides/conventions/upgrading-contracts.mdx @@ -110,7 +110,7 @@ pub fn upgrade(e: Env, new_wasm_hash: BytesN<32>) { 4. The `update_current_contract_wasm` host function will also emit a `SYSTEM` contract [event] that contains the old and new wasm reference, allowing downstream users to be notified when a contract they use is updated. The event structure will have `topics = ["executable_update", old_executable: ContractExecutable, old_executable: ContractExecutable]` and `data = []`. -[here]: https://docs.rs/soroban-sdk/20.0.2/soroban_sdk/struct.Env.html#method.update_current_contract_wasm +[here]: https://docs.rs/soroban-sdk/22.0.4/soroban_sdk/struct.Env.html#method.update_current_contract_wasm [event]: ../../../learn/encyclopedia/contract-development/events.mdx#event-types ## Tests diff --git a/docs/build/guides/conventions/wasm-metadata.mdx b/docs/build/guides/conventions/wasm-metadata.mdx index 191dbb803..43d84c0f5 100644 --- a/docs/build/guides/conventions/wasm-metadata.mdx +++ b/docs/build/guides/conventions/wasm-metadata.mdx @@ -28,5 +28,5 @@ contractmeta!( pub trait LiquidityPoolTrait {... ``` -[`contractmeta!`]: https://docs.rs/soroban-sdk/20.0.2/soroban_sdk/macro.contractmeta.html -[liquidity pool example]: https://github.com/stellar/soroban-examples/blob/v21.6.0/liquidity_pool/src/lib.rs#L152-L155 +[`contractmeta!`]: https://docs.rs/soroban-sdk/22.0.4/soroban_sdk/macro.contractmeta.html +[liquidity pool example]: https://github.com/stellar/soroban-examples/blob/v22.0.1/liquidity_pool/src/lib.rs#L152-L155 diff --git a/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx b/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx index 26a05ece0..72bb1b9e2 100644 --- a/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx +++ b/docs/build/smart-contracts/example-contracts/liquidity-pool.mdx @@ -59,7 +59,9 @@ cargo test You should see the output: ```sh -running 1 test +running 3 tests +test test::deposit_amount_zero_should_panic - should panic ... ok +test test::swap_reserve_one_nonzero_other_zero - should panic ... ok test test::test ... ok ``` @@ -78,16 +80,16 @@ Since our liquidity pool will be issuing its own token to establish the nuber of ```rust title=liquidity_pool/src/lib.rs #![no_std] +#![no_std] mod test; mod token; use num_integer::Roots; use soroban_sdk::{ - contract, contractimpl, contractmeta, Address, BytesN, ConversionError, Env, IntoVal, - TryFromVal, Val, + contract, contractimpl, contractmeta, Address, BytesN, ConversionError, Env, TryFromVal, Val, }; -use token::create_contract; +use token::create_share_token; #[derive(Clone, Copy)] #[repr(u32)] @@ -221,7 +223,7 @@ fn get_deposit_amounts( (desired_a, amount_b) } else { let amount_a = desired_b * reserve_a / reserve_b; - if amount_a > desired_a || desired_a < min_a { + if amount_a > desired_a || amount_a < min_a { panic!("amount_a invalid") } (amount_a, desired_b) @@ -234,62 +236,42 @@ contractmeta!( val = "Constant product AMM with a .3% swap fee" ); -pub trait LiquidityPoolTrait { - // Sets the token contract addresses for this pool - fn initialize(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address); - - // Returns the token contract address for the pool share token - fn share_id(e: Env) -> Address; - - // Deposits token_a and token_b. Also mints pool shares for the "to" Identifier. The amount minted - // is determined based on the difference between the reserves stored by this contract, and - // the actual balance of token_a and token_b for this contract. - fn deposit(e: Env, to: Address, desired_a: i128, min_a: i128, desired_b: i128, min_b: i128); - - // If "buy_a" is true, the swap will buy token_a and sell token_b. This is flipped if "buy_a" is false. - // "out" is the amount being bought, with in_max being a safety to make sure you receive at least that amount. - // swap will transfer the selling token "to" to this contract, and then the contract will transfer the buying token to "to". - fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128); - - // transfers share_amount of pool share tokens to this contract, burns all pools share tokens in this contracts, and sends the - // corresponding amount of token_a and token_b to "to". - // Returns amount of both tokens withdrawn - fn withdraw(e: Env, to: Address, share_amount: i128, min_a: i128, min_b: i128) -> (i128, i128); - - fn get_rsrvs(e: Env) -> (i128, i128); -} - #[contract] struct LiquidityPool; #[contractimpl] -impl LiquidityPoolTrait for LiquidityPool { - fn initialize(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { +impl LiquidityPool { + pub fn __constructor(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { if token_a >= token_b { panic!("token_a must be less than token_b"); } - let share_contract = create_contract(&e, token_wasm_hash, &token_a, &token_b); - token::Client::new(&e, &share_contract).initialize( - &e.current_contract_address(), - &7u32, - &"Pool Share Token".into_val(&e), - &"POOL".into_val(&e), - ); + let share_contract = create_share_token(&e, token_wasm_hash, &token_a, &token_b); put_token_a(&e, token_a); put_token_b(&e, token_b); - put_token_share(&e, share_contract.try_into().unwrap()); + put_token_share(&e, share_contract); put_total_shares(&e, 0); put_reserve_a(&e, 0); put_reserve_b(&e, 0); } - fn share_id(e: Env) -> Address { + // Returns the token contract address for the pool share token + pub fn share_id(e: Env) -> Address { get_token_share(&e) } - fn deposit(e: Env, to: Address, desired_a: i128, min_a: i128, desired_b: i128, min_b: i128) { + // Deposits token_a and token_b. Also mints pool shares for the "to" Identifier. The amount minted + // is determined based on the difference between the reserves stored by this contract, and + // the actual balance of token_a and token_b for this contract. + pub fn deposit( + e: Env, + to: Address, + desired_a: i128, + min_a: i128, + desired_b: i128, + min_b: i128, + ) { // Depositor needs to authorize the deposit to.require_auth(); @@ -329,7 +311,10 @@ impl LiquidityPoolTrait for LiquidityPool { put_reserve_b(&e, balance_b); } - fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128) { + // If "buy_a" is true, the swap will buy token_a and sell token_b. This is flipped if "buy_a" is false. + // "out" is the amount being bought, with in_max being a safety to make sure you receive at least that amount. + // swap will transfer the selling token "to" to this contract, and then the contract will transfer the buying token to "to". + pub fn swap(e: Env, to: Address, buy_a: bool, out: i128, in_max: i128) { to.require_auth(); let (reserve_a, reserve_b) = (get_reserve_a(&e), get_reserve_b(&e)); @@ -339,6 +324,10 @@ impl LiquidityPoolTrait for LiquidityPool { (reserve_a, reserve_b) }; + if reserve_buy < out { + panic!("not enough token to buy"); + } + // First calculate how much needs to be sold to buy amount out from the pool let n = reserve_sell * out * 1000; let d = (reserve_buy - out) * 997; @@ -402,7 +391,16 @@ impl LiquidityPoolTrait for LiquidityPool { put_reserve_b(&e, new_reserve_b); } - fn withdraw(e: Env, to: Address, share_amount: i128, min_a: i128, min_b: i128) -> (i128, i128) { + // transfers share_amount of pool share tokens to this contract, burns all pools share tokens in this contracts, and sends the + // corresponding amount of token_a and token_b to "to". + // Returns amount of both tokens withdrawn + pub fn withdraw( + e: Env, + to: Address, + share_amount: i128, + min_a: i128, + min_b: i128, + ) -> (i128, i128) { to.require_auth(); // First transfer the pool shares that need to be redeemed @@ -418,7 +416,7 @@ impl LiquidityPoolTrait for LiquidityPool { let out_a = (balance_a * balance_shares) / total_shares; let out_b = (balance_b * balance_shares) / total_shares; - if out_a < min_a || out_b (i128, i128) { + pub fn get_rsrvs(e: Env) -> (i128, i128) { (get_reserve_a(&e), get_reserve_b(&e)) } } @@ -442,13 +440,13 @@ impl LiquidityPoolTrait for LiquidityPool { ```rust title=liquidity_pool/src/token.rs #![allow(unused)] -use soroban_sdk::{xdr::ToXdr, Address, Bytes, BytesN, Env}; +use soroban_sdk::{symbol_short, xdr::ToXdr, Address, Bytes, BytesN, Env, FromVal, String, Symbol}; soroban_sdk::contractimport!( file = "../token/target/wasm32-unknown-unknown/release/soroban_token_contract.wasm" ); -pub fn create_contract( +pub fn create_share_token( e: &Env, token_wasm_hash: BytesN<32>, token_a: &Address, @@ -458,9 +456,15 @@ pub fn create_contract( salt.append(&token_a.to_xdr(e)); salt.append(&token_b.to_xdr(e)); let salt = e.crypto().sha256(&salt); - e.deployer() - .with_current_contract(salt) - .deploy(token_wasm_hash) + e.deployer().with_current_contract(salt).deploy_v2( + token_wasm_hash, + ( + e.current_contract_address(), + 7u32, + String::from_val(e, &"Pool Share Token"), + String::from_val(e, &"POOL"), + ), + ) } ``` @@ -479,7 +483,7 @@ Open the `liquidity_pool/src/lib.rs` file or see the code above to follow along. ### Initialize the Contract -When this contract is first deployed, it could create a liquidity pool for _any_ pair of tokens available on Soroban. It must first be initialized with the following information: +When this contract is deployed, the constructor will automatically be called, so the following arguments must be passed int: - **`token_wasm_hash`:** The contract will end up [creating its own `POOL` token] as well as [interacting with contracts for `token_a` and `token_b`]. The way this example works is by using the [`token` example contract] for both of these jobs. When our liquidity pool contract is initialized it wants us to pass the wasm hash of the **already installed** token contract. It will then deploy a contract that will run the WASM bytecode stored at that hash as a new token contract for the `POOL` tokens. - **`token_a`:** The contract `Address` for an **already deployed** (or wrapped) token that will be held in reserve by the liquidity pool. @@ -488,15 +492,16 @@ When this contract is first deployed, it could create a liquidity pool for _any_ Bear in mind that which token is `token_a` and which is `token_b` is **not** an arbitrary distinction. In line with the Built-in Stellar liquidity pools, this contract can only make a single liquidity pool for a given set of tokens. So, the token addresses must be provided in [lexicographical order] at the time of initialization. ```rust title=liquidity_pool/src/lib.rs -fn initialize(e: Env, token_wasm_hash: BytesN<32>, taken_a: Address, token_b: Address) { +pub fn __constructor(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { if token_a >= token_b { panic!("token_a must be less than token_b"); } - // The initialization function also stores important information in the contract's instance storage + let share_contract = create_share_token(&e, token_wasm_hash, &token_a, &token_b); + put_token_a(&e, token_a); put_token_b(&e, token_b); - put_token_share(&e, share_contract.try_into().unwrap()); + put_token_share(&e, share_contract); put_total_shares(&e, 0); put_reserve_a(&e, 0); put_reserve_b(&e, 0); @@ -545,10 +550,10 @@ This liquidity pool contract will operate with a total of three different Soroba We are utilizing the compiled `token` example contract as our asset contract for the `POOL` token. This means it follows all the conventions of the [Token Interface], and can be treated just like any other token. They could be transferred, burned, minted, etc. It also means the LP developer _could_ take advantage of the administrative features such as clawbacks, authorization, and more. -The `token.rs` file contains a `create_contract` function that we will use to deploy this particular token contract. +The `token.rs` file contains a `create_share_token` function that we will use to deploy this particular token contract. ```rust title="src/token.rs" -pub fn create_contract( +pub fn create_share_token( e: &Env, token_wasm_hash: BytesN<32>, token_a: &Address, @@ -558,23 +563,15 @@ pub fn create_contract( salt.append(&token_a.to_xdr(e)); salt.append(&token_b.to_xdr(e)); let salt = e.crypto().sha256(&salt); - e.deployer() - .with_current_contract(salt) - .deploy(token_wasm_hash) -} -``` - -This `POOL` token contract is then created within the `initialize` function. - -```rust title=liquidity_pool/src/lib.rs -fn initialize(e: Env, token_wasm_hash: BytesN<32>, token_a: Address, token_b: Address) { - let share_contract = create_contract(&e, token_wasm_hash, &token_a, &token_b); - token::Client::new(&e, &share_contract).initialize( - &e.current_contract_address(), - &7u32, - &"Pool Share Token".into_val(&e), - &"POOL".into_val(&e), - ); + e.deployer().with_current_contract(salt).deploy_v2( + token_wasm_hash, + ( + e.current_contract_address(), + 7u32, + String::from_val(e, &"Pool Share Token"), + String::from_val(e, &"POOL"), + ), + ) } ``` @@ -676,7 +673,11 @@ use soroban_sdk::{ }; fn create_token_contract<'a>(e: &Env, admin: &Address) -> token::Client<'a> { - token::Client::new(e, &e._register_stellar_asset_contract_v2(admin.clone()).address()) + token::Client::new( + e, + &e.register_stellar_asset_contract_v2(admin.clone()) + .address(), + ) } fn create_liqpool_contract<'a>( @@ -685,9 +686,13 @@ fn create_liqpool_contract<'a>( token_a: &Address, token_b: &Address, ) -> LiquidityPoolClient<'a> { - let liqpool = LiquidityPoolClient::new(e, &e.register_contract(None, crate::LiquidityPool {})); - liqpool.initialize(token_wasm_hash, token_a, token_b); - liqpool + LiquidityPoolClient::new( + e, + &e.register( + crate::LiquidityPool {}, + (token_wasm_hash.clone(), token_a, token_b), + ), + ) } fn install_token_wasm(e: &Env) -> BytesN<32> { @@ -702,8 +707,8 @@ fn test() { let e = Env::default(); e.mock_all_auths(); - let mut admin1 = Address::random(&e); - let mut admin2 = Address::random(&e); + let mut admin1 = Address::generate(&e); + let mut admin2 = Address::generate(&e); let mut token1 = create_token_contract(&e, &admin1); let mut token2 = create_token_contract(&e, &admin2); @@ -711,7 +716,7 @@ fn test() { std::mem::swap(&mut token1, &mut token2); std::mem::swap(&mut admin1, &mut admin2); } - let user1 = Address::random(&e); + let user1 = Address::generate(&e); let liqpool = create_liqpool_contract( &e, &install_token_wasm(&e), @@ -827,6 +832,79 @@ fn test() { assert_eq!(token2.balance(&liqpool.address), 0); assert_eq!(token_share.balance(&liqpool.address), 0); } + +#[test] +#[should_panic] +fn deposit_amount_zero_should_panic() { + let e = Env::default(); + e.mock_all_auths(); + + // Create contracts + let mut admin1 = Address::generate(&e); + let mut admin2 = Address::generate(&e); + + let mut token_a = create_token_contract(&e, &admin1); + let mut token_b = create_token_contract(&e, &admin2); + if &token_b.address < &token_a.address { + std::mem::swap(&mut token_a, &mut token_b); + std::mem::swap(&mut admin1, &mut admin2); + } + let liqpool = create_liqpool_contract( + &e, + &install_token_wasm(&e), + &token_a.address, + &token_b.address, + ); + + // Create a user + let user1 = Address::generate(&e); + + token_a.mint(&user1, &1000); + assert_eq!(token_a.balance(&user1), 1000); + + token_b.mint(&user1, &1000); + assert_eq!(token_b.balance(&user1), 1000); + + liqpool.deposit(&user1, &1, &0, &0, &0); +} + +#[test] +#[should_panic] +fn swap_reserve_one_nonzero_other_zero() { + let e = Env::default(); + e.mock_all_auths(); + + // Create contracts + let mut admin1 = Address::generate(&e); + let mut admin2 = Address::generate(&e); + + let mut token_a = create_token_contract(&e, &admin1); + let mut token_b = create_token_contract(&e, &admin2); + if &token_b.address < &token_a.address { + std::mem::swap(&mut token_a, &mut token_b); + std::mem::swap(&mut admin1, &mut admin2); + } + let liqpool = create_liqpool_contract( + &e, + &install_token_wasm(&e), + &token_a.address, + &token_b.address, + ); + + // Create a user + let user1 = Address::generate(&e); + + token_a.mint(&user1, &1000); + assert_eq!(token_a.balance(&user1), 1000); + + token_b.mint(&user1, &1000); + assert_eq!(token_b.balance(&user1), 1000); + + // Try to get to a situation where the reserves are 1 and 0. + // It shouldn't be possible. + token_b.transfer(&user1, &liqpool.address, &1); + liqpool.swap(&user1, &false, &1, &1); +} ``` [`liquidity_pool/src/test.rs`]: https://github.com/stellar/soroban-examples/blob/v22.0.1/liquidity_pool/src/test.rs @@ -847,7 +925,11 @@ We have abstracted into a few functions the tasks of creating token contracts, d ```rust title=liquidity_pool/src/test.rs fn create_token_contract<'a>(e: &Env, admin: &Address) -> token::Client<'a> { - token::Client::new(e, &e.register_stellar_asset_contract_v2(admin.clone()).address()) + token::Client::new( + e, + &e.register_stellar_asset_contract_v2(admin.clone()) + .address(), + ) } fn create_liqpool_contract<'a>( @@ -856,9 +938,13 @@ fn create_liqpool_contract<'a>( token_a: &Address, token_b: &Address, ) -> LiquidityPoolClient<'a> { - let liqpool = LiquidityPoolClient::new(e, &e.register_contract(None, crate::LiquidityPool {})); - liqpool.initialize(token_wasm_hash, token_a, token_b); - liqpool + LiquidityPoolClient::new( + e, + &e.register( + crate::LiquidityPool {}, + (token_wasm_hash.clone(), token_a, token_b), + ), + ) } fn install_token_wasm(e: &Env) -> BytesN<32> { diff --git a/docs/build/smart-contracts/example-contracts/tokens.mdx b/docs/build/smart-contracts/example-contracts/tokens.mdx index b037dada2..385802d09 100644 --- a/docs/build/smart-contracts/example-contracts/tokens.mdx +++ b/docs/build/smart-contracts/example-contracts/tokens.mdx @@ -49,14 +49,12 @@ cargo test You should see the output: ```sh -running 8 tests -test test::initialize_already_initialized - should panic ... ok -test test::transfer_spend_deauthorized - should panic ... ok +running 6 tests test test::decimal_is_over_eighteen - should panic ... ok -test test::test_burn ... ok -test test::transfer_receive_deauthorized - should panic ... ok -test test::transfer_from_insufficient_allowance - should panic ... ok test test::transfer_insufficient_balance - should panic ... ok +test test::test_zero_allowance ... ok +test test::transfer_from_insufficient_allowance - should panic ... ok +test test::test_burn ... ok test test::test ... ok ``` @@ -80,7 +78,6 @@ mod admin; mod allowance; mod balance; mod contract; -mod event; mod metadata; mod storage_types; mod test; @@ -92,13 +89,9 @@ pub use crate::contract::TokenClient; ```rust title="token/src/admin.rs" -use crate::storage_types::DataKey; -use soroban_sdk::{Address, Env, symbol_short}; +use soroban_sdk::{Address, Env}; -pub fn has_administrator(e: &Env) -> bool { - let key = DataKey::Admin; - e.storage().instance().has(&key) -} +use crate::storage_types::DataKey; pub fn read_administrator(e: &Env) -> Address { let key = DataKey::Admin; @@ -185,12 +178,15 @@ pub fn spend_allowance(e: &Env, from: Address, spender: Address, amount: i128) { ```rust title="token/src/balance.rs" -use crate::storage_types::DataKey; +use crate::storage_types::{DataKey, BALANCE_BUMP_AMOUNT, BALANCE_LIFETIME_THRESHOLD}; use soroban_sdk::{Address, Env}; pub fn read_balance(e: &Env, addr: Address) -> i128 { let key = DataKey::Balance(addr); if let Some(balance) = e.storage().persistent().get::(&key) { + e.storage() + .persistent() + .extend_ttl(&key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT); balance } else { 0 @@ -200,40 +196,23 @@ pub fn read_balance(e: &Env, addr: Address) -> i128 { fn write_balance(e: &Env, addr: Address, amount: i128) { let key = DataKey::Balance(addr); e.storage().persistent().set(&key, &amount); + e.storage() + .persistent() + .extend_ttl(&key, BALANCE_LIFETIME_THRESHOLD, BALANCE_BUMP_AMOUNT); } pub fn receive_balance(e: &Env, addr: Address, amount: i128) { let balance = read_balance(e, addr.clone()); - if !is_authorized(e, addr.clone()) { - panic!("can't receive when deauthorized"); - } write_balance(e, addr, balance + amount); } pub fn spend_balance(e: &Env, addr: Address, amount: i128) { let balance = read_balance(e, addr.clone()); - if !is_authorized(e, addr.clone()) { - panic!("can't spend when deauthorized"); - } if balance < amount { panic!("insufficient balance"); } write_balance(e, addr, balance - amount); } - -pub fn is_authorized(e: &Env, addr: Address) -> bool { - let key = DataKey::State(addr); - if let Some(state) = e.storage().persistent().get::(&key) { - state - } else { - true - } -} - -pub fn write_authorization(e: &Env, addr: Address, is_authorized: bool) { - let key = DataKey::State(addr); - e.storage().persistent().set(&key, &is_authorized); -} ``` @@ -242,50 +221,17 @@ pub fn write_authorization(e: &Env, addr: Address, is_authorized: bool) { ```rust title="token/src/contract.rs" //! This contract demonstrates a sample implementation of the Soroban token //! interface. -use crate::admin::{has_administrator, read_administrator, write_administrator}; +use crate::admin::{read_administrator, write_administrator}; use crate::allowance::{read_allowance, spend_allowance, write_allowance}; -use crate::balance::{is_authorized, write_authorization}; use crate::balance::{read_balance, receive_balance, spend_balance}; -use crate::event; use crate::metadata::{read_decimal, read_name, read_symbol, write_metadata}; -use soroban_sdk::{contractimpl, Address, String, Env}; -use soroban_token_sdk::TokenMetadata; - -pub trait TokenTrait { - fn initialize(e: Env, admin: Address, decimal: u32, name: String, symbol: String); - - fn allowance(e: Env, from: Address, spender: Address) -> i128; - - fn approve(e: Env, from: Address, spender: Address, amount: i128, expiration_ledger: u32); - - fn balance(e: Env, id: Address) -> i128; - - fn spendable_balance(e: Env, id: Address) -> i128; - - fn authorized(e: Env, id: Address) -> bool; - - fn transfer(e: Env, from: Address, to: Address, amount: i128); - - fn transfer_from(e: Env, spender: Address, from: Address, to: Address, amount: i128); - - fn burn(e: Env, from: Address, amount: i128); - - fn burn_from(e: Env, spender: Address, from: Address, amount: i128); - - fn clawback(e: Env, from: Address, amount: i128); - - fn set_authorized(e: Env, id: Address, authorize: bool); - - fn mint(e: Env, to: Address, amount: i128); - - fn set_admin(e: Env, new_admin: Address); - - fn decimals(e: Env) -> u32; - - fn name(e: Env) -> String; - - fn symbol(e: Env) -> String; -} +#[cfg(test)] +use crate::storage_types::{AllowanceDataKey, AllowanceValue, DataKey}; +use crate::storage_types::{INSTANCE_BUMP_AMOUNT, INSTANCE_LIFETIME_THRESHOLD}; +use soroban_sdk::token::{self, Interface as _}; +use soroban_sdk::{contract, contractimpl, Address, Env, String}; +use soroban_token_sdk::metadata::TokenMetadata; +use soroban_token_sdk::TokenUtils; fn check_nonnegative_amount(amount: i128) { if amount < 0 { @@ -297,16 +243,12 @@ fn check_nonnegative_amount(amount: i128) { pub struct Token; #[contractimpl] -impl TokenTrait for Token { - fn initialize(e: Env, admin: Address, decimal: u32, name: String, symbol: String) { - if has_administrator(&e) { - panic!("already initialized") - } - write_administrator(&e, &admin); +impl Token { + pub fn __constructor(e: Env, admin: Address, decimal: u32, name: String, symbol: String) { if decimal > 18 { panic!("Decimal must not be greater than 18"); } - + write_administrator(&e, &admin); write_metadata( &e, TokenMetadata { @@ -317,8 +259,46 @@ impl TokenTrait for Token { ) } + pub fn mint(e: Env, to: Address, amount: i128) { + check_nonnegative_amount(amount); + let admin = read_administrator(&e); + admin.require_auth(); + + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + + receive_balance(&e, to.clone(), amount); + TokenUtils::new(&e).events().mint(admin, to, amount); + } + + pub fn set_admin(e: Env, new_admin: Address) { + let admin = read_administrator(&e); + admin.require_auth(); + + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + + write_administrator(&e, &new_admin); + TokenUtils::new(&e).events().set_admin(admin, new_admin); + } + + #[cfg(test)] + pub fn get_allowance(e: Env, from: Address, spender: Address) -> Option { + let key = DataKey::Allowance(AllowanceDataKey { from, spender }); + let allowance = e.storage().temporary().get::<_, AllowanceValue>(&key); + allowance + } +} + +#[contractimpl] +impl token::Interface for Token { fn allowance(e: Env, from: Address, spender: Address) -> i128 { - read_allowance(&e, from, spender) + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + read_allowance(&e, from, spender).amount } fn approve(e: Env, from: Address, spender: Address, amount: i128, expiration_ledger: u32) { @@ -326,144 +306,93 @@ impl TokenTrait for Token { check_nonnegative_amount(amount); + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + write_allowance(&e, from.clone(), spender.clone(), amount, expiration_ledger); - event::approve(&e, from, spender, amount, expiration_ledger); + TokenUtils::new(&e) + .events() + .approve(from, spender, amount, expiration_ledger); } fn balance(e: Env, id: Address) -> i128 { + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); read_balance(&e, id) } - fn spendable_balance(e: Env, id: Address) -> i128 { - read_balance(&e, id) - } - - fn authorized(e: Env, id: Address) -> bool { - is_authorized(&e, id) - } - fn transfer(e: Env, from: Address, to: Address, amount: i128) { from.require_auth(); check_nonnegative_amount(amount); + + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + spend_balance(&e, from.clone(), amount); receive_balance(&e, to.clone(), amount); - event::transfer(&e, from, to, amount); + TokenUtils::new(&e).events().transfer(from, to, amount); } fn transfer_from(e: Env, spender: Address, from: Address, to: Address, amount: i128) { spender.require_auth(); check_nonnegative_amount(amount); + + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + spend_allowance(&e, from.clone(), spender, amount); spend_balance(&e, from.clone(), amount); receive_balance(&e, to.clone(), amount); - event::transfer(&e, from, to, amount) + TokenUtils::new(&e).events().transfer(from, to, amount) } fn burn(e: Env, from: Address, amount: i128) { from.require_auth(); check_nonnegative_amount(amount); + + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); + spend_balance(&e, from.clone(), amount); - event::burn(&e, from, amount); + TokenUtils::new(&e).events().burn(from, amount); } fn burn_from(e: Env, spender: Address, from: Address, amount: i128) { spender.require_auth(); check_nonnegative_amount(amount); - spend_allowance(&e, from.clone(), spender, amount); - spend_balance(&e, from.clone(), amount); - event::burn(&e, from, amount) - } - - fn clawback(e: Env, from: Address, amount: i128) { - check_nonnegative_amount(amount); - let admin = read_administrator(&e); - admin.require_auth(); - spend_balance(&e, from.clone(), amount); - event::clawback(&e, admin, from, amount); - } - fn set_authorized(e: Env, id: Address, authorize: bool) { - let admin = read_administrator(&e); - admin.require_auth(); - write_authorization(&e, id.clone(), authorize); - event::set_authorized(&e, admin, id, authorize); - } + e.storage() + .instance() + .extend_ttl(INSTANCE_LIFETIME_THRESHOLD, INSTANCE_BUMP_AMOUNT); - fn mint(e: Env, to: Address, amount: i128) { - check_nonnegative_amount(amount); - let admin = read_administrator(&e); - admin.require_auth(); - receive_balance(&e, to.clone(), amount); - event::mint(&e, admin, to, amount); - } - - fn set_admin(e: Env, new_admin: Address) { - let admin = read_administrator(&e); - admin.require_auth(); - write_administrator(&e, &new_admin); - event::set_admin(&e, admin, new_admin); + spend_allowance(&e, from.clone(), spender, amount); + spend_balance(&e, from.clone(), amount); + TokenUtils::new(&e).events().burn(from, amount) } fn decimals(e: Env) -> u32 { read_decimal(&e) } - fn name(e: Env) -> Bytes { + fn name(e: Env) -> String { read_name(&e) } - fn symbol(e: Env) -> Bytes { + fn symbol(e: Env) -> String { read_symbol(&e) } } ``` - - - -```rust title="token/src/event.rs" -use soroban_sdk::{Address, Env, Symbol, symbol_short}; - -pub(crate) fn approve(e: &Env, from: Address, to: Address, amount: i128, expiration_ledger: u32) { - let topics = (Symbol::new(e, "approve"), from, to); - e.events().publish(topics, (amount, expiration_ledger)); -} - -pub(crate) fn transfer(e: &Env, from: Address, to: Address, amount: i128) { - let topics = (symbol_short!("transfer"), from, to); - e.events().publish(topics, amount); -} - -pub(crate) fn mint(e: &Env, admin: Address, to: Address, amount: i128) { - let topics = (symbol_short!("mint"), admin, to); - e.events().publish(topics, amount); -} - -pub(crate) fn clawback(e: &Env, admin: Address, from: Address, amount: i128) { - let topics = (symbol_short!("clawback"), admin, from); - e.events().publish(topics, amount); -} - -pub(crate) fn set_authorized(e: &Env, admin: Address, id: Address, authorize: bool) { - let topics = (Symbol::new(e, "set_authorized"), admin, id); - e.events().publish(topics, authorize); -} - -pub(crate) fn set_admin(e: &Env, admin: Address, new_admin: Address) { - let topics = (symbol_short!("set_admin"), admin); - e.events().publish(topics, new_admin); -} - -pub(crate) fn burn(e: &Env, from: Address, amount: i128) { - let topics = (symbol_short!("burn"), from); - e.events().publish(topics, amount); -} -``` - @@ -543,13 +472,10 @@ You have likely noticed that this example contract is broken into discrete modul For example, most of the token logic exists in the `contract.rs` module. Functions like `mint`, `burn`, `transfer`, etc. are written and programmed in that file. The Token Interface describes how some of these functions should emit events when they occur. However, keeping all that event-emitting logic bundled in with the rest of the contract code could make it harder to track what is happening in the code, and that confusion could ultimately lead to errors. -Instead, we have a separate `events.rs` module that takes away all the headache of emitting events when other functions run. Here is the function to emit an event whenever the token is minted: +Instead, we have a separate `soroban_token_sdk::TokenUtils` module that takes away all the headache of emitting events when other functions run. Here is the event emitted when a token is minted: ```rust -pub(crate) fn mint(e: &Env, admin: Address, to: Address, amount: i128) { - let topics = (symbol_short!("mint"), admin, to); - e.events().publish(topics, amount); -} +TokenUtils::new(&e).events().mint(admin, to, amount); ``` Admittedly, this is a simple example, but constructing the contract this way makes it very clear to the developer what is happening and where. This function is then used by the `contract.rs` module whenever the `mint` function is invoked: @@ -564,7 +490,7 @@ fn mint(e: Env, to: Address, amount: i128) { admin.require_auth(); receive_balance(&e, to.clone(), amount); // highlight-next-line - event::mint(&e, admin, to, amount); + TokenUtils::new(&e).events().mint(admin, to, amount); } ``` @@ -611,12 +537,23 @@ Open the `token/src/test.rs` file to follow along. extern crate std; use crate::{contract::Token, TokenClient}; -use soroban_sdk::{testutils::Address as _, Address, Env, IntoVal, Symbol}; +use soroban_sdk::{ + symbol_short, + testutils::{Address as _, AuthorizedFunction, AuthorizedInvocation}, + Address, Env, FromVal, IntoVal, String, Symbol, +}; fn create_token<'a>(e: &Env, admin: &Address) -> TokenClient<'a> { - let token = TokenClient::new(e, &e.register_contract(None, Token {})); - token.initialize(admin, &7, &"name".into_val(e), &"symbol".into_val(e)); - token + let token_contract = e.register( + Token, + ( + admin, + 7_u32, + String::from_val(e, &"name"), + String::from_val(e, &"symbol"), + ), + ); + TokenClient::new(e, &token_contract) } #[test] @@ -624,11 +561,11 @@ fn test() { let e = Env::default(); e.mock_all_auths(); - let admin1 = Address::random(&e); - let admin2 = Address::random(&e); - let user1 = Address::random(&e); - let user2 = Address::random(&e); - let user3 = Address::random(&e); + let admin1 = Address::generate(&e); + let admin2 = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let user3 = Address::generate(&e); let token = create_token(&e, &admin1); token.mint(&user1, &1000); @@ -721,44 +658,7 @@ fn test() { )] ); - token.set_authorized(&user2, &false); - assert_eq!( - e.auths(), - std::vec![( - admin2.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - Symbol::new(&e, "set_authorized"), - (&user2, false).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - assert_eq!(token.authorized(&user2), false); - - token.set_authorized(&user3, &true); - assert_eq!(token.authorized(&user3), true); - - token.clawback(&user3, &100); - assert_eq!( - e.auths(), - std::vec![( - admin2.clone(), - AuthorizedInvocation { - function: AuthorizedFunction::Contract(( - token.address.clone(), - symbol_short!("clawback"), - (&user3, 100_i128).into_val(&e), - )), - sub_invocations: std::vec![] - } - )] - ); - assert_eq!(token.balance(&user3), 200); - - // Set allowance to 500 + // Increase to 500 token.approve(&user2, &user3, &500, &200); assert_eq!(token.allowance(&user2, &user3), 500); token.approve(&user2, &user3, &0, &200); @@ -784,9 +684,9 @@ fn test_burn() { let e = Env::default(); e.mock_all_auths(); - let admin = Address::random(&e); - let user1 = Address::random(&e); - let user2 = Address::random(&e); + let admin = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); let token = create_token(&e, &admin); token.mint(&user1, &1000); @@ -841,9 +741,9 @@ fn transfer_insufficient_balance() { let e = Env::default(); e.mock_all_auths(); - let admin = Address::random(&e); - let user1 = Address::random(&e); - let user2 = Address::random(&e); + let admin = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); let token = create_token(&e, &admin); token.mint(&user1, &1000); @@ -852,52 +752,16 @@ fn transfer_insufficient_balance() { token.transfer(&user1, &user2, &1001); } -#[test] -#[should_panic(expected = "can't receive when deauthorized")] -fn transfer_receive_deauthorized() { - let e = Env::default(); - e.mock_all_auths(); - - let admin = Address::random(&e); - let user1 = Address::random(&e); - let user2 = Address::random(&e); - let token = create_token(&e, &admin); - - token.mint(&user1, &1000); - assert_eq!(token.balance(&user1), 1000); - - token.set_authorized(&user2, &false); - token.transfer(&user1, &user2, &1); -} - -#[test] -#[should_panic(expected = "can't spend when deauthorized")] -fn transfer_spend_deauthorized() { - let e = Env::default(); - e.mock_all_auths(); - - let admin = Address::random(&e); - let user1 = Address::random(&e); - let user2 = Address::random(&e); - let token = create_token(&e, &admin); - - token.mint(&user1, &1000); - assert_eq!(token.balance(&user1), 1000); - - token.set_authorized(&user1, &false); - token.transfer(&user1, &user2, &1); -} - #[test] #[should_panic(expected = "insufficient allowance")] fn transfer_from_insufficient_allowance() { let e = Env::default(); e.mock_all_auths(); - let admin = Address::random(&e); - let user1 = Address::random(&e); - let user2 = Address::random(&e); - let user3 = Address::random(&e); + let admin = Address::generate(&e); + let user1 = Address::generate(&e); + let user2 = Address::generate(&e); + let user3 = Address::generate(&e); let token = create_token(&e, &admin); token.mint(&user1, &1000); @@ -910,22 +774,37 @@ fn transfer_from_insufficient_allowance() { } #[test] -#[should_panic(expected = "already initialized")] -fn initialize_already_initialized() { +#[should_panic(expected = "Decimal must not be greater than 18")] +fn decimal_is_over_eighteen() { let e = Env::default(); - let admin = Address::random(&e); - let token = create_token(&e, &admin); - - token.initialize(&admin, &10, &"name".into_val(&e), &"symbol".into_val(&e)); + let admin = Address::generate(&e); + let _ = TokenClient::new( + &e, + &e.register( + Token, + ( + admin, + 19_u32, + String::from_val(&e, &"name"), + String::from_val(&e, &"symbol"), + ), + ), + ); } #[test] -#[should_panic(expected = "Decimal must not be greater than 18")] -fn decimal_is_over_eighteen() { +fn test_zero_allowance() { + // Here we test that transfer_from with a 0 amount does not create an empty allowance let e = Env::default(); + e.mock_all_auths(); + let admin = Address::generate(&e); - let token = TokenClient::new(&e, &e.register_contract(None, Token {})); - token.initialize(&admin, &19, &"name".into_val(&e), &"symbol".into_val(&e)); + let spender = Address::generate(&e); + let from = Address::generate(&e); + let token = create_token(&e, &admin); + + token.transfer_from(&spender, &from, &spender, &0); + assert!(token.get_allowance(&from, &spender).is_none()); } ``` @@ -941,14 +820,21 @@ We mock authentication checks in the tests, which allows the tests to proceed as e.mock_all_auths(); ``` -We're also using a `create_token` function to ease the repetition of having to register and initialize our token contract. The resulting `token` client is then used to invoke the contract during each test. +We're also using a `create_token` function to ease the repetition of having to register our token contract. The resulting `token` client is then used to invoke the contract during each test. ```rust // It is defined at the top of the file... fn create_token<'a>(e: &Env, admin: &Address) -> TokenClient<'a> { - let token = TokenClient::new(e, &e.register_contract(None, Token {})); - token.initialize(admin, &7, &"name".into_val(e), &"symbol".into_val(e)); - token + let token_contract = e.register( + Token, + ( + admin, + 7_u32, + String::from_val(e, &"name"), + String::from_val(e, &"symbol"), + ), + ); + TokenClient::new(e, &token_contract) } // ... and it is used inside each test @@ -957,16 +843,14 @@ let token = create_token(&e, &admin); All public functions within an `impl` block that has been annotated with the `#[contractimpl]` attribute will have a corresponding function in the test's generated client type. The client type will be named the same as the contract type with `Client` appended. For example, in our contract, the contract type is named `Token`, and the client type is named `TokenClient`. -The eight tests created for this example contract test a range of possible conditions and ensure the contract responds appropriately to each one: +The six tests created for this example contract test a range of possible conditions and ensure the contract responds appropriately to each one: - **`test()`** - This function makes use of a variety of the built-in token functions to test the "predictable" way an asset might be interacted with by a user, as well as an administrator. - **`test_burn()`** - This function ensures a `burn()` invocation decreases a user's balance, and that a `burn_from()` invocation decreases a user's balance as well as consuming another user's allowance of that balance. +- **`test_zero_allowance()`** - This function makes sure that a `transfer_from()` with an zero balance doesn't create an empty allowance. - **`transfer_insufficient_balance()`** - This function ensures a `transfer()` invocation panics when the `from` user doesn't have the balance to cover it. -- **`transfer_receive_deauthorized()`** - This function ensures a user who is specifically de-authorized to hold the token cannot be the beneficiary of a `transfer()` invocation. -- **`transfer_spend_deauthorized()`** - This function ensures a user with a token balance, who is subsequently de-authorized cannot be the source of a `transfer()` invocation. - **`transfer_from_insufficient_allowance()`** - This function ensures a user with an existing allowance for someone else's balance cannot make a `transfer()` greater than that allowance. -- **`initialize_already_initialized()`** - This function checks that the contract cannot have it's `initialize()` function invoked a second time. -- **`decimal_is_over_eighteen()`** - This function tests that invoking `initialize()` with too high of a decimal precision will not succeed. +- **`decimal_is_over_eighteen()`** - This function tests that constructing a token with too high of a decimal precision will not succeed. ## Build the Contract