Skip to main content

Improving the Equillar Contract with OpenZeppelin

· 6 min read

Introduction

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.

1. Financial Mathematics

The Previous Approach

The contract calculated interest amounts manually. Something like this:

// 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,
}

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.

The New Approach: OpenZeppelin's WAD

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.

// 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),
}
}

What Did We Improve?

  • 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.

2. Access Control

The Previous Approach

I had my own access control system for admin-only functions:

// 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
}
}

This is correct as it uses Soroban's authentication standard, but we can improve the code with OpenZeppelin's Ownable module.

The New Approach: OpenZeppelin's Ownable Module

// 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
}
}

What Did We Improve?

  • Simple ownership transfer: ownable::transfer_ownership() included.
  • Less code: I removed the require_admin() function and the macro does the work.

3. Pausing the Contract

The Original Approach

I had a Paused state in my enum and custom functions:

// 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);
// ...
}

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.

The New Approach: Pausable Trait

// 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
);
}
}

Now the State enum only reflects business logic:

// Clean state: only business logic
#[contracttype]
pub enum State {
Active = 2, // Accepting investments
FundsReached = 3, // Goal reached
}

What Did We Improve?

  • 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

Conclusion

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.