OpenZeppelin Stellar Contracts Architecture
July 3, 2026 · View on GitHub
This document outlines the architectural design and structure of the OpenZeppelin Stellar Contracts library, a comprehensive collection of smart contracts for the Stellar network built using the Soroban SDK.
Overview
The OpenZeppelin Stellar Contracts library follows a modular, trait-based architecture that promotes code reusability, extensibility, and maintainability. The architecture is designed to provide both high-level convenience functions and low-level granular control, allowing developers to choose the appropriate level of abstraction for their use cases.
Project Structure
stellar-contracts/
├── packages/ # Core library packages
│ ├── access/ # Role-based access controls and ownable patterns
│ ├── contract-utils/ # Utilities (pausable, upgradeable, cryptography)
│ ├── macros/ # Procedural and derive macros
│ └── tokens/ # Token implementations (fungible, non-fungible)
│ ├── src/
│ │ ├── fungible/ # Fungible token implementation
│ │ │ ├── extensions/ # Optional token extensions
│ │ │ ├── utils/ # Utility functions and helpers
│ │ │ ├── mod.rs # Core trait definitions, constants, errors, and events
│ │ │ ├── storage.rs # Storage management and state operations
│ │ │ └── test.rs # Comprehensive test suite
│ │ └── non_fungible/ # Non-fungible token implementation
│ └── lib.rs
├── examples/ # Example contract implementations
└── audits/ # Security audit reports
Core Architectural Principles
1. Trait-Based Design with Associated Types
The library extensively uses Rust traits to define standard interfaces and behaviors, with a sophisticated approach to enable method overriding, and enforce mutually exclusive extensions through associated types:
Enforcing Mutually Exclusive Extensions
One of the most sophisticated aspects of this architecture is how it prevents incompatible extensions from being used together. This is achieved through associated types and trait bounds:
// Core trait with associated type
trait NonFungibleToken {
type ContractType: ContractOverrides;
fn transfer(e: &Env, from: Address, to: Address, token_id: u32) {
Self::ContractType::transfer(e, from, to, token_id);
}
// ... other methods
}
// Contract type markers
pub struct Base; // Default implementation
pub struct Enumerable; // For enumeration features
pub struct Consecutive; // For batch minting optimization
Extension Trait Constraints
Extensions are constrained to specific contract types using associated type bounds:
// Enumerable can only be used with Enumerable contract type
trait NonFungibleEnumerable: NonFungibleToken<ContractType = Enumerable> {
fn total_supply(e: &Env) -> u32;
fn get_owner_token_id(e: &Env, owner: Address, index: u32) -> u32;
// ...
}
// Consecutive can only be used with Consecutive contract type
trait NonFungibleConsecutive: NonFungibleToken<ContractType = Consecutive> {
// Batch minting functionality
}
Mutual Exclusivity Enforcement
This design makes it impossible to implement conflicting extensions:
// ✅ This works - using Enumerable
impl NonFungibleToken for MyContract {
type ContractType = Enumerable;
// ... implementations
}
impl NonFungibleEnumerable for MyContract {
// ... enumerable methods
}
// ❌ This CANNOT compile - Consecutive requires different ContractType
// impl NonFungibleConsecutive for MyContract { ... }
// ^^^ Error: expected `Consecutive`, found `Enumerable`
Override Mechanism Through ContractOverrides
Note:
ContractOverrides(and its siblings such asBurnableOverrides) are internal plumbing of the library. Contract authors do not need to implement, name, or import them. AContractTypeis selected on the token trait, the method bodies can be left empty, and the#[contractimpl(contracttrait)]macro routes each call to the right behavior. The mechanism below is described only to explain what happens under the hood.
The ContractOverrides trait provides the actual implementations that vary by contract type:
trait ContractOverrides {
fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) {
// Default implementation (used by Base)
Base::transfer(e, from, to, token_id);
}
// ... other overridable methods
}
// Base uses default implementations
impl ContractOverrides for Base {}
// Enumerable overrides specific methods
impl ContractOverrides for Enumerable {
fn transfer(e: &Env, from: &Address, to: &Address, token_id: u32) {
// Custom enumerable transfer logic
Enumerable::transfer(e, from, to, token_id);
}
}
// Consecutive overrides different methods
impl ContractOverrides for Consecutive {
fn owner_of(e: &Env, token_id: u32) -> Address {
// Custom consecutive ownership lookup
Consecutive::owner_of(e, token_id)
}
}
Benefits of This Approach
- Compile-Time Safety: Incompatible extensions cannot be combined
- Zero Runtime Overhead: All dispatch is resolved at compile time
- Intuitive API: Developers don't need to specify generics or complex types
- Automatic Behavior Override: Methods automatically use the correct implementation based on contract type
- Modular Design: Extensions can be developed and maintained independently
This pattern represents a novel solution to the challenge of providing both type safety and developer ergonomics in a trait-based extension system, avoiding the need for runtime checks or complex generic constraints.
2. Dual-Layer Architecture
The library provides two levels of abstraction:
High-Level Functions
- Include all necessary checks, verifications, and authorizations
- Handle state-changing logic and event emissions automatically
- Provide secure defaults and comprehensive error handling
- Ideal for standard use cases and rapid development
Low-Level Functions
- Offer granular control for custom workflows
- Require manual handling of verifications and authorizations
- Enable composition of complex business logic
- Suitable for advanced use cases requiring customization
3. Modular Extension System
The architecture supports optional extensions that can be mixed and matched. Below is the list of the extensions for the Fungible Token:
- Burnable: Token destruction capabilities
- Capped: Maximum supply limits
- Allowlist: Whitelist-based access control
- Blocklist: Blacklist-based access control
- Metadata: Enhanced token information (name, symbol, decimals)
- Vault: Asset deposit/withdrawal with share tokenization
Storage Architecture
Storage Key Design
The library uses a structured approach to storage keys:
#[contracttype]
pub enum StorageKey {
TotalSupply,
Balance(Address),
Allowance(AllowanceKey),
}
TTL Management:
This library handles extension of storage entries to prevent expiration, except from instance storage entry.
Extending the instance storage entries is the responsibility of the contract developer.
Contract Implementation Patterns
1. Base Implementation Pattern
#[contract]
pub struct MyToken;
#[contractimpl(contracttrait)]
impl FungibleToken for MyToken {
type ContractType = Base;
// The macro fills in every method body, no manual overrides needed.
// Alternatively, custom overrides can be provided here (optional)
}
2. Extension Composition Pattern
#[contractimpl(contracttrait)]
impl FungibleBurnable for MyToken {
// Empty body: burn / burn_from are provided by the macro.
}
#[contractimpl(contracttrait)]
impl Pausable for MyToken {
// Pausable functionality
}
3. Macros As Helpers
The library provides macros to improve clarity of the code by annotating the function instead of having the business logic inside the function as a regular code to improve the DevX (i.e. #[only_owner], #[when_not_paused])
Principles for Introducing New Macros
- The reduction in boilerplate must justify the added complexity of a new domain-specific language (DSL).
- Macros should be intuitive: developers should be able to adopt them with minimal reference to documentation; a couple of clear examples should suffice.
- Whenever possible, the logic abstracted by the macro should remain transparent and debuggable, for example by using cargo expand to inspect the generated code. To elaborate more on this: the newly introduced macro should preferably not work directly with other heavy proc macros, since the expanded code will be intertwined with the expanded code of the other macros, and will be much harder to inspect and debug.
Integration Architecture
SEP-41 Compliance
The library ensures full compatibility with SEP-41 (Stellar Enhancement Proposal 41) for fungible tokens:
- Standard interface implementation
- Required function signatures
- Event emission standards
- Error handling conventions
Cross-Ecosystem Compatibility
Designed to mirror familiar standards:
- ERC-20 Similarity: Familiar interface for Ethereum developers
- Stellar Asset Contract Compatibility: Seamless integration with existing Stellar infrastructure
NFT Standard Compatibility
The non-fungible token implementation is designed to be compatible with existing NFT standards while leveraging Stellar's unique capabilities:
-
ERC-721 Similarity: Provides familiar interfaces and patterns for Ethereum developers working with NFTs
- Core ownership and transfer functionality
- Approval mechanisms (single token and operator approvals)
- Metadata handling
- Standard events (Transfer, Approval, ApprovalForAll)
-
SEP Extensions: Incorporates Stellar-specific enhancements for NFT functionality
- Optimized for Stellar's execution environment
- Compatible with the broader Stellar ecosystem
- Designed for cross-chain interoperability
Performance Considerations
- Read operations are free in Stellar: this means when designing the contract, the main goal is to minimize the write operations. We can be generous with read operations.
- Computation is generally cheap in Stellar: having clean code, that is maintainable, readable, and optimized for the developer experience is a higher priority than squeezing every last bit of performance out of the contract. Optimization whenever possible, is still our goal, but not at the cost of developer experience. A good balance can be seen in
enumerableandconsecutiveextensions designs. These extensions have taken costs into consideration, have optimal code that minimizes the gas usage, yet provides clean and maintainable code that is easy to understand and debug.
Testing Architecture
Comprehensive Test Coverage
- Unit Tests: Individual function testing
- Integration Tests: Cross-module interaction testing
- Property-Based Testing: Invariant verification
- Fuzzing: Edge case discovery
Deployment Architecture
WASM Compilation
- Target:
wasm32v1-none - Optimization: Release builds with size optimization
- No-std Environment: Minimal runtime footprint
Noir Circuits
Token kinds that require zero-knowledge proofs (currently confidential/) ship Noir circuits alongside the Soroban contract. Noir is compiled by nargo, not cargo, and its packages are described by Nargo.toml, so the Noir tree forms its own workspace independent of the Cargo workspace — nothing in Cargo.toml references it and nothing inside the Noir tree references Cargo.toml.
Noir package model
Noir has two package types, declared by type = in each Nargo.toml:
lib— exports items, has no entry point. Equivalent to a Rust[lib].bin— defines one circuit viafn main(...). The parameters taggedpubare the circuit's public inputs; everything else is a private witness.nargo compileemits ACIR for that circuit;nargo inforeports its constraint cost;nargo prove/nargo verifyoperate on it.
Layout
Noir artifacts for a token kind live under packages/tokens/src/<kind>/circuits/:
packages/tokens/src/<kind>/circuits/
├── Nargo.toml # Nargo workspace manifest
├── lib/ # Shared primitives (type = "lib", no `main`)
│ ├── src/lib.nr
│ └── testdata/*.json # cross-language test vectors
├── gadgets/<primitive>/ # one tiny `bin` crate per primitive
│ └── src/main.nr # measurement-only: wraps one lib call
└── <operation>/ # full circuits (planned -- one per proof type,
└── src/main.nr # e.g. register / withdraw / transfer)
The main.nr files in gadgets/ and <operation>/ are both Noir circuit entry points, but they serve very different purposes:
- Gadget
mains are measurement-only. Each one wraps a single lib primitive in the smallest circuit that exercises it, sonargo infocan report per-primitive constraint costs. A Noirbinpackage has exactly onefn main, andnargo infoonly measuresmain. Per-primitive constraint counts therefore require onebinpackage per primitive. The alternative (one gadget that calls every primitive in sequence) collapses everything into a single combined number and loses the per-primitive breakdown the constraint baseline depends on. - Operation
mains are the production circuits — one per proof type listed in the design doc's §7. They consume on-chain state as public inputs and enforce the constraint set that the wrapper contract relies on.
Commands
| Task | Command (run from packages/tokens/src/<kind>/circuits/) |
|---|---|
| Type-check the workspace | nargo check |
| Run all tests | nargo test |
| Per-primitive constraint counts | nargo info |
| Compile a single circuit | nargo compile --package <name> |
Compiled ACIR and proving artifacts are gitignored; testdata/*.json is the only Noir-side artifact committed to the repo.
Code Conventions
- We are strictly following
cargo fmtandcargo clippyrules - We prefer to use declarative code over imperative code
- We aim for the most idiomatic Rust code
AI Usage Guidelines
- Follow code conventions and folder structure
- Use existing types/functions when possible
- Avoid introducing new dependencies without justification
- Always prefer declarative over imperative code where applicable