From 715e84830d1827a4c34b125c0485b2cc157bc97a Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:53:06 +0200 Subject: [PATCH 1/5] Stellar: adapt fungible and non-fungible docs to latest stellar-contracts - Select the contract type with Compose<(...)> and document the valid combinations for fungible and non-fungible tokens - Document minting through Self::ContractType (fungible mint, NFT mint, mint_with_id and mint_range) instead of calling Base primitives - Add the opt-in Total Supply extension and rework Capped as a contract type exposing FungibleCapped - Route NFT royalty setters through the contract type - Fix the consecutive source link --- .../tokens/fungible/fungible.mdx | 189 ++++++++++++++++-- .../tokens/non-fungible/nft-consecutive.mdx | 26 ++- .../tokens/non-fungible/nft-enumerable.mdx | 25 ++- .../tokens/non-fungible/non-fungible.mdx | 169 ++++++++++++++-- 4 files changed, 356 insertions(+), 53 deletions(-) diff --git a/content/stellar-contracts/tokens/fungible/fungible.mdx b/content/stellar-contracts/tokens/fungible/fungible.mdx index b1c12891..4b70ece1 100644 --- a/content/stellar-contracts/tokens/fungible/fungible.mdx +++ b/content/stellar-contracts/tokens/fungible/fungible.mdx @@ -11,16 +11,67 @@ same value and properties, with balances and ownership tracked through Soroban s ## Overview The [fungible](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/fungible) -module provides three different Fungible Token variants that differ in how certain features like -token transfers and approvals are handled: +module provides the `FungibleToken` trait, which exposes the SEP-41 interface, together with a set of +**contract types** that define how the token behaves. All contract types share a common interface and +expose identical contract functions as entry-points. They differ in the logic they run around the core +operations, such as checking an allowlist before a transfer or updating the total supply on every mint +and burn. -The module provides several implementation options to suit different use cases: +The contract type is selected through the `ContractType` associated type of `FungibleToken`, by listing +what the token is made of with `Compose`: -1. **Base implementation** (`FungibleToken` with `Base` contract type): Suitable for most standard token use cases. -2. **AllowList extension** (`FungibleToken` with `AllowList` contract type): For tokens that require an allowlist mechanism to control who can transfer tokens. -3. **BlockList extension** (`FungibleToken` with `BlockList` contract type): For tokens that need to block specific addresses from transferring tokens. +```rust +#[contractimpl(contracttrait)] +impl FungibleToken for MyToken { + type ContractType = Compose<(AllowList, TotalSupply)>; +} +``` + +The following contract types are available: + +| Contract type | Behavior | Enables | +| --- | --- | --- | +| `Base` | Standard SEP-41 behavior. Suitable for most use cases. | - | +| `AllowList` | Only allowed accounts can send, receive, approve or burn tokens. | `FungibleAllowList` | +| `BlockList` | Blocked accounts cannot send, receive, approve or burn tokens. | `FungibleBlockList` | +| `TotalSupply` | Tracks the total supply on every mint and burn. | `FungibleTotalSupply` | +| `Capped` | Enforces a maximum total supply on every mint. | `FungibleCapped` | +| `FungibleVotes` | Moves voting units on every balance change. | `Votes` | +| [`RWA`](/stellar-contracts/tokens/rwa/rwa) | Enforces identity verification and compliance rules. | `RWAToken` | +| [`Vault`](/stellar-contracts/tokens/vault/vault) | Issues the token as shares of a tokenized vault. | `FungibleVault` | + +Contract types can be combined as follows: + +- `AllowList`, `BlockList` and `FungibleVotes` can be combined with each other, in pairs or all three + together, e.g. `Compose<(AllowList, BlockList)>` or `Compose<(AllowList, FungibleVotes)>`. When both + lists are used, an account has to be allowed and must not be blocked. +- `TotalSupply` can be added to `AllowList`, `BlockList` or both, e.g. `Compose<(BlockList, TotalSupply)>`. +- `Capped`, `RWA` and `Vault` rely on the tracked supply, so they require `TotalSupply` in the list, + e.g. `Compose<(RWA, TotalSupply)>`. `Capped` can be added to any supply-tracking list, e.g. + `Compose<(AllowList, Capped, TotalSupply)>` or `Compose<(Vault, Capped, TotalSupply)>`. +- `FungibleVotes` tracks voting units rather than the token supply, so it cannot be combined with + `TotalSupply` or `Capped`. + +The order of the list does not matter, and additive extensions that don't override any behavior, such as +`Burnable`, may be listed as well. A list that is not supported is rejected at compile time with an error +naming the valid combinations. Likewise, implementing an extension trait that the contract type does not +back (e.g. `FungibleTotalSupply` without `TotalSupply` in the list) does not compile. + +### Minting + +`mint` is not part of SEP-41, so it is not exposed by the `FungibleToken` trait: the contract defines its +own mint entry-point, with the access control it needs. That entry-point must mint through the selected +contract type, which applies the bookkeeping of every listed extension: + +```rust +::ContractType::mint(e, &to, amount); +``` -These implementations share core functionality and a common interface, exposing identical contract functions as entry-points. However, the extensions provide specialized behavior by overriding certain functions to implement their specific requirements. + +Do not call a specific primitive such as `Base::mint` directly. Depending on the contract type, doing so +would skip updating the total supply (causing a later burn to fail), enforcing the cap, or moving the +voting units. + ## Usage @@ -31,10 +82,10 @@ and players can transfer tokens between accounts. Here’s what a basic fungible token contract might look like: ```rust -use soroban_sdk::{contract, contractimpl, Address, Env, String}; -use stellar_tokens::fungible::{burnable::FungibleBurnable, Base, ContractOverrides, FungibleToken}; +use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_access::ownable::{self as ownable, Ownable}; use stellar_macros::only_owner; +use stellar_tokens::fungible::{burnable::FungibleBurnable, Base, Compose, FungibleToken}; #[contract] pub struct GameCurrency; @@ -56,18 +107,21 @@ impl GameCurrency { #[only_owner] pub fn mint_tokens(e: &Env, to: Address, amount: i128) { - // Mint tokens to the recipient - Base::mint(e, &to, amount); + // Mint tokens to the recipient through the contract type + ::ContractType::mint(e, &to, amount); } } #[contractimpl(contracttrait)] impl FungibleToken for GameCurrency { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contractimpl(contracttrait)] impl FungibleBurnable for GameCurrency {} + +#[contractimpl(contracttrait)] +impl Ownable for GameCurrency {} ``` ## Extensions @@ -81,30 +135,131 @@ The `FungibleBurnable` trait extends the `FungibleToken` trait to provide the ca To fully comply with the SEP-41 specification, a contract must implement both the `FungibleToken` and `FungibleBurnable` traits. +An empty implementation is enough: the burn logic is selected based on the contract type, e.g. burning +checks the allowlist with `AllowList` and decreases the total supply with `TotalSupply`. + +### - Total Supply +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/fungible/extensions/total_supply) + +Tracking the total supply is not required by SEP-41, so it is not part of the `FungibleToken` trait. +Keeping a global supply counter has a scalability cost: every mint and burn writes the same storage entry, +and transactions writing the same entry cannot be executed in parallel. Tokens that do not need the +counter are better off without it, which is why supply tracking is provided as an opt-in extension. + +To track the total supply, list `TotalSupply` in the contract type and implement the `FungibleTotalSupply` +trait, which exposes the `total_supply()` function. Building on the [example](#usage) above: + +```rust +#[contractimpl] +impl GameCurrency { + #[only_owner] + pub fn mint_tokens(e: &Env, to: Address, amount: i128) { + // Resolves to the `TotalSupply` mint, which increases the supply + ::ContractType::mint(e, &to, amount); + } +} + +#[contractimpl(contracttrait)] +impl FungibleToken for GameCurrency { + type ContractType = Compose<(TotalSupply,)>; +} + +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for GameCurrency {} +``` + +Burns performed through `FungibleBurnable` decrease the supply automatically. The supply is stored in its +own `persistent` entry, so mints and burns only conflict with each other and never with plain transfers. + ### - Capped [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/fungible/extensions/capped) -Unlike other extensions, the capped extension does not expose a separate trait. Instead, -it offers helper functions designed to assist in implementing the mint function, enforcing a supply cap. +The capped extension enforces a maximum total supply. It consists of the `Capped` contract type, whose mint +checks the cap before minting, and the `FungibleCapped` trait, which exposes the `cap()` function. + +Since the cap is compared against the tracked supply, `Capped` requires `TotalSupply` in the contract type, +and `FungibleCapped` requires `FungibleTotalSupply` to be implemented: + +```rust +use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; +use stellar_access::ownable::{self as ownable, Ownable}; +use stellar_macros::only_owner; +use stellar_tokens::fungible::{ + capped::{Capped, FungibleCapped}, + total_supply::{FungibleTotalSupply, TotalSupply}, + Base, Compose, FungibleToken, +}; + +#[contract] +pub struct CappedToken; + +#[contractimpl] +impl CappedToken { + pub fn __constructor(e: &Env, initial_owner: Address, cap: i128) { + Base::set_metadata(e, 7, String::from_str(e, "Capped Token"), String::from_str(e, "CAP")); + ownable::set_owner(e, &initial_owner); + Capped::set_cap(e, cap); + } + + #[only_owner] + pub fn mint(e: &Env, to: Address, amount: i128) { + // Resolves to the `Capped` mint: checks the cap, then increases the supply + ::ContractType::mint(e, &to, amount); + } +} + +#[contractimpl(contracttrait)] +impl FungibleToken for CappedToken { + type ContractType = Compose<(Capped, TotalSupply)>; +} + +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for CappedToken {} + +#[contractimpl(contracttrait)] +impl FungibleCapped for CappedToken {} + +#[contractimpl(contracttrait)] +impl Ownable for CappedToken {} +``` + +`Capped::set_cap` is best called once, in the constructor, so that holders can rely on a fixed cap. If the +cap needs to be adjusted later, it can also be called from an access-controlled function. ### - AllowList [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/fungible/extensions/allowlist) The `FungibleAllowList` trait extends the `FungibleToken` trait to provide an allowlist mechanism that can be managed by an authorized account. This extension ensures that only allowed accounts can -transfer/receive tokens or approve token transfers. +transfer/receive tokens or approve token transfers. It requires `AllowList` in the contract type, +e.g. `Compose<(AllowList,)>`. + +`allow_user` and `disallow_user` have no default implementation because they are privileged operations: +they take an `operator` parameter on which the desired access control should be enforced. See the +[example](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/fungible-allowlist) for a +complete integration. ### - BlockList [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/fungible/extensions/blocklist) The `FungibleBlockList` trait extends the `FungibleToken` trait to provide a blocklist mechanism that can be managed by an authorized account. This extension ensures that blocked accounts cannot transfer/receive -tokens, or approve token transfers. +tokens, or approve token transfers. It requires `BlockList` in the contract type, e.g. `Compose<(BlockList,)>`. + +`block_user` and `unblock_user` have no default implementation because they are privileged operations: +they take an `operator` parameter on which the desired access control should be enforced. See the +[example](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/fungible-blocklist) for a +complete integration. + +The AllowList and BlockList extensions can also be used together, with `Compose<(AllowList, BlockList)>`. ### - Votes [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/fungible/extensions/votes) -The `FungibleVotes` extension integrates with the governance [Votes](/stellar-contracts/governance/votes) module to track voting power based on token balances. It overrides `transfer`, `transfer_from`, `burn`, and `burn_from` to call `transfer_voting_units` after each balance change, enabling delegation-based governance. Token holders must call `delegate` (even to themselves) before their voting power is counted. +The `FungibleVotes` contract type integrates with the governance [Votes](/stellar-contracts/governance/votes) module to track voting power based on token balances. It overrides `transfer`, `transfer_from`, `burn`, `burn_from` and `mint` to call `transfer_voting_units` after each balance change, enabling delegation-based governance. Token holders must call `delegate` (even to themselves) before their voting power is counted. + +It is selected with `Compose<(FungibleVotes,)>` and can be combined with `AllowList`, `BlockList` or both. +The total amount of voting units is exposed through `get_total_supply()` from the `Votes` trait. ## Stellar Asset Contract (SAC) diff --git a/content/stellar-contracts/tokens/non-fungible/nft-consecutive.mdx b/content/stellar-contracts/tokens/non-fungible/nft-consecutive.mdx index d0c224e7..2dad1ddc 100644 --- a/content/stellar-contracts/tokens/non-fungible/nft-consecutive.mdx +++ b/content/stellar-contracts/tokens/non-fungible/nft-consecutive.mdx @@ -2,7 +2,7 @@ title: Non-Fungible Consecutive --- -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non-fungible/extensions/consecutive) +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible/extensions/consecutive) Consecutive extension for [Non-Fungible Token](/stellar-contracts/tokens/non-fungible/non-fungible) is useful for efficiently minting multiple tokens in a single transaction. This can significantly @@ -18,8 +18,9 @@ implement access control to restrict who can mint. ```rust use soroban_sdk::{contract, contractimpl, Address, Env, String}; use stellar_tokens::non_fungible::{ + burnable::NonFungibleBurnable, consecutive::{Consecutive, NonFungibleConsecutive}, - Base, ContractOverrides, NonFungibleToken, + Base, Compose, NonFungibleToken, }; #[contract] @@ -38,19 +39,28 @@ impl GameItem { pub fn award_items(e: &Env, to: Address, amount: u32) -> u32 { // access control might be needed - Consecutive::batch_mint(e, &to, amount) - } - - pub fn burn(e: &Env, from: Address, token_id: u32) { - Consecutive::burn(e, &from, token_id); + // Mints `amount` tokens with consecutive IDs and returns the last one + ::ContractType::mint_range(e, &to, amount) } } #[contractimpl(contracttrait)] impl NonFungibleToken for GameItem { - type ContractType = Consecutive; + type ContractType = Compose<(Consecutive,)>; } // no entry-point functions required, marker impl impl NonFungibleConsecutive for GameItem {} + +#[contractimpl(contracttrait)] +impl NonFungibleBurnable for GameItem {} ``` + + +Tokens must be minted only with `mint_range`, reached through the contract type. The `Consecutive` contract +type has no single-token mint, so `mint` and `mint_with_id` do not compile with it, and calling `Base::mint` +directly would break the ownership tracking. + + +A single call to `mint_range` can mint up to 32,000 tokens. The `Consecutive` contract type can be combined +with `NonFungibleVotes`, i.e. `Compose<(Consecutive, NonFungibleVotes)>`, but not with `Enumerable`. diff --git a/content/stellar-contracts/tokens/non-fungible/nft-enumerable.mdx b/content/stellar-contracts/tokens/non-fungible/nft-enumerable.mdx index 5f69ef33..bcb9a8da 100644 --- a/content/stellar-contracts/tokens/non-fungible/nft-enumerable.mdx +++ b/content/stellar-contracts/tokens/non-fungible/nft-enumerable.mdx @@ -17,8 +17,9 @@ can call `award_item` and we might want to implement access control to restrict ```rust use soroban_sdk::{contract, contractimpl, Address, Env, String}; use stellar_tokens::non_fungible::{ + burnable::NonFungibleBurnable, enumerable::{Enumerable, NonFungibleEnumerable}, - Base, ContractOverrides, NonFungibleToken, + Base, Compose, NonFungibleToken, }; #[contract] @@ -37,21 +38,23 @@ impl GameItem { pub fn award_item(e: &Env, to: Address) -> u32 { // access control might be needed - Enumerable::sequential_mint(e, &to) - } - - pub fn burn(e: &Env, from: Address, token_id: u32) { - Enumerable::sequential_burn(e, &from, token_id); + // Resolves to the `Enumerable` mint, which records the token in the + // enumeration lists + ::ContractType::mint(e, &to) } } #[contractimpl(contracttrait)] impl NonFungibleToken for GameItem { - type ContractType = Enumerable; + type ContractType = Compose<(Enumerable,)>; } #[contractimpl(contracttrait)] impl NonFungibleEnumerable for GameItem {} + +// Burning removes the token from the enumeration lists +#[contractimpl(contracttrait)] +impl NonFungibleBurnable for GameItem {} ``` The extension exposes additionally the following entry-point functions: @@ -61,3 +64,11 @@ fn total_supply(e: &Env) -> u32; fn get_owner_token_id(e: &Env, owner: Address, index: u32) -> u32; fn get_token_id(e: &Env, index: u32) -> u32; ``` + + +Tokens must be minted through the contract type, with `mint` or `mint_with_id`. Calling `Base::mint` directly +would leave the token out of the enumeration lists. + + +The `Enumerable` contract type can be combined with `NonFungibleVotes`, i.e. `Compose<(Enumerable, NonFungibleVotes)>`, +but not with `Consecutive`. diff --git a/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx b/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx index ff6fdceb..8302e189 100644 --- a/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx +++ b/content/stellar-contracts/tokens/non-fungible/non-fungible.mdx @@ -13,17 +13,66 @@ represents something distinct, with ownership tracked through Soroban smart cont ## Overview The [non-fungible](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible) module -provides three different NFT variants that differ in how certain features like ownership tracking, -token creation and destruction are handled: +provides the `NonFungibleToken` trait together with a set of **contract types** that differ in how certain +features like ownership tracking, token creation and destruction are handled. All contract types share a +common interface and expose identical contract functions as entry-points. -1. **Base**: Contract variant that implements the base logic for the NonFungibleToken interface. Suitable for most use cases. -2. **Consecutive**: Contract variant for optimized minting of batches of tokens. Builds on top of the base variant, and overrides the necessary functions from the `Base` variant. -3. **Enumerable**: Contract variant that allows enumerating the tokens on-chain. Builds on top of the base variant, and overrides the necessary functions from the `Base` variant. +The contract type is selected through the `ContractType` associated type of `NonFungibleToken`, by listing +what the token is made of with `Compose`: -These three variants share core functionality and a common interface, exposing identical contract functions as -entry-points. However, composing custom flows must be handled with extra caution. That is required because of the -incompatible nature between the business logic of the different NFT variants or the need to wrap the base -functionality with additional logic. +```rust +#[contractimpl(contracttrait)] +impl NonFungibleToken for MyToken { + type ContractType = Compose<(Enumerable,)>; +} +``` + +The following contract types are available: + +| Contract type | Behavior | Enables | +| --- | --- | --- | +| `Base` | Implements the base logic for the `NonFungibleToken` interface. Suitable for most use cases. | - | +| `Enumerable` | Allows enumerating the tokens on-chain. | `NonFungibleEnumerable` | +| `Consecutive` | Optimized for minting batches of tokens with consecutive IDs. | `NonFungibleConsecutive` | +| `NonFungibleVotes` | Moves one voting unit per token on every ownership change. | `Votes` | + +`NonFungibleVotes` can be combined with either `Enumerable` or `Consecutive`, e.g. +`Compose<(Enumerable, NonFungibleVotes)>`. `Enumerable` and `Consecutive` are mutually exclusive, because +each of them has its own business logic for creating and destroying tokens. + +The order of the list does not matter, and additive extensions that don't override any behavior, such as +`Burnable` and `Royalties`, may be listed as well. A list that is not supported is rejected at compile time +with an error naming the valid combinations. Likewise, implementing an extension trait that the contract +type does not back (e.g. `NonFungibleEnumerable` without `Enumerable` in the list) does not compile. + +### Minting + +Minting is not part of the `NonFungibleToken` trait: the contract defines its own mint entry-point, with +the access control it needs. That entry-point must mint through the selected contract type, which +applies the bookkeeping of every listed extension. Depending on how the contract type stores ownership, +it provides different mint functions: + +| Function | Description | Contract types | +| --- | --- | --- | +| `mint(e, &to) -> u32` | Mints the next ID from the sequential counter and returns it. | `Base`, `Enumerable`, `NonFungibleVotes` | +| `mint_with_id(e, &to, token_id)` | Mints a token with an ID chosen by the caller. | `Base`, `Enumerable`, `NonFungibleVotes` | +| `mint_range(e, &to, amount) -> u32` | Mints `amount` tokens with consecutive IDs and returns the last one. | `Consecutive` | + +For example: + +```rust +let token_id = ::ContractType::mint(e, &to); +``` + +Calling a mint function that the contract type does not support results in a compile error. + + +Do not call a specific primitive such as `Base::mint` directly. On an `Enumerable` token, for example, it +would compile but leave the token out of the enumeration. + +`mint` does not check whether the next ID from the counter is already in use. If both `mint` and +`mint_with_id` are used in the same contract, the uniqueness of token IDs must be ensured. + ## Usage @@ -38,7 +87,7 @@ Here’s what a contract for tokenized items might look like: use soroban_sdk::{contract, contractimpl, Address, Env, String}; use stellar_tokens::non_fungible::{ burnable::NonFungibleBurnable, - Base, ContractOverrides, NonFungibleToken, + Base, Compose, NonFungibleToken, }; #[contract] @@ -57,13 +106,13 @@ impl GameItem { pub fn award_item(e: &Env, to: Address) -> u32 { // access control might be needed - Base::sequential_mint(e, &to) + ::ContractType::mint(e, &to) } } #[contractimpl(contracttrait)] impl NonFungibleToken for GameItem { - type ContractType = Base; + type ContractType = Compose<(Base,)>; } #[contractimpl(contracttrait)] @@ -78,24 +127,22 @@ The following optional extensions are provided to enhance capabilities: [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible/extensions/burnable) The `NonFungibleBurnable` trait extends the `NonFungibleToken` trait to provide the capability to burn tokens. +An empty implementation is enough: the burn logic is selected based on the contract type, and is compatible +with all of them. ### - Consecutive [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible/extensions/consecutive) -The `NonFungibleConsecutive` extension is optimized for batch minting of tokens with consecutive IDs. This approach drastically reduces storage writes during minting by storing ownership only at boundaries and inferring ownership for other tokens. See [Non-Fungible Consecutive](/stellar-contracts/tokens/non-fungible/nft-consecutive) for detailed documentation. +The `NonFungibleConsecutive` extension is optimized for batch minting of tokens with consecutive IDs. This approach drastically reduces storage writes during minting by storing ownership only at boundaries and inferring ownership for other tokens. -This extension is build around the contract variant `Consecutive`. Here is an example usage: - -* [Non-Fungible Consecutive](/stellar-contracts/tokens/non-fungible/nft-consecutive) +This extension is built around the `Consecutive` contract type, selected with `Compose<(Consecutive,)>`. See [Non-Fungible Consecutive](/stellar-contracts/tokens/non-fungible/nft-consecutive) for detailed documentation and an example usage. ### - Enumerable [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible/extensions/enumerable) -The `NonFungibleEnumerable` extension enables on-chain enumeration of tokens owned by an address. See [Non-Fungible Enumerable](/stellar-contracts/tokens/non-fungible/nft-enumerable) for detailed documentation. - -This extension is build around the contract variant `Enumerable`. Here is an example usage: +The `NonFungibleEnumerable` extension enables on-chain enumeration of all the tokens in the contract, as well as the tokens owned by each address. -* [Non-Fungible Enumerable](/stellar-contracts/tokens/non-fungible/nft-enumerable) +This extension is built around the `Enumerable` contract type, selected with `Compose<(Enumerable,)>`. See [Non-Fungible Enumerable](/stellar-contracts/tokens/non-fungible/nft-enumerable) for detailed documentation and an example usage. ### - Royalties [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible/extensions/royalties) @@ -104,7 +151,87 @@ The `NonFungibleRoyalties` trait extends the `NonFungibleToken` trait to provide Note: The royalties extension allows both collection-wide default royalties and per-token royalty settings. +The setters are privileged operations, so they have no default implementation: they take an `operator` +parameter on which the desired access control should be enforced. Since setting or removing a per-token +royalty first checks that the token exists, and the way ownership is stored depends on the contract type, +those functions are called on the contract type: + +```rust +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String, Symbol, Vec}; +use stellar_access::access_control::{self as access_control, AccessControl}; +use stellar_macros::{only_admin, only_role}; +use stellar_tokens::non_fungible::{ + royalties::{NonFungibleRoyalties, Royalties, RoyaltySupport}, + Base, Compose, NonFungibleToken, +}; + +#[contract] +pub struct GameItem; + +#[contractimpl] +impl GameItem { + pub fn __constructor(e: &Env, admin: Address, manager: Address) { + Base::set_metadata( + e, + String::from_str(e, "www.mygame.com"), + String::from_str(e, "My Game Items Collection"), + String::from_str(e, "MGMC"), + ); + access_control::set_admin(e, &admin); + + // Grant the "manager" role, allowed to manage royalties + access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin); + + // Set a default royalty of 5% for the entire collection + Base::set_default_royalty(e, &admin, 500); + } + + #[only_admin] + pub fn award_item(e: &Env, to: Address) -> u32 { + ::ContractType::mint(e, &to) + } +} + +#[contractimpl(contracttrait)] +impl NonFungibleToken for GameItem { + type ContractType = Compose<(Base, Royalties)>; +} + +#[contractimpl(contracttrait)] +impl NonFungibleRoyalties for GameItem { + #[only_role(operator, "manager")] + fn set_default_royalty(e: &Env, receiver: Address, basis_points: u32, operator: Address) { + Base::set_default_royalty(e, &receiver, basis_points); + } + + #[only_role(operator, "manager")] + fn set_token_royalty( + e: &Env, + token_id: u32, + receiver: Address, + basis_points: u32, + operator: Address, + ) { + // Routed through the contract type, so that the token existence + // check matches its ownership model + Self::ContractType::set_token_royalty(e, token_id, &receiver, basis_points); + } + + #[only_role(operator, "manager")] + fn remove_token_royalty(e: &Env, token_id: u32, operator: Address) { + Self::ContractType::remove_token_royalty(e, token_id); + } +} + +#[contractimpl(contracttrait)] +impl AccessControl for GameItem {} +``` + +The royalties extension can be used with every contract type, including `Consecutive`. + ### - Votes [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/non_fungible/extensions/votes) -The `NonFungibleVotes` extension integrates with the governance [Votes](/stellar-contracts/governance/votes) module. Each NFT represents one voting unit. It overrides `transfer`, `transfer_from`, `burn`, and `burn_from` to call `transfer_voting_units` after each balance change. Token holders must call `delegate` (even to themselves) before their voting power is counted. +The `NonFungibleVotes` contract type integrates with the governance [Votes](/stellar-contracts/governance/votes) module. Each NFT represents one voting unit. It overrides `transfer`, `transfer_from`, `burn`, `burn_from` and the mint functions to call `transfer_voting_units` after each balance change. Token holders must call `delegate` (even to themselves) before their voting power is counted. + +It is selected with `Compose<(NonFungibleVotes,)>`, and can be combined with `Enumerable` or `Consecutive`, e.g. `Compose<(Consecutive, NonFungibleVotes)>`. From 27e4266843ed7bc6e40f24d5361f62b9728aeb1a Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:07:53 +0200 Subject: [PATCH 2/5] Stellar: adapt votes, access control, RWA and vault docs to latest stellar-contracts - Votes: select FungibleVotes through Compose and note that minting must go through the contract type. - Access Control: mint through ContractType::mint_with_id and make both examples complete contracts. - RWA: use Compose<(RWA, TotalSupply)> with FungibleTotalSupply, implement Pausable and the required RWAToken functions, mint through the contract type, drop the operator argument from the RWA helpers, describe the snapshot-based compliance hooks (no more can_transfer/can_create), and fix the moved identity verification source links. - Vault: use Compose<(Vault, TotalSupply)> with FungibleTotalSupply, rely on the default FungibleVault functions (which already require operator auth), and mention the capped variant. --- .../access/access-control.mdx | 25 +- .../stellar-contracts/governance/votes.mdx | 9 +- content/stellar-contracts/tokens/rwa/rwa.mdx | 277 ++++++++++++++---- .../stellar-contracts/tokens/vault/vault.mdx | 53 ++-- 4 files changed, 260 insertions(+), 104 deletions(-) diff --git a/content/stellar-contracts/access/access-control.mdx b/content/stellar-contracts/access/access-control.mdx index 9cc40a4d..a644fed1 100644 --- a/content/stellar-contracts/access/access-control.mdx +++ b/content/stellar-contracts/access/access-control.mdx @@ -36,7 +36,7 @@ This allows for creating complex organizational structures with chains of comman Here's how to establish and use role hierarchies in practice: ```rust -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol}; +use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, Symbol, Vec}; use stellar_access::access_control::{self as access_control, AccessControl}; const MANAGER_ROLE: Symbol = symbol_short!("manager"); @@ -71,6 +71,9 @@ impl MyContract { access_control::revoke_role_no_auth(e, &guardian1, &GUARDIAN_ROLE, &manager); } } + +#[contractimpl(contracttrait)] +impl AccessControl for MyContract {} ``` In this example: @@ -162,12 +165,13 @@ pub fn multi_role_check(e: &Env, caller: Address) { ## Usage Example -Here’s a simple example of using the Access Control module: +Here’s a simple example of using the Access Control module in a non-fungible token contract: ```rust -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env}; +use soroban_sdk::{contract, contractimpl, symbol_short, vec, Address, Env, String, Symbol, Vec}; use stellar_access::access_control::{self as access_control, AccessControl}; -use stellar_macros::{has_role, only_admin}; +use stellar_macros::{has_any_role, only_admin, only_any_role, only_role}; +use stellar_tokens::non_fungible::{Base, Compose, NonFungibleToken}; #[contract] pub struct MyContract; @@ -190,11 +194,10 @@ impl MyContract { // we want `require_auth()` provided by the macro, since there is no - // `require_auth()` in `Base::mint`. + // `require_auth()` in the contract type's `mint_with_id`. #[only_role(caller, "minter")] pub fn mint(e: &Env, to: Address, token_id: u32, caller: Address) { - Base::mint(e, &to, token_id) - + ::ContractType::mint_with_id(e, &to, token_id) } @@ -213,6 +216,14 @@ impl MyContract { String::from_str(e, "multi_role_auth_action_success") } } + +#[contractimpl(contracttrait)] +impl NonFungibleToken for MyContract { + type ContractType = Compose<(Base,)>; +} + +#[contractimpl(contracttrait)] +impl AccessControl for MyContract {} ``` ## Benefits and Trade-offs diff --git a/content/stellar-contracts/governance/votes.mdx b/content/stellar-contracts/governance/votes.mdx index 765b692d..5b35c22d 100644 --- a/content/stellar-contracts/governance/votes.mdx +++ b/content/stellar-contracts/governance/votes.mdx @@ -72,19 +72,22 @@ If you are using the Fungible or Non-Fungible token from this library, the **Vot ```rust use stellar_governance::votes::Votes; -use stellar_tokens::fungible::{votes::FungibleVotes, FungibleToken}; +use stellar_tokens::fungible::{votes::FungibleVotes, Compose, FungibleToken}; -// 1. Set ContractType to FungibleVotes — this hooks into all balance +// 1. Select the FungibleVotes contract type — this hooks into all balance // changes (transfers, mints, burns) and updates voting units automatically. #[contractimpl(contracttrait)] impl FungibleToken for MyToken { - type ContractType = FungibleVotes; + type ContractType = Compose<(FungibleVotes,)>; } // 2. Implement the Votes trait to expose voting queries to the Governor. #[contractimpl(contracttrait)] impl Votes for MyToken {} ``` + +Mint through the contract type, e.g. `::ContractType::mint(e, &to, amount)`, so that the +minted voting units are tracked. Calling `Base::mint` directly skips the voting-unit update. ## The Votes Trait diff --git a/content/stellar-contracts/tokens/rwa/rwa.mdx b/content/stellar-contracts/tokens/rwa/rwa.mdx index e8e5ea94..adeb1cc1 100644 --- a/content/stellar-contracts/tokens/rwa/rwa.mdx +++ b/content/stellar-contracts/tokens/rwa/rwa.mdx @@ -55,7 +55,7 @@ graph TB IVS[Custom Identity Suite] end - RWA -->|"can_transfer()
can_create()
transferred()
created()
destroyed()"| COMP + RWA -->|"transferred()
created()
destroyed()"| COMP RWA -->|"verify_identity()
recovery_target()"| IDV RWA -.-> DM @@ -68,19 +68,26 @@ graph TB ### Core Components -1. **RWA Token Contract**: The main token contract implementing the `RWAToken` trait, which extends both -`FungibleToken` and `Pausable` traits. +1. **RWA Token Contract**: The main token contract implementing the `RWAToken` trait, which extends the +`FungibleToken`, `FungibleTotalSupply` and `Pausable` traits. 2. **Identity Verifier**: A separate contract responsible for verifying user identities. -The RWA token expects the following function to be available: `fn verify_identity(e: &Env, account: &Address);`. +The RWA token expects the following functions to be available: + - `fn verify_identity(e: &Env, account: &Address);` + - `fn recovery_target(e: &Env, old_account: &Address) -> Option
;` 3. **Compliance Contract**: A separate contract that validates transfers, minting, and burning operations. The RWA token expects the following functions to be available: - - `fn can_transfer(e: &Env, from: Address, to: Address, amount: i128, token: Address) -> bool;` - - `fn can_create(e: &Env, to: Address, amount: i128, token: Address) -> bool;` - - `fn created(e: &Env, to: Address, amount: i128, token: Address);` - - `fn destroyed(e: &Env, from: Address, amount: i128, token: Address);` - - `fn transferred(e: &Env, from: Address, to: Address, amount: i128, token: Address);` + - `fn transferred(e: &Env, from: AccountSnapshot, to: AccountSnapshot, amount: i128, kind: TransferKind, token: Address);` + - `fn created(e: &Env, to: AccountSnapshot, amount: i128, token: Address);` + - `fn destroyed(e: &Env, from: AccountSnapshot, amount: i128, token: Address);` + + These hooks are called after the operation's state changes, within the same transaction. The compliance contract + rejects an operation by panicking, which reverts it entirely. Each account is passed as an `AccountSnapshot` holding + its `balance` and `frozen` amount before the operation, so that compliance never needs to call back into the token. + `TransferKind` tells whether the transfer is a `Standard` one, a `Delegated` one (via `transfer_from`), or a + privileged `Forced` or `Recovery` one, so that compliance rules can exempt privileged operations while still + keeping their records up to date. This loose coupling between the RWA token and the modules allows the following: - Share identity verifier and compliance contracts across multiple RWA tokens @@ -102,13 +109,26 @@ The module provides default implementations for common use cases: We'll create a regulated security token for tokenized real estate shares. The token requires KYC verification, implements transfer restrictions, and provides administrative controls for compliance. +The RWA token uses the `RWA` contract type, which tracks the total supply, so it is selected with +`Compose<(RWA, TotalSupply)>` and the contract implements `FungibleTotalSupply`. The privileged functions of +`RWAToken` (minting, burning, freezing, forced transfers, recovery, and setting the compliance and identity verifier +contracts) have no default implementation: they take an `operator` argument, and each contract must implement them +and enforce its own access control on it. Similarly, `pause` and `unpause` from `Pausable` must be implemented. + Here's what a basic RWA token contract might look like (only the base token contract, not the modules): ```rust -use soroban_sdk::{contract, contractimpl, symbol_short, Address, Env, String}; +use soroban_sdk::{ + contract, contractimpl, symbol_short, Address, Env, MuxedAddress, String, Symbol, Vec, +}; 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, FungibleToken}, + fungible::{ + total_supply::{FungibleTotalSupply, TotalSupply}, + Base, Compose, FungibleToken, + }, rwa::{RWAToken, RWA}, }; @@ -144,25 +164,148 @@ impl RealEstateToken { access_control::grant_role_no_auth(e, &manager, &symbol_short!("manager"), &admin); // Mint initial supply to the admin (must be a verified identity) - RWA::mint(e, &admin, initial_supply); + ::ContractType::mint(e, &admin, initial_supply); } } -// Implement the FungibleToken trait with RWA contract type +// Implement the FungibleToken trait with the RWA contract type #[contractimpl(contracttrait)] impl FungibleToken for RealEstateToken { - type ContractType = RWA; + type ContractType = Compose<(RWA, TotalSupply)>; } -// Implement the RWAToken trait for regulatory features #[contractimpl(contracttrait)] -impl RWAToken for RealEstateToken {} +impl FungibleTotalSupply for RealEstateToken {} + +#[contractimpl(contracttrait)] +impl Pausable for RealEstateToken { + #[only_admin] + fn pause(e: &Env, _caller: Address) { + pausable::pause(e); + } + + #[only_admin] + fn unpause(e: &Env, _caller: Address) { + pausable::unpause(e); + } +} + +// Implement the RWAToken trait for regulatory features, restricting the +// privileged functions to the "manager" role +#[contractimpl(contracttrait)] +impl RWAToken for RealEstateToken { + #[only_role(operator, "manager")] + fn mint(e: &Env, to: Address, amount: i128, operator: Address) { + ::ContractType::mint(e, &to, amount); + } + + #[only_role(operator, "manager")] + fn batch_mint(e: &Env, to_list: Vec
, amounts: Vec, operator: Address) { + ::ContractType::batch_mint(e, &to_list, &amounts); + } + + #[only_role(operator, "manager")] + fn burn(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::burn(e, &user_address, amount); + } + + #[only_role(operator, "manager")] + fn batch_burn(e: &Env, user_addresses: Vec
, amounts: Vec, operator: Address) { + RWA::batch_burn(e, &user_addresses, &amounts); + } + + #[only_role(operator, "manager")] + fn forced_transfer(e: &Env, from: Address, to: Address, amount: i128, operator: Address) { + RWA::forced_transfer(e, &from, &to, amount); + } + + #[only_role(operator, "manager")] + fn batch_forced_transfer( + e: &Env, + from_list: Vec
, + to_list: Vec
, + amounts: Vec, + operator: Address, + ) { + RWA::batch_forced_transfer(e, &from_list, &to_list, &amounts); + } + + #[only_role(operator, "manager")] + fn recover_balance( + e: &Env, + old_account: Address, + new_account: Address, + operator: Address, + ) -> bool { + RWA::recover_balance(e, &old_account, &new_account) + } + + #[only_role(operator, "manager")] + fn set_address_frozen(e: &Env, user_address: Address, freeze: bool, operator: Address) { + RWA::set_address_frozen(e, &user_address, freeze); + } + + #[only_role(operator, "manager")] + fn batch_set_address_frozen( + e: &Env, + user_addresses: Vec
, + freeze_list: Vec, + operator: Address, + ) { + RWA::batch_set_address_frozen(e, &user_addresses, &freeze_list); + } + + #[only_role(operator, "manager")] + fn freeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::freeze_partial_tokens(e, &user_address, amount); + } + + #[only_role(operator, "manager")] + fn batch_freeze_partial_tokens( + e: &Env, + user_addresses: Vec
, + amounts: Vec, + operator: Address, + ) { + RWA::batch_freeze_partial_tokens(e, &user_addresses, &amounts); + } + + #[only_role(operator, "manager")] + fn unfreeze_partial_tokens(e: &Env, user_address: Address, amount: i128, operator: Address) { + RWA::unfreeze_partial_tokens(e, &user_address, amount); + } + + #[only_role(operator, "manager")] + fn batch_unfreeze_partial_tokens( + e: &Env, + user_addresses: Vec
, + amounts: Vec, + operator: Address, + ) { + RWA::batch_unfreeze_partial_tokens(e, &user_addresses, &amounts); + } + + #[only_role(operator, "manager")] + fn set_compliance(e: &Env, compliance: Address, operator: Address) { + RWA::set_compliance(e, &compliance); + } + + #[only_role(operator, "manager")] + fn set_identity_verifier(e: &Env, identity_verifier: Address, operator: Address) { + RWA::set_identity_verifier(e, &identity_verifier); + } +} // Implement AccessControl for role-based permissions #[contractimpl(contracttrait)] impl AccessControl for RealEstateToken {} ``` +Tokens are minted through the contract type, `::ContractType::mint`. To cap the supply, +add `Capped` to the list, i.e. `Compose<(RWA, Capped, TotalSupply)>`, set the cap with `Capped::set_cap` in the +constructor and implement `FungibleCapped`. Minting through the contract type then enforces the cap, whereas +calling `RWA::mint` directly would bypass it. + ## Key Features ### Identity Verification @@ -178,15 +321,15 @@ identity verifier contract to ensure the recipient has a valid identity with the ```rust // Example: Minting tokens // Internally calls: identity_verifier.verify_identity(&recipient) -// Then calls: compliance.can_create(&recipient, &amount) -// Finally calls: compliance.created(&recipient, &amount) after minting -RWA::mint(e, &recipient, amount); +// Then mints and calls: compliance.created(recipient_snapshot, amount, token), +// which reverts the mint if a compliance module rejects it +::ContractType::mint(e, &recipient, amount); // Example: Transferring tokens // Internally calls: identity_verifier.verify_identity(&from) // Then calls: identity_verifier.verify_identity(&to) -// Then calls: compliance.can_transfer(&from, &to, &amount) -// Finally calls: compliance.transferred(&from, &to, &amount) after transfer +// Then transfers and calls: compliance.transferred(from_snapshot, to_snapshot, amount, TransferKind::Standard, token), +// which reverts the transfer if a compliance module rejects it RWA::transfer(e, &from, &to, amount); // Example: Wallet recovery for lost/old accounts @@ -194,7 +337,7 @@ RWA::transfer(e, &from, &to, amount); // Then calls: identity_verifier.recovery_target(&old_account) to verify the new account // is the authorized recovery target for the old account // Transfers all tokens and preserves frozen status -RWA::recover_balance(e, &old_account, &new_account, &operator); +RWA::recover_balance(e, &old_account, &new_account); ``` **Identity Verification Flow:** @@ -207,13 +350,13 @@ RWA::recover_balance(e, &old_account, &new_account, &operator); Compliance Validation is handled on a separate contract as a module. -The compliance framework allows you to implement custom transfer and minting rules through a modular hook system: +The compliance framework allows you to implement custom transfer and minting rules through a modular hook system. +Each hook is called after the token operation within the same transaction, so a hook can both validate the operation, +rejecting it by panicking, and update its own state: -- **CanTransfer**: Validates if a transfer should be allowed -- **CanCreate**: Validates if a mint operation should be allowed -- **Transferred**: Updates state after a successful transfer -- **Created**: Updates state after a successful mint -- **Destroyed**: Updates state after a successful burn +- **Transferred**: Called after a transfer +- **Created**: Called after a mint +- **Destroyed**: Called after a burn ### Freezing Mechanisms @@ -225,16 +368,20 @@ RWA tokens support two types of freezing: 2. **Partial token freezing**: Freeze a specific amount of tokens for an address ```rust -// Freeze an entire address -RWA::set_address_frozen(e, &user_address, bool, &operator); +// Freeze an entire address (pass `false` to unfreeze it) +RWA::set_address_frozen(e, &user_address, true); // Freeze a specific amount of tokens -RWA::freeze_partial_tokens(e, &user_address, amount, &operator); +RWA::freeze_partial_tokens(e, &user_address, amount); // Unfreeze tokens -RWA::unfreeze_partial_tokens(e, &user_address, amount, &operator); +RWA::unfreeze_partial_tokens(e, &user_address, amount); ``` +These `RWA` functions, like the ones for recovery and forced transfers below, do not perform any authorization +checks. The corresponding `RWAToken` functions take an `operator` argument on which the contract must enforce access +control, as in the [Usage](#usage) example. + ### Recovery System The recovery system allows authorized operators to transfer tokens from a lost/old account to a @@ -245,7 +392,7 @@ on a separate contract as a module (may be a part of the Identity Registry contr ```rust // Recover tokens from old account to new account -RWA::recover_balance(e, &old_account, &new_account, &operator); +RWA::recover_balance(e, &old_account, &new_account); ``` ### Forced Transfers @@ -256,7 +403,7 @@ Authorized operators can force transfers between verified wallets for regulatory ```rust // Force a transfer for regulatory reasons -RWA::forced_transfer(e, &from, &to, amount, &operator); +RWA::forced_transfer(e, &from, &to, amount); ``` ## Modules @@ -269,26 +416,26 @@ The RWA package includes several supporting modules that work together to provid This is a mandatory module, since RWA token contract expects the compliance checks and hooks to be available. Provides a modular framework for implementing custom compliance rules through a hook-based architecture where multiple -[compliance modules](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/tokens/src/rwa/compliance/mod.rs#L327) -can be registered to the [Compliance Contract](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/tokens/src/rwa/compliance/mod.rs#L67). +[compliance modules](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/tokens/src/rwa/compliance/modules/mod.rs) +can be registered to the [Compliance Contract](https://github.com/OpenZeppelin/stellar-contracts/blob/main/packages/tokens/src/rwa/compliance/mod.rs). **Compliance Validation Flow:** ```mermaid graph TD - A[RWA Token Transfer/Mint] --> B[Compliance Contract] - B --> C{CanTransfer
or
CanCreate} + A[RWA Token Transfer/Mint/Burn] --> B[Compliance Contract] + B --> C{Transferred
or
Created
or
Destroyed} C --> D[Registered Modules 1..N] D --> E[Transfer Limit Module] D --> F[Country Restriction Module] D --> G[Investor Count Module] D --> H[Custom Module X] - E --> I{All modules
return true?} + E --> I{Any module
panics?} F --> I G --> I H --> I - I -->|Yes| J[Operation Proceeds] - I -->|No| K[Operation Reverts] + I -->|No| J[Operation Proceeds] + I -->|Yes| K[Operation Reverts] ``` The compliance contract is designed to be shared across multiple RWA tokens, with each hook function accepting a @@ -301,25 +448,33 @@ Individual compliance modules implement the `ComplianceModule` trait, which defi ```rust pub trait ComplianceModule { - fn on_transfer(e: &Env, token: Address, from: Address, to: Address, amount: i128); - fn on_created(e: &Env, token: Address, to: Address, amount: i128); - fn on_destroyed(e: &Env, token: Address, from: Address, amount: i128); - fn can_transfer(e: &Env, token: Address, from: Address, to: Address, amount: i128) -> bool; - fn can_create(e: &Env, token: Address, to: Address, amount: i128) -> bool; + fn on_transfer( + e: &Env, + from: AccountSnapshot, + to: AccountSnapshot, + amount: i128, + kind: TransferKind, + token: Address, + ); + fn on_created(e: &Env, to: AccountSnapshot, amount: i128, token: Address); + fn on_destroyed(e: &Env, from: AccountSnapshot, amount: i128, token: Address); fn name(e: &Env) -> String; - fn get_compliance_address(e: &Env) -> Address; - fn set_compliance_address(e: &Env, compliance: Address); + fn get_compliance_address(e: &Env, token: Address) -> Address; + fn set_compliance_address(e: &Env, token: Address, compliance: Address, operator: Address); } ``` -Modules can be registered for specific hooks via the compliance contract. The `ComplianceHook` enum defines which hooks a module handles: `Transferred`, `Created`, `Destroyed`, `CanTransfer`, `CanCreate`. +Modules can be registered for specific hooks via the compliance contract. The `ComplianceHook` enum defines which hooks a module handles: `Transferred`, `Created`, `Destroyed`. +A module rejects an operation by panicking from the hook. Policy modules can use the `TransferKind` passed to +`on_transfer` to exempt privileged `Forced` and `Recovery` transfers, while bookkeeping modules update their state for +every kind of transfer. -State-modifying hooks (`on_transfer`, `on_created`, `on_destroyed`) should restrict their caller to the compliance contract address. Read-only hooks (`can_transfer`, `can_create`) can be exposed more broadly. +Hook arguments, including `token`, can be forged, since any contract can call a module's hooks directly. Hooks that modify state should authenticate their caller, the compliance contract bound to `token`, e.g. with `Self::get_compliance_address(e, token).require_auth()`. Hooks that only validate and modify no state need no authentication. ### - Identity Verifier -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verifier) +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verification) This is a mandatory module, since RWA token contract expects `verify_identity(e: &Env, address: Address)` function to be available. @@ -357,7 +512,7 @@ graph TB ``` ### - Claim Topics and Issuers -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/claim_topics_and_issuers) +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verification/claim_topics_and_issuers) This module is an implementation detail. It is provided as the suggested implementation for the **Claim-based** approach. @@ -415,7 +570,7 @@ Each claim contains: ### - Identity Registry Storage -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_registry_storage) +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verification/identity_registry_storage) This module is an implementation detail. It is provided as the suggested implementation for the **Claim-based** approach. @@ -427,7 +582,7 @@ Stores identity information for verified investors, including: - Recovery account mappings for lost wallet scenarios ### - Identity Claims -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_claims) +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verification/identity_claims) This module is an implementation detail. It is provided as the suggested implementation for the **Claim-based** approach. @@ -447,7 +602,7 @@ Claims are issued by trusted authorities and contain: - Issuer information ### - Claim Issuer -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/claim_issuer) +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verification/claim_issuer) This module is an implementation detail. It is provided as the suggested implementation for the **Claim-based** approach. @@ -520,7 +675,7 @@ sequenceDiagram Note over Token,ComplianceContract: 1. Eligibility InvestorA->>Token: transfer(B, amount) - Token->>IdentityVerifier: is_verified(B) + Token->>IdentityVerifier: verify_identity(B) IdentityVerifier->>IdentityRegistryStorage: Fetch identity: stored_identity(B) IdentityVerifier->>ClaimTopicsAndIssuers: Fetch required claims and issuers: get_claim_topics_and_issuers() @@ -535,14 +690,12 @@ sequenceDiagram IdentityVerifier-->>Token: B eligible - Note over Token,ComplianceContract: 2. Compliance - - Token->>ComplianceContract: can_transfer(A, B, amount) - ComplianceContract->>ComplianceContract: checks all compliance modules - ComplianceContract-->>Token: transfer compliant + Note over Token,ComplianceContract: 2. Transfer and compliance Token->>Token: process transfer - Token->>InvestorA: notification hook: transferred() + Token->>ComplianceContract: transferred(A snapshot, B snapshot, amount, Standard) + ComplianceContract->>ComplianceContract: runs all registered modules (any can revert) + ComplianceContract-->>Token: transfer compliant ``` ## Security Considerations diff --git a/content/stellar-contracts/tokens/vault/vault.mdx b/content/stellar-contracts/tokens/vault/vault.mdx index 9f12c726..abc96ec3 100644 --- a/content/stellar-contracts/tokens/vault/vault.mdx +++ b/content/stellar-contracts/tokens/vault/vault.mdx @@ -120,7 +120,8 @@ For more details about the mechanics of this attack, see the [OpenZeppelin ERC-4 ### Custom Authorization -Custom authorization logic can be implemented as needed: +`deposit`, `mint`, `withdraw` and `redeem` already require authorization from `operator`. Additional +authorization logic can be implemented as needed by overriding them in the `FungibleVault` implementation: ```rust fn deposit( @@ -135,8 +136,8 @@ fn deposit( panic_with_error!(e, Error::NotWhitelisted); } - operator.require_auth(); - Vault::deposit(e, assets, receiver, from, operator) + // `operator.require_auth()` is called by the contract type's `deposit` + Self::ContractType::deposit(e, assets, receiver, from, operator) } ``` @@ -163,12 +164,16 @@ Aside from this deviation, the vault implementation for Soroban provides: ### Basic Implementation -To create a vault contract, implement both the `FungibleToken` and `FungibleVault` traits: +To create a vault contract, select the `Vault` contract type, which requires supply tracking, i.e. +`Compose<(Vault, TotalSupply)>`, and implement the `FungibleToken`, `FungibleTotalSupply` and `FungibleVault` traits: ```rust -use soroban_sdk::{contract, contractimpl, Address, Env, String}; +use soroban_sdk::{contract, contractimpl, Address, Env, MuxedAddress, String}; use stellar_tokens::{ - fungible::{Base, FungibleToken}, + fungible::{ + total_supply::{FungibleTotalSupply, TotalSupply}, + Base, Compose, FungibleToken, + }, vault::{FungibleVault, Vault}, }; @@ -197,41 +202,25 @@ impl VaultContract { #[contractimpl(contracttrait)] impl FungibleToken for VaultContract { - type ContractType = Vault; + type ContractType = Compose<(Vault, TotalSupply)>; fn decimals(e: &Env) -> u32 { Vault::decimals(e) } } -#[contractimpl] -impl FungibleVault for VaultContract { - fn deposit( - e: &Env, - assets: i128, - receiver: Address, - from: Address, - operator: Address, - ) -> i128 { - operator.require_auth(); - Vault::deposit(e, assets, receiver, from, operator) - } - - fn withdraw( - e: &Env, - assets: i128, - receiver: Address, - owner: Address, - operator: Address, - ) -> i128 { - operator.require_auth(); - Vault::withdraw(e, assets, receiver, owner, operator) - } +#[contractimpl(contracttrait)] +impl FungibleTotalSupply for VaultContract {} - // Implement other required methods... -} +// All vault functions have default implementations +#[contractimpl(contracttrait)] +impl FungibleVault for VaultContract {} ``` +To cap the total amount of shares, add `Capped` to the list, i.e. `Compose<(Vault, Capped, TotalSupply)>`, set the cap +with `Capped::set_cap` in the constructor and implement `FungibleCapped`. `deposit` and `mint` then fail if they would +mint shares beyond the cap. See [Capped](/stellar-contracts/tokens/fungible/fungible#--capped). + ### Initialization The vault **must** be properly initialized in the constructor: From cbd7b05967d1b62bd469976c68377343dcf8eff3 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:03:05 +0200 Subject: [PATCH 3/5] Stellar: align RWA, vault and SAC admin docs with latest stellar-contracts - RWA: describe forced transfers accurately (only `to` is verified, pause and freezing are bypassed), document custodial (muxed) destinations and how to refuse them, batch operations and their sizing, per-token recovery including frozen zero-balance accounts, burn behaviour with frozen and locked tokens, and the identity registry removal and tombstoning rules. List the ready-made compliance modules with the hooks they need and their preset phase, replace the non-existent investor count module in the diagram, fix the verify_identity signature and the token binder capacity (100 tokens in a single entry). - Vault: deposits that would mint zero shares now revert with VaultZeroShares and redemptions that would return zero assets with VaultZeroAssets; rewrite the inflation attack section after the upstream analysis, which no longer claims the offset makes the attack infeasible. - SAC Admin Generic: import CustomAccountInterface from soroban_sdk::auth, add the missing stellar_tokens import and the ensure_minting_limit helper. - Overview: fix the Vault description copied from the fungible token. --- content/stellar-contracts/index.mdx | 2 +- .../tokens/fungible/sac-admin-generic.mdx | 23 +++- content/stellar-contracts/tokens/rwa/rwa.mdx | 101 ++++++++++++++++-- .../stellar-contracts/tokens/vault/vault.mdx | 29 ++++- 4 files changed, 137 insertions(+), 18 deletions(-) diff --git a/content/stellar-contracts/index.mdx b/content/stellar-contracts/index.mdx index 6df8c0f2..32878cf7 100644 --- a/content/stellar-contracts/index.mdx +++ b/content/stellar-contracts/index.mdx @@ -15,7 +15,7 @@ for access control and contract management. * **[Fungible Tokens](/stellar-contracts/tokens/fungible/fungible)**: Digital assets representing a fixed or dynamic supply of identical units. * **[Non-Fungible Tokens (NFTs)](/stellar-contracts/tokens/non-fungible/non-fungible)**: Unique digital assets with verifiable ownership. * **[Real World Assets (RWAs)](/stellar-contracts/tokens/rwa/rwa)**: Digital assets representing real-world assets. -* **[Vault](/stellar-contracts/tokens/vault/vault)**: Digital assets representing a fixed or dynamic supply of identical units. +* **[Vault](/stellar-contracts/tokens/vault/vault)**: ERC-4626 tokenized vaults issuing shares that represent proportional ownership of an underlying asset. * **[Confidential Token](/stellar-contracts/tokens/confidential/confidential)**: SEP-41 wrapper with private balances and transfer amounts, verified on-chain with zero-knowledge proofs. ## Access Control diff --git a/content/stellar-contracts/tokens/fungible/sac-admin-generic.mdx b/content/stellar-contracts/tokens/fungible/sac-admin-generic.mdx index f423c6e3..e5b6b8db 100644 --- a/content/stellar-contracts/tokens/fungible/sac-admin-generic.mdx +++ b/content/stellar-contracts/tokens/fungible/sac-admin-generic.mdx @@ -86,11 +86,14 @@ function: ```rust use soroban_sdk::{ - auth::Context, CustomAccountInterface, + auth::{Context, CustomAccountInterface}, contract, contracterror, contractimpl, contracttype, crypto::Hash, Address, BytesN, Env, IntoVal, Val, Vec, }; +use stellar_tokens::fungible::sac_admin_generic::{ + extract_sac_contract_context, get_sac_address, set_sac_address, SacFn, +}; #[contractimpl] impl CustomAccountInterface for SacAdminExampleContract { @@ -170,6 +173,24 @@ fn ensure_caller_operator>( _ => Err(SACAdminGenericError::Unauthorized), } } + +fn ensure_minting_limit( + e: &Env, + caller: &BytesN<32>, + amount: i128, +) -> Result<(), SACAdminGenericError> { + let key = SacDataKey::MintingLimit(caller.clone()); + + let (max, curr): (i128, i128) = e.storage().instance().get(&key).expect("limit not set"); + let new_limit: i128 = curr.checked_add(amount).expect("overflow"); + if new_limit > max { + return Err(SACAdminGenericError::MintingLimitExceeded); + } + + // update + e.storage().instance().set(&key, &(max, new_limit)); + Ok(()) +} ``` ## Benefits and Trade-offs diff --git a/content/stellar-contracts/tokens/rwa/rwa.mdx b/content/stellar-contracts/tokens/rwa/rwa.mdx index adeb1cc1..7fe94f55 100644 --- a/content/stellar-contracts/tokens/rwa/rwa.mdx +++ b/content/stellar-contracts/tokens/rwa/rwa.mdx @@ -25,7 +25,7 @@ security tokens, including: - **Transfer Controls**: Sophisticated transfer restrictions and validations - **Freezing Mechanisms**: Address-level and partial token freezing capabilities - **Recovery System**: Lost/old account recovery for verified investors -- **Pausable Operations**: Emergency pause functionality for the entire token +- **Pausable Operations**: Emergency pause that halts minting and transfers - **Role-Based Access Control (RBAC)**: Flexible privilege management for administrative functions ## Architecture @@ -325,7 +325,7 @@ identity verifier contract to ensure the recipient has a valid identity with the // which reverts the mint if a compliance module rejects it ::ContractType::mint(e, &recipient, amount); -// Example: Transferring tokens +// Example: Transferring tokens (`to` is a `MuxedAddress`, see Custodial Destinations below) // Internally calls: identity_verifier.verify_identity(&from) // Then calls: identity_verifier.verify_identity(&to) // Then transfers and calls: compliance.transferred(from_snapshot, to_snapshot, amount, TransferKind::Standard, token), @@ -382,6 +382,12 @@ These `RWA` functions, like the ones for recovery and forced transfers below, do checks. The corresponding `RWAToken` functions take an `operator` argument on which the contract must enforce access control, as in the [Usage](#usage) example. +Burning bypasses freezing: when the amount exceeds the free (unfrozen) balance, `RWA::burn` unfreezes the difference +first. Tokens locked by a compliance module are not treated the same way, since the `Destroyed` hook has no notion of a +privileged burn. For example, with the `initial_lockup_period` module registered, a burn that reaches still-locked +tokens is rejected. Destroying a locked position takes a forced transfer to a treasury wallet, which consumes the locks, +followed by a burn from that treasury. + ### Recovery System The recovery system allows authorized operators to transfer tokens from a lost/old account to a @@ -391,21 +397,70 @@ Balance Recovery System is handled on the RWA token contract, whereas Identity R on a separate contract as a module (may be a part of the Identity Registry contract). ```rust -// Recover tokens from old account to new account -RWA::recover_balance(e, &old_account, &new_account); +// Recover tokens from old account to new account, returns whether any tokens were moved +let moved: bool = RWA::recover_balance(e, &old_account, &new_account); ``` +`recover_balance` moves the whole balance and carries both the address freeze and the partially frozen amount over to +the new account. Balances and freezes are stored per token, so after the identity has been recovered in the registry, +`recover_balance` must be called on each token separately. It is still needed on a token where the old account holds +nothing but is frozen, otherwise the freeze is not carried over to the new account. + ### Forced Transfers Forced Transfers are handled on the RWA token contract. -Authorized operators can force transfers between verified wallets for regulatory compliance: +Authorized operators can force transfers for regulatory compliance, e.g. court-ordered seizures: ```rust // Force a transfer for regulatory reasons RWA::forced_transfer(e, &from, &to, amount); ``` +Only `to` must pass identity verification. `from` is deliberately not verified, so that tokens can still be pulled out +of accounts whose identity is no longer valid (sanctioned, revoked or compromised wallets). Forced transfers bypass the +freezing checks and unfreeze tokens of `from` as needed. Like `burn` and `recover_balance`, they also keep working +while the token is paused, whereas minting and transfers are halted. + +### Custodial (Muxed) Destinations + +`transfer` accepts a muxed destination (`MuxedAddress`), which enables omnibus custody. The muxed address is narrowed to +its base address before any check runs, so identity verification, compliance and the balance update all operate on the +base address, and the muxed ID only appears in the `transfer` event, where it lets a custodian attribute the transfer to +one of its off-chain sub-accounts. + +The muxed ID is supplied by the caller and is not verified on-chain, so a verified holder may be a custodian holding on +behalf of beneficiaries who have no on-chain identity of their own. An issuer that requires beneficial owners on the +on-chain register can refuse muxed destinations by overriding `transfer`: + +```rust +#[contractimpl(contracttrait)] +impl FungibleToken for RealEstateToken { + type ContractType = Compose<(RWA, TotalSupply)>; + + fn transfer(e: &Env, from: Address, to: MuxedAddress, amount: i128) { + if to.id().is_some() { + panic_with_error!(e, RWAError::IdentityVerificationFailed); + } + RWA::transfer(e, &from, &to, amount); + } +} +``` + +### Batch Operations + +The operator-gated functions of `RWAToken` have `batch_` siblings (`batch_mint`, `batch_burn`, `batch_forced_transfer`, +`batch_set_address_frozen`, `batch_freeze_partial_tokens` and `batch_unfreeze_partial_tokens`) that apply the same +operation to a list of accounts in a single invocation, mirroring the batch functions of ERC-3643. In addition, +`RWAToken` provides a default `batch_transfer(from, recipients, amounts)` entry point, which the sender authorizes once +and where the sender's identity is verified once for the whole batch. + +A batch is atomic: an item that fails reverts the whole call, and every item emits the same event as its single-account +counterpart. The batch size is bounded by the [per-transaction network limits](https://lab.stellar.org/network-limits) +rather than by the library. Measured against a stack with two compliance modules and one required claim topic, the +limit is reached at roughly 35 accounts for `batch_mint` and `batch_transfer`, and 99 for the freezing functions. Every +additional compliance module or required claim topic lowers these numbers, so measure against your own configuration. + ## Modules The RWA package includes several supporting modules that work together to provide comprehensive regulatory compliance: @@ -426,9 +481,9 @@ graph TD A[RWA Token Transfer/Mint/Burn] --> B[Compliance Contract] B --> C{Transferred
or
Created
or
Destroyed} C --> D[Registered Modules 1..N] - D --> E[Transfer Limit Module] - D --> F[Country Restriction Module] - D --> G[Investor Count Module] + D --> E[Time Transfers Limits Module] + D --> F[Country Restrict Module] + D --> G[Max Balance Module] D --> H[Custom Module X] E --> I{Any module
panics?} F --> I @@ -473,10 +528,29 @@ every kind of transfer. Hook arguments, including `token`, can be forged, since any contract can call a module's hooks directly. Hooks that modify state should authenticate their caller, the compliance contract bound to `token`, e.g. with `Self::get_compliance_address(e, token).require_auth()`. Hooks that only validate and modify no state need no authentication. +The following ready-made modules are ports of the T-REX modular compliance modules. Each one has an example contract +under [`examples/rwa`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/rwa) and must be +registered on the hooks listed: + +| Module | Rule | Hooks | +| ------ | ---- | ----- | +| `country_allow` | Only recipients whose identity has at least one country code on the allowlist can receive tokens | `Transferred`, `Created` | +| `country_restrict` | Recipients whose identity has a country code on the restriction list cannot receive tokens | `Transferred`, `Created` | +| `transfer_allow` | A transfer passes only when the sender or the recipient is on the address allowlist; mints and burns are not restricted | `Transferred` | +| `time_transfers_limits` | Caps the outgoing volume of each sender identity within configurable time windows | `Transferred` | +| `max_balance` | Caps the aggregate balance of each identity across all its wallets | `Transferred`, `Created`, `Destroyed` | +| `supply_limit` | Caps the total supply through an internal counter | `Created`, `Destroyed` | +| `initial_lockup_period` | Locks minted tokens for a configurable period; transfers and burns can only spend unlocked tokens | `Transferred`, `Created`, `Destroyed` | + +`max_balance` and `supply_limit` track balances and supply on their own. When one of them is registered on a token that +already has holders or circulating supply, the operator must seed its state during the preset phase +(`preset_id_balance` or `batch_preset_id_balances` for `max_balance`, `preset_supply_count` for `supply_limit`) and then +close the phase with `mark_preset_completed`. + ### - Identity Verifier [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verification) -This is a mandatory module, since RWA token contract expects `verify_identity(e: &Env, address: Address)` +This is a mandatory module, since RWA token contract expects `verify_identity(e: &Env, account: &Address)` function to be available. The **Identity Verifier Module** provides the interface for verifying user identities. @@ -581,6 +655,11 @@ Stores identity information for verified investors, including: - Support for both individual and organizational identities - Recovery account mappings for lost wallet scenarios +`remove_identity` is rejected while the wallet still holds a balance in any linked token, and a wallet replaced through +`recover_identity` is permanently tombstoned and can never be registered again. There is no in-place identity swap: +replacing a wallet's identity contract takes emptying the wallet first, through regular or forced transfers, then +`remove_identity` followed by `add_identity` with the new identity. + ### - Identity Claims [Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/tokens/src/rwa/identity_verification/identity_claims) @@ -654,8 +733,8 @@ registries and compliance contracts. This allows a single periphery contract to Features: - Bind/unbind tokens to periphery services - Query all linked tokens -- Efficient storage with bucket-based architecture (supports up to 10,000 tokens) -- Swap-remove pattern for compact storage +- All bound tokens are kept in a single storage entry, capped at 100 tokens per periphery contract +- Swap-remove pattern for compact storage (the list order is not stable across unbinds) ## Architecture Overview diff --git a/content/stellar-contracts/tokens/vault/vault.mdx b/content/stellar-contracts/tokens/vault/vault.mdx index abc96ec3..bfe1463e 100644 --- a/content/stellar-contracts/tokens/vault/vault.mdx +++ b/content/stellar-contracts/tokens/vault/vault.mdx @@ -107,14 +107,28 @@ If a higher offset is required, a custom version of `set_decimals_offset()` must ### Inflation (Precision) Attacks -The virtual decimals offset helps protect against inflation attacks where an attacker: +In empty or nearly empty vaults, deposits are at risk of being stolen through a "donation" that inflates the price of a +share. In an inflation attack, an attacker: 1. Deposits 1 stroop to get the first share (becoming the sole shareholder) 2. **Donates** (not deposits) an enormous amount of assets directly to the vault contract via a direct transfer, without receiving any shares in return. This inflates the vault's total assets while keeping total shares at 1, making that single share worth an enormous amount -3. When a legitimate user tries to deposit (e.g., 1000 stroops), the share calculation rounds down to 0 shares because their deposit is negligible compared to the inflated vault balance. The user loses their deposit while receiving nothing +3. When a legitimate user deposits (e.g., 1000 stroops), the share calculation rounds down because their deposit is small compared to the inflated vault balance, and the assets lost to rounding accrue to the attacker's share -For example: If the attacker donates 1,000,000 stroops after their initial 1 stroop deposit, the vault has 1,000,001 total assets and 1 total share. A user depositing 1000 stroops would receive `(1000 × 1) / 1,000,001 = 0.000999` shares, which rounds down to 0. +For example: without any mitigation, if the attacker donates 1,000,000 stroops after their initial 1 stroop deposit, the vault has 1,000,001 total assets and 1 total share. A user depositing 1000 stroops would receive `(1000 × 1) / 1,000,001 = 0.000999` shares, which rounds down to 0. -The offset adds virtual shares and assets to the conversion formula, making such attacks economically infeasible by ensuring the denominator is never so small that legitimate deposits round to zero. +The vault mitigates this in two ways: + +- **Zero-share deposit rejection**: `deposit` reverts with `VaultZeroShares` when a positive amount of assets would mint + zero shares, so no depositor can lose their entire deposit to an inflated price. Each deposit can still lose up to one + share's worth of assets to rounding. Integrators that need a tighter bound should compare `preview_deposit` against a + caller-supplied minimum before depositing. +- **Virtual shares and assets**: the offset adds virtual shares and assets to the conversion formula. They do not fully + prevent the attack. With the default offset of 0, diluting a single deposit costs the attacker at least as much as + that deposit, so attacking a single victim is never profitable, but the rounding loss of every deposit accrues to the + existing shareholders, and an attacker holding most of the real shares profits once enough deposits land on the + inflated price. With a larger offset, the attack becomes orders of magnitude more expensive than it is profitable. + +Vault deployers can also make an initial deposit of a non-trivial amount of the asset ("dead shares"), so that +manipulating the share price becomes economically unviable. For more details about the mechanics of this attack, see the [OpenZeppelin ERC-4626 security documentation](https://docs.openzeppelin.com/contracts/5.x/erc4626#security-concern-inflation-attack). @@ -247,6 +261,8 @@ let shares_received = vault_client.deposit( ); ``` +A positive deposit that would mint zero shares reverts with `VaultZeroShares`. + Alternatively, mint a specific amount of shares: ```rust @@ -285,6 +301,9 @@ let assets_received = vault_client.redeem( ); ``` +A positive redemption that would return zero assets reverts with `VaultZeroAssets`. When the owner's whole balance is +worth zero assets, `max_redeem` returns `0` and the redemption fails with `VaultExceededMaxRedeem` instead. + ### Preview Functions Preview functions allow you to simulate operations without executing them: @@ -326,7 +345,7 @@ let total_assets = vault_client.total_assets(); let max_deposit = vault_client.max_deposit(&user_address); let max_mint = vault_client.max_mint(&user_address); let max_withdraw = vault_client.max_withdraw(&user_address); -let max_redeem = vault_client.max_redeem(&user_address); +let max_redeem = vault_client.max_redeem(&user_address); // 0 if the balance is worth zero assets ``` ### Operator Pattern From a657f95162d815ca8c99726dc4e8b82bf391db3b Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Tue, 6 Oct 2026 17:19:54 +0200 Subject: [PATCH 4/5] Stellar: align governance, access, utils and fee abstraction docs with latest stellar-contracts - Timelock Controller: add the missing auth_contexts/context_meta length check to the __check_auth example (without it, anyone could authorize arbitrary calls as the timelock), and rewrite the example around the Timelock trait and TimelockClient. - Governor: implement Governor with #[contractimpl(contracttrait)] so the default methods are exported, add a constructor, document the non-zero voting period (InvalidVotingPeriod), the derived vs stored states and that the governor does not enforce the queue eta. - Ownable: implement the Ownable trait in the examples, remove the duplicate owner auth in finalize_and_lock, fix the missing BytesN import and replace the deprecated update_current_contract_wasm. - Pausable and Upgradeable: complete the examples (imports, access control on migrate, owner-gated Upgrader) and link the upstream examples. Use #[...] headings for the macros, also in Access Control. - WAD: rename pow to powi, document ln, exp and powf, the operator overflow bounds and InvalidBase, and use the checked methods in the examples, which panicked on realistic inputs. - Add a Fixed-Point Math page for the i128/I256 mul_div functions, Rounding and phantom overflow handling. - Crypto and Merkle Distributor: fix the crate path, the broken source link and the non-compiling leaf clone, set the root in the constructor, and document Grumpkin, verify_with_index, verify_with_index_and_set_claimed, errors and events. - Fee Abstraction: fix collect_fee_and_invoke, describe the current Eager (pull max, pay fee, refund) and Lazy (inline approval) flows and the balance-conserving token requirement, the allowlist behaviour, and point the relayer links to 1.5.x. - Overview: list the WAD, Fixed-Point Math and Merkle Distributor pages. --- .../access/access-control.mdx | 10 +- content/stellar-contracts/access/ownable.mdx | 27 +++- content/stellar-contracts/fee-abstraction.mdx | 60 ++++++-- .../stellar-contracts/governance/governor.mdx | 84 ++++++++--- .../governance/timelock-controller.mdx | 117 +++++++++------ content/stellar-contracts/index.mdx | 3 + .../stellar-contracts/utils/crypto/crypto.mdx | 98 +++++++++++- .../utils/crypto/merkle-distributor.mdx | 69 +++++++-- .../utils/math/fixed-point.mdx | 130 ++++++++++++++++ content/stellar-contracts/utils/math/wad.mdx | 142 ++++++++++++++---- content/stellar-contracts/utils/pausable.mdx | 57 ++++++- .../stellar-contracts/utils/upgradeable.mdx | 42 +++++- src/navigation/stellar.json | 5 + 13 files changed, 702 insertions(+), 142 deletions(-) create mode 100644 content/stellar-contracts/utils/math/fixed-point.mdx diff --git a/content/stellar-contracts/access/access-control.mdx b/content/stellar-contracts/access/access-control.mdx index a644fed1..52e70a93 100644 --- a/content/stellar-contracts/access/access-control.mdx +++ b/content/stellar-contracts/access/access-control.mdx @@ -99,7 +99,7 @@ The module includes several procedural macros to simplify authorization checks i These macros automatically call `require_auth()` on the specified account before executing the function: -#### @only_admin +#### `#[only_admin]` Restricts access to the contract admin only: @@ -111,7 +111,7 @@ pub fn admin_function(e: &Env) { } ``` -#### @only_role +#### `#[only_role]` Restricts access to accounts with a specific role: @@ -123,7 +123,7 @@ pub fn mint(e: &Env, to: Address, token_id: u32, caller: Address) { } ``` -#### @only_any_role +#### `#[only_any_role]` Restricts access to accounts with any of the specified roles: @@ -139,7 +139,7 @@ pub fn multi_role_action(e: &Env, caller: Address) { These macros check role membership but do **not** enforce authorization. You must manually call `require_auth()` if needed: -#### @has_role +#### `#[has_role]` Checks if an account has a specific role: @@ -151,7 +151,7 @@ pub fn conditional_mint(e: &Env, to: Address, token_id: u32, caller: Address) { } ``` -#### @has_any_role +#### `#[has_any_role]` Checks if an account has any of the specified roles: diff --git a/content/stellar-contracts/access/ownable.mdx b/content/stellar-contracts/access/ownable.mdx index 255a43d8..1ceedae1 100644 --- a/content/stellar-contracts/access/ownable.mdx +++ b/content/stellar-contracts/access/ownable.mdx @@ -32,8 +32,9 @@ This feature is useful for contracts that need to become fully decentralized aft A common use case is renouncing ownership after initial setup to ensure the contract becomes immutable and cannot be upgraded: ```rust -use soroban_sdk::{contract, contractimpl, Address, Env}; +use soroban_sdk::{contract, contractimpl, Address, BytesN, Env}; use stellar_access::ownable::{self as ownable, Ownable}; +use stellar_contract_utils::upgradeable; use stellar_macros::only_owner; #[contract] @@ -45,7 +46,8 @@ impl MyContract { ownable::set_owner(e, &initial_owner); } - #[only_owner] + // No `#[only_owner]` here: `renounce_ownership` already requires the owner's + // authorization, and requiring it twice in the same call would panic. pub fn finalize_and_lock(e: &Env) { // Perform any final configuration here // ... @@ -60,9 +62,12 @@ impl MyContract { #[only_owner] pub fn emergency_upgrade(e: &Env, new_wasm_hash: BytesN<32>) { // This function becomes permanently inaccessible after renounce_ownership - e.deployer().update_current_contract_wasm(new_wasm_hash); + upgradeable::upgrade(e, &new_wasm_hash); } } + +#[contractimpl(contracttrait)] +impl Ownable for MyContract {} ``` **Important Notes:** @@ -75,7 +80,7 @@ impl MyContract { The module includes a procedural macro to simplify owner authorization checks: -#### @only_owner +#### `#[only_owner]` Ensures the caller is the owner before executing the function: @@ -86,7 +91,12 @@ pub fn restricted_function(e: &Env, other_param: u32) { } ``` -This expands to code that retrieves the owner from storage and requires authorization before executing the function body. +This expands to a call to `stellar_access::ownable::enforce_owner_auth(e)` at the start of the function body, which +retrieves the owner from storage and requires its authorization. It panics with `OwnableError::OwnerNotSet` (`2100`) +if no owner is set, e.g. after ownership has been renounced. + +Don't add `#[only_owner]` to a function that calls `ownable::transfer_ownership()` or `ownable::renounce_ownership()`: +these already enforce the owner's authorization, and requiring it twice in the same call panics. ## Usage Example @@ -119,8 +129,15 @@ impl MyContract { 42 } } + +// Exposes `get_owner`, `transfer_ownership`, `accept_ownership` and `renounce_ownership` +#[contractimpl(contracttrait)] +impl Ownable for MyContract {} ``` +For a complete contract with tests, see the +[ownable example](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/ownable). + ## Benefits and Trade-offs ### Benefits diff --git a/content/stellar-contracts/fee-abstraction.mdx b/content/stellar-contracts/fee-abstraction.mdx index 9c38d02e..80eae8cf 100644 --- a/content/stellar-contracts/fee-abstraction.mdx +++ b/content/stellar-contracts/fee-abstraction.mdx @@ -40,8 +40,13 @@ sequenceDiagram Relayer->>FeeForwarder: 7. Execute forward():
submit a tx and pay XLM fees FeeForwarder->>FeeForwarder: 8. Validate authorizations - FeeForwarder->>Token: 9. Approve max fee amount (optional) - FeeForwarder->>Token: 10. Transfer fee amount to fee recipient + alt Eager approval + FeeForwarder->>Token: 9. Approve max_fee_amount + FeeForwarder->>Token: 10. Pull max_fee_amount from user,
pay fee_amount to fee recipient,
refund the remainder to user + else Lazy approval + FeeForwarder->>Token: 9. Approve max_fee_amount
(only if allowance is insufficient) + FeeForwarder->>Token: 10. Transfer fee_amount from user to fee recipient + end FeeForwarder->>Target Contract: 11. Invoke target_fn(target_args) Target Contract-->>User: Result ``` @@ -57,7 +62,7 @@ FeeForwarder.forward() └─ Target.target_fn() ← nested sub-invocation (if needed) ``` -Equally important for the fee abstraction model is enabling the relayer to collect the compensation fee. This is done through the common 2-step fungible token flow of `approve` + `transfer_from`. Soroban authorization framework allows embedding the approval step in the same transaction, so the user might also need to sign `Token.approve()`. +Equally important for the fee abstraction model is enabling the relayer to collect the compensation fee. This relies on the fungible token allowance mechanism: the user approves the FeeForwarder, which then pulls the tokens with `transfer_from`. Soroban authorization framework allows embedding the approval step in the same transaction, so the user might also need to sign `Token.approve()`. To guarantee the atomicity of both, the target invocation and the fee collection, the [fee-abstraction](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/fee-abstraction) package exposes the function `collect_fee_and_invoke()`, which, in most of the cases, will require signing an authorization tree that contains one entry with two sub-invocations: @@ -71,15 +76,35 @@ The approval sub-invocation might be optional depending on the chosen approval s 1. **Lazy** - The user pre-approves a lump sum to the FeeForwarder contract in a separate transaction. Each forwarded call then draws from this existing allowance. This strategy is more resource efficient per call, but requires an initial approval transaction and trust in the FeeForwarder contract. + The FeeForwarder approves `max_fee_amount` only when the user's current allowance is below it, then transfers + `fee_amount` from the user directly to the fee recipient with `transfer_from`. The `Token.approve()` sub-invocation + is thus needed only when the allowance is insufficient, and calls covered by an existing allowance (left over from + a previous call or approved separately) don't need it. The unspent remainder stays approved for later calls, which + makes this strategy cheaper per call, but requires trust in the FeeForwarder contract. 2. **Eager** - The approval is embedded directly in the authorization entry as a sub-invocation for every call, no preliminary transactions needed. This strategy is self-contained with no trust assumptions, but more expensive per call (~25%) due to the nested approval. + The FeeForwarder always approves `max_fee_amount` (overwriting any existing allowance), pulls the full + `max_fee_amount` from the user with `transfer_from`, pays `fee_amount` to the fee recipient (skipped when the + recipient is the FeeForwarder itself) and refunds the remainder to the user. The approval is embedded as a + sub-invocation in every call, but no allowance is left behind, so there are no trust assumptions. This strategy is + more expensive per call due to the approval and the extra transfers, and the user needs a spendable balance of at + least `max_fee_amount`, not just `fee_amount`. + + +Eager requires a balance-conserving fee token, i.e. one whose transfers credit the receiver exactly the amount +debited from the sender. The FeeForwarder pays out the fee and the remainder regardless of the amount it received, so +with fee-on-transfer or other non-conserving tokens the shortfall is drawn from the FeeForwarder's own balance, or the +call reverts when that balance is insufficient. Use Lazy for such tokens, as it transfers directly from the user to +the fee recipient. In both strategies, the `fee_collected` event reports the requested `fee_amount`, which can exceed +what the recipient actually receives from a non-conserving token. + ## Fee Token Allowlist -The package includes optional allowlist functionality to restrict which tokens can be used for fee payment: +The package includes optional allowlist functionality to restrict which tokens can be used for fee payment. The +allowlist is enabled implicitly once at least one token is added: while it is empty (including after the last token +is removed), every token is allowed. `is_fee_token_allowlist_enabled` reports whether it is currently enabled. ```rust // Enable a token for fee payments @@ -92,7 +117,12 @@ set_allowed_fee_token(e, &deprecated_token, false); let is_allowed = is_allowed_fee_token(e, &token); ``` -The allowlist uses a swap-and-pop algorithm for efficient O(1) removal and automatically extends TTL for frequently-used tokens. +Allowing a token that is already allowed panics with `FeeTokenAlreadyAllowed`, and disallowing a token that is not +allowed panics with `FeeTokenNotAllowed`. + +The allowlist uses a swap-and-pop algorithm for efficient O(1) removal and automatically extends TTL for frequently-used +tokens. When a removal moves the last token into the freed slot, the TTL of the moved token's storage entries is +extended as well. ## Token Sweeping @@ -126,7 +156,7 @@ A role-based implementation where only authorized executors can relay transactio - Uses `#[only_role]` macro for executor authorization - Collects fees to the contract (not directly to relayer) - Managers can sweep accumulated fees to designated recipients -- Uses lazy approval strategy (requires pre-approval transaction) +- Uses lazy approval strategy (`Token.approve()` sub-invocation needed only when the allowance is insufficient) - Suitable for trusted relayer networks ```rust @@ -143,7 +173,7 @@ pub fn forward( user: Address, relayer: Address, ) -> Val { - collect_fee_then_invoke( + collect_fee_and_invoke( e, &fee_token, fee_amount, @@ -166,7 +196,7 @@ An open implementation where anyone can act as a relayer. Fees go directly to th **Characteristics:** - No role restrictions - Fees transfer directly to the relayer -- Uses eager approval strategy (approval embedded in each call) +- Uses eager approval strategy (approval embedded in each call, no allowance left behind) - Suitable for open relayer markets ```rust @@ -202,7 +232,7 @@ pub fn forward( ## Integration with OpenZeppelin Relayer -The fee abstraction package is designed to work seamlessly with [OpenZeppelin Relayer](/relayer/1.3.x). The Relayer can: +The fee abstraction package is designed to work seamlessly with [OpenZeppelin Relayer](/relayer/1.5.x). The Relayer can: 1. **Submit sponsored transactions**: Pay XLM fees on behalf of users 2. **Calculate optimal fees**: Determine the actual fee based on network conditions @@ -223,16 +253,16 @@ sequenceDiagram User->>User: Sign authorization User->>OZ Relayer: Submit signed authorization OZ Relayer->>Forwarder: Execute forward (pays XLM) - Forwarder->>Target: Invoke user's action Forwarder->>OZ Relayer: Transfer token fee + Forwarder->>Target: Invoke user's action OZ Relayer-->>User: Transaction complete ``` -For detailed integration instructions, see the [Stellar Sponsored Transactions Guide](/relayer/1.3.x/guides/stellar-sponsored-transactions-guide). +For detailed integration instructions, see the [Stellar Sponsored Transactions Guide](/relayer/1.5.x/guides/stellar-sponsored-transactions-guide). ## See Also - [Smart Accounts](/stellar-contracts/accounts/smart-account) - [Access Control](/stellar-contracts/access/access-control) -- [OpenZeppelin Relayer](/relayer/1.3.x) -- [Stellar Sponsored Transactions Guide](/relayer/1.3.x/guides/stellar-sponsored-transactions-guide) +- [OpenZeppelin Relayer](/relayer/1.5.x) +- [Stellar Sponsored Transactions Guide](/relayer/1.5.x/guides/stellar-sponsored-transactions-guide) diff --git a/content/stellar-contracts/governance/governor.mdx b/content/stellar-contracts/governance/governor.mdx index d5f9b7b9..1acb82ab 100644 --- a/content/stellar-contracts/governance/governor.mdx +++ b/content/stellar-contracts/governance/governor.mdx @@ -30,14 +30,15 @@ stateDiagram-v2 Succeeded --> Executed: execute() Succeeded --> Queued: queue() (if enabled) Queued --> Executed: execute() - Queued --> Expired: expiration passes + Queued --> Expired: set by an extension (not built in) Pending --> Canceled: cancel() Active --> Canceled: cancel() + Defeated --> Canceled: cancel() Succeeded --> Canceled: cancel() Queued --> Canceled: cancel() ``` -1. **Propose**: Anyone with enough voting power (above the `proposal_threshold`) creates a proposal. A **voting delay** begins — a buffer period that gives token holders time to acquire tokens or delegate before the vote opens. +1. **Propose**: Anyone with enough voting power (at least the `proposal_threshold`) creates a proposal. A **voting delay** begins — a buffer period that gives token holders time to acquire tokens or delegate before the vote opens. 2. **Vote**: Once the delay passes, the proposal becomes **Active** and token holders can vote: Against (0), For (1), or Abstain (2). Each voter's power is looked up at the snapshot ledger (when the voting period started), not at the moment they vote. This prevents flash loan attacks. @@ -53,7 +54,11 @@ stateDiagram-v2 ### Optional: Queuing with a Timelock -For systems that need a safety delay between a successful vote and execution, the Governor supports an optional **queue** step. When enabled, succeeded proposals must first be queued, which starts a timelock delay. During this delay, community members can review the upcoming change and exit the protocol if they disagree. +For systems that need a safety delay between a successful vote and execution, the Governor supports an optional +**queue** step. When enabled, succeeded proposals must first be queued before they can be executed. The Governor itself +does not enforce the delay (`queue` only emits the `eta` in an event); it comes from the integration, typically a +[Timelock Controller](/stellar-contracts/governance/timelock-controller) that schedules the proposal's execution. +During this delay, community members can review the upcoming change and exit the protocol if they disagree. This flow becomes: **Propose → Vote → Queue → Execute** @@ -131,13 +136,21 @@ The `Governor` trait defines the full governance interface. Most methods have de fn name(e: &Env) -> String; fn version(e: &Env) -> String; fn voting_delay(e: &Env) -> u32; // ledgers before voting starts -fn voting_period(e: &Env) -> u32; // ledgers during which voting is open +fn voting_period(e: &Env) -> u32; // ledgers during which voting is open (non-zero) fn proposal_threshold(e: &Env) -> u128; // minimum voting power to propose fn get_token_contract(e: &Env) -> Address; // the Votes-enabled token contract fn counting_mode(e: &Env) -> Symbol; // identifies the counting strategy fn proposals_need_queuing(e: &Env) -> bool; // defaults to false ``` +The default getters read values written by the storage setters `set_name`, `set_version`, `set_token_contract`, +`set_voting_delay`, `set_voting_period`, `set_proposal_threshold` and `set_quorum`. These setters perform no +authorization checks and are typically called from the constructor (see [Example](#example)). Until a value is set, the +corresponding getter panics with a `*NotSet` error (e.g. `GovernorError::VotingPeriodNotSet`), so proposing and voting +fail. `set_token_contract` can only be called once, and `set_voting_period` rejects `0` with +`GovernorError::InvalidVotingPeriod`, since a zero-length voting window would make every proposal go straight from +`Pending` to `Defeated`. + ### Proposal Lifecycle ```rust @@ -184,37 +197,69 @@ pub enum ProposalState { Defeated = 2, // Voting ended without success Canceled = 3, // Cancelled by authorized account Succeeded = 4, // Met quorum and vote thresholds - Queued = 5, // Queued for execution (via extension) + Queued = 5, // Queued for execution (when queuing is enabled) Expired = 6, // Expired after queuing (via extension) Executed = 7, // Successfully executed } ``` -States are divided into **time-based** (Pending, Active, Defeated) — derived from the current ledger, never stored — and **explicit** (all others) — persisted in storage, taking precedence once set. +States are divided into **derived** (Pending, Active, Succeeded, Defeated) — computed from the current ledger and the +vote tallies, never stored — and **stored** (Canceled, Queued, Executed, Expired) — persisted by the lifecycle +functions, taking precedence once set. The library never sets `Expired`; it is reserved for extensions that implement +an expiration window. ## Example +The constructor initializes every configuration value, and the `Governor` implementation uses +`#[contractimpl(contracttrait)]` so that the trait's default methods (`propose`, `cast_vote`, `queue`, `name`, +`proposal_state`, ...) are exported alongside `execute` and `cancel`. With a plain `#[contractimpl]`, only the methods +written out in the `impl` block would be callable. Types used in the default method signatures, such as +`ProposalState`, must be in scope. + ```rust use soroban_sdk::{contract, contractimpl, Address, BytesN, Env, String, Symbol, Val, Vec}; -use stellar_governance::governor::{self, storage, Governor, hash_proposal, get_proposal_snapshot}; +use stellar_governance::governor::{self as governor, Governor, ProposalState}; #[contract] pub struct MyGovernor; #[contractimpl] +impl MyGovernor { + pub fn __constructor( + e: &Env, + token_contract: Address, + voting_delay: u32, + voting_period: u32, + proposal_threshold: u128, + quorum: u128, + ) { + governor::set_name(e, String::from_str(e, "MyGovernor")); + governor::set_version(e, String::from_str(e, "1.0.0")); + governor::set_token_contract(e, &token_contract); + governor::set_voting_delay(e, voting_delay); + governor::set_voting_period(e, voting_period); // panics if 0 + governor::set_proposal_threshold(e, proposal_threshold); + governor::set_quorum(e, quorum); + } +} + +#[contractimpl(contracttrait)] impl Governor for MyGovernor { - // Open execution — anyone can trigger a succeeded proposal + // Open execution: any account can trigger a succeeded proposal, + // as long as they authenticate themselves as `executor`. fn execute( e: &Env, targets: Vec
, functions: Vec, args: Vec>, description_hash: BytesN<32>, - _executor: Address, + executor: Address, ) -> BytesN<32> { - let proposal_id = hash_proposal(e, &targets, &functions, &args, &description_hash); - let quorum = Self::quorum(e, get_proposal_snapshot(e, &proposal_id)); - storage::execute( + executor.require_auth(); + let proposal_id = + governor::hash_proposal(e, &targets, &functions, &args, &description_hash); + let quorum = Self::quorum(e, governor::get_proposal_snapshot(e, &proposal_id)); + governor::execute( e, targets, functions, args, &description_hash, Self::proposals_need_queuing(e), quorum, ) @@ -229,17 +274,22 @@ impl Governor for MyGovernor { description_hash: BytesN<32>, operator: Address, ) -> BytesN<32> { - let proposal_id = storage::hash_proposal( - e, &targets, &functions, &args, &description_hash, - ); - let proposer = storage::get_proposal_proposer(e, &proposal_id); + let proposal_id = + governor::hash_proposal(e, &targets, &functions, &args, &description_hash); + let proposer = governor::get_proposal_proposer(e, &proposal_id); assert!(operator == proposer); operator.require_auth(); - storage::cancel(e, targets, functions, args, &description_hash) + governor::cancel(e, targets, functions, args, &description_hash) } } ``` +For complete contracts with tests, see the +[fungible-governor](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/fungible-governor) example +and [fungible-governor-timelock](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/fungible-governor-timelock), +which enables queuing and routes execution through a +[Timelock Controller](/stellar-contracts/governance/timelock-controller). + ## See Also - [Votes](/stellar-contracts/governance/votes) diff --git a/content/stellar-contracts/governance/timelock-controller.mdx b/content/stellar-contracts/governance/timelock-controller.mdx index 4c3230b9..20a6574c 100644 --- a/content/stellar-contracts/governance/timelock-controller.mdx +++ b/content/stellar-contracts/governance/timelock-controller.mdx @@ -37,7 +37,7 @@ Timelocked operations follow a specific lifecycle: ### Schedule -When a proposer calls `schedule_operation`, the `OperationState` moves from `Unset` to `Waiting`. This starts a timer that must be greater than or equal to the minimum delay (specified in ledger sequence numbers). The timer expires at a ledger sequence accessible through `get_operation_ledger`. Once the current ledger passes that point, the `OperationState` automatically moves to the `Ready` state. At this point, it can be executed. +When a proposer calls `schedule_operation`, the `OperationState` moves from `Unset` to `Waiting`. This starts a timer that must be greater than or equal to the minimum delay (specified in ledger sequence numbers). The timer expires at a ledger sequence accessible through `get_operation_ledger`. Once the current ledger reaches that point (`current_ledger >= ready_ledger`), the `OperationState` automatically moves to the `Ready` state. At this point, it can be executed. ### Execute @@ -84,9 +84,41 @@ After initialization, the only way to change the timelock's minimum delay is to The minimum delay of a contract is accessible through `get_min_delay`. +## The Timelock Trait + +The `Timelock` trait defines the standard interface of a timelock controller contract. Implement it with +`#[contractimpl(contracttrait)]` so that its default methods are exported too. The trait also generates a +`TimelockClient` that other contracts use for type-safe cross-contract calls: for example, a +[Governor](/stellar-contracts/governance/governor) with queuing enabled schedules and cancels proposals on the +timelock through it (see the +[fungible-governor-timelock](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/fungible-governor-timelock) +example). + +```rust +// Default implementations +fn get_min_delay(e: &Env) -> u32; +fn hash_operation(e: &Env, target: Address, function: Symbol, args: Vec, + predecessor: BytesN<32>, salt: BytesN<32>) -> BytesN<32>; +fn get_operation_ledger(e: &Env, operation_id: BytesN<32>) -> u32; +fn get_operation_state(e: &Env, operation_id: BytesN<32>) -> OperationState; + +// No default — must implement +fn schedule(e: &Env, target: Address, function: Symbol, args: Vec, + predecessor: BytesN<32>, salt: BytesN<32>, delay: u32, proposer: Address) -> BytesN<32>; +fn execute(e: &Env, target: Address, function: Symbol, args: Vec, + predecessor: BytesN<32>, salt: BytesN<32>, executor: Option
) -> Val; +fn cancel(e: &Env, operation_id: BytesN<32>, canceller: Address); +fn update_delay(e: &Env, new_delay: u32, operator: Address); +``` + +`schedule`, `execute`, `cancel` and `update_delay` have no default implementation because their access control varies +per deployment. An implementation performs its authorization checks and then delegates to the library functions +`schedule_operation`, `execute_operation`, `cancel_operation` and `set_min_delay`, which perform the state transitions +and emit the events. + ## Usage Example -We are providing below a complete timelock controller implementation with role-based access control. +Below is a timelock controller that implements the `Timelock` trait with role-based access control. ### Roles @@ -106,21 +138,29 @@ When the contract is deployed with `admin` set to `None`, the contract address i For self-administration operations (e.g., updating the minimum delay, granting and revoking roles), the proposal lifecycle is: 1. Proposer schedules the operation targeting the timelock contract itself -2. After the delay passes, call the admin function directly (not via `execute_op`) +2. Once the operation is ready, call the admin function directly (not via `execute`) 3. The `CustomAccountInterface` implementation validates the operation is ready and marks it as executed -**Why not use `execute_op` for self-administration?** Soroban does not allow re-entrancy: a contract cannot call its own public functions during execution. For example, `execute_op` cannot internally call `update_delay` on the same contract. To work around this, the `CustomAccountInterface` implementation validates and marks operations as executed without performing the cross-contract call, allowing admin functions to be called directly. +**Why not use `execute` for self-administration?** Soroban does not allow re-entrancy: a contract cannot call its own public functions during execution. For example, `execute` cannot internally call `update_delay` on the same contract. To work around this, the `CustomAccountInterface` implementation validates and marks operations as executed without performing the cross-contract call, allowing admin functions to be called directly. The custom `__check_auth` implementation validates that: +- Every authorization context has its own `OperationMeta` entry - The operation targets the timelock contract itself - The operation was properly scheduled and is ready for execution - The predecessor and salt match the scheduled operation - The executor (if any) has the required role and has authorized the invocation + +The length check at the top of `__check_auth` is essential. `zip` stops at the shorter of the two vectors, so without +it a signature with fewer `OperationMeta` entries than there are contexts (or an empty one) would leave the remaining +contexts unvalidated and return `Ok`. Anyone could then authorize arbitrary calls as the timelock, including +privileged calls on the contracts it owns, bypassing the delay entirely. + + ```rust use soroban_sdk::{ auth::{Context, ContractContext, CustomAccountInterface}, - contract, contractimpl, contracttype, + contract, contracterror, contractimpl, contracttype, crypto::Hash, panic_with_error, symbol_short, Address, BytesN, Env, IntoVal, Symbol, Val, Vec, }; @@ -128,14 +168,17 @@ use stellar_access::access_control::{ ensure_role, get_role_member_count, grant_role_no_auth, set_admin, AccessControl, }; use stellar_governance::timelock::{ - cancel_operation, execute_operation, get_min_delay as timelock_get_min_delay, - get_operation_state, hash_operation as timelock_hash_operation, - is_operation_done, is_operation_pending, is_operation_ready, operation_exists, - schedule_operation, set_execute_operation, set_min_delay as timelock_set_min_delay, - Operation, OperationState, TimelockError, + cancel_operation, execute_operation, schedule_operation, set_execute_operation, + set_min_delay as timelock_set_min_delay, Operation, OperationState, Timelock, TimelockError, }; use stellar_macros::{only_admin, only_role}; +#[contracterror] +#[repr(u32)] +enum TimelockControllerError { + Mismatch = 0, +} + const PROPOSER_ROLE: Symbol = symbol_short!("proposer"); const EXECUTOR_ROLE: Symbol = symbol_short!("executor"); const CANCELLER_ROLE: Symbol = symbol_short!("canceller"); @@ -162,6 +205,11 @@ impl CustomAccountInterface for TimelockController { context_meta: Vec, auth_contexts: Vec, ) -> Result<(), Self::Error> { + // `zip` stops at the shorter vector: without this check, extra + // contexts would be authorized without any validation. + if auth_contexts.len() != context_meta.len() { + panic_with_error!(&e, TimelockControllerError::Mismatch); + } for (context, meta) in auth_contexts.iter().zip(context_meta) { match context.clone() { Context::Contract(ContractContext { contract, fn_name, args }) => { @@ -227,9 +275,14 @@ impl TimelockController { timelock_set_min_delay(e, min_delay); } +} +// The getters (`get_min_delay`, `hash_operation`, `get_operation_ledger`, +// `get_operation_state`) come from the trait's default implementations. +#[contractimpl(contracttrait)] +impl Timelock for TimelockController { #[only_role(proposer, "proposer")] - pub fn schedule_op( + fn schedule( e: &Env, target: Address, function: Symbol, @@ -243,7 +296,7 @@ impl TimelockController { schedule_operation(e, &operation, delay) } - pub fn execute_op( + fn execute( e: &Env, target: Address, function: Symbol, @@ -263,46 +316,14 @@ impl TimelockController { } #[only_role(canceller, "canceller")] - pub fn cancel_op(e: &Env, operation_id: BytesN<32>, canceller: Address) { + fn cancel(e: &Env, operation_id: BytesN<32>, canceller: Address) { cancel_operation(e, &operation_id); } #[only_admin] - pub fn update_delay(e: &Env, new_delay: u32) { + fn update_delay(e: &Env, new_delay: u32, _operator: Address) { timelock_set_min_delay(e, new_delay); } - - pub fn get_min_delay(e: &Env) -> u32 { - timelock_get_min_delay(e) - } - - pub fn hash_operation( - e: &Env, - target: Address, - function: Symbol, - args: Vec, - predecessor: BytesN<32>, - salt: BytesN<32>, - ) -> BytesN<32> { - let operation = Operation { target, function, args, predecessor, salt }; - timelock_hash_operation(e, &operation) - } - - pub fn get_operation_state(e: &Env, operation_id: BytesN<32>) -> OperationState { - get_operation_state(e, &operation_id) - } - - pub fn is_operation_pending(e: &Env, operation_id: BytesN<32>) -> bool { - is_operation_pending(e, &operation_id) - } - - pub fn is_operation_ready(e: &Env, operation_id: BytesN<32>) -> bool { - is_operation_ready(e, &operation_id) - } - - pub fn is_operation_done(e: &Env, operation_id: BytesN<32>) -> bool { - is_operation_done(e, &operation_id) - } } #[contractimpl(contracttrait)] @@ -311,6 +332,10 @@ impl AccessControl for TimelockController {} The `OperationMeta` struct is used as the signature type for `CustomAccountInterface`. When calling a self-administration function, the caller must provide the `predecessor` and `salt` values that were used when scheduling the operation, allowing `__check_auth` to validate and mark the operation as executed. +`update_delay` ignores `operator`: `#[only_admin]` requires the admin's authorization, which with self-administration is +granted by `__check_auth` once the scheduled operation is ready. The full example, with tests, is available in +[examples/timelock-controller](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/timelock-controller). + ## See Also * [Access Control](/stellar-contracts/access/access-control) diff --git a/content/stellar-contracts/index.mdx b/content/stellar-contracts/index.mdx index 32878cf7..8e7e6740 100644 --- a/content/stellar-contracts/index.mdx +++ b/content/stellar-contracts/index.mdx @@ -33,7 +33,10 @@ for access control and contract management. * **[Pausable](/stellar-contracts/utils/pausable)**: Pause and unpause contract functions, useful for emergency response. * **[Upgradeable](/stellar-contracts/utils/upgradeable)**: Manage contract upgrades and data migrations seamlessly. +* **[WAD](/stellar-contracts/utils/math/wad)**: Fixed-point decimal arithmetic with 18 decimals of precision. +* **[Fixed-Point Math](/stellar-contracts/utils/math/fixed-point)**: Full-precision `x * y / denominator` on `i128` and `I256` with explicit rounding. * **[Cryptography](/stellar-contracts/utils/crypto/crypto)**: A set of cryptographic primitives and utilities for Soroban contracts. +* **[Merkle Distributor](/stellar-contracts/utils/crypto/merkle-distributor)**: Merkle-proof-based claims, e.g. for airdrops. ## Fee Abstraction diff --git a/content/stellar-contracts/utils/crypto/crypto.mdx b/content/stellar-contracts/utils/crypto/crypto.mdx index bf3cf746..bde922f0 100644 --- a/content/stellar-contracts/utils/crypto/crypto.mdx +++ b/content/stellar-contracts/utils/crypto/crypto.mdx @@ -17,7 +17,7 @@ The Cryptography Utilities consist of two main packages: ## Crypto Package The crypto package provides fundamental cryptographic primitives and utilities for Soroban contracts, -with a focus on hashing and Merkle tree operations. +with a focus on hashing, Merkle tree operations and Grumpkin elliptic curve arithmetic. ### Key Components @@ -69,6 +69,79 @@ where pub fn verify(e: &Env, proof: Vec, root: Bytes32, leaf: Bytes32) -> bool { // Implementation verifies that the leaf is part of the tree defined by root } + + pub fn verify_with_index( + e: &Env, + proof: Vec, + root: Bytes32, + leaf: Bytes32, + index: u32, + ) -> bool { + // Same check for trees whose pairs are not sorted, using the leaf's position + } +} +``` + +* `verify` assumes that each pair of leaves and each pair of pre-images is sorted (commutative hashing), as in trees + generated by OpenZeppelin's [merkle-tree](https://github.com/OpenZeppelin/merkle-tree) library. +* `verify_with_index` makes no sorting assumption: the 0-based `index` of the leaf decides, at each level, whether + the current node is hashed as the left or the right child. It panics with `MerkleProofOutOfBounds` (`1400`) if the + proof has 32 or more elements, and with `MerkleIndexOutOfBounds` (`1401`) if `index >= 2^proof.len()`. + +#### Grumpkin Curve Arithmetic + +The `grumpkin` module provides affine point arithmetic on Grumpkin, the prime-order curve `y² = x³ - 17` defined over +the BN254 scalar field. Because Grumpkin's base field equals BN254's scalar field, every coordinate operation reduces +to a `Bn254Fr` host call (the `bn254_fr_*` functions of CAP-80, available from protocol 26). The +[Confidential Token](/stellar-contracts/tokens/confidential/confidential) builds its commitments on this module. + +A `Point` is a `BytesN<64>` laid out as `be_bytes(x) || be_bytes(y)`, each coordinate a canonical 32-byte big-endian +`Bn254Fr` value. The all-zero encoding represents the identity (point at infinity). + +```rust +pub type Point = BytesN<64>; + +impl Grumpkin { + // Point arithmetic, validating every point input + pub fn add(e: &Env, p1: &Point, p2: &Point) -> Point; + pub fn sub(e: &Env, p1: &Point, p2: &Point) -> Point; + pub fn neg(e: &Env, p: &Point) -> Point; + pub fn mul(e: &Env, p: &Point, scalar: u128) -> Point; + + // Constants + pub fn identity(e: &Env) -> Point; + pub fn generator(e: &Env) -> Point; // Barretenberg's generator, as used by its Pedersen commitments + + // Non-panicking validation helpers + pub fn is_on_curve(e: &Env, p: &Point) -> bool; + pub fn is_identity(p: &Point) -> bool; + pub fn is_not_identity(p: &Point) -> bool; + pub fn is_canonical_point(p: &Point) -> bool; + pub fn is_canonical_field(f: &BytesN<32>) -> bool; + + // Encoding + pub fn from_xy(e: &Env, x: &Bn254Fr, y: &Bn254Fr) -> Point; + pub fn coordinates(e: &Env, p: &Point) -> (Bn254Fr, Bn254Fr); +} +``` + +`add`, `sub`, `neg` and `mul` validate every point input at entry: the canonical identity encoding is accepted, any +other input must be a canonical on-curve point, and anything else panics with `CryptoError::InvalidPoint` (`1403`). +Intermediate results are on the curve by construction and are not re-validated. + +The validation helpers let a contract reject foreign bytes, such as user-supplied public keys, with its own error +before they reach the arithmetic. `is_on_curve` also checks that both coordinates are canonical and returns `false` +for the identity. `coordinates` performs no validation: the host reduces non-canonical coordinates modulo `r` +instead of rejecting them, so distinct byte strings can decode to the same point. + +#### Errors + +```rust +pub enum CryptoError { + MerkleProofOutOfBounds = 1400, + MerkleIndexOutOfBounds = 1401, + HasherEmptyState = 1402, + InvalidPoint = 1403, } ``` @@ -94,8 +167,8 @@ let hash = hasher.finalize(); ```rust use soroban_sdk::{BytesN, Env, Vec}; -use stellar_crypto::keccak::Keccak256; -use stellar_crypto::merkle::Verifier; +use stellar_contract_utils::crypto::keccak::Keccak256; +use stellar_contract_utils::crypto::merkle::Verifier; // Verify that a leaf is part of a Merkle tree let e = Env::default(); @@ -106,4 +179,23 @@ let proof = /* proof as Vec> */; let is_valid = Verifier::::verify(&e, proof, root, leaf); ``` +#### Grumpkin Point Arithmetic + +```rust +use soroban_sdk::{panic_with_error, Env}; +use stellar_contract_utils::crypto::grumpkin::{Grumpkin, Point}; + +// Add `amount · G` to a stored commitment, e.g. on deposit +fn add_to_commitment(e: &Env, commitment: &Point, amount: u128) -> Point { + let amount_point = Grumpkin::mul(e, &Grumpkin::generator(e), amount); + Grumpkin::add(e, commitment, &amount_point) +} + +// Reject a user-supplied public key with a contract-specific error instead of `InvalidPoint` +fn validate_public_key(e: &Env, key: &Point) { + if !Grumpkin::is_on_curve(e, key) { + panic_with_error!(e, MyError::InvalidPublicKey); + } +} +``` diff --git a/content/stellar-contracts/utils/crypto/merkle-distributor.mdx b/content/stellar-contracts/utils/crypto/merkle-distributor.mdx index 75a28e7d..e19cb316 100644 --- a/content/stellar-contracts/utils/crypto/merkle-distributor.mdx +++ b/content/stellar-contracts/utils/crypto/merkle-distributor.mdx @@ -2,7 +2,7 @@ title: Merkle Distributor --- -[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/contract-utils/src/merkle-distributor) +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/contract-utils/src/merkle_distributor) The Merkle Distributor package builds on the [crypto package](./crypto) to provide a system for distributing tokens or other assets using Merkle proofs for verification. @@ -20,26 +20,72 @@ pub trait IndexableLeaf { Each leaf in the merkle tree requires a unique index to track whether it has been claimed to prevent double-claiming. The custom leaf struct should implement this trait by returning a unique `u32` value for each recipient. This index serves as the key for storing claim status on-chain. +The leaf must also implement `ToXdr`, which every `#[contracttype]` does. The distributor hashes the leaf's XDR +encoding with its hasher to obtain the leaf hash, so the off-chain tree must be built from the same encoding. + ### MerkleDistributor The `MerkleDistributor` struct is a generic component that manages the verification and claiming process. It is parameterized by a hash function (e.g. `Keccak256`) and provides the following core functionality: #### Setting the Merkle Root -`set_root(env: &Env, root: BytesN<32>)` stores the Merkle tree root on-chain. This is typically called once during contract initialization or when updating the distribution list. +`set_root(e: &Env, root: BytesN<32>)` stores the Merkle tree root on-chain and emits a `SetRoot` event. This is typically called once during contract construction or when updating the distribution list. + + +`set_root` performs no authorization checks. Call it only from the constructor or from an admin function that +enforces its own authorization. + + +The root can be updated after the initial setup, and previously claimed indices remain claimed. This supports +"append-only" distributions where an admin periodically expands the set of eligible claimants. The new tree must +preserve the same index-to-leaf mapping for existing entries; otherwise, new claimants assigned to already-claimed +indices will be unable to claim. + +`get_root(e: &Env) -> BytesN<32>` returns the stored root and panics with `RootNotSet` if none has been set. #### Checking Claim Status -`is_claimed(env: &Env, index: u32) -> bool` queries whether a specific index has already been claimed. This allows you to check claim status before attempting verification. +`is_claimed(e: &Env, index: u32) -> bool` queries whether a specific index has already been claimed. This allows you to check claim status before attempting verification. Claim flags are kept in persistent storage, and checking a claimed index extends its TTL. #### Verifying and Claiming -`verify_and_set_claimed(env: &Env, leaf: impl IndexableLeaf, proof: Vec>)` performs two operations atomically: +```rust +pub fn verify_and_set_claimed(e: &Env, leaf: N, proof: Vec>); +pub fn verify_with_index_and_set_claimed(e: &Env, leaf: N, proof: Vec>); +``` + +Both functions perform two operations atomically: + +1. **Verification**: Hashes the leaf's XDR encoding and validates that the leaf hash and proof correctly reconstruct the stored Merkle root +2. **Claiming**: Marks the leaf's index as claimed to prevent duplicate claims and emits a `SetClaimed` event + +They differ in how the tree is expected to be built: + +* `verify_and_set_claimed` uses `Verifier::verify` and expects a tree built with commutative hashing, i.e. sorted + pairs. +* `verify_with_index_and_set_claimed` uses `Verifier::verify_with_index` and expects a tree built without sorting, + where the leaf's index determines its position. It also panics with the `CryptoError` codes of + [`verify_with_index`](./crypto#merkle-tree-verification) for oversized proofs or out-of-range indices. + +Both panic if the root is not set, if the index has already been claimed, or if the proof is invalid, ensuring the integrity of the distribution process. + +### Errors + +```rust +pub enum MerkleDistributorError { + /// The merkle root is not set. + RootNotSet = 1300, + /// The provided index was already claimed. + IndexAlreadyClaimed = 1301, + /// The proof is invalid. + InvalidProof = 1302, +} +``` -1. **Verification**: Validates that the provided leaf and proof correctly reconstruct the stored Merkle root -2. **Claiming**: Marks the leaf's index as claimed to prevent duplicate claims +### Events -This function will panic if the proof is invalid or if the index has already been claimed, ensuring the integrity of the distribution process. +* `SetRoot { root: Bytes }` (topic `set_root`): emitted by `set_root`. +* `SetClaimed { index: u32 }` (topic `set_claimed`): emitted when an index is claimed. ## Usage Example @@ -50,7 +96,8 @@ use stellar_contract_utils::merkle_distributor::{IndexableLeaf, MerkleDistributo // Define a leaf node structure #[contracttype] -struct LeafData { +#[derive(Clone)] +pub struct LeafData { pub index: u32, pub address: Address, pub amount: i128, @@ -68,9 +115,9 @@ pub struct TokenDistributor; #[contractimpl] impl TokenDistributor { - // Initialize the distributor with a Merkle root - pub fn initialize(e: &Env, root: BytesN<32>) { - MerkleDistributor::::set_root(e, root); + // Set the Merkle root at deployment, since `set_root` performs no authorization checks + pub fn __constructor(e: Env, root: BytesN<32>) { + MerkleDistributor::::set_root(&e, root); } // Claim tokens by providing a proof diff --git a/content/stellar-contracts/utils/math/fixed-point.mdx b/content/stellar-contracts/utils/math/fixed-point.mdx new file mode 100644 index 00000000..123c3cb3 --- /dev/null +++ b/content/stellar-contracts/utils/math/fixed-point.mdx @@ -0,0 +1,130 @@ +--- +title: Fixed-Point Math +description: Full-precision x * y / denominator for i128 and I256 with explicit rounding +--- + +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/contract-utils/src/math) + +## Overview + +Besides the [`Wad`](/stellar-contracts/utils/math/wad) decimal type, the `math` module exposes free functions that +compute `x * y / denominator` on `i128` and `I256` values with full precision and an explicit rounding direction. +They are the building blocks for share conversions, price scaling and other ratio computations where the +intermediate product may not fit the operand type. + +Each operation comes in a panicking and a checked variant: + +- **Panicking** variants panic with `SorobanFixedPointError::Overflow` (`1500`) when the result overflows. +- **Checked** variants (`checked_*`) return `None` instead, for every failure including a zero denominator. + +## Functions + +### `i128_fixed_point` + +```rust +pub fn mul_div_with_rounding(e: &Env, x: i128, y: i128, denominator: i128, rounding: Rounding) -> i128; +pub fn checked_mul_div_with_rounding( + e: &Env, x: i128, y: i128, denominator: i128, rounding: Rounding, +) -> Option; + +pub fn mul_div(e: &Env, x: &i128, y: &i128, denominator: &i128) -> i128; // truncates toward zero +pub fn mul_div_floor(e: &Env, x: &i128, y: &i128, denominator: &i128) -> i128; +pub fn mul_div_ceil(e: &Env, x: &i128, y: &i128, denominator: &i128) -> i128; + +pub fn checked_mul_div(e: &Env, x: &i128, y: &i128, denominator: &i128) -> Option; +pub fn checked_mul_div_floor(e: &Env, x: &i128, y: &i128, denominator: &i128) -> Option; +pub fn checked_mul_div_ceil(e: &Env, x: &i128, y: &i128, denominator: &i128) -> Option; +``` + +### `i256_fixed_point` + +`I256` carries its own `Env`, so these functions take no `&Env`. + +```rust +pub fn mul_div_with_rounding(x: I256, y: I256, denominator: I256, rounding: Rounding) -> I256; +pub fn checked_mul_div_with_rounding( + x: I256, y: I256, denominator: I256, rounding: Rounding, +) -> Option; + +pub fn mul_div(x: &I256, y: &I256, denominator: &I256) -> I256; // truncates toward zero +pub fn mul_div_floor(x: &I256, y: &I256, denominator: &I256) -> I256; +pub fn mul_div_ceil(x: &I256, y: &I256, denominator: &I256) -> I256; + +pub fn checked_mul_div(x: &I256, y: &I256, denominator: &I256) -> Option; +pub fn checked_mul_div_floor(x: &I256, y: &I256, denominator: &I256) -> Option; +pub fn checked_mul_div_ceil(x: &I256, y: &I256, denominator: &I256) -> Option; +``` + +In both modules, the `*_with_rounding` variants take their operands by value and the others by reference. + +## Rounding + +```rust +#[contracttype] +pub enum Rounding { + Floor, // toward negative infinity + Ceil, // toward positive infinity + Truncate, // toward zero +} +``` + +The direction matters whenever the result is not exact. Protocols typically round against the caller, e.g. a vault +rounds shares minted on deposit down and assets required for a mint up. + +## Phantom Overflow + +Both widths recover from an intermediate `x * y` that overflows while the final result is representable, a case +known as *phantom overflow*: + +- **`i128`**: the operands are promoted to `I256`, and the result is scaled back if it fits `i128`. +- **`I256`**: there is no wider type to promote to, so both operands are split by the denominator and the division + is distributed, which is an exact identity rather than an approximation: + + ```text + |x| = q1*D + r1 |y| = q2*D + r2 (D = |denominator|, 0 <= r1, r2 < D) + floor(|x*y|/D) = q1*q2*D + q1*r2 + r1*q2 + floor(r1*r2/D) + ``` + + This holds for every `|denominator| <= 2^128` (roughly `3.4e38`, far above the fixed-point scales in practical + use: `10^18`, `10^27`, `2^96`, `10^38`). Within that domain the result is bit-for-bit what a 512-bit intermediate + would produce. Above `2^128` the operation rejects rather than returning an incorrect value, and rejection is + permitted rather than guaranteed: a large denominator whose remainders happen to be small still succeeds. + +Phantom overflow handling covers all the free functions above and the `checked_*` methods of `Wad`. It does not +cover `Wad`'s operators (`+`, `-`, `*`, `/`), which cannot reach an `Env` to build the intermediate; see the +[`Wad` overflow bounds](/stellar-contracts/utils/math/wad#4-operator-overloading). + +## Errors + +- The panicking variants raise `SorobanFixedPointError::Overflow` (`1500`) when the result does not fit. +- Domain errors (a zero `denominator`, `MIN / -1`) are left to the platform rather than mapped to a contract error. + Native `i128` arithmetic surfaces as a generic wasm trap (`Error(WasmVm, InvalidAction)`), and `I256` host + arithmetic as `Error(Object, ArithDomain)`. Inside the `I256` phantom overflow fallback, every failure, a zero + `denominator` included, surfaces as `Overflow`. +- The checked variants return `None` for all of the above. + +A caller that needs a distinct division-by-zero error has to validate the denominator up front, as +`Wad::from_ratio` does with `SorobanFixedPointError::DivisionByZero` (`1501`). + +## Example + +```rust +use soroban_sdk::{Env, I256}; +use stellar_contract_utils::math::{i128_fixed_point, i256_fixed_point, Rounding}; + +// Share conversion, rounding in the vault's favour. +pub fn to_shares(e: &Env, assets: i128, total_shares: i128, total_assets: i128) -> i128 { + i128_fixed_point::mul_div_with_rounding(e, assets, total_shares, total_assets, Rounding::Floor) +} + +// Assets required to mint `shares`, rounding up; `None` instead of panicking, +// e.g. when `total_shares` is zero. +pub fn assets_for_mint(e: &Env, shares: i128, total_assets: i128, total_shares: i128) -> Option { + i128_fixed_point::checked_mul_div_ceil(e, &shares, &total_assets, &total_shares) +} + +// The same share conversion at 256-bit width. +pub fn to_shares_256(assets: &I256, total_shares: &I256, total_assets: &I256) -> I256 { + i256_fixed_point::mul_div_floor(assets, total_shares, total_assets) +} +``` diff --git a/content/stellar-contracts/utils/math/wad.mdx b/content/stellar-contracts/utils/math/wad.mdx index 3630fbcf..a639459a 100644 --- a/content/stellar-contracts/utils/math/wad.mdx +++ b/content/stellar-contracts/utils/math/wad.mdx @@ -3,6 +3,8 @@ title: WAD Fixed-Point Math description: High-precision decimal arithmetic --- +[Source Code](https://github.com/OpenZeppelin/stellar-contracts/tree/main/packages/contract-utils/src/math) + # Overview The WAD library provides fixed-point decimal arithmetic for Soroban smart contracts with 18 decimal places of precision. @@ -17,6 +19,9 @@ It is a fixed-point representation where: This allows precise decimal arithmetic using only integer operations, avoiding the pitfalls of floating-point arithmetic in smart contracts. +For `x * y / denominator` on raw `i128` or `I256` values with an explicit rounding direction, see +[Fixed-Point Math](/stellar-contracts/utils/math/fixed-point). + # Why WAD? ## Shortcomings of Integers and Float Numbers @@ -83,6 +88,9 @@ All operations truncate toward zero rather than rounding: - **Conservative**: In financial calculations, truncation is often safer (e.g., don't over-calculate interest) - **Fast**: No additional logic needed +When you need to round up or down explicitly (e.g. in favour of a vault), use the +[fixed-point free functions](/stellar-contracts/utils/math/fixed-point) with a `Rounding` direction. + ## 4. Operator Overloading We provide operator overloading (`+`, `-`, `*`, `/`, `-`) for convenience: @@ -104,12 +112,31 @@ Operator overloading is supported across WAD and native i128 types where unambig **Overflow Behavior** -Just like regular Rust, operator overloading does not include overflow checks: +Operators work directly on the raw `i128` and panic on overflow. With `overflow-checks = true`, which the standard +Stellar release profile sets and `stellar contract build` enforces, the panic is a native wasm trap +(`Error(WasmVm, InvalidAction)`), not a `SorobanFixedPointError`. In a build without it, every row below except +`a / n` wraps silently instead. + +Unlike the `checked_*` methods, operators get no [phantom overflow](#notes-on-powi-and-phantom-overflow) handling, +because they cannot reach an `Env` to promote the intermediate product to `I256`. Their domains are therefore much +narrower: -- Use `checked_*` methods (`checked_add()`, `checked_sub()`, `checked_mul()`, etc.) when handling user inputs or when overflow is possible. These return `Option` for safe error handling. -- Use operator overloads (`+`, `-`, `*`, `/`) when you want to reduce computational overhead by skipping overflow checks, or when you're confident the operation cannot overflow. +| Expression | Panics once | +|------------|-------------| +| `a + b`, `a - b` | the result's magnitude passes `i128::MAX / WAD_SCALE` ≈ `1.7014 × 10^20` | +| `a * b` | the **product** `a * b` passes `i128::MAX / WAD_SCALE^2` ≈ `170.1412` | +| `a / b` | **`a`** alone passes `i128::MAX / WAD_SCALE^2` ≈ `170.1412`, whatever `b` is | +| `a * n`, `n * a` (`i128`) | `a.raw() * n` leaves `i128` | +| `a / n` (`i128`) | `n == 0`, or `a.raw() == i128::MIN && n == -1` | +| `-a` | `a.raw() == i128::MIN` | -This design follows Rust's standard library pattern: operators for performance, checked methods for safety. +The `*` bound constrains the product rather than either operand: `Wad(17) * Wad(10)` succeeds while +`Wad(171) * Wad(1)` panics. Values below 1 keep plenty of headroom, but a price of `2000` scaled by a quantity +already exceeds the bound. + +- Use `checked_mul()` / `checked_div()` wherever an operand can plausibly exceed these bounds, e.g. token amounts + or prices. They succeed over the full range in which the result is representable and return `Option`. +- Use operators only when the operands are known to stay within the bounds above. @@ -157,28 +184,64 @@ impl Div for Wad { ## Exponentiation -WAD supports raising a value to an unsigned integer exponent via `pow`. +WAD supports raising a value to an unsigned integer exponent via `powi`. -- `pow(&e, exponent)` is optimized using exponentiation by squaring (O(log n) multiplications). +- `powi(&e, exponent)` is optimized using exponentiation by squaring (O(log n) multiplications). - Each multiplication keeps WAD semantics (fixed-point multiplication and truncation toward zero). - Overflow is reported via Soroban errors. -In addition to `pow`, WAD also provides `checked_pow`, which returns `None` on overflow. +In addition to `powi`, WAD also provides `checked_powi`, which returns `None` on overflow. ```rust // Compound interest multiplier: (1.05)^10 let rate = Wad::from_ratio(&e, 105, 100); // 1.05 -let multiplier = rate.pow(&e, 10); +let multiplier = rate.powi(&e, 10); ``` -### Notes on `pow` and Phantom Overflow +### Notes on `powi` and Phantom Overflow -`pow` / `checked_pow` are implemented using exponentiation by squaring and rely +`powi` / `checked_powi` are implemented using exponentiation by squaring and rely on Soroban fixed-point helpers that can automatically scale intermediate products to `I256` when needed. This avoids **phantom overflow** cases where an intermediate multiplication would -overflow `i128`, but the final scaled result would still fit in `i128`. +overflow `i128`, but the final scaled result would still fit in `i128`. The same handling applies to `from_ratio`, +`checked_mul`, `checked_div` and `powf`, but not to the operators. See +[Fixed-Point Math](/stellar-contracts/utils/math/fixed-point#phantom-overflow) for how it works. + +## Logarithm, Exponential and Fractional Powers + +`Wad` also provides transcendental functions, each in a panicking and a checked variant returning `Option`: + +- `ln(&e)` / `checked_ln(&e)`: natural logarithm. Panics with `InvalidBase` when `self <= 0`. +- `exp(&e)` / `checked_exp(&e)`: natural exponential `e^self`, where `e ≈ 2.71828` is Euler's number. This is not a + generic power function; use `powf` for `x^y`. The result truncates to `0` for inputs below `≈ -41.446`. Since the + result must fit in `i128`, it overflows (`exp` panics with `Overflow`, `checked_exp` returns `None`) once the input + exceeds `ln(i128::MAX / 10^18) ≈ 46.58`, well before the `≈ 135.305` limit of the internal 256-bit computation. +- `powf(&e, exponent)` / `checked_powf(&e, exponent)`: raises `self` to a `Wad` exponent, computed as + `exp(exponent * ln(self))`. When `exponent` is an exact non-negative integer that fits in `u32`, it delegates to + `powi` instead, which is faster, more precise, and also accepts a negative base (e.g. `(-2)^3 = -8`). + +`powf` special cases: + +- `x^0 = 1` for any `x`, including `0^0 = 1` (the same convention as `powi`) +- `0^y = 0` for `y > 0` +- `1^y = 1` and `x^1 = x` + +`powf` panics with `DivisionByZero` for `0^y` with `y < 0`, with `InvalidBase` for a negative base unless the +exponent is a non-negative integer in the `u32` range, and with `Overflow` when the result does not fit. +`checked_powf` returns `None` in all of these cases. + +```rust +// 2^0.5 ≈ √2 +let two = Wad::from_integer(&e, 2); +let half = Wad::from_ratio(&e, 1, 2); +let sqrt_two = two.powf(&e, half); + +// ln and exp are inverses +let ten = Wad::from_integer(&e, 10); +let roundtrip = ten.ln(&e).exp(&e); // ≈ 10.0 +``` ## Token Conversions @@ -240,18 +303,19 @@ let result2 = a * (b * c); // Truncates after inner multiplication use soroban_sdk::Env; use stellar_contract_utils::math::wad::Wad; -fn calculate_interest(e: &Env, principal: i128, rate_bps: u32) -> i128 { +fn calculate_interest(e: &Env, principal: i128, rate_bps: u32) -> Option { // Convert principal (assume 6 decimals like USDC) let principal_wad = Wad::from_token_amount(e, principal, 6); // Rate in basis points (e.g., 550 = 5.5%) let rate_wad = Wad::from_ratio(e, rate_bps as i128, 10_000); - // Calculate interest - let interest_wad = principal_wad * rate_wad; + // Calculate interest. `principal_wad * rate_wad` would panic once the + // product exceeds ~170.14, i.e. for any principal above ~3,093 USDC at 5.5% + let interest_wad = principal_wad.checked_mul(e, rate_wad)?; // Convert back to token amount - interest_wad.to_token_amount(e, 6) + Some(interest_wad.to_token_amount(e, 6)) } ``` @@ -263,19 +327,20 @@ fn calculate_swap_output( amount_in: i128, reserve_in: i128, reserve_out: i128, -) -> i128 { +) -> Option { // Convert to WAD let amount_in_wad = Wad::from_token_amount(e, amount_in, 6); let reserve_in_wad = Wad::from_token_amount(e, reserve_in, 6); let reserve_out_wad = Wad::from_token_amount(e, reserve_out, 6); // Constant product formula: amount_out = (amount_in * reserve_out) / (reserve_in + amount_in) - let numerator = amount_in_wad * reserve_out_wad; - let denominator = reserve_in_wad + amount_in_wad; - let amount_out_wad = numerator / denominator; + // Reserves easily exceed the operator bounds, so use the checked methods + let numerator = amount_in_wad.checked_mul(e, reserve_out_wad)?; + let denominator = reserve_in_wad.checked_add(amount_in_wad)?; + let amount_out_wad = numerator.checked_div(e, denominator)?; // Convert back - amount_out_wad.to_token_amount(e, 6) + Some(amount_out_wad.to_token_amount(e, 6)) } ``` @@ -287,15 +352,18 @@ fn calculate_compound_interest( principal: i128, annual_rate_bps: u32, days: u32, -) -> i128 { +) -> Option { let principal_wad = Wad::from_token_amount(e, principal, 6); + let one = Wad::from_integer(e, 1); let rate = Wad::from_ratio(e, annual_rate_bps as i128, 10_000); - let time_fraction = Wad::from_ratio(e, days as i128, 365); + let years = Wad::from_ratio(e, days as i128, 365); - // Simple interest: principal * rate * time - let interest = principal_wad * rate * time_fraction; + // Annual compounding over a fractional number of years: + // interest = principal * ((1 + rate)^years - 1) + let growth = one.checked_add(rate)?.checked_powf(e, years)?; + let interest = principal_wad.checked_mul(e, growth.checked_sub(one)?)?; - interest.to_token_amount(e, 6) + Some(interest.to_token_amount(e, 6)) } ``` @@ -348,6 +416,9 @@ fn safe_multiply(e: &Env, a: i128, b: i128) -> Result { | `a / n` | Divide WAD by integer | `Wad(7.5) / 3` → 2.5 | | `-a` | Negation | `-Wad(5.0)` → -5.0 | +Operators panic with a native wasm trap on overflow and have narrower bounds than the checked methods; see +[Operator Overloading](#4-operator-overloading). + ## Checked Arithmetic | Method | Returns | Description | @@ -357,8 +428,11 @@ fn safe_multiply(e: &Env, a: i128, b: i128) -> Result { | `checked_mul(e, rhs)` | `Option` | Multiplication with overflow check (handles phantom overflow internally) | | `checked_div(e, rhs)` | `Option` | Division with overflow/zero check | | `checked_mul_int(n)` | `Option` | Integer multiplication with overflow check | -| `checked_div_int(n)` | `Option` | Integer division with zero check | -| `checked_pow(e, exponent)` | `Option` | Exponentiation with overflow check | +| `checked_div_int(n)` | `Option` | Integer division; `None` on division by zero or `i128::MIN / -1` | +| `checked_powi(e, exponent)` | `Option` | Integer exponentiation with overflow check | +| `checked_powf(e, exponent)` | `Option` | `Wad` exponentiation; `None` on overflow, `0^y` with `y < 0`, or an invalid base | +| `checked_ln(e)` | `Option` | Natural logarithm; `None` when `self <= 0` | +| `checked_exp(e)` | `Option` | Natural exponential `e^self`; `None` on overflow | ## Utility Methods @@ -367,15 +441,25 @@ fn safe_multiply(e: &Env, a: i128, b: i128) -> Result { | `abs()` | Absolute value | | `min(other)` | Minimum of two values | | `max(other)` | Maximum of two values | -| `pow(e, exponent)` | Raises WAD to an unsigned integer power (panics with Soroban error on overflow) | +| `powi(e, exponent)` | Raises WAD to an unsigned integer power (panics with Soroban error on overflow) | +| `powf(e, exponent)` | Raises WAD to a `Wad` power via `exp(exponent * ln(self))` (panics with Soroban error) | +| `ln(e)` | Natural logarithm (panics with `InvalidBase` when `self <= 0`) | +| `exp(e)` | Natural exponential `e^self` (panics with `Overflow` when the result does not fit) | ## Error Handling -WAD uses Soroban's contract error system via `SorobanFixedPointError`: +The panicking methods (`from_integer`, `from_ratio`, `powi`, `powf`, `ln`, `exp`, etc.) use Soroban's contract +error system via `SorobanFixedPointError`: ```rust pub enum SorobanFixedPointError { Overflow = 1500, DivisionByZero = 1501, + // `ln(x)` with `x <= 0`, or `powf` with a negative base and an exponent + // that is not a non-negative integer + InvalidBase = 1502, } ``` + +The operators (`+`, `-`, `*`, `/`, unary `-`) do not raise these errors: they panic with a native arithmetic trap, +which surfaces on-chain as `Error(WasmVm, InvalidAction)`. diff --git a/content/stellar-contracts/utils/pausable.mdx b/content/stellar-contracts/utils/pausable.mdx index fc741f70..fcc5619e 100644 --- a/content/stellar-contracts/utils/pausable.mdx +++ b/content/stellar-contracts/utils/pausable.mdx @@ -8,11 +8,12 @@ title: Pausable Allows contracts to be paused and unpaused by authorized accounts. -This utility contract can be used with any token standard (fungible, non-fungible, multi-token). +This utility can be used in any contract, including token contracts (e.g. fungible or non-fungible tokens). ## Design -To make it easier to spot when inspecting the code, we turned this simple functionality into a macro that can annotate your smart contract functions. +To make it easier to spot when inspecting the code, we turned this simple functionality into two macros that can +annotate your smart contract functions: `#[when_not_paused]` and `#[when_paused]`. An example: ```rust @@ -26,15 +27,56 @@ Which will expand into the below code: ```rust pub fn emergency_reset(e: &Env) { - when_paused(e); + stellar_contract_utils::pausable::when_paused(e); e.storage().instance().set(&DataKey::Counter, &0); } ``` +`#[when_not_paused]` works the same way, injecting `stellar_contract_utils::pausable::when_not_paused(e)`. +`when_paused` panics with `PausableError::ExpectedPause` (`1001`) when the contract is not paused, and `when_not_paused` +panics with `PausableError::EnforcedPause` (`1000`) when it is. + ## Implementing Pausable Trait +The `pausable::pause()` and `pausable::unpause()` helpers don't check authorization, so access control must be added +by the implementor. The example below restricts them to the owner with +[`#[only_owner]`](/stellar-contracts/access/ownable): + ```rust +use soroban_sdk::{contract, contractimpl, contracttype, Address, Env}; +use stellar_access::ownable::{self as ownable, Ownable}; +use stellar_contract_utils::pausable::{self as pausable, Pausable}; +use stellar_macros::{only_owner, when_not_paused, when_paused}; + +#[contracttype] +pub enum DataKey { + Counter, +} + +#[contract] +pub struct ExampleContract; + +#[contractimpl] +impl ExampleContract { + pub fn __constructor(e: &Env, owner: Address) { + ownable::set_owner(e, &owner); + e.storage().instance().set(&DataKey::Counter, &0); + } + + #[when_not_paused] + pub fn increment(e: &Env) -> i32 { + let counter: i32 = e.storage().instance().get(&DataKey::Counter).unwrap(); + e.storage().instance().set(&DataKey::Counter, &(counter + 1)); + counter + 1 + } + + #[when_paused] + pub fn emergency_reset(e: &Env) { + e.storage().instance().set(&DataKey::Counter, &0); + } +} + #[contractimpl] impl Pausable for ExampleContract { fn paused(e: &Env) -> bool { @@ -51,6 +93,13 @@ impl Pausable for ExampleContract { pausable::unpause(e); } } -``` +#[contractimpl(contracttrait)] +impl Ownable for ExampleContract {} +``` +See the [pausable example](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/pausable) for a +complete contract, and the +[fungible-pausable example](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/fungible-pausable) +for `#[when_not_paused]` applied to the `transfer`, `transfer_from`, `burn` and `burn_from` overrides of a fungible +token. diff --git a/content/stellar-contracts/utils/upgradeable.mdx b/content/stellar-contracts/utils/upgradeable.mdx index 3992e0c4..8dcad051 100644 --- a/content/stellar-contracts/utils/upgradeable.mdx +++ b/content/stellar-contracts/utils/upgradeable.mdx @@ -99,6 +99,7 @@ let config: Config = e.storage().instance().get(&key).unwrap(); ### Pattern 1: Eager Migration (Bounded Data) For bounded data in instance storage (config, metadata, settings), add a `migrate` function to the upgraded contract that reads old-format data and converts it. Use `set_schema_version` / `get_schema_version` to guard against double invocation. +Like `upgrade`, `migrate` must enforce its own access control; `set_schema_version` has no authorization checks. The old type must be defined in the new contract code so the host can deserialize it correctly. @@ -118,6 +119,7 @@ pub struct Config { const CONFIG_KEY: Symbol = symbol_short!("CONFIG"); +#[only_role(operator, "migrator")] pub fn migrate(e: &Env, operator: Address) { assert!(upgradeable::get_schema_version(e) < 2, "already migrated"); @@ -229,28 +231,54 @@ atomic upgrade-and-migrate process. This approach ensures that the migration log upgrade without requiring a separate transaction. ```rust -use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Val}; +use soroban_sdk::{contract, contractimpl, symbol_short, Address, BytesN, Env, Symbol, Val}; +use stellar_access::ownable; use stellar_contract_utils::upgradeable::UpgradeableClient; +use stellar_macros::only_owner; + +pub const MIGRATE: Symbol = symbol_short!("migrate"); #[contract] pub struct Upgrader; #[contractimpl] impl Upgrader { + pub fn __constructor(e: &Env, owner: Address) { + ownable::set_owner(e, &owner); + } + + #[only_owner] pub fn upgrade_and_migrate( - env: Env, + e: &Env, contract_address: Address, operator: Address, wasm_hash: BytesN<32>, migration_data: soroban_sdk::Vec, ) { - operator.require_auth(); - let contract_client = UpgradeableClient::new(&env, &contract_address); + let contract_client = UpgradeableClient::new(e, &contract_address); contract_client.upgrade(&wasm_hash, &operator); - // The types of the arguments to the migrate function are unknown to this - // contract, so we need to call it with invoke_contract. - env.invoke_contract::<()>(&contract_address, &symbol_short!("migrate"), migration_data); + // The types of the arguments to the migrate function are unknown to + // this contract, so we need to call it with invoke_contract. + e.invoke_contract::<()>(&contract_address, &MIGRATE, migration_data); } } ``` + +The `Upgrader` is gated with `#[only_owner]`, while the target contract still enforces its own access control on +`upgrade` and `migrate`: `operator` and the account passed in `migration_data` must hold the required roles and +authorize those calls. + +## Examples + +Full examples are available under +[`examples/upgradeable`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/upgradeable): + +* [`v1`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/upgradeable/v1) / + [`v2`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/upgradeable/v2) — eager migration of + bounded instance storage (Pattern 1). +* [`lazy-v1`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/upgradeable/lazy-v1) / + [`lazy-v2`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/upgradeable/lazy-v2) — lazy + per-entry migration of unbounded persistent storage (Pattern 2). +* [`upgrader`](https://github.com/OpenZeppelin/stellar-contracts/tree/main/examples/upgradeable/upgrader) — the + `Upgrader` helper above, upgrading `v1` to `v2` and migrating atomically. diff --git a/src/navigation/stellar.json b/src/navigation/stellar.json index 66ff9563..61db4ab5 100644 --- a/src/navigation/stellar.json +++ b/src/navigation/stellar.json @@ -203,6 +203,11 @@ "type": "page", "name": "WAD", "url": "/stellar-contracts/utils/math/wad" + }, + { + "type": "page", + "name": "Fixed-Point", + "url": "/stellar-contracts/utils/math/fixed-point" } ] }, From 98aacc2d083c03841d7c7d6409e67c468a171218 Mon Sep 17 00:00:00 2001 From: brozorec <9572072+brozorec@users.noreply.github.com> Date: Wed, 7 Oct 2026 10:54:24 +0200 Subject: [PATCH 5/5] Stellar: align error code conventions with stellar-contracts allocation - List the governance blocks (Timelock 40XX, Votes 41XX, Governor 42XX), now that GovernorError moved from 5000-5023 to 4200-4223 (#928). - Drop Upgradeable 11XX: the Upgradeable module has no error enum. - Mention the reserved 39XX block and that codes below 100 are left to contract-specific errors. --- content/stellar-contracts/index.mdx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/content/stellar-contracts/index.mdx b/content/stellar-contracts/index.mdx index 8e7e6740..a8beb21e 100644 --- a/content/stellar-contracts/index.mdx +++ b/content/stellar-contracts/index.mdx @@ -47,8 +47,9 @@ for access control and contract management. Our contracts are built with security as a top priority. You can find our audit reports [here](https://github.com/OpenZeppelin/stellar-contracts/tree/main/audits). ## Error Codes -In Stellar Soroban, each error variant is assigned an integer. To prevent duplication of error codes, -we use the following convention: +In Stellar Soroban, each error variant is assigned an integer. A failed invocation reports only that integer, +e.g. `Error(Contract, #4200)`, and not which contract in the call tree raised it, so every error code is unique +across the library. We use the following convention: * Fungible: `1XX` * Non-Fungible: `2XX` @@ -62,7 +63,6 @@ Similarly, utilities and other modules have their own error codes: * Utilities: `1XXX` * Pausable: `10XX` - * Upgradeable: `11XX` * Merkle Distributor: `13XX` * Crypto: `14XX` * Math: `15XX` @@ -77,9 +77,15 @@ Similarly, utilities and other modules have their own error codes: * Token: `35XX` * Compliance: `36XX` * Governance: `4XXX` + * Timelock: `40XX` + * Votes: `41XX` + * Governor: `42XX` * Fee Abstraction: `5XXX` * ZK Email: `6XXX` +The library never uses `39XX`, which is reserved for smart account policies built outside of it, nor codes +below `100`, which are left to contract-specific errors. + ## Important Notes As a deliberate design choice, this library manages the TTL for temporary and persistent storage items. To provide flexibility to the owner of the contract, this library deliberately does not manage the TTL for instance storage items.