Splitter V2 Contract
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.
Repository: github.com/Dobprotocol/stellar-distribution-contracts
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+ (no gas limit issue) |
| Share snapshot | At distribution time | At claim time (reflects transfers) |
| Commission | Deducted per-recipient | Deducted once at round creation |
Why Pull-Based
In V1, the admin calls distribute() and the contract loops through every shareholder, calculating and sending their share in a single transaction. At ~500 shareholders, the transaction hits Soroban's gas limit and fails.
In V2, the admin calls create_distribution() which records the total amount and total shares for that round -- O(1). Each shareholder then calls claim() individually to receive their portion -- also O(1). The gas cost is independent of the number of shareholders.
Core Functions
Initialization
fn init(
env: Env,
admin: Address,
shares: Vec<(Address, i128)>,
allow_entry: bool,
participation_token: Address,
)
Initializes the pool with an admin, initial share allocations, and the SAC participation token address. The allow_entry flag controls whether new shareholders can join after initialization.
fn init_with_type(
env: Env,
admin: Address,
shares: Vec<(Address, i128)>,
allow_entry: bool,
participation_token: Address,
pool_type: PoolType,
)
Extended initialization that sets the pool type (Reward, Payroll, Treasury, or Crowdfunding).
Distribution
fn create_distribution(
env: Env,
admin: Address,
reward_token: Address,
amount: i128,
) -> u64
Creates a new distribution round. The admin deposits amount of reward_token into the contract. Returns the round ID. Commission is deducted at this point (once, not per-recipient).
Claiming
fn claim(
env: Env,
user: Address,
round_id: u64,
) -> i128
Claims the user's share from a specific distribution round. The claimable amount is calculated based on the user's share proportion at claim time:
user_amount = round_total_amount * (user_shares / total_shares)
Returns the amount claimed.
fn claim_all(
env: Env,
user: Address,
) -> Vec<(u64, i128)>
Claims from all unclaimed rounds in a single call. Returns a vector of (round_id, amount_claimed) pairs.
Share Management
fn transfer_shares(
env: Env,
from: Address,
to: Address,
amount: i128,
)
Transfers shares between addresses. Because V2 uses SAC participation tokens, shares are DEX-compatible and can be traded on Stellar's native DEX.
Share System
Shares in V2 use a 10,000-point scale:
10,000 shares = 100% ownership
1,000 shares = 10% ownership
100 shares = 1% ownership
1 share = 0.01% ownership
Shares are represented as Stellar Asset Contract (SAC) tokens, which means they are native Stellar assets that can be:
- Transferred between wallets using standard Stellar operations
- Listed on Stellar's native DEX (SDEX)
- Traded on DobProtocol's marketplace
- Held in any Stellar wallet (Freighter, Lobstr, etc.)
SAC Participation Tokens
When a pool is initialized, it is associated with a SAC token address. This token represents pool shares:
Pool: CCJHTPU3QHKULJEEVTMIH7YUHGGA6QGV7P3FXRVPSTSY2F7AODN6XLSL
SAC Token: CDTWX7U2HRD3N742IRLO4VY26HKNKVRIWM3CDLRL65SKJZQILH3ZNGML
Holder A: 5,000 shares (50%)
Holder B: 3,000 shares (30%)
Holder C: 2,000 shares (20%)
If Holder A transfers 1,000 shares to a new address, the new address becomes a shareholder and can claim from future distribution rounds.
Time-Gating
V2 introduces configurable time-gating to prevent distribution spam:
pub struct DistributionConfig {
pub min_interval_seconds: u64, // Default: 43200 (12 hours)
// ...
}
If the admin tries to create a distribution within min_interval_seconds of the last one, the transaction fails with DistributionTooSoon.
Setting min_interval_seconds to 0 disables time-gating.
Claim Window
A configurable delay between distribution creation and when claims open:
pub struct DistributionConfig {
pub claim_delay_seconds: u64, // e.g., 3600 (1 hour)
// ...
}
After a distribution round is created, users must wait claim_delay_seconds before they can claim. Attempting to claim too early fails with ClaimsNotOpenYet.
This allows time for share transfers to settle before claims begin, ensuring fair distribution.
Round Expiry and Reclaim
Distribution rounds can expire after a configurable period:
pub struct DistributionConfig {
pub round_expiry_seconds: u64, // e.g., 365 * 24 * 60 * 60 (1 year)
// ...
}
After expiry:
- Users can no longer claim from the expired round (
RoundExpirederror) - The admin can reclaim unclaimed funds:
fn reclaim_expired_round(
env: Env,
admin: Address,
round_id: u64,
) -> i128
Returns the amount reclaimed. Fails with RoundNotExpired if the round has not expired, or NothingToReclaim if all funds were already claimed.
Auto-Scheduling
V2 supports automatic distribution scheduling:
fn set_schedule(
env: Env,
admin: Address,
first_time: u64,
interval_seconds: u64,
total_distributions: u64, // 0 = unlimited
)
Configures a schedule where distributions can be triggered at regular intervals:
first_time: March 1, 2026 00:00 UTC
interval_seconds: 2592000 (30 days)
total_distributions: 12 (monthly for 1 year)
Once a scheduled distribution is due, anyone can trigger it:
fn trigger_scheduled_distribution(
env: Env,
reward_token: Address,
) -> u64
Fails with ScheduledDistributionNotDue if triggered early, or ScheduleCompleted if all scheduled distributions have been created.
Disable scheduling:
fn disable_schedule(env: Env, admin: Address)
Schedule Storage
pub struct ScheduleConfig {
pub enabled: bool,
pub first_distribution_time: u64,
pub interval_seconds: u64,
pub total_distributions: u64, // 0 = unlimited
pub completed_distributions: u64,
}
Pool Types
V2 supports pool type classification as metadata:
pub enum PoolType {
Reward = 0, // Default -- reward distribution pools
Payroll = 1, // Employee/contractor payments
Treasury = 2, // DAO treasury management
Crowdfunding = 3, // Crowdfunding campaigns
}
Pool type is set at initialization via init_with_type() and affects frontend display and behavior (e.g., crowdfunding pools show "Contributors" instead of "Members").
Admin Transfer
Following the Stellar SEP standard pattern:
fn set_admin(env: Env, new_admin: Address)
Transfers admin rights to a new address. Requires authorization from the current admin. Emits a SetAdmin event.
Events
| Event | Description | Data |
|---|---|---|
SetAdmin | Admin transferred | admin (topic), new_admin (data) |
dist_created | Distribution round created | Round ID, token, amount, shares |
claimed | User claimed from round | Round ID, user, amount |
config_updated | Distribution config changed | Updated config fields |
schedule_set | Schedule configured | Schedule config |
sched_off | Schedule disabled | - |
sched_dist | Scheduled distribution triggered | Round ID |
reclaimed | Admin reclaimed expired round | Round ID, amount |
Error Types
| Error | Code | Description |
|---|---|---|
DistributionTooSoon | 25 | Time-gating interval not met |
ClaimsNotOpenYet | 26 | Claim window delay not elapsed |
RoundExpired | 27 | Round has passed expiry time |
ScheduleNotConfigured | 28 | No schedule set |
ScheduleNotEnabled | 29 | Schedule is disabled |
ScheduleCompleted | 30 | All scheduled distributions done |
ScheduledDistributionNotDue | 31 | Too early to trigger |
InvalidScheduleConfig | 32 | Bad schedule parameters |
RoundNotExpired | 33 | Cannot reclaim active round |
NothingToReclaim | 34 | All funds already claimed |
Data Structures
DistributionRound
pub struct DistributionRound {
pub id: u64,
pub token: Address,
pub total_amount: i128,
pub total_shares: i128,
pub is_finalized: bool,
pub created_at: u64,
pub claimable_from: u64, // created_at + claim_delay_seconds
pub expires_at: u64, // created_at + round_expiry_seconds
pub total_claimed: i128,
}
DistributionConfig
pub struct DistributionConfig {
pub min_interval_seconds: u64, // Time-gating (default: 43200)
pub claim_delay_seconds: u64, // Claim window delay
pub round_expiry_seconds: u64, // Round expiry time
pub last_distribution_time: u64, // Timestamp of last distribution
}
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 to mainnet
stellar contract install \
--wasm target/wasm32-unknown-unknown/release/soro_splitter_v2.wasm \
--source admin \
--network mainnet