Splitter V2 Contract
Source on GitHub: github.com/Dobprotocol/stellar-distribution-contracts —
contracts/splitter_v2/
The Splitter V2 (soro-splitter-v2) is the current-generation distribution contract for Stellar Soroban. It replaces V1's push-based distribution model with a lazy pull-based architecture that scales to 10,000+ shareholders at constant gas cost.
V1 vs V2
| Aspect | V1 (Push) | V2 (Pull) |
|---|---|---|
| Distribution creation | O(n) — loops all shareholders | O(1) — records round metadata |
| Claiming | Automatic (during distribute call) | User calls claim() — O(1) per claim |
| Gas scaling | Linear with shareholders | Constant regardless of shareholders |
| Max shareholders | ~500 (gas limit) | 10,000+ |
| Share snapshot | At distribution time | Total-supply snapshot at creation; user balance read at claim time |
| Commission | Per-recipient | Once at round creation |
Core Functions
Initialization
fn init(
env: Env,
admin: Address,
shares: Vec<ShareDataKey>, // initial shareholders, must sum to 10,000
mutable: bool, // false = locked, admin cannot modify
participation_token: Address, // SAC token used for participation shares
) -> Result<(), Error>;
The mutable flag controls whether the admin can later call update_shares and similar mutating functions. Once lock_contract() is called, mutable becomes false permanently.
fn init_with_type(
env: Env,
admin: Address,
shares: Vec<ShareDataKey>,
mutable: bool,
participation_token: Address,
pool_type: PoolType, // Reward | Payroll | Treasury | Crowdfunding
) -> Result<(), Error>;
Distribution
fn create_distribution(env: Env, token_address: Address) -> Result<u64, Error>;
Calculates the distributable amount sitting in the contract for token_address, snapshots the participation-token total supply, deducts the platform commission once, and creates a DistributionRound with claimable_from = now + claim_delay_seconds and expires_at = now + round_expiry_seconds. Returns the new round ID.
Claiming
fn claim(env: Env, shareholder: Address, round_id: u64) -> Result<i128, Error>;
fn claim_all(env: Env, shareholder: Address) -> Result<i128, Error>;
Each claim computes:
user_amount = round.total_amount * (user_share_balance / round.total_supply_snapshot)
claim_all iterates active rounds and claims those that are currently within their claim window.
Share Management
fn transfer_shares(env: Env, from: Address, to: Address, amount: i128) -> Result<(), Error>;
fn update_shares(env: Env, shares: Vec<ShareDataKey>) -> Result<(), Error>; // admin-only, mutable contracts only
Because shares are SAC tokens, they can also be transferred via standard Stellar token operations.
Share System
The frontend (Token Studio) treats 10,000 shares = 100% as a UI convention so that users see clean percentages:
10,000 shares = 100% ownership
1,000 shares = 10% ownership
1 share = 0.01% ownership
This 10,000 unit total is enforced at init and update_shares only (storage.rs::TOTAL_SHARES = 10_000). The participation token itself is a regular SAC with i128 balances; the contract does not enforce that future totals stay at 10,000 if the SAC is modified directly.
SAC Participation Tokens
Pools are wired to a Stellar Asset Contract token at init. Holders can transfer those tokens via standard Stellar operations, list them on SDEX, hold them in any Stellar wallet (Freighter, Lobstr), or trade them on the DobProtocol marketplace.
Distribution Configuration
pub struct DistributionConfig {
pub min_interval_seconds: u64, // default 12h (DEFAULT_MIN_DISTRIBUTION_INTERVAL)
pub claim_delay_seconds: u64, // default 0
pub round_expiry_seconds: u64, // default 1 year
pub last_distribution_time: u64,
}
- Time-gating (
min_interval_seconds): admin attemptingcreate_distributionbefore the interval elapses getsError::DistributionTooSoon. Set to0to disable. - Claim window (
claim_delay_seconds): claims open atcreated_at + claim_delay_seconds. - Round expiry (
round_expiry_seconds): after expiry, claims revert and the admin mayreclaim_expired_round.
Auto-Scheduling
fn set_schedule(env: Env, first_distribution_time: u64, interval_seconds: u64, total_distributions: u32) -> Result<(), Error>;
fn disable_schedule(env: Env) -> Result<(), Error>;
fn trigger_scheduled_distribution(env: Env, token_address: Address) -> Result<u64, Error>;
Anyone can call trigger_scheduled_distribution once the next slot is due — the contract enforces the schedule, so bots can act as triggers without admin intervention.
Pool Types
pub enum PoolType {
Reward = 0,
Payroll = 1,
Treasury = 2,
Crowdfunding = 3,
}
Pool type is metadata used by the frontend (e.g. crowdfunding pools display "Contributors" rather than "Members").
Commission
const DEFAULT_COMMISSION_ADDRESS: &str = "GCYBJHXG4JRODEFRVXHFWDHRQQSEYYBM2P455ME3OGETCURTQJLZVX72";
const BUY_COMMISSION_BPS: i128 = 150; // 1.5% on share buys via the contract
const DISTRIBUTION_COMMISSION_BPS: i128 = 50; // 0.5% on distributions
The commission recipient (default address above) can update both rates and rotate the recipient via set_commission_recipient, set_buy_commission_rate, set_distribution_commission_rate (capped at 5000 bps).
Marketplace (sale listings)
SaleListingDataKey lets a holder escrow participation tokens with a price-per-share denominated in any payment token (typically USDC). Active listings are tracked in ActiveListings.
Admin Transfer & Lock
fn set_admin(env: Env, new_admin: Address) -> Result<(), Error>;
fn lock_contract(env: Env) -> Result<(), Error>;
lock_contract flips mutable to false permanently — useful once a pool's structure is finalised.
Events
| Event topic | Emitted on |
|---|---|
("admin", "updated") | set_admin |
("dist", "created") | create_distribution |
("dist", "claimed") | claim, claim_all |
("config", "updated") | set_distribution_config |
("schedule", "set") / ("schedule", "off") | set_schedule / disable_schedule |
("schedule", "trig") | trigger_scheduled_distribution |
("dist", "reclaimed") | reclaim_expired_round |
(See splitter_v2/src/event.rs for exact topics.)
Errors
| Error | Description |
|---|---|
DistributionTooSoon | min_interval_seconds not yet elapsed. |
ClaimsNotOpenYet | claim_delay_seconds not yet elapsed. |
RoundExpired | Past expires_at. |
ScheduleNotConfigured / ScheduleNotEnabled / ScheduleCompleted / ScheduledDistributionNotDue | Auto-schedule failures. |
RoundNotExpired / NothingToReclaim | reclaim_expired_round guards. |
InvalidCommissionRate | Setting commission outside [0, 5000] bps. |
Data Structures
pub struct DistributionRound {
pub id: u64,
pub token: Address,
pub total_amount: i128,
pub total_supply_snapshot: i128,
pub created_at: u64,
pub claimable_from: u64,
pub expires_at: u64,
pub is_finalized: bool,
pub total_claimed: i128,
}
pub struct ConfigDataKey {
pub admin: Address,
pub mutable: bool,
pub participation_token: Address,
pub pool_type: PoolType,
}
Building and Testing
cd /path/to/stellar-distribution-contracts
# Run tests (65 tests)
cargo test -p soro-splitter-v2
# Build WASM
cargo build --release --target wasm32-unknown-unknown -p soro-splitter-v2
# Deploy
stellar contract install \
--wasm target/wasm32-unknown-unknown/release/soro_splitter_v2.wasm \
--source admin \
--network mainnet
Deployed WASM Hashes
TODO: verify and fill in. The active per-network WASM hashes are stored in the Token Studio backend
networkstable (stellar_pool_wasm_hash_v2column).