diff --git a/examples/fungible-allowlist/src/contract.rs b/examples/fungible-allowlist/src/contract.rs index 932bd15de..f46ecba24 100644 --- a/examples/fungible-allowlist/src/contract.rs +++ b/examples/fungible-allowlist/src/contract.rs @@ -4,6 +4,10 @@ //! SEP-41-compliant fungible token. It includes essential features such as //! controlled token transfers by an admin who can allow or disallow specific //! accounts. +//! +//! It also demonstrates composing the contract type: `Compose<(AllowList, +//! TotalSupply)>` pairs the allowlist transfer policy with total supply +//! tracking. use soroban_sdk::{ contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec, @@ -13,7 +17,9 @@ use stellar_macros::only_role; use stellar_tokens::fungible::{ allowlist::{AllowList, FungibleAllowList}, burnable::FungibleBurnable, - Base, Compose, FungibleToken, + combinations::Compose, + total_supply::{self, FungibleTotalSupply, TotalSupply}, + Base, FungibleToken, }; #[contract] @@ -40,15 +46,18 @@ impl ExampleContract { AllowList::allow_user(e, &admin); // Mint initial supply to the admin - Base::mint(e, &admin, initial_supply); + total_supply::mint(e, &admin, initial_supply); } } #[contractimpl(contracttrait)] impl FungibleToken for ExampleContract { - type ContractType = Compose<(AllowList,)>; + type ContractType = Compose<(AllowList, TotalSupply)>; } +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for ExampleContract {} + #[contractimpl(contracttrait)] impl FungibleAllowList for ExampleContract { #[only_role(operator, "manager")] diff --git a/examples/fungible-capped/src/contract.rs b/examples/fungible-capped/src/contract.rs index 7392341f0..38445c44c 100644 --- a/examples/fungible-capped/src/contract.rs +++ b/examples/fungible-capped/src/contract.rs @@ -10,7 +10,8 @@ use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_tokens::fungible::{ capped::{check_cap, set_cap}, - Base, Compose, FungibleToken, + total_supply::{self, FungibleTotalSupply, TotalSupply}, + Compose, FungibleToken, }; #[contract] @@ -23,12 +24,15 @@ impl ExampleContract { } pub fn mint(e: &Env, to: Address, amount: i128) { - check_cap(e, amount, Base::total_supply(e)); - Base::mint(e, &to, amount); + check_cap(e, amount, total_supply::total_supply(e)); + total_supply::mint(e, &to, amount); } } #[contractimpl(contracttrait)] impl FungibleToken for ExampleContract { - type ContractType = Compose<(Base,)>; + type ContractType = Compose<(TotalSupply,)>; } + +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for ExampleContract {} diff --git a/examples/fungible-pausable/src/contract.rs b/examples/fungible-pausable/src/contract.rs index 823163f95..a122c75a0 100644 --- a/examples/fungible-pausable/src/contract.rs +++ b/examples/fungible-pausable/src/contract.rs @@ -15,7 +15,11 @@ use soroban_sdk::{ }; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_macros::when_not_paused; -use stellar_tokens::fungible::{burnable::FungibleBurnable, Base, Compose, FungibleToken}; +use stellar_tokens::fungible::{ + burnable::FungibleBurnable, + total_supply::{self, FungibleTotalSupply, TotalSupply}, + Base, Compose, ContractOverrides, FungibleToken, +}; pub const OWNER: Symbol = symbol_short!("OWNER"); @@ -39,7 +43,7 @@ impl ExampleContract { initial_supply: i128, ) { Base::set_metadata(e, 18, name, symbol); - Base::mint(e, &owner, initial_supply); + total_supply::mint(e, &owner, initial_supply); e.storage().instance().set(&OWNER, &owner); } @@ -51,7 +55,7 @@ impl ExampleContract { let owner: Address = e.storage().instance().get(&OWNER).expect("owner should be set"); owner.require_auth(); - Base::mint(e, &to, amount); + total_supply::mint(e, &to, amount); } } @@ -90,11 +94,7 @@ impl Pausable for ExampleContract { #[contractimpl] impl FungibleToken for ExampleContract { - type ContractType = Compose<(Base,)>; - - fn total_supply(e: &Env) -> i128 { - Self::ContractType::total_supply(e) - } + type ContractType = Compose<(TotalSupply,)>; fn balance(e: &Env, account: Address) -> i128 { Self::ContractType::balance(e, &account) @@ -131,6 +131,9 @@ impl FungibleToken for ExampleContract { } } +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for ExampleContract {} + #[contractimpl] impl FungibleBurnable for ExampleContract { #[when_not_paused] diff --git a/examples/fungible-vault/src/contract.rs b/examples/fungible-vault/src/contract.rs index de1cdd8e8..3ea9f0eae 100644 --- a/examples/fungible-vault/src/contract.rs +++ b/examples/fungible-vault/src/contract.rs @@ -2,7 +2,7 @@ use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_tokens::{ - fungible::{Base, Compose, FungibleToken}, + fungible::{total_supply::FungibleTotalSupply, Base, Compose, FungibleToken}, vault::{FungibleVault, Vault}, }; @@ -38,5 +38,8 @@ impl FungibleToken for ExampleContract { } } +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for ExampleContract {} + #[contractimpl(contracttrait)] impl FungibleVault for ExampleContract {} diff --git a/examples/fungible-votes/src/contract.rs b/examples/fungible-votes/src/contract.rs index 1e1d29a83..a06eb1e4e 100644 --- a/examples/fungible-votes/src/contract.rs +++ b/examples/fungible-votes/src/contract.rs @@ -3,7 +3,8 @@ use stellar_access::ownable::{set_owner, Ownable}; use stellar_governance::votes::Votes; use stellar_macros::only_owner; use stellar_tokens::fungible::{ - burnable::FungibleBurnable, votes::FungibleVotes, Base, Compose, FungibleToken, + burnable::FungibleBurnable, total_supply::FungibleTotalSupply, votes::FungibleVotes, Base, + Compose, FungibleToken, }; #[contract] @@ -27,6 +28,10 @@ impl FungibleToken for ExampleContract { type ContractType = Compose<(FungibleVotes,)>; } +// The total supply is served from the voting checkpoints. +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for ExampleContract {} + #[contractimpl(contracttrait)] impl Votes for ExampleContract {} diff --git a/examples/rwa/token/src/contract.rs b/examples/rwa/token/src/contract.rs index d29e4e180..d42ee4c0d 100644 --- a/examples/rwa/token/src/contract.rs +++ b/examples/rwa/token/src/contract.rs @@ -7,7 +7,7 @@ use stellar_access::access_control::{self as access_control, AccessControl}; use stellar_contract_utils::pausable::{self as pausable, Pausable}; use stellar_macros::{only_admin, only_role}; use stellar_tokens::{ - fungible::{Base, Compose, FungibleToken}, + fungible::{total_supply::FungibleTotalSupply, Base, Compose, FungibleToken}, rwa::{RWAToken, RWA}, }; @@ -57,6 +57,9 @@ impl FungibleToken for RWATokenContract { type ContractType = Compose<(RWA,)>; } +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for RWATokenContract {} + #[contractimpl(contracttrait)] impl RWAToken for RWATokenContract { #[only_role(operator, "manager")] diff --git a/packages/tokens/src/fungible/extensions/allowlist/mod.rs b/packages/tokens/src/fungible/extensions/allowlist/mod.rs index 2869cf379..b2eadfa55 100644 --- a/packages/tokens/src/fungible/extensions/allowlist/mod.rs +++ b/packages/tokens/src/fungible/extensions/allowlist/mod.rs @@ -8,6 +8,14 @@ pub use storage::AllowList; use crate::fungible::FungibleToken; +/// Marker trait for contract types that enforce the allowlist transfer +/// policy: [`AllowList`] itself, or a combination resolved by +/// [`crate::fungible::combinations::Compose`] that includes it, e.g. +/// `Compose<(AllowList, TotalSupply)>`. +pub trait AllowListContractType {} + +impl AllowListContractType for AllowList {} + /// AllowList Trait for Fungible Token /// /// The `FungibleAllowList` trait extends the `FungibleToken` trait to @@ -30,7 +38,7 @@ use crate::fungible::FungibleToken; /// "storage.rs", because the authorizations are to be handled in the access /// control helpers or directly implemented #[contracttrait] -pub trait FungibleAllowList: FungibleToken { +pub trait FungibleAllowList: FungibleToken { /// Returns the allowed status of an account. /// /// # Arguments diff --git a/packages/tokens/src/fungible/extensions/blocklist/mod.rs b/packages/tokens/src/fungible/extensions/blocklist/mod.rs index 54ef8380d..d235db2a9 100644 --- a/packages/tokens/src/fungible/extensions/blocklist/mod.rs +++ b/packages/tokens/src/fungible/extensions/blocklist/mod.rs @@ -8,6 +8,14 @@ pub use storage::BlockList; use crate::fungible::FungibleToken; +/// Marker trait for contract types that enforce the blocklist transfer +/// policy: [`BlockList`] itself, or a combination resolved by +/// [`crate::fungible::combinations::Compose`] that includes it, e.g. +/// `Compose<(BlockList, TotalSupply)>`. +pub trait BlockListContractType {} + +impl BlockListContractType for BlockList {} + /// BlockList Trait for Fungible Token /// /// The `FungibleBlockList` trait extends the `FungibleToken` trait to @@ -30,7 +38,7 @@ use crate::fungible::FungibleToken; /// "storage.rs", because the authorizations are to be handled in the access /// control helpers or directly implemented. #[contracttrait] -pub trait FungibleBlockList: FungibleToken { +pub trait FungibleBlockList: FungibleToken { /// Returns the blocked status of an account. /// /// # Arguments diff --git a/packages/tokens/src/fungible/extensions/burnable/mod.rs b/packages/tokens/src/fungible/extensions/burnable/mod.rs index 0b5e2f07a..3cf007a5a 100644 --- a/packages/tokens/src/fungible/extensions/burnable/mod.rs +++ b/packages/tokens/src/fungible/extensions/burnable/mod.rs @@ -40,8 +40,9 @@ use crate::fungible::{overrides::BurnableOverrides, FungibleToken}; /// background. #[contracttrait] pub trait FungibleBurnable: FungibleToken { - /// Destroys `amount` of tokens from `from`. Updates the total - /// supply accordingly. + /// Destroys `amount` of tokens from `from`. For supply-aware contract + /// types (e.g. [`crate::fungible::total_supply::TotalSupply`]), the total + /// supply is updated accordingly. /// /// # Arguments /// @@ -64,8 +65,9 @@ pub trait FungibleBurnable: FungibleToken { Self::ContractType::burn(e, &from, amount); } - /// Destroys `amount` of tokens from `from`. Updates the total - /// supply accordingly. + /// Destroys `amount` of tokens from `from`. For supply-aware contract + /// types (e.g. [`crate::fungible::total_supply::TotalSupply`]), the total + /// supply is updated accordingly. /// /// # Arguments /// diff --git a/packages/tokens/src/fungible/extensions/burnable/storage.rs b/packages/tokens/src/fungible/extensions/burnable/storage.rs index 88cf85731..8a5f6dcc6 100644 --- a/packages/tokens/src/fungible/extensions/burnable/storage.rs +++ b/packages/tokens/src/fungible/extensions/burnable/storage.rs @@ -3,8 +3,7 @@ use soroban_sdk::{Address, Env}; use crate::fungible::{extensions::burnable::emit_burn, Base}; impl Base { - /// Destroys `amount` of tokens from `from`. Updates the total - /// supply accordingly. + /// Destroys `amount` of tokens from `from`. /// /// # Arguments /// @@ -32,7 +31,6 @@ impl Base { /// Destroys `amount` of tokens from `from` using the allowance mechanism. /// `amount` is then deducted from `spender` allowance. - /// Updates the total supply accordingly. /// /// # Arguments /// diff --git a/packages/tokens/src/fungible/extensions/burnable/test.rs b/packages/tokens/src/fungible/extensions/burnable/test.rs index c7241e742..22185d427 100644 --- a/packages/tokens/src/fungible/extensions/burnable/test.rs +++ b/packages/tokens/src/fungible/extensions/burnable/test.rs @@ -18,7 +18,6 @@ fn burn_works() { Base::mint(&e, &account, 100); Base::burn(&e, &account, 50); assert_eq!(Base::balance(&e, &account), 50); - assert_eq!(Base::total_supply(&e), 50); let mut event_assert = EventAssertion::new(&e, address.clone()); event_assert.assert_event_count(2); @@ -40,7 +39,6 @@ fn burn_with_allowance_works() { Base::burn_from(&e, &spender, &owner, 30); assert_eq!(Base::balance(&e, &owner), 70); assert_eq!(Base::balance(&e, &spender), 0); - assert_eq!(Base::total_supply(&e), 70); let mut event_assert = EventAssertion::new(&e, address.clone()); event_assert.assert_event_count(3); @@ -60,7 +58,6 @@ fn burn_with_insufficient_balance_panics() { e.as_contract(&address, || { Base::mint(&e, &account, 100); assert_eq!(Base::balance(&e, &account), 100); - assert_eq!(Base::total_supply(&e), 100); Base::burn(&e, &account, 101); }); } @@ -76,7 +73,6 @@ fn burn_with_no_allowance_panics() { e.as_contract(&address, || { Base::mint(&e, &owner, 100); assert_eq!(Base::balance(&e, &owner), 100); - assert_eq!(Base::total_supply(&e), 100); Base::burn_from(&e, &spender, &owner, 50); }); } @@ -94,7 +90,6 @@ fn burn_with_insufficient_allowance_panics() { Base::approve(&e, &owner, &spender, 50, 100); assert_eq!(Base::allowance(&e, &owner, &spender), 50); assert_eq!(Base::balance(&e, &owner), 100); - assert_eq!(Base::total_supply(&e), 100); Base::burn_from(&e, &spender, &owner, 60); }); } diff --git a/packages/tokens/src/fungible/extensions/capped/mod.rs b/packages/tokens/src/fungible/extensions/capped/mod.rs index f8edc9ea0..732e59c44 100644 --- a/packages/tokens/src/fungible/extensions/capped/mod.rs +++ b/packages/tokens/src/fungible/extensions/capped/mod.rs @@ -4,12 +4,17 @@ /// Instead, the `capped` extension modifies the business logic of the `mint` /// function to enforce a supply cap. /// +/// The cap is checked against the tracked total supply, so this extension is +/// meant to be used together with +/// [`crate::fungible::total_supply::FungibleTotalSupply`] (or another +/// supply-aware contract type). +/// /// This module provides the following helper functions: /// - `set_cap`: Sets the maximum token supply. /// - `query_cap`: Returns the maximum token supply. /// - `check_cap`: Panics if minting a specified `amount` added to the given /// `total_supply` would exceed the cap. Should be used before calling -/// `mint()`. +/// [`crate::fungible::total_supply::mint`]. mod storage; pub use self::storage::{check_cap, query_cap, set_cap, CapStorageKey}; #[cfg(test)] diff --git a/packages/tokens/src/fungible/extensions/capped/test.rs b/packages/tokens/src/fungible/extensions/capped/test.rs index 6cc50234f..ae0348015 100644 --- a/packages/tokens/src/fungible/extensions/capped/test.rs +++ b/packages/tokens/src/fungible/extensions/capped/test.rs @@ -3,7 +3,10 @@ extern crate std; use soroban_sdk::{contract, testutils::Address as _, Address, Env}; use crate::fungible::{ - extensions::capped::{check_cap, query_cap, set_cap}, + extensions::{ + capped::{check_cap, query_cap, set_cap}, + total_supply::{mint, total_supply}, + }, Base, }; @@ -19,11 +22,11 @@ fn test_mint_under_cap() { e.as_contract(&contract_address, || { set_cap(&e, 1000); - check_cap(&e, 500, Base::total_supply(&e)); - Base::mint(&e, &user, 500); + check_cap(&e, 500, total_supply(&e)); + mint(&e, &user, 500); assert_eq!(Base::balance(&e, &user), 500); - assert_eq!(Base::total_supply(&e), 500); + assert_eq!(total_supply(&e), 500); }); } @@ -36,11 +39,11 @@ fn test_mint_exact_cap() { e.as_contract(&contract_address, || { set_cap(&e, 1000); - check_cap(&e, 1000, Base::total_supply(&e)); - Base::mint(&e, &user, 1000); + check_cap(&e, 1000, total_supply(&e)); + mint(&e, &user, 1000); assert_eq!(Base::balance(&e, &user), 1000); - assert_eq!(Base::total_supply(&e), 1000); + assert_eq!(total_supply(&e), 1000); }); } @@ -54,8 +57,8 @@ fn test_mint_exceeds_cap() { e.as_contract(&contract_address, || { set_cap(&e, 1000); - check_cap(&e, 1001, Base::total_supply(&e)); - Base::mint(&e, &user, 1001); // This should panic + check_cap(&e, 1001, total_supply(&e)); + mint(&e, &user, 1001); // This should panic }); } @@ -70,15 +73,15 @@ fn test_mint_multiple_exceeds_cap() { set_cap(&e, 1000); // Mint 600 tokens first - check_cap(&e, 600, Base::total_supply(&e)); - Base::mint(&e, &user, 600); + check_cap(&e, 600, total_supply(&e)); + mint(&e, &user, 600); assert_eq!(Base::balance(&e, &user), 600); - assert_eq!(Base::total_supply(&e), 600); + assert_eq!(total_supply(&e), 600); // Attempt to mint 500 more tokens (would exceed cap) - check_cap(&e, 500, Base::total_supply(&e)); - Base::mint(&e, &user, 500); // This should panic + check_cap(&e, 500, total_supply(&e)); + mint(&e, &user, 500); // This should panic }); } @@ -91,9 +94,9 @@ fn test_check_cap_overflows() { e.as_contract(&contract_address, || { set_cap(&e, i128::MAX); - Base::mint(&e, &user, i128::MAX); + mint(&e, &user, i128::MAX); - check_cap(&e, 1, Base::total_supply(&e)); // should overflow + check_cap(&e, 1, total_supply(&e)); // should overflow }); } diff --git a/packages/tokens/src/fungible/extensions/combinations/mod.rs b/packages/tokens/src/fungible/extensions/combinations/mod.rs index 4515c2357..2fd2c1d20 100644 --- a/packages/tokens/src/fungible/extensions/combinations/mod.rs +++ b/packages/tokens/src/fungible/extensions/combinations/mod.rs @@ -4,8 +4,10 @@ //! [`crate::fungible::FungibleToken`] implementation, and that single slot //! decides all overridable behavior. The contract type is selected uniformly //! with [`Compose`], by listing the contract types that override the `Base` -//! behavior. [`Compose`] resolves the list to the curated contract type at -//! compile time. +//! behavior: a single one, or several whose overrides have to apply at the +//! same time. [`Compose`] resolves the list to the curated contract type at +//! compile time, so no dedicated combination type has to be named by the +//! contract author. //! //! Note that [`Compose`] only selects the `ContractType`; it says nothing //! about extensions that add new functionality without overriding the base @@ -18,25 +20,38 @@ //! ```ignore //! #[contractimpl(contracttrait)] //! impl FungibleToken for MyToken { -//! type ContractType = Compose<(AllowList,)>; +//! type ContractType = Compose<(AllowList, TotalSupply)>; //! } //! //! #[contractimpl(contracttrait)] +//! impl FungibleTotalSupply for MyToken {} +//! +//! #[contractimpl(contracttrait)] //! impl FungibleAllowList for MyToken { //! // ... //! } //! ``` //! -//! No multi-type combinations are curated for fungible tokens yet; every -//! valid list currently holds a single contract type. Invalid lists do not -//! compile: `AllowList` and `BlockList` are mutually exclusive, and -//! implementing an extension trait the list does not back (e.g. -//! [`crate::fungible::allowlist::FungibleAllowList`] without `AllowList` in -//! the list) is rejected by that trait's bound. +//! The list is order-insensitive: `Compose<(TotalSupply, AllowList)>` and +//! `Compose<(AllowList, TotalSupply)>` resolve to the same contract type. +//! Invalid lists do not compile: `AllowList` and `BlockList` are mutually +//! exclusive, and implementing an extension trait the list does not back +//! (e.g. [`crate::fungible::total_supply::FungibleTotalSupply`] without +//! `TotalSupply` in the list) is rejected by that trait's bound. + +mod storage; + +#[cfg(test)] +mod test; + +use storage::{TotalSupplyAllowList, TotalSupplyBlockList}; use crate::{ fungible::{ - extensions::{allowlist::AllowList, blocklist::BlockList, votes::FungibleVotes}, + extensions::{ + allowlist::AllowList, blocklist::BlockList, total_supply::TotalSupply, + votes::FungibleVotes, + }, Base, ContractOverrides, }, rwa::RWA, @@ -44,7 +59,7 @@ use crate::{ }; /// Resolves a list of contract types to the combined contract type, e.g. -/// `Compose<(AllowList,)>`. +/// `Compose<(AllowList, TotalSupply)>`. /// /// This is shorthand for `::Out`; refer to [`Composable`] for /// the valid lists. @@ -54,14 +69,15 @@ pub type Compose = ::Out; /// resolves to its combined contract type through the `Out` associated type. /// /// Single contract types resolve to themselves (with or without the -/// one-element tuple form). Mutually exclusive contract types (e.g. +/// one-element tuple form), and pairs are declared in both orders, making the +/// list order-insensitive. Mutually exclusive contract types (e.g. /// `AllowList` and `BlockList`) have no implementation, so combining them /// does not compile. #[diagnostic::on_unimplemented( message = "`{Self}` is not a valid contract type combination", - note = "valid single contract types: `Base`, `AllowList`, `BlockList`, `RWA`, `Vault`, \ - `FungibleVotes`", - note = "no multi-type combinations are curated for fungible tokens yet", + note = "valid single contract types: `Base`, `AllowList`, `BlockList`, `TotalSupply`, `RWA`, \ + `Vault`, `FungibleVotes`", + note = "valid combinations: `(AllowList, TotalSupply)`, `(BlockList, TotalSupply)`", note = "`AllowList` and `BlockList` are mutually exclusive" )] pub trait Composable { @@ -89,6 +105,12 @@ impl Composable for BlockList { impl Composable for (BlockList,) { type Out = BlockList; } +impl Composable for TotalSupply { + type Out = TotalSupply; +} +impl Composable for (TotalSupply,) { + type Out = TotalSupply; +} impl Composable for RWA { type Out = RWA; } @@ -107,3 +129,18 @@ impl Composable for FungibleVotes { impl Composable for (FungibleVotes,) { type Out = FungibleVotes; } + +// Pairs are declared in both orders, so the contract type list is +// order-insensitive. +impl Composable for (AllowList, TotalSupply) { + type Out = TotalSupplyAllowList; +} +impl Composable for (TotalSupply, AllowList) { + type Out = TotalSupplyAllowList; +} +impl Composable for (BlockList, TotalSupply) { + type Out = TotalSupplyBlockList; +} +impl Composable for (TotalSupply, BlockList) { + type Out = TotalSupplyBlockList; +} diff --git a/packages/tokens/src/fungible/extensions/combinations/storage.rs b/packages/tokens/src/fungible/extensions/combinations/storage.rs new file mode 100644 index 000000000..9220942f3 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/combinations/storage.rs @@ -0,0 +1,152 @@ +use soroban_sdk::{Address, Env, MuxedAddress}; + +use crate::fungible::{ + extensions::{ + allowlist::{AllowList, AllowListContractType}, + blocklist::{BlockList, BlockListContractType}, + total_supply::{decrease_total_supply, mint, total_supply, TotalSupplyOverrides}, + }, + overrides::BurnableOverrides, + ContractOverrides, +}; + +/// Contract type combining the [`AllowList`] transfer policy with total +/// supply tracking. +pub struct TotalSupplyAllowList; + +/// Contract type combining the [`BlockList`] transfer policy with total +/// supply tracking. +pub struct TotalSupplyBlockList; + +impl TotalSupplyOverrides for TotalSupplyAllowList {} +impl TotalSupplyOverrides for TotalSupplyBlockList {} + +// The combined contract types keep enforcing their respective list policy. +impl AllowListContractType for TotalSupplyAllowList {} +impl BlockListContractType for TotalSupplyBlockList {} + +// Transfers and approvals never touch the total supply, so they are routed +// to the respective list policy unchanged. +impl ContractOverrides for TotalSupplyAllowList { + fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { + AllowList::transfer(e, from, to, amount); + } + + fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) { + AllowList::transfer_from(e, spender, from, to, amount); + } + + fn approve(e: &Env, owner: &Address, spender: &Address, amount: i128, live_until_ledger: u32) { + AllowList::approve(e, owner, spender, amount, live_until_ledger); + } +} + +impl ContractOverrides for TotalSupplyBlockList { + fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { + BlockList::transfer(e, from, to, amount); + } + + fn transfer_from(e: &Env, spender: &Address, from: &Address, to: &Address, amount: i128) { + BlockList::transfer_from(e, spender, from, to, amount); + } + + fn approve(e: &Env, owner: &Address, spender: &Address, amount: i128, live_until_ledger: u32) { + BlockList::approve(e, owner, spender, amount, live_until_ledger); + } +} + +impl BurnableOverrides for TotalSupplyAllowList { + fn burn(e: &Env, from: &Address, amount: i128) { + TotalSupplyAllowList::burn(e, from, amount); + } + + fn burn_from(e: &Env, spender: &Address, from: &Address, amount: i128) { + TotalSupplyAllowList::burn_from(e, spender, from, amount); + } +} + +impl BurnableOverrides for TotalSupplyBlockList { + fn burn(e: &Env, from: &Address, amount: i128) { + TotalSupplyBlockList::burn(e, from, amount); + } + + fn burn_from(e: &Env, spender: &Address, from: &Address, amount: i128) { + TotalSupplyBlockList::burn_from(e, spender, from, amount); + } +} + +impl TotalSupplyAllowList { + /// Returns the total amount of tokens in circulation. + /// + /// refer to [`total_supply`] for the inline documentation. + pub fn total_supply(e: &Env) -> i128 { + total_supply(e) + } + + /// Creates `amount` of tokens and assigns them to `to`, increasing the + /// total supply accordingly. + /// + /// refer to [`mint`] for the inline documentation. + pub fn mint(e: &Env, to: &Address, amount: i128) { + mint(e, to, amount); + } + + /// Destroys `amount` of tokens from `from` through the allowlist burn + /// policy and decreases the total supply accordingly. + /// + /// refer to [`AllowList::burn`] and [`decrease_total_supply`] for the + /// inline documentation. + pub fn burn(e: &Env, from: &Address, amount: i128) { + AllowList::burn(e, from, amount); + decrease_total_supply(e, amount); + } + + /// Destroys `amount` of tokens from `from` using the allowance mechanism, + /// through the allowlist burn policy, and decreases the total supply + /// accordingly. + /// + /// refer to [`AllowList::burn_from`] and [`decrease_total_supply`] for + /// the inline documentation. + pub fn burn_from(e: &Env, spender: &Address, from: &Address, amount: i128) { + AllowList::burn_from(e, spender, from, amount); + decrease_total_supply(e, amount); + } +} + +impl TotalSupplyBlockList { + /// Returns the total amount of tokens in circulation. + /// + /// refer to [`total_supply`] for the inline documentation. + pub fn total_supply(e: &Env) -> i128 { + total_supply(e) + } + + /// Creates `amount` of tokens and assigns them to `to`, increasing the + /// total supply accordingly. + /// + /// refer to [`mint`] for the inline documentation. + pub fn mint(e: &Env, to: &Address, amount: i128) { + mint(e, to, amount); + } + + /// Destroys `amount` of tokens from `from` through the blocklist burn + /// policy and decreases the total supply accordingly. + /// + /// refer to [`BlockList::burn`] and [`decrease_total_supply`] for the + /// inline documentation. + pub fn burn(e: &Env, from: &Address, amount: i128) { + BlockList::burn(e, from, amount); + decrease_total_supply(e, amount); + } + + /// Destroys `amount` of tokens from `from` using the allowance mechanism, + /// through the blocklist burn policy, and decreases the total supply + /// accordingly. + /// + /// refer to [`BlockList::burn_from`] and [`decrease_total_supply`] for + /// the inline documentation. + pub fn burn_from(e: &Env, spender: &Address, from: &Address, amount: i128) { + BlockList::burn_from(e, spender, from, amount); + decrease_total_supply(e, amount); + } +} diff --git a/packages/tokens/src/fungible/extensions/combinations/test.rs b/packages/tokens/src/fungible/extensions/combinations/test.rs new file mode 100644 index 000000000..c6c7172a3 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/combinations/test.rs @@ -0,0 +1,102 @@ +extern crate std; + +use soroban_sdk::{contract, testutils::Address as _, Address, Env, MuxedAddress}; + +use crate::fungible::{ + allowlist::AllowList, + blocklist::BlockList, + extensions::combinations::Compose, + overrides::BurnableOverrides, + total_supply::{mint, total_supply, TotalSupply}, + Base, ContractOverrides, +}; + +type AllowListWithSupply = Compose<(AllowList, TotalSupply)>; +// deliberately the swapped order, asserting that the list is +// order-insensitive +type BlockListWithSupply = Compose<(TotalSupply, BlockList)>; + +#[contract] +struct MockContract; + +#[test] +fn allowlist_burn_decreases_supply() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let account = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &account, 100); + AllowList::allow_user(&e, &account); + ::burn(&e, &account, 40); + assert_eq!(Base::balance(&e, &account), 60); + assert_eq!(total_supply(&e), 60); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #113)")] +fn allowlist_burn_respects_policy() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let account = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &account, 100); + // `account` is not allowed, the allowlist policy has to reject the + // burn + ::burn(&e, &account, 40); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #113)")] +fn allowlist_transfer_respects_policy() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let from = Address::generate(&e); + let recipient = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &from, 100); + // neither account is allowed, the allowlist policy has to reject the + // transfer + ::transfer( + &e, + &from, + &MuxedAddress::from(recipient), + 30, + ); + }); +} + +#[test] +fn blocklist_burn_from_decreases_supply() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &owner, 100); + Base::approve(&e, &owner, &spender, 40, e.ledger().sequence() + 100); + ::burn_from(&e, &spender, &owner, 40); + assert_eq!(Base::balance(&e, &owner), 60); + assert_eq!(total_supply(&e), 60); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #114)")] +fn blocklist_burn_respects_policy() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let account = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &account, 100); + BlockList::block_user(&e, &account); + // `account` is blocked, the blocklist policy has to reject the burn + ::burn(&e, &account, 40); + }); +} diff --git a/packages/tokens/src/fungible/extensions/mod.rs b/packages/tokens/src/fungible/extensions/mod.rs index 3af97ab36..491d98595 100644 --- a/packages/tokens/src/fungible/extensions/mod.rs +++ b/packages/tokens/src/fungible/extensions/mod.rs @@ -3,4 +3,5 @@ pub mod blocklist; pub mod burnable; pub mod capped; pub mod combinations; +pub mod total_supply; pub mod votes; diff --git a/packages/tokens/src/fungible/extensions/total_supply/mod.rs b/packages/tokens/src/fungible/extensions/total_supply/mod.rs new file mode 100644 index 000000000..b545713b2 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/total_supply/mod.rs @@ -0,0 +1,90 @@ +//! # Total Supply Extension for Fungible Token. +//! +//! Tracking the total supply is not required by SEP-41 and is therefore not +//! part of the base [`crate::fungible::FungibleToken`] trait. Keeping a +//! global supply counter has a scalability cost: every mint and burn writes +//! the same storage entry, and a transaction writing an entry cannot execute +//! in parallel with transactions reading it. Tokens that do not need the +//! counter are better off without it, which is why supply tracking is +//! provided as this opt-in extension. +//! +//! The extension consists of two parts: +//! +//! - [`FungibleTotalSupply`]: exposes the `total_supply()` function on the +//! contract. +//! - A supply-aware contract type, so that burns performed through +//! [`crate::fungible::burnable::FungibleBurnable`] decrease the supply: +//! [`TotalSupply`] for the vanilla behavior, or a combination resolved by +//! [`crate::fungible::combinations::Compose`] (e.g. `Compose<(AllowList, +//! TotalSupply)>`) to pair tracking with the allowlist or blocklist policy. +//! +//! Minting has to go through [`mint`] (instead of +//! [`crate::fungible::Base::mint`]) for the supply to be increased. +//! +//! Usage: +//! +//! ```ignore +//! #[contractimpl(contracttrait)] +//! impl FungibleToken for MyToken { +//! type ContractType = Compose<(TotalSupply,)>; +//! } +//! +//! #[contractimpl(contracttrait)] +//! impl FungibleTotalSupply for MyToken {} +//! ``` +//! +//! The [`crate::rwa::RWA`], [`crate::vault::Vault`] and +//! [`crate::fungible::votes::FungibleVotes`] contract types are inherently +//! supply-aware; [`FungibleTotalSupply`] can be implemented on top of them +//! directly. +//! +//! The supply is stored in its own `persistent` entry, ensuring that mints +//! and burns only conflict with each other and never with plain transfers. + +pub mod storage; + +#[cfg(test)] +mod test; + +use soroban_sdk::{contracttrait, Env}; +pub use storage::{ + decrease_total_supply, increase_total_supply, mint, total_supply, TotalSupply, + TotalSupplyStorageKey, +}; + +// The trait is defined alongside its siblings (`ContractOverrides`, +// `BurnableOverrides`) in the private `overrides` module; this re-export is +// its public path. +pub use crate::fungible::overrides::TotalSupplyOverrides; +use crate::fungible::FungibleToken; + +/// Total Supply Trait for Fungible Token +/// +/// The `FungibleTotalSupply` trait extends the `FungibleToken` trait to +/// expose the total amount of tokens in circulation. +/// +/// This trait can only be implemented when the contract's `ContractType` +/// accounts for the total supply: +/// +/// * [`TotalSupply`] (vanilla behavior), +/// * `Compose<(AllowList, TotalSupply)>` and `Compose<(BlockList, +/// TotalSupply)>` (refer to [`crate::fungible::combinations::Compose`]), +/// * [`crate::rwa::RWA`], +/// * [`crate::vault::Vault`], +/// * [`crate::fungible::votes::FungibleVotes`] (the supply is served from the +/// voting checkpoints). +/// +/// When using one of the `TotalSupply*` contract types, minting has to be +/// performed with [`mint`] so that the supply is increased; burns through +/// [`crate::fungible::burnable::FungibleBurnable`] decrease it automatically. +#[contracttrait] +pub trait FungibleTotalSupply: FungibleToken { + /// Returns the total amount of tokens in circulation. + /// + /// # Arguments + /// + /// * `e` - Access to the Soroban environment. + fn total_supply(e: &Env) -> i128 { + Self::ContractType::total_supply(e) + } +} diff --git a/packages/tokens/src/fungible/extensions/total_supply/storage.rs b/packages/tokens/src/fungible/extensions/total_supply/storage.rs new file mode 100644 index 000000000..3fc1ebcb7 --- /dev/null +++ b/packages/tokens/src/fungible/extensions/total_supply/storage.rs @@ -0,0 +1,198 @@ +use soroban_sdk::{contracttype, panic_with_error, Address, Env}; + +use crate::fungible::{ + overrides::{BurnableOverrides, TotalSupplyOverrides}, + Base, ContractOverrides, FungibleTokenError, TOTAL_SUPPLY_EXTEND_AMOUNT, + TOTAL_SUPPLY_TTL_THRESHOLD, +}; + +/// Contract type that layers total supply accounting on top of the vanilla +/// [`Base`] behavior. +/// +/// For combining total supply tracking with the allowlist or blocklist +/// transfer policies, refer to +/// [`crate::fungible::combinations::Compose`], e.g. +/// `Compose<(AllowList, TotalSupply)>`. +pub struct TotalSupply; + +/// Storage keys for the data associated with the total supply extension of +/// `FungibleToken` +#[contracttype] +pub enum TotalSupplyStorageKey { + TotalSupply, +} + +impl TotalSupplyOverrides for TotalSupply {} + +impl ContractOverrides for TotalSupply {} + +impl BurnableOverrides for TotalSupply { + fn burn(e: &Env, from: &Address, amount: i128) { + TotalSupply::burn(e, from, amount); + } + + fn burn_from(e: &Env, spender: &Address, from: &Address, amount: i128) { + TotalSupply::burn_from(e, spender, from, amount); + } +} + +impl TotalSupply { + /// Returns the total amount of tokens in circulation. + /// + /// refer to [`total_supply`] for the inline documentation. + pub fn total_supply(e: &Env) -> i128 { + total_supply(e) + } + + /// Creates `amount` of tokens and assigns them to `to`, increasing the + /// total supply accordingly. + /// + /// refer to [`mint`] for the inline documentation. + pub fn mint(e: &Env, to: &Address, amount: i128) { + mint(e, to, amount); + } + + /// Destroys `amount` of tokens from `from` and decreases the total supply + /// accordingly. + /// + /// refer to [`crate::fungible::Base::burn`] and + /// [`decrease_total_supply`] for the inline documentation. + pub fn burn(e: &Env, from: &Address, amount: i128) { + Base::burn(e, from, amount); + decrease_total_supply(e, amount); + } + + /// Destroys `amount` of tokens from `from` using the allowance mechanism + /// and decreases the total supply accordingly. + /// + /// refer to [`crate::fungible::Base::burn_from`] and + /// [`decrease_total_supply`] for the inline documentation. + pub fn burn_from(e: &Env, spender: &Address, from: &Address, amount: i128) { + Base::burn_from(e, spender, from, amount); + decrease_total_supply(e, amount); + } +} + +// ################## QUERY STATE ################## + +/// Returns the total amount of tokens in circulation. Defaults to `0` if no +/// supply is recorded. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +pub fn total_supply(e: &Env) -> i128 { + let key = TotalSupplyStorageKey::TotalSupply; + if let Some(supply) = e.storage().persistent().get::<_, i128>(&key) { + e.storage().persistent().extend_ttl( + &key, + TOTAL_SUPPLY_TTL_THRESHOLD, + TOTAL_SUPPLY_EXTEND_AMOUNT, + ); + supply + } else { + 0 + } +} + +// ################## CHANGE STATE ################## + +/// Creates `amount` of tokens and assigns them to `to`, increasing the total +/// supply accordingly. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `to` - The address receiving the new tokens. +/// * `amount` - The amount of tokens to mint. +/// +/// # Errors +/// +/// * [`FungibleTokenError::MathOverflow`] - When the total supply overflows. +/// * refer to [`crate::fungible::Base::update`] errors. +/// +/// # Events +/// +/// * topics - `["mint", to: Address]` +/// * data - `[amount: i128]` +/// +/// # Security Warning +/// +/// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️ +/// +/// It is the responsibility of the implementer to establish appropriate +/// access controls to ensure that only authorized accounts can execute +/// minting operations. Failure to implement proper authorization could +/// lead to security vulnerabilities and unauthorized token creation. +/// +/// The implementation will typically look similar to the following +/// (pseudo-code): +/// +/// ```ignore +/// let admin = read_administrator(e); +/// admin.require_auth(); +/// ``` +pub fn mint(e: &Env, to: &Address, amount: i128) { + increase_total_supply(e, amount); + Base::mint(e, to, amount); +} + +// ################## LOW-LEVEL HELPERS ################## + +/// Adds `amount` to the total supply. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The amount to be added to the total supply. +/// +/// # Errors +/// +/// * [`FungibleTokenError::LessThanZero`] - When `amount < 0`. +/// * [`FungibleTokenError::MathOverflow`] - When the total supply overflows. +/// +/// # Notes +/// +/// This is a raw accounting helper: it does not move any balances and does +/// not emit events. [`mint`] should be preferred unless a custom minting +/// flow is being composed. +pub fn increase_total_supply(e: &Env, amount: i128) { + if amount < 0 { + panic_with_error!(e, FungibleTokenError::LessThanZero); + } + let Some(new_total_supply) = total_supply(e).checked_add(amount) else { + panic_with_error!(e, FungibleTokenError::MathOverflow); + }; + e.storage().persistent().set(&TotalSupplyStorageKey::TotalSupply, &new_total_supply); +} + +/// Subtracts `amount` from the total supply. +/// +/// # Arguments +/// +/// * `e` - Access to the Soroban environment. +/// * `amount` - The amount to be subtracted from the total supply. +/// +/// # Errors +/// +/// * [`FungibleTokenError::LessThanZero`] - When `amount < 0`. +/// * [`FungibleTokenError::MathOverflow`] - When `amount` exceeds the recorded +/// total supply. +/// +/// # Notes +/// +/// This is a raw accounting helper: it does not move any balances and does +/// not emit events. `amount` can only exceed the recorded supply when +/// balances were created without supply accounting (e.g. via +/// [`crate::fungible::Base::mint`]); mixing tracked and untracked flows in +/// the same contract is a bug. +pub fn decrease_total_supply(e: &Env, amount: i128) { + if amount < 0 { + panic_with_error!(e, FungibleTokenError::LessThanZero); + } + let supply = total_supply(e); + if amount > supply { + panic_with_error!(e, FungibleTokenError::MathOverflow); + } + e.storage().persistent().set(&TotalSupplyStorageKey::TotalSupply, &(supply - amount)); +} diff --git a/packages/tokens/src/fungible/extensions/total_supply/test.rs b/packages/tokens/src/fungible/extensions/total_supply/test.rs new file mode 100644 index 000000000..1d60bf57e --- /dev/null +++ b/packages/tokens/src/fungible/extensions/total_supply/test.rs @@ -0,0 +1,155 @@ +extern crate std; + +use soroban_sdk::{contract, testutils::Address as _, Address, Env, MuxedAddress, String}; + +use crate::fungible::{ + extensions::total_supply::{ + decrease_total_supply, increase_total_supply, mint, total_supply, TotalSupply, + TotalSupplyOverrides, + }, + overrides::BurnableOverrides, + Base, ContractOverrides, +}; + +#[contract] +struct MockContract; + +#[test] +fn initial_supply_is_zero() { + let e = Env::default(); + let address = e.register(MockContract, ()); + e.as_contract(&address, || { + assert_eq!(total_supply(&e), 0); + assert_eq!(::total_supply(&e), 0); + }); +} + +#[test] +fn mint_increases_supply_and_balance() { + let e = Env::default(); + let address = e.register(MockContract, ()); + let account = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &account, 100); + assert_eq!(Base::balance(&e, &account), 100); + assert_eq!(total_supply(&e), 100); + + mint(&e, &account, 50); + assert_eq!(Base::balance(&e, &account), 150); + assert_eq!(total_supply(&e), 150); + }); +} + +#[test] +fn burn_decreases_supply() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let account = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &account, 100); + ::burn(&e, &account, 40); + assert_eq!(Base::balance(&e, &account), 60); + assert_eq!(total_supply(&e), 60); + }); +} + +#[test] +fn burn_from_decreases_supply() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let owner = Address::generate(&e); + let spender = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &owner, 100); + Base::approve(&e, &owner, &spender, 40, e.ledger().sequence() + 100); + ::burn_from(&e, &spender, &owner, 40); + assert_eq!(Base::balance(&e, &owner), 60); + assert_eq!(total_supply(&e), 60); + }); +} + +#[test] +fn contract_type_delegates_token_operations() { + let e = Env::default(); + e.mock_all_auths(); + let address = e.register(MockContract, ()); + let from = Address::generate(&e); + let recipient = Address::generate(&e); + e.as_contract(&address, || { + mint(&e, &from, 100); + TotalSupply::transfer(&e, &from, &MuxedAddress::from(recipient.clone()), 30); + assert_eq!(TotalSupply::balance(&e, &from), 70); + assert_eq!(TotalSupply::balance(&e, &recipient), 30); + // transfers leave the supply untouched + assert_eq!(total_supply(&e), 100); + }); + + // separate invocation, so that `from` can authorize again + e.as_contract(&address, || { + TotalSupply::approve(&e, &from, &recipient, 20, e.ledger().sequence() + 100); + assert_eq!(TotalSupply::allowance(&e, &from, &recipient), 20); + TotalSupply::transfer_from(&e, &recipient, &from, &recipient, 20); + assert_eq!(TotalSupply::balance(&e, &recipient), 50); + assert_eq!(total_supply(&e), 100); + + Base::set_metadata(&e, 7, String::from_str(&e, "My Token"), String::from_str(&e, "TKN")); + assert_eq!(TotalSupply::decimals(&e), 7); + assert_eq!(TotalSupply::name(&e), String::from_str(&e, "My Token")); + assert_eq!(TotalSupply::symbol(&e), String::from_str(&e, "TKN")); + }); +} + +#[test] +fn increase_and_decrease_total_supply_work() { + let e = Env::default(); + let address = e.register(MockContract, ()); + e.as_contract(&address, || { + increase_total_supply(&e, 100); + assert_eq!(total_supply(&e), 100); + decrease_total_supply(&e, 60); + assert_eq!(total_supply(&e), 40); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #103)")] +fn increase_total_supply_rejects_negative_amount() { + let e = Env::default(); + let address = e.register(MockContract, ()); + e.as_contract(&address, || { + increase_total_supply(&e, -1); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #103)")] +fn decrease_total_supply_rejects_negative_amount() { + let e = Env::default(); + let address = e.register(MockContract, ()); + e.as_contract(&address, || { + decrease_total_supply(&e, -1); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #104)")] +fn increase_total_supply_overflow_panics() { + let e = Env::default(); + let address = e.register(MockContract, ()); + e.as_contract(&address, || { + increase_total_supply(&e, i128::MAX); + increase_total_supply(&e, 1); + }); +} + +#[test] +#[should_panic(expected = "Error(Contract, #104)")] +fn decrease_total_supply_underflow_panics() { + let e = Env::default(); + let address = e.register(MockContract, ()); + e.as_contract(&address, || { + decrease_total_supply(&e, 1); + }); +} diff --git a/packages/tokens/src/fungible/extensions/votes/storage.rs b/packages/tokens/src/fungible/extensions/votes/storage.rs index be656a6d9..d5db6a923 100644 --- a/packages/tokens/src/fungible/extensions/votes/storage.rs +++ b/packages/tokens/src/fungible/extensions/votes/storage.rs @@ -1,10 +1,23 @@ -use soroban_sdk::{Address, Env, MuxedAddress}; -use stellar_governance::votes::transfer_voting_units; +use soroban_sdk::{panic_with_error, Address, Env, MuxedAddress}; +use stellar_governance::votes::{get_total_supply, transfer_voting_units}; -use crate::fungible::{overrides::BurnableOverrides, Base, ContractOverrides}; +use crate::fungible::{ + overrides::BurnableOverrides, total_supply::TotalSupplyOverrides, Base, ContractOverrides, + FungibleTokenError, +}; pub struct FungibleVotes; +// The voting checkpoints already track the total supply; the query is served +// from them instead of a separate supply entry. +impl TotalSupplyOverrides for FungibleVotes { + fn total_supply(e: &Env) -> i128 { + get_total_supply(e) + .try_into() + .unwrap_or_else(|_| panic_with_error!(e, FungibleTokenError::MathOverflow)) + } +} + impl ContractOverrides for FungibleVotes { fn transfer(e: &Env, from: &Address, to: &MuxedAddress, amount: i128) { FungibleVotes::transfer(e, from, to, amount); diff --git a/packages/tokens/src/fungible/extensions/votes/test.rs b/packages/tokens/src/fungible/extensions/votes/test.rs index 1963e9d27..12067691a 100644 --- a/packages/tokens/src/fungible/extensions/votes/test.rs +++ b/packages/tokens/src/fungible/extensions/votes/test.rs @@ -3,7 +3,9 @@ extern crate std; use soroban_sdk::{contract, testutils::Address as _, Address, Env, MuxedAddress}; use stellar_governance::votes::{delegate, get_delegate, get_votes, get_voting_units}; -use crate::fungible::{extensions::votes::FungibleVotes, Base, ContractOverrides}; +use crate::fungible::{ + extensions::votes::FungibleVotes, total_supply::TotalSupplyOverrides, Base, ContractOverrides, +}; #[contract] struct MockContract; @@ -24,7 +26,7 @@ fn mint_updates_voting_units() { FungibleVotes::mint(&e, &alice, 100); assert_eq!(Base::balance(&e, &alice), 100); - assert_eq!(Base::total_supply(&e), 100); + assert_eq!(::total_supply(&e), 100); assert_eq!(get_voting_units(&e, &alice), 100); }); } @@ -55,7 +57,7 @@ fn burn_updates_voting_units() { FungibleVotes::burn(&e, &alice, 30); assert_eq!(Base::balance(&e, &alice), 70); - assert_eq!(Base::total_supply(&e), 70); + assert_eq!(::total_supply(&e), 70); assert_eq!(get_voting_units(&e, &alice), 70); }); } diff --git a/packages/tokens/src/fungible/mod.rs b/packages/tokens/src/fungible/mod.rs index 22f9cef31..3252ab783 100644 --- a/packages/tokens/src/fungible/mod.rs +++ b/packages/tokens/src/fungible/mod.rs @@ -3,7 +3,7 @@ //! Implements utilities for handling fungible tokens in a Soroban contract. //! //! This module provides essential storage functionalities required for managing -//! balances, allowances, and total supply of fungible tokens. +//! balances and allowances of fungible tokens. //! //! ## Design Overview //! @@ -27,16 +27,16 @@ //! //! The base module includes: //! -//! - Total supply management //! - Transfers and allowances //! //! The following optional extensions are available: //! //! - Metadata: Provides additional information about the token, such as name, //! symbol, and decimals. -//! - Burnable: Enables token holders to destroy their tokens, reducing the -//! total supply. -//! - Capped: Enables the contract to set a maximum limit on the total supply. +//! - Burnable: Enables token holders to destroy their tokens. +//! - TotalSupply: Tracks and exposes the total amount of tokens in circulation. +//! - Capped: Enables the contract to set a maximum limit on the total supply +//! (requires the TotalSupply extension). //! //! ## Compatibility and Compliance //! @@ -75,7 +75,8 @@ mod utils; mod test; pub use extensions::{ - allowlist, blocklist, burnable, capped, combinations, combinations::Compose, votes, + allowlist, blocklist, burnable, capped, combinations, combinations::Compose, total_supply, + votes, }; pub use overrides::{Base, ContractOverrides}; use soroban_sdk::{ @@ -89,8 +90,10 @@ pub use utils::{sac_admin_generic, sac_admin_wrapper}; /// The `FungibleToken` trait defines the core functionality for fungible /// tokens, adhering to SEP-41. It provides a standard interface for managing /// balances, allowances, and metadata associated with fungible tokens. -/// Additionally, this trait includes the `total_supply()` function, which is -/// not part of SEP-41 but is commonly used in token contracts. +/// +/// Tracking the total supply is not part of SEP-41 and is not provided by +/// this trait; it is available as the opt-in +/// [`crate::fungible::total_supply::FungibleTotalSupply`] extension. /// /// To fully comply with the SEP-41 specification one has to implement the /// `FungibleBurnable` trait in addition to this one. SEP-41 mandates support @@ -115,15 +118,22 @@ pub use utils::{sac_admin_generic, sac_admin_wrapper}; /// incompatible with [`crate::fungible::allowlist::AllowList`] trait and /// [`crate::rwa::RWA`] trait. /// * [`crate::rwa::RWA`] (enabling the compatibility and overrides for -/// [`crate::rwa::RWAToken`]) trait, incompatible with -/// [`crate::fungible::allowlist::AllowList`] trait and +/// [`crate::rwa::RWAToken`] and +/// [`crate::fungible::total_supply::FungibleTotalSupply`]) traits, +/// incompatible with [`crate::fungible::allowlist::AllowList`] trait and /// [`crate::fungible::blocklist::BlockList`] trait. +/// * [`crate::fungible::total_supply::TotalSupply`] (additionally tracking the +/// total supply, enabling the compatibility and overrides for +/// [`crate::fungible::total_supply::FungibleTotalSupply`]) trait. /// * [`crate::vault::Vault`] (enabling the compatibility and overrides for -/// [`crate::vault::FungibleVault`]) trait. +/// [`crate::vault::FungibleVault`] and +/// [`crate::fungible::total_supply::FungibleTotalSupply`]) traits. /// * [`crate::fungible::votes::FungibleVotes`] (enabling the compatibility and -/// overrides for [`stellar_governance::votes::Votes`]) trait. +/// overrides for [`stellar_governance::votes::Votes`] and +/// [`crate::fungible::total_supply::FungibleTotalSupply`]) traits. /// -/// The contract type is selected with +/// Contract types that can work together (e.g. `AllowList` with +/// `TotalSupply`) are combined by listing them in /// [`crate::fungible::combinations::Compose`]; invalid combinations are /// rejected at compile time. /// @@ -140,21 +150,12 @@ pub trait FungibleToken { /// The contract type is selected with /// [`crate::fungible::combinations::Compose`], by listing the contract /// types that override the `Base` behavior: `Compose<(Base,)>` for the - /// vanilla case, `Compose<(AllowList,)>` for an allowlist token, and so - /// on. Extensions that add functionality without overriding behavior - /// (e.g. [`crate::fungible::burnable::FungibleBurnable`]) have no - /// contract type and do not appear in the list. + /// vanilla case, `Compose<(AllowList, TotalSupply)>` for a combination, + /// and so on. Extensions that add functionality without overriding + /// behavior (e.g. [`crate::fungible::burnable::FungibleBurnable`]) have + /// no contract type and do not appear in the list. type ContractType: ContractOverrides; - /// Returns the total amount of tokens in circulation. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - fn total_supply(e: &Env) -> i128 { - Self::ContractType::total_supply(e) - } - /// Returns the amount of tokens held by `account`. /// /// # Arguments @@ -302,7 +303,7 @@ pub enum FungibleTokenError { InvalidLiveUntilLedger = 102, /// Indicates an error when an input that must be >= 0 LessThanZero = 103, - /// Indicates overflow when adding two values + /// Indicates an overflow or underflow in an arithmetic operation MathOverflow = 104, /// Indicates access to uninitialized metadata UnsetMetadata = 105, @@ -334,6 +335,8 @@ pub const BALANCE_EXTEND_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; pub const BALANCE_TTL_THRESHOLD: u32 = BALANCE_EXTEND_AMOUNT - DAY_IN_LEDGERS; pub const ALLOW_BLOCK_EXTEND_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; pub const ALLOW_BLOCK_TTL_THRESHOLD: u32 = ALLOW_BLOCK_EXTEND_AMOUNT - DAY_IN_LEDGERS; +pub const TOTAL_SUPPLY_EXTEND_AMOUNT: u32 = 30 * DAY_IN_LEDGERS; +pub const TOTAL_SUPPLY_TTL_THRESHOLD: u32 = TOTAL_SUPPLY_EXTEND_AMOUNT - DAY_IN_LEDGERS; pub const INSTANCE_EXTEND_AMOUNT: u32 = 7 * DAY_IN_LEDGERS; pub const INSTANCE_TTL_THRESHOLD: u32 = INSTANCE_EXTEND_AMOUNT - DAY_IN_LEDGERS; diff --git a/packages/tokens/src/fungible/overrides.rs b/packages/tokens/src/fungible/overrides.rs index 7a7bdd44f..cfc62aa8a 100644 --- a/packages/tokens/src/fungible/overrides.rs +++ b/packages/tokens/src/fungible/overrides.rs @@ -1,5 +1,7 @@ use soroban_sdk::{Address, Env, MuxedAddress, String}; +use crate::fungible::extensions::total_supply::total_supply; + /// Internal override hook for [`crate::fungible::FungibleToken`]. /// /// # Note @@ -29,10 +31,6 @@ use soroban_sdk::{Address, Env, MuxedAddress, String}; /// } /// ``` pub trait ContractOverrides { - fn total_supply(e: &Env) -> i128 { - Base::total_supply(e) - } - fn balance(e: &Env, account: &Address) -> i128 { Base::balance(e, account) } @@ -94,3 +92,27 @@ pub trait BurnableOverrides { } impl BurnableOverrides for Base {} + +/// Internal override hook for +/// [`crate::fungible::total_supply::FungibleTotalSupply`]. +/// +/// # Note +/// +/// Like [`ContractOverrides`], this trait is internal plumbing of the +/// library. There is no need to implement or import it: implementing +/// [`crate::fungible::total_supply::FungibleTotalSupply`] with an empty body +/// is enough, and the right behavior is picked based on the contract's +/// `ContractType`. The library ships implementations for its supply-aware +/// contract types ([`crate::fungible::total_supply::TotalSupply`], the +/// combined contract types resolved by +/// [`crate::fungible::combinations::Compose`], `RWA`, `Vault`, +/// `FungibleVotes`). +/// +/// Unlike `BurnableOverrides`, there is deliberately no implementation for +/// [`Base`]: exposing the total supply requires a supply-tracking contract +/// type. +pub trait TotalSupplyOverrides { + fn total_supply(e: &Env) -> i128 { + total_supply(e) + } +} diff --git a/packages/tokens/src/fungible/storage.rs b/packages/tokens/src/fungible/storage.rs index b1f85b676..fb375fd0b 100644 --- a/packages/tokens/src/fungible/storage.rs +++ b/packages/tokens/src/fungible/storage.rs @@ -32,7 +32,6 @@ pub struct Metadata { #[contracttype] pub enum FungibleStorageKey { Meta, - TotalSupply, Balance(Address), Allowance(AllowanceKey), } @@ -40,16 +39,6 @@ pub enum FungibleStorageKey { impl Base { // ################## QUERY STATE ################## - /// Returns the total amount of tokens in circulation. If no supply is - /// recorded, it defaults to `0`. - /// - /// # Arguments - /// - /// * `e` - Access to the Soroban environment. - pub fn total_supply(e: &Env) -> i128 { - e.storage().instance().get(&FungibleStorageKey::TotalSupply).unwrap_or(0) - } - /// Returns the amount of tokens held by `account`. Defaults to `0` if no /// balance is stored. /// @@ -383,8 +372,7 @@ impl Base { } /// Transfers `amount` of tokens from `from` to `to` or alternatively - /// mints (or burns) tokens if `from` (or `to`) is `None`. Updates the total - /// supply accordingly. + /// mints (or burns) tokens if `from` (or `to`) is `None`. /// /// # Arguments /// @@ -398,12 +386,17 @@ impl Base { /// * [`FungibleTokenError::InsufficientBalance`] - When attempting to /// transfer more tokens than `from` current balance. /// * [`FungibleTokenError::LessThanZero`] - When `amount < 0`. - /// * [`FungibleTokenError::MathOverflow`] - When `total_supply` overflows. + /// * [`FungibleTokenError::MathOverflow`] - When the `to` balance + /// overflows. /// /// # Notes /// /// This function does not enforce authorization. Ensure that authorization /// is handled at a higher level. + /// + /// This function does not account for the total supply; supply-aware + /// flows (e.g. [`crate::fungible::total_supply::mint`], the RWA and Vault + /// contract types) layer the supply bookkeeping on top of it. pub fn update(e: &Env, from: Option<&Address>, to: Option<&Address>, amount: i128) { if amount < 0 { panic_with_error!(e, FungibleTokenError::LessThanZero); @@ -418,33 +411,19 @@ impl Base { e.storage() .persistent() .set(&FungibleStorageKey::Balance(account.clone()), &from_balance); - } else { - // `from` is None, so we're minting tokens. - let total_supply = Base::total_supply(e); - let Some(new_total_supply) = total_supply.checked_add(amount) else { - panic_with_error!(e, FungibleTokenError::MathOverflow); - }; - e.storage().instance().set(&FungibleStorageKey::TotalSupply, &new_total_supply); } if let Some(account) = to { - // NOTE: can't overflow because balance + amount is at most total_supply. - let to_balance = Base::balance(e, account) + amount; + let Some(to_balance) = Base::balance(e, account).checked_add(amount) else { + panic_with_error!(e, FungibleTokenError::MathOverflow); + }; e.storage() .persistent() .set(&FungibleStorageKey::Balance(account.clone()), &to_balance); - } else { - // `to` is None, so we're burning tokens. - - // NOTE: can't overflow because amount <= total_supply or amount <= from_balance - // <= total_supply. - let total_supply = Base::total_supply(e) - amount; - e.storage().instance().set(&FungibleStorageKey::TotalSupply, &total_supply); } } - /// Creates `amount` of tokens and assigns them to `to`. Updates - /// the total supply accordingly. + /// Creates `amount` of tokens and assigns them to `to`. /// /// # Arguments /// @@ -461,6 +440,13 @@ impl Base { /// * topics - `["mint", to: Address]` /// * data - `[amount: i128]` /// + /// # Notes + /// + /// This function does not account for the total supply. For tokens that + /// track it (i.e. implementers of + /// [`crate::fungible::total_supply::FungibleTotalSupply`]), + /// [`crate::fungible::total_supply::mint`] should be used instead. + /// /// # Security Warning /// /// ⚠️ SECURITY RISK: This function has NO AUTHORIZATION CONTROLS ⚠️ diff --git a/packages/tokens/src/fungible/test.rs b/packages/tokens/src/fungible/test.rs index 27678a85e..7a4edbd60 100644 --- a/packages/tokens/src/fungible/test.rs +++ b/packages/tokens/src/fungible/test.rs @@ -23,7 +23,6 @@ fn initial_state() { let address = e.register(MockContract, ()); let account = Address::generate(&e); e.as_contract(&address, || { - assert_eq!(Base::total_supply(&e), 0); assert_eq!(Base::balance(&e, &account), 0); }); } @@ -495,7 +494,6 @@ fn update_mints_tokens() { e.as_contract(&address, || { Base::update(&e, None, Some(&to), 100); assert_eq!(Base::balance(&e, &to), 100); - assert_eq!(Base::total_supply(&e), 100); }); } @@ -509,7 +507,6 @@ fn update_burns_tokens() { Base::mint(&e, &from, 100); Base::update(&e, Some(&from), None, 50); assert_eq!(Base::balance(&e, &from), 50); - assert_eq!(Base::total_supply(&e), 50); }); } @@ -762,7 +759,6 @@ fn mint_works() { e.as_contract(&address, || { Base::mint(&e, &account, 100); assert_eq!(Base::balance(&e, &account), 100); - assert_eq!(Base::total_supply(&e), 100); let mut event_assert = EventAssertion::new(&e, address.clone()); event_assert.assert_event_count(1); diff --git a/packages/tokens/src/rwa/mod.rs b/packages/tokens/src/rwa/mod.rs index dc4231ec5..d2a99b545 100644 --- a/packages/tokens/src/rwa/mod.rs +++ b/packages/tokens/src/rwa/mod.rs @@ -128,6 +128,11 @@ use crate::fungible::FungibleToken; /// - Freezing mechanisms for regulatory enforcement /// - Recovery system for lost/old account scenarios /// - Administrative controls for token management +/// +/// The `RWA` contract type tracks the total supply (as mandated by T-REX +/// through the ERC-20 interface), and RWA contracts are expected to also +/// implement [`crate::fungible::total_supply::FungibleTotalSupply`] to +/// expose it. #[contracttrait] pub trait RWAToken: Pausable + FungibleToken { // ################## CORE TOKEN FUNCTIONS ################## diff --git a/packages/tokens/src/rwa/storage.rs b/packages/tokens/src/rwa/storage.rs index 076a45e8d..4ba4c0f10 100644 --- a/packages/tokens/src/rwa/storage.rs +++ b/packages/tokens/src/rwa/storage.rs @@ -2,7 +2,11 @@ use soroban_sdk::{contracttype, panic_with_error, Address, Env, MuxedAddress, St use stellar_contract_utils::pausable::{paused, PausableError}; use crate::{ - fungible::{emit_transfer, Base, ContractOverrides}, + fungible::{ + emit_transfer, + total_supply::{decrease_total_supply, increase_total_supply, TotalSupplyOverrides}, + Base, ContractOverrides, + }, rwa::{ compliance::{AccountSnapshot, ComplianceClient, TransferKind}, emit_address_frozen, emit_burn, emit_compliance_set, emit_identity_verifier_set, emit_mint, @@ -41,6 +45,10 @@ impl ContractOverrides for RWA { } } +// T-REX (ERC-3643) tokens expose the total supply, so the RWA contract type +// is inherently supply-aware. +impl TotalSupplyOverrides for RWA {} + impl RWA { // ################## QUERY STATE ################## @@ -319,6 +327,7 @@ impl RWA { // Snapshot before the mint so the hook observes the pre-mint balance. let to_snapshot = Self::account_snapshot(e, to); + increase_total_supply(e, amount); Base::update(e, None, Some(to), amount); let compliance_addr = Self::compliance(e); @@ -374,6 +383,7 @@ impl RWA { } Base::update(e, Some(user_address), None, amount); + decrease_total_supply(e, amount); let compliance_addr = Self::compliance(e); let compliance_client = ComplianceClient::new(e, &compliance_addr); diff --git a/packages/tokens/src/rwa/test.rs b/packages/tokens/src/rwa/test.rs index 15f91081e..154f04477 100644 --- a/packages/tokens/src/rwa/test.rs +++ b/packages/tokens/src/rwa/test.rs @@ -8,7 +8,7 @@ use stellar_contract_utils::pausable; use stellar_event_assertion::EventAssertion; use crate::{ - fungible::ContractOverrides, + fungible::{total_supply::TotalSupplyOverrides, ContractOverrides}, rwa::{ compliance::{AccountSnapshot, TransferKind}, storage::RWAStorageKey, diff --git a/packages/tokens/src/vault/mod.rs b/packages/tokens/src/vault/mod.rs index 39cebeb99..8353ef807 100644 --- a/packages/tokens/src/vault/mod.rs +++ b/packages/tokens/src/vault/mod.rs @@ -17,6 +17,10 @@ use crate::fungible::FungibleToken; /// /// The vault maintains a conversion rate between shares and assets based on /// the total supply of shares and total assets held by the vault contract. +/// The `Vault` contract type is therefore inherently supply-aware, and vault +/// contracts are expected to also implement +/// [`crate::fungible::total_supply::FungibleTotalSupply`] to expose the +/// supply of shares. /// /// # Design Overview /// diff --git a/packages/tokens/src/vault/storage.rs b/packages/tokens/src/vault/storage.rs index ecc3d8332..12656f31a 100644 --- a/packages/tokens/src/vault/storage.rs +++ b/packages/tokens/src/vault/storage.rs @@ -2,7 +2,10 @@ use soroban_sdk::{contracttype, panic_with_error, token, Address, Env}; use stellar_contract_utils::math::{i128_fixed_point::mul_div_with_rounding, Rounding}; use crate::{ - fungible::{Base, ContractOverrides}, + fungible::{ + total_supply::{decrease_total_supply, increase_total_supply, TotalSupplyOverrides}, + Base, ContractOverrides, + }, vault::{emit_deposit, emit_withdraw, VaultTokenError, MAX_DECIMALS_OFFSET}, }; @@ -14,6 +17,10 @@ impl ContractOverrides for Vault { } } +// The vault's share math requires the total supply of shares, so the vault +// contract type is inherently supply-aware. +impl TotalSupplyOverrides for Vault {} + /// Storage keys for the data associated with the vault extension #[contracttype] pub enum VaultStorageKey { @@ -744,6 +751,7 @@ impl Vault { token_client.transfer_from(operator, from, &e.current_contract_address(), &assets); } + increase_total_supply(e, shares); Base::update(e, None, Some(receiver), shares); } @@ -787,6 +795,7 @@ impl Vault { Base::spend_allowance(e, owner, operator, shares); } Base::update(e, Some(owner), None, shares); + decrease_total_supply(e, shares); let token_client = token::Client::new(e, &Self::query_asset(e)); // `safeTransfer` mechanism is not present in the base module, (will be provided // as an extension) diff --git a/packages/tokens/src/vault/test.rs b/packages/tokens/src/vault/test.rs index e19751a84..7d08df03c 100644 --- a/packages/tokens/src/vault/test.rs +++ b/packages/tokens/src/vault/test.rs @@ -3,7 +3,7 @@ extern crate std; use soroban_sdk::{contract, contractimpl, testutils::Address as _, Address, Env}; use crate::{ - fungible::Base, + fungible::{total_supply::total_supply, Base}, vault::{Vault, MAX_DECIMALS_OFFSET}, }; @@ -219,7 +219,7 @@ fn deposit_functionality() { // Check results assert_eq!(Base::balance(&e, &user), shares_minted); - assert_eq!(Base::total_supply(&e), shares_minted); + assert_eq!(total_supply(&e), shares_minted); assert_eq!(Vault::total_assets(&e), deposit_amount); // For first deposit, shares should equal assets with offset @@ -249,7 +249,7 @@ fn mint_functionality() { Vault::mint(&e, shares_to_mint, user.clone(), user.clone(), user.clone()); assert_eq!(Base::balance(&e, &user), shares_to_mint); - assert_eq!(Base::total_supply(&e), shares_to_mint); + assert_eq!(total_supply(&e), shares_to_mint); assert_eq!(assets_deposited, required_assets); }); } @@ -312,7 +312,7 @@ fn redeem_functionality() { // Check results assert_eq!(Base::balance(&e, &user), shares_minted - shares_to_redeem); - assert_eq!(Base::total_supply(&e), shares_minted - shares_to_redeem); + assert_eq!(total_supply(&e), shares_minted - shares_to_redeem); // Should receive approximately half the original deposit let expected_assets = deposit_amount / 2; @@ -544,7 +544,7 @@ fn deposit_transfer_from() { // Check results assert_eq!(Base::balance(&e, &user), shares_minted); - assert_eq!(Base::total_supply(&e), shares_minted); + assert_eq!(total_supply(&e), shares_minted); assert_eq!(Vault::total_assets(&e), deposit_amount); // For first deposit, shares should equal assets with offset @@ -611,7 +611,7 @@ fn mint_transfer_from() { // Check results assert_eq!(Base::balance(&e, &user), shares_to_mint); - assert_eq!(Base::total_supply(&e), shares_to_mint); + assert_eq!(total_supply(&e), shares_to_mint); assert_eq!(assets_deposited, required_assets); }); }