Introduction
One of the questions I kept coming back to during the development and improvement of the Equillar contract is: what happens if the project fails or doesn't generate the expected income? Investors need some kind of guarantee — and even better if it's verifiable, trustless, and enforced by code. What I ended up building is an on-chain collateral mechanism that projects can use to back their payment obligations. This article explains how it works under the hood.
The problem: a promise without a guarantee isn't enough
The Equillar contract lets projects raise funding from investors in exchange for periodic payments that include principal plus interest. A model similar to a bond, but entirely on-chain on the Stellar network.
The obvious risk is default: if the company doesn't transfer funds to the contract on time, investors don't get paid. In traditional finance this is managed with bank guarantees or credit insurance; in blockchain we have something more direct: assets locked inside the contract itself that act as collateral.
Collateral as an on-chain guarantee
The idea is simple: the address registered by the company at deployment time transfers collateral tokens to the contract. Those tokens are locked there and can be liquidated to pay investors if the project can't meet its obligations.
The contract stores the collateral state in a Collateral struct:
pub struct Collateral {
pub token_collateral_address: Address, // which token is used as collateral
pub token_collateral_symbol: String, // human-readable symbol (e.g. "USDC" ....)
pub address_collateral_token: Address, // who deposited the collateral
pub collateral_amount: i128, // total amount deposited
pub collateral_level: u32, // coverage in basis points
}
The contract also keeps internal accounting related to collateral in its balance:
pub collateral_received: i128, // total collateral received so far
pub collateral_liquidated: i128, // total already used to pay investors
pub payment_obligations: i128, // sum of what the contract owes all investors
This makes it possible to know at any time how much real backing exists versus how much is owed.
How the contract manages collateral deposits
The public function that accepts collateral deposits has this signature:
pub fn add_collateral(
env: Env,
collateral_token_addr: Address,
collateral_token_amount: i128,
collateral_token_symbol: String,
collateral_addr: Address,
) -> Result<Collateral, Error>
Reflector is used as the price oracle, and its address is fixed in the constructor and stored in ContractData.
// In the constructor — set once by the admin:
ContractData { ..., price_oracle, ... }
// In add_collateral — read from storage, not accepted as a parameter:
let contract_data = Storage::get_contract_data(&env);
// → contract_data.price_oracle
Execution follows these ordered steps:
Authentication
collateral_addr.require_auth();
The contract requires the transaction to be signed by the address that will deposit the collateral. No one can deposit collateral on behalf of another without their explicit signature.
Single token rule
if let Some(coll) = Storage::get_collateral(&env) {
if coll.token_collateral_address != collateral_token_addr {
return Err(Error::OnlyOneCollateralTokenAllowed);
}
}
If collateral is already registered and the token being deposited differs from the existing one, the contract rejects the operation. This greatly simplifies valuation and prevents the collateral from being fragmented across multiple assets with independent prices.
Balance check
if collateral_token_client.balance(&collateral_addr) < collateral_token_amount {
return Err(Error::AddressInsufficientBalance);
}
A preventive check before attempting the transfer. It's cleaner to return a descriptive error than to let the token reject it with a generic one.
Actual transfer to the contract
collateral_token_client
.try_transfer(&collateral_addr, &env.current_contract_address(), &collateral_token_amount)
.map_err(|_| Error::RecipientCannotReceivePayment)?
.map_err(|_| Error::InvalidPaymentData)?;
Tokens are transferred from the depositor to the contract's own address. From this point on, the contract is the real custodian of those assets.
Internal balance update
let mut contract_balances = Storage::get_balances_or_new(&env);
contract_balances.recalculate_from_collateral_received(&collateral_token_amount);
Storage::update_contract_balances(&env, &contract_balances);
The collateral_received counter is incremented, keeping an accounting record of how much has been accumulated.
The coverage level: measuring the guarantee with a price oracle
Depositing tokens into the contract is necessary but not sufficient. What really matters for investors is whether the value of the collateral covers the pending obligations. To calculate this, the contract needs to query an external oracle, as mentioned above, we use Reflector.
How the oracle interface is defined
In Soroban, to call an external contract we can declare its interface as a Rust trait and annotate it with #[contractclient]. The SDK automatically generates a typed client from that declaration.
For Reflector we need two data types upfront: the Asset enum, which represents how to identify a token in the oracle, and PriceData, which is what it returns when asked for a price:
#[contracttype]
pub enum Asset {
Stellar(Address), // token deployed on Stellar (SAC or contract)
Other(Symbol), // native asset like XLM
}
#[contracttype]
pub struct PriceData {
pub price: i128, // price in units of the quote asset
pub timestamp: u64, // timestamp of the last recorded tick
}
With those types defined, the trait declares the two oracle functions we care about:
#[contractclient(name = "ReflectorClient")]
pub trait ReflectorOracle {
fn decimals(env: &Env) -> u32;
fn x_last_price(env: &Env, base_asset: Asset, quote_asset: Asset) -> Option<PriceData>;
}
The #[contractclient(name = "ReflectorClient")] macro does the heavy lifting: it generates a ReflectorClient struct with methods for each function in the trait. At runtime, each call to those methods becomes a cross-contract invocation to the address passed in. The compiler guarantees that the types match, and the Soroban runtime guarantees that the call is atomic with the rest of the transaction.
Calling the oracle is then as straightforward as:
let oracle = ReflectorClient::new(env, &contract_data.price_oracle);
let oracle_decimals = oracle.decimals();
let price_data = oracle.x_last_price(
&Asset::Stellar(collateral_token_addr.clone()),
&Asset::Stellar(contract_token_addr.clone()),
)?; // None if the pair has no price → the deposit is rejected
The mock in tests
For unit tests we don't want to depend on a real oracle deployed on the network. Since the oracle is just a trait, we can implement it in a stub contract that returns a fixed price:
#[contract]
pub struct ReflectorMock;
#[contractimpl]
impl ReflectorOracle for ReflectorMock {
fn x_last_price(_env: &Env, _base: Asset, _quote: Asset) -> Option<PriceData> {
Some(PriceData { price: 936, timestamp: 65587445447 })
}
fn decimals(_env: &Env) -> u32 { 3 }
}
4.3 The coverage calculation
The valuation logic lives in calculate_collateral_level, whose signature is:
pub fn calculate_collateral_level(
env: &Env,
oracle_addr: &Address,
collateral_token_addr: &Address,
collateral_amount: i128,
collateral_decimals: u32,
contract_token_addr: &Address,
contract_token_decimals: u32,
payment_obligations: i128,
) -> Option<u32>
Let's walk through its logic step by step:
Step 1 — Check if there's anything to back. If there are no investors yet, payment_obligations is zero and computing any ratio is meaningless. The function returns None and the contract rejects the deposit:
if payment_obligations == 0 {
return None;
}
Step 2 — Query the price from the oracle. The Reflector client is instantiated with the address stored in ContractData and two things are requested: the oracle's own decimals (needed to normalise the price) and the price of the collateral token expressed in the investment token. If the pair is not registered in Reflector, x_last_price returns None and the ? operator propagates that None outward:
let oracle = ReflectorClient::new(env, oracle_addr);
let oracle_decimals = oracle.decimals();
let price_data = oracle.x_last_price(
&Asset::Stellar(collateral_token_addr.clone()),
&Asset::Stellar(contract_token_addr.clone()),
)?;
Step 3 — Normalise to 18 decimals (OpenZeppelin Wad). price_data.price is an integer that carries the oracle's decimals embedded in it. The Wad library converts it to a fixed-point representation with 18 internal decimals:
let price_wad = Wad::from_token_amount(env, price_data.price, oracle_decimals as u8);
Step 4 — Collateral value in the investment currency. The collateral amount is normalised the same way and multiplied by the price. The result is then brought back (to_token_amount) to the decimals of the contract token:
let collateral_wad = Wad::from_token_amount(env, collateral_amount, collateral_decimals as u8);
let collateral_value_wad = collateral_wad * price_wad; // both normalised to 18 decimals
let collateral_value = collateral_value_wad.to_token_amount(env, contract_token_decimals as u8); // result expressed in the contract token's decimals
Step 5 — Coverage ratio in basis points. The collateral value is multiplied by 10_000_i128 and divided by the total obligations. The result is an integer with two implicit decimals: 6000 means 60.00% coverage; 10000 means 100%:
let level = collateral_value * 10_000_i128 / payment_obligations;
Some(level as u32)
This straightforward integer division is precise enough here because
collateral_valueis already expressed in the contract token's units after the previous step.Wadwas necessary in steps 3 and 4 to normalise price and amount across tokens with different decimal counts.
The function returnsNoneif the oracle has no price for the pair, causingadd_collateralto treat that case the same as an insufficient coverage level and reject the deposit.
Tests
Having the logic split across well-defined functions and a trait-based oracle makes the whole mechanism straightforward to test without any external dependencies.
Success: depositing collateral and verifying the coverage level
With ~155 tokens invested and the mock oracle returning a price of 936 (3 decimals = 0.936), depositing 100 collateral tokens should yield exactly 60% coverage. A second deposit of another 100 tokens then pushes the level above 60%, since it accumulates on top of the first:
// ~155 tokens invested, 100 collateral tokens deposited
// Oracle (fixed in the constructor) returns price: 936 with 3 decimals
let collateral = test_data.client.add_collateral(
&token_collateral.address,
&100_i128,
&String::from_str(&e, "TEST"),
&collateral_addr,
);
assert_eq!(collateral.collateral_level, 6000_u32); // 60% coverage
// Depositing another 100 tokens raises the level (now 200 tokens vs ~155 obligations)
let collateral = test_data.client.add_collateral(
&token_collateral.address,
&100_i128,
&String::from_str(&e, "TEST"),
&collateral_addr,
);
assert!(collateral.collateral_level > 6000_u32); // now > 60%
Error cases
The contract can return three specific errors related to collateral. Each one protects something different:
| Error (code) | When it occurs | What it protects |
|---|---|---|
AddressInsufficientBalance (#1) |
The depositor doesn't have enough tokens | Prevents a failed collateral transfer |
OnlyOneCollateralTokenAllowed (#34) |
A second token type is being deposited | Simplicity and comparability of collateral |
CollateralLevelTooLow (#33) |
The deposit doesn't reach a minimum coverage level | Prevents decorative collateral that covers nothing |
// Error #1: no balance in the depositor's wallet
#[should_panic(expected = "HostError: Error(Contract, #1)")]
fn test_add_collateral_insufficient_balance() {
// Token is created but NOT minted to collateral_addr
test_data.client.add_collateral(
&token_collateral.address, &100_i128, &String::from_str(&e,"TEST"), &collateral_addr
);
}
// Error #34: two different tokens in the same contract
#[should_panic(expected = "HostError: Error(Contract, #34)")]
fn test_add_collateral_only_one_collateral_token_allowed() {
// First deposit with token_collateral → OK
test_data.client.add_collateral(&token_collateral.address, &100_i128, ...);
// Second deposit with token_collateral_2 → rejected
test_data.client.add_collateral(&token_collateral_2.address, &100_i128, ...);
}
Conclusion: programmable trust for investors and companies
What we've built isn't complicated conceptually, but it is in its implications. A project that deposits collateral into the contract is making a promise that the code will enforce — with or without their future cooperation. Investors can check the coverage level at any time with a simple on-chain query. No need to trust external audits or certificates: the blockchain itself is the record.
In the next articles we'll look at how to liquidate the collateral — that is, pay investors in proportion to the amount they deposited — and how to return the collateral to the company once all payment obligations have been fulfilled.
You can see and explore the code in the equillar contract github repo: https://github.com/icolomina/equillar-soroban (Branch equillar_collateral)
After finishing the rest of the functionalities (liquidate the collateral and return the collateral to the company) the branch will be merged to main.
Top comments (0)