This guide covers everything Rust/Soroban contributors need to know: module layout, adding a new public function, testing conventions, event emission rules, error variant guidelines, and the PR checklist.
For general contribution guidelines (branching, commit style, frontend), see CONTRIBUTING.md.
- Module Layout
- Adding a New Public Function
- Testing Conventions
- Benchmarks
- Event Emission Rules
- Error Variant Guidelines
- PR Checklist
contract/src/
├── lib.rs # Contract entry point: DataKey enum, Subscription struct,
│ # FlowPay impl block with all public functions,
│ # subscribe_inner, pay_per_use_inner,
│ # check_and_update_global_volume, bump_instance_ttl
├── errors.rs # ContractError enum (#[contracterror], 34 variants)
├── events.rs # All publish_* helpers (~30+ event types)
├── batch.rs # batch_charge, batch_cancel, batch_extend_subscription_ttl;
│ # ChargeResult/CancelResult enums; MAX_BATCH_SIZE = 50
├── charge_exec.rs # Charge prechecks, auto-resume logic, simulation,
│ # fee-aware transfer execution; ChargeSimResult enum
├── admin.rs # require_admin, initialize_admin, two-step admin transfer
├── fee.rs # Protocol fee calculation (calculate_fee_amount),
│ # two-step propose/commit, fee-aware transfers
│ # (transfer_subscription_charge, transfer_pay_per_use),
│ # cumulative fee tracking, merchant fee recipient routing
├── grace.rs # Two-step grace period proposal/commit
├── limits.rs # Placeholder — currently empty (single comment line)
├── merchant_stats.rs # Per-merchant revenue tracking (cumulative, daily buckets,
│ # history Vec), subscriber counts, merchant index,
│ # top merchants ranking, revenue summaries
├── migration.rs # Schema version tracking (current v3),
│ # v1→v2 (add paused field), v2→v3 (populate referrer)
├── min_interval.rs # Minimum subscription interval floor (default 3600s)
├── referral.rs # Referrer storage/lookup/removal with self-referral check
├── spending_limit.rs # Per-user daily spending limits (temporary storage, ~1 day TTL),
│ # day window anchoring via DayStart key
├── storage.rs # Low-level storage helpers: subscription get/set, TTL extension,
│ # admin get/set, token get, contract pause get/set,
│ # pause expiry get/set/clear
├── subscription_count.rs # Active subscription counter, append-only subscriber index
│ # with tombstoning, per-merchant subscriber count
├── subscription_history.rs # Per-user charge history (max 12 entries, circular buffer),
│ # paginated reads with ascending/descending
├── subscription_metadata.rs # Short subscription labels (max 64 bytes)
├── token.rs # UNRELATED — contains AcademyVestingContract (not used by FlowPay)
├── trial.rs # Trial period end computation and trial extension
├── upgrade.rs # Two-step WASM upgrade (propose/commit),
│ # test-only direct upgrade
├── validation.rs # Input validation: amounts, intervals, allowance checks
├── whitelist.rs # Merchant whitelist with indexed pagination,
│ # freeze/unfreeze with reasons, whitelist enabled toggle
├── bench.rs # Benchmark tests (gated by #[cfg(feature = "bench")] in lib.rs)
└── test.rs # Unit tests (gated by #[cfg(test)])
Rules:
- Business logic belongs in a focused module, not in
lib.rs. lib.rsonly wires public contract functions to module helpers — no logic inline.bench.rsis gated with#[cfg(feature = "bench")]inlib.rs— it is not compiled bycargo testunless you pass--features bench.test.rsis gated with#[cfg(test)].token.rscontains an unrelated vesting contract and is not used by FlowPay. Do not add FlowPay logic to it.limits.rsis an empty placeholder. Do not add logic there without first checking whether the behavior belongs in an existing module.
Follow these five steps every time.
In lib.rs, add a variant to DataKey for any new persistent state:
// lib.rs — DataKey enum
DataKey::UserPreference(Address),Create or extend a module file. Keep functions small and single-purpose:
// src/my_feature.rs
use soroban_sdk::{Address, Env};
use crate::DataKey;
pub fn get_preference(env: &Env, user: &Address) -> Option<u32> {
env.storage()
.persistent()
.get(&DataKey::UserPreference(user.clone()))
}
pub fn set_preference(env: &Env, user: &Address, value: u32) {
env.storage()
.persistent()
.set(&DataKey::UserPreference(user.clone()), &value);
}// lib.rs
mod my_feature;// lib.rs — FlowPay impl block
/// Returns the caller's preference value, or `None` if unset.
pub fn get_preference(env: Env, user: Address) -> Option<u32> {
my_feature::get_preference(&env, &user)
}
/// Sets the caller's preference value. Requires caller auth.
pub fn set_preference(env: Env, user: Address, value: u32) {
user.require_auth();
my_feature::set_preference(&env, &user, value);
events::publish_preference_set(&env, &user, value);
}Auth rule: any function that mutates user state or moves funds must call user.require_auth() before doing anything else.
See Testing Conventions and Event Emission Rules below.
All tests live in contract/src/test.rs and are gated with #![cfg(test)].
Use the shared setup() helper for a standard environment with one funded user and one merchant:
fn setup() -> (Env, Address, Address, Address, Address) {
let env = Env::default();
env.mock_all_auths();
let token_admin = Address::generate(&env);
let token_id = env.register_stellar_asset_contract_v2(token_admin.clone());
let token_addr = token_id.address();
let contract_id = env.register_contract(None, FlowPay);
let user = Address::generate(&env);
let merchant = Address::generate(&env);
// Mint and approve tokens for the user
let sac = StellarAssetClient::new(&env, &token_addr);
sac.mint(&user, &10_000_0000000);
let token = TokenClient::new(&env, &token_addr);
token.approve(&user, &contract_id, &10_000_0000000, &200);
(env, contract_id, token_addr, user, merchant)
}Only deviate from setup() when a test requires a genuinely different environment.
Name tests after the behaviour being verified, not the function name:
test_daily_limit_blocks_overspend ✓
test_set_daily_limit ✗ (describes what it calls, not what it checks)
Group related tests using a shared prefix so cargo test <prefix> runs the whole suite:
test_daily_limit_allows_spend_within_limit
test_daily_limit_accumulates_across_calls
test_daily_limit_blocks_cumulative_overspend
test_daily_limit_visibility_and_spend_tracking
test_daily_limit_removed_event_emitted
Every new public function needs at minimum:
| Test | What it verifies |
|---|---|
| Happy path | Function succeeds under normal conditions |
| Precondition failure | Correct error when inputs are invalid |
| Auth enforcement | Panics when called without the required auth |
| State after | Storage reflects the expected change |
| Event emitted | The correct event was published (for state-changing functions) |
Use env.events().all() and check the last event:
fn assert_last_user_event(env: &Env, topic: &str, user: &Address) {
let events = env.events().all();
let (_, topics, _) = events.get(events.len() - 1).unwrap();
assert_eq!(topics.get(0).unwrap(), Symbol::new(env, topic).into_val(env));
assert_eq!(topics.get(1).unwrap(), user.clone().into_val(env));
}Use env.ledger().set() to simulate time passing:
env.ledger().set(soroban_sdk::testutils::LedgerInfo {
timestamp: current + 86_400, // advance by one day
..env.ledger().get()
});cd contract
cargo test # all tests
cargo test daily_limit # tests matching the prefix
cargo test -- --nocapture # show println! output (useful for bench)The contract/test_snapshots/ directory contains JSON ledger snapshots produced by tests. These are plain JSON files (not managed by insta or similar snapshot crates). When a test's expected ledger state changes:
- Run
cargo test— tests will fail if snapshots diverge. - Inspect the diff in the failing test output to confirm the change is intentional.
- Update the corresponding
.jsonfile intest_snapshots/to match the new expected state. - Re-run
cargo testto confirm the snapshot matches.
Snapshot files are organized by test name under test_snapshots/test/. Do not rename or move snapshot files independently of their corresponding test functions.
Benchmark tests live in contract/src/bench.rs and measure CPU instruction counts to detect performance regressions.
The benchmark module is gated with #[cfg(feature = "bench")] in lib.rs. Standard cargo test does not compile benchmarks. To run them:
cd contract
cargo test bench --features bench -- --nocapture| Function | CPU instructions | Memory bytes |
|---|---|---|
subscribe() |
~4 200 000 | ~200 000 |
charge() |
~3 800 000 | ~180 000 |
pay_per_use() |
~3 600 000 | ~170 000 |
batch_charge() — 10 users |
~28 000 000 | ~1 200 000 |
Thresholds in bench.rs include ~10% headroom. If your change shifts a baseline by more than 5%, update the table and the constant.
#[test]
fn bench_my_feature() {
let (env, contract_id, token_addr, user, merchant) = bench_setup();
let client = FlowPayClient::new(&env, &contract_id);
// ... setup state ...
let usage = env.budget().cpu_instruction_count();
client.my_function(&user);
let delta = env.budget().cpu_instruction_count() - usage;
println!("my_function instructions: {delta}");
assert!(delta < MY_FUNCTION_MAX_INSTRUCTIONS);
}- Every state-changing public function must emit an event. Read-only functions must not.
- All
publish_*helpers live inevents.rs. Add new ones there; never callenv.events().publish()directly fromlib.rsor module files. - Use a two-element topic tuple
(Symbol, Address)for user-scoped events, or a single-element(Symbol,)for contract-wide events. - Topic symbols must be 32 characters or fewer (Soroban
Symbollimit). - Name symbols with snake_case matching the action:
subscribed,charged,daily_limit_set,merchant_frozen.
Adding a new event:
// events.rs
pub fn publish_preference_set(env: &Env, user: &Address, value: u32) {
env.events().publish(
(Symbol::new(env, "preference_set"), user.clone()),
value,
);
}Then call it from lib.rs after the state has been written:
my_feature::set_preference(&env, &user, value);
events::publish_preference_set(&env, &user, value); // always lastThe full event catalogue is documented in docs/EVENTS.md.
All contract errors are defined in errors.rs as variants of ContractError (#[contracterror]).
- One variant per distinct failure reason. Do not reuse a variant for different conditions.
- Assign the next sequential
u32discriminant. Never reuse or reorder existing values. - Add a doc comment explaining exactly when the error is returned.
- Return errors via
Err(ContractError::VariantName)from internal helpers;lib.rsshould propagate orunwrap_or_elseto panic with a clear message only as a last resort.
// errors.rs
/// Returned when a user's daily spending limit would be exceeded by this payment.
DailyLimitExceeded = 24,The full error reference is in docs/ERROR-CODES.md.
Every new contract entrypoint must address these concerns before merging:
| Concern | Requirement | Where to enforce |
|---|---|---|
| Authorization | Call require_auth() on every address that owns state or funds, before any mutation |
lib.rs entry point |
| Validation | Reject invalid amounts, intervals, addresses, and out-of-range values before any mutation | validation.rs or inline in the module |
| Errors | Use a dedicated ContractError variant (sequential discriminant, doc comment) — never panic with a raw string |
errors.rs |
| Events | Emit a publish_* event from events.rs after all state writes succeed |
events.rs + lib.rs |
| Tests | Happy path, auth enforcement, precondition failure, state-after, and event assertion | test.rs |
This checklist is a summary of the detailed guidance in the sections above. Use it as a quick reminder during code review.
Before opening a pull request against main:
Contract
-
cargo testpasses with no failures -
cargo clippy -- -D warningspasses with no warnings -
cargo checkpasses - Every new public function has tests (happy path, auth, error cases, event)
- Every new state-changing function emits an event via
events.rs - New
ContractErrorvariants have doc comments and sequential discriminants - New
DataKeyvariants are documented in a comment explaining the storage type (persistent / temporary / instance) and TTL if relevant - No
unwrap()on user-controlled input — useok_or(ContractError::...)instead - No floating-point arithmetic — all amounts are in stroops (i128)
-
#![no_std]is preserved — nostd::imports anywhere
Benchmarks
- If your change touches
subscribe,charge,pay_per_use, orbatch_charge, runcargo test bench --features bench -- --nocaptureand confirm instruction counts are within the documented baselines - Update
bench.rsconstants and the baselines table if a deliberate change shifts a baseline
Documentation
- New public functions are added to
docs/API.md - New events are added to
docs/EVENTS.md - New error codes are added to
docs/ERROR-CODES.md - PR description explains what changed, why, and links to the relevant issue
CI
- The
Backend (Rust)GitHub Actions workflow passes (cargo build+cargo test)
- General contribution guide:
CONTRIBUTING.md - API reference:
docs/API.md - Event catalogue:
docs/EVENTS.md - Error codes:
docs/ERROR-CODES.md - Architecture and storage design:
docs/ARCHITECTURE.md - Testing runbook:
docs/development/testing_runbook.md