This document describes the current FlowPay contract architecture, module responsibilities, storage layout, event flow, and frontend integration.
FlowPay has two runtime pieces:
- The Soroban contract in
contract/src/, which owns subscription state and all on-chain policy. - The React frontend in
frontend/, which builds transactions and submits them through Freighter.
A keeper process is the only off-chain service required for recurring billing. It calls charge() or batch_charge() on schedule.
graph TD
user[User / Merchant]
ui[Frontend React app]
wallet[Freighter wallet]
keeper[Keeper service]
contract[FlowPay contract]
token[SAC token contract]
user --> ui --> wallet --> contract
keeper --> contract
contract --> token
contract --> token
| Module | Responsibility |
|---|---|
lib.rs |
Public entry points, contract data types, storage keys, and cross-module orchestration. Also owns subscribe_inner, pay_per_use_inner, check_and_update_global_volume, and TTL extension logic. |
admin.rs |
require_admin, admin initialization, and two-step admin transfer (transfer_admin / accept_admin). |
batch.rs |
batch_charge, batch_cancel, batch_extend_subscription_ttl. Defines ChargeResult and CancelResult enums. Default MAX_BATCH_SIZE = 50. |
bench.rs |
Instruction-count benchmarks gated by #[cfg(feature = "bench")]. Not compiled by cargo test unless --features bench is passed. |
charge_exec.rs |
Charge prechecks (precheck_charge), auto-resume logic (try_auto_resume), simulation (simulate_charge), and fee-aware transfer execution (execute_charge). Defines ChargeSimResult enum. |
errors.rs |
ContractError enum with 34 variants. |
events.rs |
All publish_* event helpers (~30+ event types) and event data structs. |
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, and per-merchant fee recipient routing. |
grace.rs |
Two-step grace period proposal and commit. |
limits.rs |
Placeholder — currently empty. |
merchant_stats.rs |
Per-merchant revenue tracking (cumulative, daily buckets, history Vec), subscriber counts, merchant index for ranking, and revenue summaries. |
migration.rs |
Schema version tracking (current: v3), v1→v2 migration (add paused field), v2→v3 migration (populate referrer). |
min_interval.rs |
Minimum billing interval floor (default 3600s). |
referral.rs |
Referrer storage, lookup, removal, and self-referral check. See REFERRALS.md. |
spending_limit.rs |
Per-user daily spending limits using 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 (instance), 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 sort. |
subscription_metadata.rs |
Short subscription labels (max 64 bytes). |
token.rs |
Not used by FlowPay. Contains an unrelated AcademyVestingContract. |
test.rs |
Contract unit tests. |
trial.rs |
Trial period end computation and trial extension. |
upgrade.rs |
Two-step WASM upgrade (propose/commit) and upgrade event emission. |
validation.rs |
Shared validation helpers for amounts, intervals, and allowance checks. |
whitelist.rs |
Merchant whitelist with indexed pagination, freeze/unfreeze with reasons, whitelist enabled toggle. |
subscribe()orsubscribe_with_metadata()validates auth, whitelist state, minimum interval, and token allowance.storage.rswrites theSubscriptionrecord.subscription_count.rsupdates the active count and subscriber index when needed.referral.rsstores an optional referrer.subscription_metadata.rsstores an optional label for the metadata path.events.rsemits the subscription event.
charge()loads the subscription and checks pause, interval, and grace period state.fee.rscalculates any protocol fee split.- The token contract performs
transfer_from(). merchant_stats.rsrecords merchant revenue.subscription_history.rsrecords the successful charge timestamp.events.rsemitscharged.
batch_charge()iterates over a list of subscriber addresses.batch.rsreuses the same charge eligibility checks as the single-charge path.- Each user produces a
ChargeResultinstead of aborting the whole transaction.
merchant_stats.rsstores cumulative revenue and daily revenue buckets.- Read helpers expose total revenue, per-day revenue, and subscriber counts.
- Administrative reset helpers clear or zero selected counters without affecting subscription state.
subscription_metadata.rsstores short labels.subscription_history.rsstores charge timestamps and supports paging and clearing.referral.rsstores the original referrer, if any.
FlowPay uses Soroban instance, persistent, and temporary storage deliberately.
| DataKey | Purpose | Storage type |
|---|---|---|
Token |
Default payment token | instance |
Admin |
Current admin | instance |
PendingAdmin |
Two-step admin transfer target | instance |
ContractPaused |
Global pause flag | instance |
PauseExpiry(user) |
Pause expiry timestamp for auto-resume | persistent |
GracePeriod |
Charge grace window | instance |
WhitelistEnabled |
Merchant whitelist flag | instance |
FeeCollector / FeeBps |
Protocol fee configuration | instance |
MinFeeBps / MaxFeeBps |
Fee bounds guardrails | instance |
PendingFee |
Pending fee proposal | temporary |
PendingGracePeriod |
Pending grace-period proposal | temporary |
MinInterval |
Minimum allowed subscription interval | instance |
SchemaVersion |
Storage schema version | instance |
ActiveCount |
Active subscription count | instance |
MaxBatchSize |
Configurable batch size limit (max 200, default 50) | instance |
GlobalVolumeWindow |
Rolling volume cap state | instance |
GlobalVolumeCapOverride |
Admin-configurable volume cap override | instance |
TotalProtocolFees |
Cumulative protocol fees across all charges | instance |
SubscriberIndexSize |
Append-only subscriber count | instance |
Subscription(user) |
Subscriber subscription record | persistent |
MerchantWhitelist(merchant) |
Whitelisted merchant flag | persistent |
MerchantFrozen(merchant) |
Frozen merchant flag | persistent |
MerchantFreezeReason(merchant) |
Freeze reason string | persistent |
MerchantRevenue(merchant) |
Cumulative merchant revenue | persistent |
MerchantRevenueDay(merchant, day) |
Daily revenue bucket | persistent |
MerchantRevenueDayIndex(merchant) |
Index of which days have revenue | persistent |
MerchantRevenueHistory(merchant) |
History vector for revenue reads | persistent |
MerchantSubCount(merchant) |
Active subscriber count per merchant | persistent |
MerchantFeeRecipient(merchant) |
Per-merchant custom fee recipient | persistent |
MerchantIndex(i) / MerchantIndexSize |
Merchant index for top-by-subs ranking | persistent |
MerchantKnown(merchant) |
Dedup flag for merchant indexing | persistent |
DailyLimit(user) |
Temporary pay-per-use limit | temporary |
DailySpent(user) |
Temporary pay-per-use spend counter | temporary |
DayStart(user) |
Day window anchor for daily spending limits | temporary |
Referral(user) |
Referrer for a subscriber (REFERRALS.md) | persistent |
SubscriptionMeta(user) |
Short subscription label | persistent |
ChargeHistory(user) |
Charge timestamps | persistent |
SubscriberIndex(i) |
Append-only subscriber list entry | persistent |
SubscriberIndexSlot(user) |
Reverse lookup for subscriber index pruning | persistent |
SubscriberIndexRemoved(i) |
Tombstone flag for subscriber index entry | persistent |
PendingUpgrade |
Two-step upgrade hash | temporary |
Persistent entries that must remain available are refreshed with TTL extensions where needed, most importantly subscription records and selected merchant-revenue data. Temporary entries are used for short-lived proposals and daily spending caps.
PendingAdmin, PendingFee, PendingGracePeriod, and (not shown above) PendingUpgrade all back a propose/commit or propose/accept two-step authorization flow rather than an instant admin action. See architecture/two-step-auth.md for the security rationale and a state diagram per flow.
Each Subscription(user) record carries its own token address rather than sharing one contract-wide token, which is what lets a single deployment serve subscribers paying in different SAC tokens. See MULTI-TOKEN.md for the full architecture, deployment models, and fee implications.
Events are emitted from events.rs and kept separate from storage mutation so the public contract methods remain small.
| Event | Trigger |
|---|---|
subscribed |
New or replaced subscription created |
referred |
Referrer stored on subscribe (REFERRALS.md) |
charged |
Successful recurring charge |
pay_per_use |
Successful one-time charge |
cancelled |
Subscription cancelled |
cancelled_with_refund |
Subscription cancelled with prorated refund |
trial_extended |
Trial period extended |
paused / resumed |
Subscription pause state changed (user-initiated) |
subscription_paused |
Subscription paused via batch admin operation |
subscription_auto_resumed |
Pause expiry passed; subscription auto-resumed on charge |
admin_transferred |
Two-step admin transfer completed |
fee_proposed / fee_committed / fee_cleared |
Fee configuration changed |
merchant_fee_recipient_set |
Per-merchant custom fee recipient configured |
merchant_added / merchant_removed |
Whitelist updated |
merchant_frozen / merchant_unfrozen |
Merchant freeze state changed |
grace_period_proposed / grace_period_committed |
Grace period updated |
sub_amount_updated / sub_interval_updated |
Admin adjusted a subscription |
merchant_withdrawal |
Merchant withdrew revenue |
daily_limit_set / daily_limit_removed |
Daily limit updated |
daily_window_started |
Daily spending window reset for a user |
subscription_transferred |
Subscription ownership moved |
sub_transferred |
Legacy subscription transfer event |
min_interval_set |
Minimum subscription interval updated |
merch_hist_cleared |
Merchant revenue history cleared |
contract_paused / contract_unpaused |
Global contract pause toggled |
upgrade |
Contract WASM upgraded |
upg_proposed |
WASM upgrade proposed (two-step) |
migration_completed |
Storage schema migration finished |
subscriber_index_ttl_extended |
Subscriber index TTL refreshed |
| Event | Trigger |
| --------------------------------------------------------------- | ------------------------------------------------------------- |
subscribed |
New or replaced subscription created |
referred |
Referrer stored on subscribe (REFERRALS.md) |
charged |
Successful recurring charge |
pay_per_use |
Successful one-time charge |
cancelled |
Subscription cancelled |
cancelled_with_refund |
Subscription cancelled with prorated refund |
trial_extended |
Trial period extended |
paused / resumed |
Subscription pause state changed (user-initiated) |
subscription_paused |
Subscription paused via batch admin operation |
subscription_auto_resumed |
Pause expiry passed; subscription auto-resumed on charge |
admin_transferred |
Two-step admin transfer completed |
fee_proposed / fee_committed / fee_cleared |
Fee configuration changed |
merchant_fee_recipient_set |
Per-merchant custom fee recipient configured |
merchant_added / merchant_removed |
Whitelist updated |
merchant_frozen / merchant_unfrozen |
Merchant freeze state changed |
grace_period_proposed / grace_period_committed |
Grace period updated |
sub_amount_updated / sub_interval_updated |
Admin adjusted a subscription |
merchant_withdrawal |
Merchant withdrew revenue |
daily_limit_set / daily_limit_removed |
Daily limit updated |
daily_window_started |
Daily spending window reset for a user |
subscription_transferred |
Subscription ownership moved |
sub_transferred |
Legacy subscription transfer event |
min_interval_set |
Minimum subscription interval updated |
merch_hist_cleared |
Merchant revenue history cleared |
contract_paused / contract_unpaused |
Global contract pause toggled |
upgrade |
Contract WASM upgraded |
upg_proposed |
WASM upgrade proposed (two-step) |
migration_completed |
Storage schema migration finished |
subscriber_index_ttl_extended |
Subscriber index TTL refreshed |
subscriber_index_cleared |
Admin repaired a stale subscriber index slot |
Events are the main off-chain integration surface for analytics, indexers, and the keeper workflow.
Merchants and auditors who need to turn those events into a finance-oriented audit trail (exports, reconciliation, retention) should follow COMPLIANCE.md.
The frontend does not talk to the contract directly. frontend/src/stellar.ts builds and simulates Soroban transactions, then Freighter signs them.
App.tsx
├── useWallet() — Freighter connection, signing, submission
├── SubscribeForm.tsx — form to create a subscription
├── Dashboard.tsx — view subscription, cancel, pay-per-use
├── MerchantDashboard.tsx — merchant subscriber management
└── AdminDashboard.tsx — admin diagnostics and subscription repair
- Admin connects Freighter wallet;
useAdmincomparespublicKeyto on-chainget_admin. - Operator enters a subscriber address and runs
validate_subscriptionvia RPC simulation. - Violations are mapped to human-readable messages in the UI (missing records, invalid transitions, corrupted references).
- If failures exist and the wallet is admin,
repair_subscriptionis submitted after confirmation. - The UI parses the
subscription_repairedevent for the exact fixed-inconsistency count and re-runs validation.
Authorization is enforced both in the UI (repair button disabled for non-admins) and on-chain (require_admin in the contract).
All Soroban SDK calls are isolated in stellar.ts. Components never import @stellar/stellar-sdk directly. This makes it easy to swap the SDK version or mock it in tests.
Typical flows:
- Subscribe: build transaction, simulate, sign, submit.
- Charge or pay-per-use: same transaction pipeline, but the user or keeper supplies the target address.
- Dashboard reads: call read-only entry points like
get_subscription(),get_protocol_stats(), andget_charge_history().
The frontend is intentionally thin. It should remain a transaction builder and state viewer, not a source of business logic.
contract/src/bench.rs contains instruction-count benchmarks for subscribe(), charge(), pay_per_use(), and a 10-user batch_charge() scenario. These are separate from unit tests and should be used to catch cost regressions.
The benchmark module is gated with #[cfg(feature = "bench")] in lib.rs. To run benchmarks:
cd contract
cargo test bench --features bench -- --nocaptureThe benchmark file prints CPU and memory costs at runtime and compares them against budget thresholds. If a change increases cost intentionally, update both the printed baseline comment and the threshold constant together.
batch_charge(users) processes each address independently and returns a Vec<ChargeResult>.
However, if the underlying token transfer (transfer_from) fails due to an insufficient allowance or insufficient balance, or if the token address is invalid, the contract panics and aborts the entire batch – no ChargeResult is produced for any user.
This means the contract does not currently tolerate per‑user allowance failures (see issue #XXX). The only safe way to avoid a batch‑wide panic is to pre‑check each user’s allowance and balance before submitting the batch.
| Scenario | Behaviour | Outcome |
|---|---|---|
User not due (interval not elapsed) |
Returns | ChargeResult::Skipped |
| Subscription paused | Returns | ChargeResult::Paused |
| Grace period elapsed | Returns | ChargeResult::GracePeriodElapsed |
| No subscription | Returns | ChargeResult::NoSubscription |
| Inactive subscription | Returns | ChargeResult::Inactive |
| Insufficient allowance (gross amount) | Panics | Whole batch aborts |
| Insufficient balance | Panics | Whole batch aborts |
| Token address is not a valid SAC contract | Panics | Whole batch aborts |
** Important**
Until the allowance‑tolerance issue is fixed (see #001), integrators must runsimulate_chargeorget_batch_charge_estimateon each candidate user and verify allowance/balance before callingbatch_charge. The keeper script usescheck-allowances.tsandsimulateto guard against these panics.
See docs/KEEPER.md for operational precheck steps.
For a complete breakdown of every DataKey variant, storage tier, TTL policy, and which functions read/write each key, see Storage and TTL Management.