Skip to content

Latest commit

 

History

History
409 lines (303 loc) · 16.8 KB

File metadata and controls

409 lines (303 loc) · 16.8 KB

Contributing to the FlowPay Contract

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.


Table of Contents


Module Layout

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.rs only wires public contract functions to module helpers — no logic inline.
  • bench.rs is gated with #[cfg(feature = "bench")] in lib.rs — it is not compiled by cargo test unless you pass --features bench.
  • test.rs is gated with #[cfg(test)].
  • token.rs contains an unrelated vesting contract and is not used by FlowPay. Do not add FlowPay logic to it.
  • limits.rs is an empty placeholder. Do not add logic there without first checking whether the behavior belongs in an existing module.

Adding a New Public Function

Follow these five steps every time.

1. Add a storage key (if needed)

In lib.rs, add a variant to DataKey for any new persistent state:

// lib.rs — DataKey enum
DataKey::UserPreference(Address),

2. Implement the logic in a module

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

3. Declare the module in lib.rs

// lib.rs
mod my_feature;

4. Expose the public contract function in lib.rs

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

5. Add tests and events

See Testing Conventions and Event Emission Rules below.


Testing Conventions

All tests live in contract/src/test.rs and are gated with #![cfg(test)].

Test setup

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.

Naming

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

What to test

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)

Asserting events

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

Advancing ledger time

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

Running tests

cd contract
cargo test                       # all tests
cargo test daily_limit           # tests matching the prefix
cargo test -- --nocapture        # show println! output (useful for bench)

Test snapshots

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:

  1. Run cargo test — tests will fail if snapshots diverge.
  2. Inspect the diff in the failing test output to confirm the change is intentional.
  3. Update the corresponding .json file in test_snapshots/ to match the new expected state.
  4. Re-run cargo test to 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.


Benchmarks

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

Baselines

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.

Adding a bench test

#[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);
}

Event Emission Rules

  • Every state-changing public function must emit an event. Read-only functions must not.
  • All publish_* helpers live in events.rs. Add new ones there; never call env.events().publish() directly from lib.rs or 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 Symbol limit).
  • 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 last

The full event catalogue is documented in docs/EVENTS.md.


Error Variant Guidelines

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 u32 discriminant. 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.rs should propagate or unwrap_or_else to 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.


Adding a New Public Function — Entrypoint Checklist

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.


PR Checklist

Before opening a pull request against main:

Contract

  • cargo test passes with no failures
  • cargo clippy -- -D warnings passes with no warnings
  • cargo check passes
  • Every new public function has tests (happy path, auth, error cases, event)
  • Every new state-changing function emits an event via events.rs
  • New ContractError variants have doc comments and sequential discriminants
  • New DataKey variants are documented in a comment explaining the storage type (persistent / temporary / instance) and TTL if relevant
  • No unwrap() on user-controlled input — use ok_or(ContractError::...) instead
  • No floating-point arithmetic — all amounts are in stroops (i128)
  • #![no_std] is preserved — no std:: imports anywhere

Benchmarks

  • If your change touches subscribe, charge, pay_per_use, or batch_charge, run cargo test bench --features bench -- --nocapture and confirm instruction counts are within the documented baselines
  • Update bench.rs constants and the baselines table if a deliberate change shifts a baseline

Documentation

CI

  • The Backend (Rust) GitHub Actions workflow passes (cargo build + cargo test)

Related