Tools
Tools: Improving the Equillar Contract with OpenZeppelin
2026-01-24
0 views
admin
Introduction ## 1. Financial Mathematics ## The Previous Approach ## The New Approach: OpenZeppelin's WAD ## What Did We Improve? ## 2. Access Control ## The Previous Approach ## The New Approach: OpenZeppelin's Ownable Module ## What Did We Improve? ## 3. Pausing the Contract ## The Original Approach ## The New Approach: Pausable Trait ## What Did We Improve? ## Conclusion I'm constantly researching ways to improve the code for the Equillar contract, and I recently discovered the OpenZeppelin libraries for Stellar. While exploring them, I realized that many of the functionalities I had implemented already existed in reliable, community-maintained libraries, so I decided to refactor parts of the contract code to leverage these tools. The result was a cleaner and more readable contract. My intention in this post is to share how I refactored these parts and what improvements I achieved. The contract calculated interest amounts manually. Something like this: The code worked, but it had a fundamental limitation: it performed calculations directly using the token's decimals (7 decimals in my case). When you perform multiplication and division operations with integers in smart contracts, there can be cases (for example when many operations are chained) where you can lose precision due to integer rounding. WAD is a standard for fixed-point arithmetic with 18 decimals of precision. It's the same one used by DeFi on Ethereum, so there are years of battle-testing behind it. I had my own access control system for admin-only functions: This is correct as it uses Soroban's authentication standard, but we can improve the code
with OpenZeppelin's Ownable module. I had a Paused state in my enum and custom functions: These two functions simply mark the contract as "Paused" and later reactivate them, in addition to controlling that already paused contracts cannot be paused nor can already active contracts be reactivated. In the next section, we'll see how we can greatly reduce the code using OpenZeppelin's Pausable trait. Now the State enum only reflects business logic: Modernizing code isn't admitting it was "wrong" before. It's recognizing that the industry evolves and there are better tools available, and that using them in your contracts or applications can make them more professional and standard. If you're writing Soroban contracts, I recommend exploring OpenZeppelin before implementing custom logic. Not for all cases, but for common ones (mathematics, access control, pausing etc), there are already better solutions that can be very useful. Note: If you want to see the complete code, you can access the repository "https://github.com/icolomina/soroban-contracts-examples/tree/main" and explore the open-zeppelin-utilities branch. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse CODE_BLOCK:
// Calculate 2.37% commission (237 basis points)
let amount_to_commission = amount * 237 / (rate_denominator as i128) / 100 / 100; // Twice by 100 to downscale and apply % // Calculate 5% reserve fund
let amount_to_reserve_fund = amount * 5 / 100; // What remains for investment
let amount_to_invest = amount - amount_to_commission - amount_to_reserve_fund; Amount { amount_to_invest, amount_to_reserve_fund, amount_to_commission,
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// Calculate 2.37% commission (237 basis points)
let amount_to_commission = amount * 237 / (rate_denominator as i128) / 100 / 100; // Twice by 100 to downscale and apply % // Calculate 5% reserve fund
let amount_to_reserve_fund = amount * 5 / 100; // What remains for investment
let amount_to_invest = amount - amount_to_commission - amount_to_reserve_fund; Amount { amount_to_invest, amount_to_reserve_fund, amount_to_commission,
} CODE_BLOCK:
// Calculate 2.37% commission (237 basis points)
let amount_to_commission = amount * 237 / (rate_denominator as i128) / 100 / 100; // Twice by 100 to downscale and apply % // Calculate 5% reserve fund
let amount_to_reserve_fund = amount * 5 / 100; // What remains for investment
let amount_to_invest = amount - amount_to_commission - amount_to_reserve_fund; Amount { amount_to_invest, amount_to_reserve_fund, amount_to_commission,
} COMMAND_BLOCK:
// Now: Using WAD
use stellar_contract_utils::wad::Wad; pub fn from_investment(e: &Env, amount: &i128, i_rate: &u32, decimals: u8) -> Amount { let rate_denominator: u32 = calculate_rate_denominator(&amount, decimals as u32); // Convert amount to WAD for high-precision calculations let amount_wad = Wad::from_token_amount(e, *amount, decimals); // Calculate commission rate as a precise ratio let commission_rate = Wad::from_ratio( e, *i_rate as i128, (rate_denominator as i128) * 10_000 ); // Reserve rate: 5% let reserve_rate = Wad::from_ratio(e, 5, 100); // Perform calculations with 18 decimals of precision let amount_to_commission_wad = amount_wad * commission_rate; let amount_to_reserve_fund_wad = amount_wad * reserve_rate; let amount_to_invest_wad = amount_wad - amount_to_commission_wad - amount_to_reserve_fund_wad; // Convert back to token amounts Amount { amount_to_commission: amount_to_commission_wad.to_token_amount(e, decimals), amount_to_reserve_fund: amount_to_reserve_fund_wad.to_token_amount(e, decimals), amount_to_invest: amount_to_invest_wad.to_token_amount(e, decimals), }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// Now: Using WAD
use stellar_contract_utils::wad::Wad; pub fn from_investment(e: &Env, amount: &i128, i_rate: &u32, decimals: u8) -> Amount { let rate_denominator: u32 = calculate_rate_denominator(&amount, decimals as u32); // Convert amount to WAD for high-precision calculations let amount_wad = Wad::from_token_amount(e, *amount, decimals); // Calculate commission rate as a precise ratio let commission_rate = Wad::from_ratio( e, *i_rate as i128, (rate_denominator as i128) * 10_000 ); // Reserve rate: 5% let reserve_rate = Wad::from_ratio(e, 5, 100); // Perform calculations with 18 decimals of precision let amount_to_commission_wad = amount_wad * commission_rate; let amount_to_reserve_fund_wad = amount_wad * reserve_rate; let amount_to_invest_wad = amount_wad - amount_to_commission_wad - amount_to_reserve_fund_wad; // Convert back to token amounts Amount { amount_to_commission: amount_to_commission_wad.to_token_amount(e, decimals), amount_to_reserve_fund: amount_to_reserve_fund_wad.to_token_amount(e, decimals), amount_to_invest: amount_to_invest_wad.to_token_amount(e, decimals), }
} COMMAND_BLOCK:
// Now: Using WAD
use stellar_contract_utils::wad::Wad; pub fn from_investment(e: &Env, amount: &i128, i_rate: &u32, decimals: u8) -> Amount { let rate_denominator: u32 = calculate_rate_denominator(&amount, decimals as u32); // Convert amount to WAD for high-precision calculations let amount_wad = Wad::from_token_amount(e, *amount, decimals); // Calculate commission rate as a precise ratio let commission_rate = Wad::from_ratio( e, *i_rate as i128, (rate_denominator as i128) * 10_000 ); // Reserve rate: 5% let reserve_rate = Wad::from_ratio(e, 5, 100); // Perform calculations with 18 decimals of precision let amount_to_commission_wad = amount_wad * commission_rate; let amount_to_reserve_fund_wad = amount_wad * reserve_rate; let amount_to_invest_wad = amount_wad - amount_to_commission_wad - amount_to_reserve_fund_wad; // Convert back to token amounts Amount { amount_to_commission: amount_to_commission_wad.to_token_amount(e, decimals), amount_to_reserve_fund: amount_to_reserve_fund_wad.to_token_amount(e, decimals), amount_to_invest: amount_to_invest_wad.to_token_amount(e, decimals), }
} COMMAND_BLOCK:
// Before: Custom admin and manual validation
#[contracttype]
pub struct ContractData { pub admin: Address, // ← Admin in the data pub project_address: Address, // ... more fields
} // Helper function to validate admin
fn require_admin(env: &Env) -> ContractData { let contract_data = get_contract_data(env); contract_data.admin.require_auth(); contract_data
} // In each administrative function:
#[contractimpl]
impl InvestmentContract { pub fn single_withdrawn(env: Env, amount: i128) -> Result<bool, Error> { require_admin(&env); // ... logic }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// Before: Custom admin and manual validation
#[contracttype]
pub struct ContractData { pub admin: Address, // ← Admin in the data pub project_address: Address, // ... more fields
} // Helper function to validate admin
fn require_admin(env: &Env) -> ContractData { let contract_data = get_contract_data(env); contract_data.admin.require_auth(); contract_data
} // In each administrative function:
#[contractimpl]
impl InvestmentContract { pub fn single_withdrawn(env: Env, amount: i128) -> Result<bool, Error> { require_admin(&env); // ... logic }
} COMMAND_BLOCK:
// Before: Custom admin and manual validation
#[contracttype]
pub struct ContractData { pub admin: Address, // ← Admin in the data pub project_address: Address, // ... more fields
} // Helper function to validate admin
fn require_admin(env: &Env) -> ContractData { let contract_data = get_contract_data(env); contract_data.admin.require_auth(); contract_data
} // In each administrative function:
#[contractimpl]
impl InvestmentContract { pub fn single_withdrawn(env: Env, amount: i128) -> Result<bool, Error> { require_admin(&env); // ... logic }
} COMMAND_BLOCK:
// Now: Using OpenZeppelin's Ownable
use stellar_access::ownable;
use stellar_macros::only_owner;
use soroban_ownable_macro::ownable; #[contract]
pub struct InvestmentContract; #[contractimpl]
impl InvestmentContract { pub fn __constructor( env: Env, owner_addr: Address, // ... more parameters ) { ownable::set_owner(&env, &owner_addr); // OpenZeppelin ownable // ... rest of setup } #[only_owner] // ← Macro that validates automatically pub fn single_withdrawn(env: Env, amount: i128) -> Result<bool, Error> { // No need for require_admin() The macro handles it // ... logic }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// Now: Using OpenZeppelin's Ownable
use stellar_access::ownable;
use stellar_macros::only_owner;
use soroban_ownable_macro::ownable; #[contract]
pub struct InvestmentContract; #[contractimpl]
impl InvestmentContract { pub fn __constructor( env: Env, owner_addr: Address, // ... more parameters ) { ownable::set_owner(&env, &owner_addr); // OpenZeppelin ownable // ... rest of setup } #[only_owner] // ← Macro that validates automatically pub fn single_withdrawn(env: Env, amount: i128) -> Result<bool, Error> { // No need for require_admin() The macro handles it // ... logic }
} COMMAND_BLOCK:
// Now: Using OpenZeppelin's Ownable
use stellar_access::ownable;
use stellar_macros::only_owner;
use soroban_ownable_macro::ownable; #[contract]
pub struct InvestmentContract; #[contractimpl]
impl InvestmentContract { pub fn __constructor( env: Env, owner_addr: Address, // ... more parameters ) { ownable::set_owner(&env, &owner_addr); // OpenZeppelin ownable // ... rest of setup } #[only_owner] // ← Macro that validates automatically pub fn single_withdrawn(env: Env, amount: i128) -> Result<bool, Error> { // No need for require_admin() The macro handles it // ... logic }
} COMMAND_BLOCK:
// Before: Custom pause state
#[contracttype]
pub enum State { Active = 2, FundsReached = 3, Paused = 4, // ← Custom state
} #[contracttype]
pub struct ContractData { pub state: State, // ...
} // Functions to pause/resume
pub fn stop_investments(env: Env) -> Result<bool, Error> { let mut contract_data = require_admin(&env); require!(contract_data.state == State::Active, Error::ContractMustBeActiveToBePaused); contract_data.state = State::Paused; update_contract_data(&env, &contract_data); Ok(true)
} pub fn restart_investments(env: Env) -> Result<bool, Error> { let mut contract_data = require_admin(&env); require!(contract_data.state == State::Paused, Error::ContractMustBePausedToRestartAgain); contract_data.state = State::Active; update_contract_data(&env, &contract_data); Ok(true)
} // In invest(), manual validation:
pub fn invest(env: Env, addr: Address, amount: i128) -> Result<Investment, Error> { require!(contract_data.state == State::Active, Error::ContractMustBeActiveToInvest); // ...
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// Before: Custom pause state
#[contracttype]
pub enum State { Active = 2, FundsReached = 3, Paused = 4, // ← Custom state
} #[contracttype]
pub struct ContractData { pub state: State, // ...
} // Functions to pause/resume
pub fn stop_investments(env: Env) -> Result<bool, Error> { let mut contract_data = require_admin(&env); require!(contract_data.state == State::Active, Error::ContractMustBeActiveToBePaused); contract_data.state = State::Paused; update_contract_data(&env, &contract_data); Ok(true)
} pub fn restart_investments(env: Env) -> Result<bool, Error> { let mut contract_data = require_admin(&env); require!(contract_data.state == State::Paused, Error::ContractMustBePausedToRestartAgain); contract_data.state = State::Active; update_contract_data(&env, &contract_data); Ok(true)
} // In invest(), manual validation:
pub fn invest(env: Env, addr: Address, amount: i128) -> Result<Investment, Error> { require!(contract_data.state == State::Active, Error::ContractMustBeActiveToInvest); // ...
} COMMAND_BLOCK:
// Before: Custom pause state
#[contracttype]
pub enum State { Active = 2, FundsReached = 3, Paused = 4, // ← Custom state
} #[contracttype]
pub struct ContractData { pub state: State, // ...
} // Functions to pause/resume
pub fn stop_investments(env: Env) -> Result<bool, Error> { let mut contract_data = require_admin(&env); require!(contract_data.state == State::Active, Error::ContractMustBeActiveToBePaused); contract_data.state = State::Paused; update_contract_data(&env, &contract_data); Ok(true)
} pub fn restart_investments(env: Env) -> Result<bool, Error> { let mut contract_data = require_admin(&env); require!(contract_data.state == State::Paused, Error::ContractMustBePausedToRestartAgain); contract_data.state = State::Active; update_contract_data(&env, &contract_data); Ok(true)
} // In invest(), manual validation:
pub fn invest(env: Env, addr: Address, amount: i128) -> Result<Investment, Error> { require!(contract_data.state == State::Active, Error::ContractMustBeActiveToInvest); // ...
} COMMAND_BLOCK:
// Now: Using OpenZeppelin's Pausable
use stellar_contract_utils::pausable::{self as pausable, Pausable};
use stellar_macros::when_not_paused; #[contract]
pub struct InvestmentContract; // Implement the trait
#[contractimpl]
impl Pausable for InvestmentContract { #[only_owner] fn pause(e: &Env, _caller: Address) { pausable::pause(e); } #[only_owner] fn unpause(e: &Env, _caller: Address) { pausable::unpause(e); }
} // Use the macro on sensitive functions
#[contractimpl]
impl InvestmentContract { #[when_not_paused] // ← Macro that automatically verifies pub fn invest(env: Env, addr: Address, amount: i128) -> Result<Investment, Error> { // If paused, the macro automatically rejects the call // We only verify business logic require!( amount >= contract_data.min_per_investment, Error::AmountLessThanMinimum, tk.balance(&addr) >= amount, Error::AddressInsufficientBalance ); }
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// Now: Using OpenZeppelin's Pausable
use stellar_contract_utils::pausable::{self as pausable, Pausable};
use stellar_macros::when_not_paused; #[contract]
pub struct InvestmentContract; // Implement the trait
#[contractimpl]
impl Pausable for InvestmentContract { #[only_owner] fn pause(e: &Env, _caller: Address) { pausable::pause(e); } #[only_owner] fn unpause(e: &Env, _caller: Address) { pausable::unpause(e); }
} // Use the macro on sensitive functions
#[contractimpl]
impl InvestmentContract { #[when_not_paused] // ← Macro that automatically verifies pub fn invest(env: Env, addr: Address, amount: i128) -> Result<Investment, Error> { // If paused, the macro automatically rejects the call // We only verify business logic require!( amount >= contract_data.min_per_investment, Error::AmountLessThanMinimum, tk.balance(&addr) >= amount, Error::AddressInsufficientBalance ); }
} COMMAND_BLOCK:
// Now: Using OpenZeppelin's Pausable
use stellar_contract_utils::pausable::{self as pausable, Pausable};
use stellar_macros::when_not_paused; #[contract]
pub struct InvestmentContract; // Implement the trait
#[contractimpl]
impl Pausable for InvestmentContract { #[only_owner] fn pause(e: &Env, _caller: Address) { pausable::pause(e); } #[only_owner] fn unpause(e: &Env, _caller: Address) { pausable::unpause(e); }
} // Use the macro on sensitive functions
#[contractimpl]
impl InvestmentContract { #[when_not_paused] // ← Macro that automatically verifies pub fn invest(env: Env, addr: Address, amount: i128) -> Result<Investment, Error> { // If paused, the macro automatically rejects the call // We only verify business logic require!( amount >= contract_data.min_per_investment, Error::AmountLessThanMinimum, tk.balance(&addr) >= amount, Error::AddressInsufficientBalance ); }
} CODE_BLOCK:
// Clean state: only business logic
#[contracttype]
pub enum State { Active = 2, // Accepting investments FundsReached = 3, // Goal reached
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// Clean state: only business logic
#[contracttype]
pub enum State { Active = 2, // Accepting investments FundsReached = 3, // Goal reached
} CODE_BLOCK:
// Clean state: only business logic
#[contracttype]
pub enum State { Active = 2, // Accepting investments FundsReached = 3, // Goal reached
} - Mathematical precision: WAD maintains 18 decimals, we only lose precision at the end.
- Multi-token ready: Tokens with different decimals can coexist.
- Battle-tested: Continuously maintained and improved by OpenZeppelin. - Simple ownership transfer: ownable::transfer_ownership() included.
- Less code: I removed the require_admin() function and the macro does the work. - Separation of concerns: Technical pause vs. business logic
- Access control: #[when_not_paused] allows us to easily reject investments when the contract is paused without needing require.
- Free queries: pausable::is_paused() included
- Automatic events: OpenZeppelin emits Paused/Unpaused events
how-totutorialguidedev.toaigitgithub