This document tracks the current public contract surface in contract/src/lib.rs. For error codes, see ERROR-CODES.md, which contains the CONTRACT-34 table. For events, see EVENTS.md. For the propose/commit and propose/accept pattern shared by transfer_admin/accept_admin, propose_fee/commit_fee, propose_grace_period/commit_grace_period, and propose_upgrade/commit_upgrade, see architecture/two-step-auth.md. For the batch-size and page-size limits enforced by the batch and pagination entrypoints, see limits.md, which tabulates every cap from a single source of truth in contract/src/caps.rs.
- Bounded API delta (Issue 110)
- Data Types
- Functions
- initialize
- bump_instance_ttl
- subscribe
- subscribe_with_metadata
- charge
- simulate_charge
- simulate_pay_per_use
- extend_subscription_ttl
- pay_per_use
- cancel
- pause
- pause_until
- resume
- transfer_admin
- accept_admin
- is_contract_paused
- get_admin
- get_token
- propose_upgrade
- cancel_pending_upgrade
- commit_upgrade
- get_pending_upgrade
- get_subscription
- next_charge_at
- is_charge_due
- get_trial_end
- propose_grace_period
- commit_grace_period
- get_grace_period
- set_subscription_amount
- set_subscription_interval
- set_min_interval
- get_min_interval
- add_merchant
- remove_merchant
- set_whitelist_enabled
- is_whitelist_enabled
- is_merchant_whitelisted
- freeze_merchant
- unfreeze_merchant
- bump_merchant_revenue_day
- prune_merchant_revenue_days
- get_merchant_revenue_day
- is_merchant_frozen
- get_fee
- propose_fee
- commit_fee
- set_fee_bounds
- get_fee_bounds
- batch_charge
- get_batch_charge_estimate
- get_active_count
- get_subscriber_count
- get_subscriber_at
- get_subscriber_page
- get_active_subscriber_page
- clear_subscriber_index_entry
- get_merchant_revenue
- withdraw_merchant_revenue
- get_merchant_revenue_history
- clear_merchant_revenue_history
- get_merchant_subscriber_count
- get_merchant_sub_count
- reset_merchant_revenue
- set_daily_limit
- remove_daily_limit
- get_daily_limit
- get_daily_spent
- get_day_start
- get_daily_limit_status
- get_referral
- get_referrer
- migrate
- get_schema_version
- set_metadata
- get_metadata
- get_subscription_label
- clear_metadata
- get_charge_history
- get_protocol_stats
- pause_contract
- unpause_contract
- set_initial_admin
- contract_health_check
- clear_charge_history
- prune_charge_history
- get_charge_history_ttl
- get_charge_history_page
- transfer_subscription
- validate_subscription
- repair_subscription
- Units & Conversions
- Events Reference
- Error Codes
NOTE: This document now provides complete coverage of all 130 public contract entrypoints exposed by #[contractimpl]. The previous scoping note referenced below has been superseded by this comprehensive revision.
Previous partial coverage tracked these symbols from Issue 110:
-
contract_health_check -
HealthReport -
simulate_charge -
get_batch_charge_estimate -
ChargeResult(includingAllowanceInsufficient; notChargeSimResult::InsufficientAllowance) -
ChargeSimResult -
set_fee_bounds -
get_fee_bounds -
pause_until - pause-expiry getter — not exposed (internal
storage::get_pause_expiryonly; no public contract method) -
get_active_subscriber_page
Related ops: EVENTS.md (paused, subscription_auto_resumed, charged, batch_charge_skips), KEEPER.md (batch_charge), MAINNET-DEPLOYMENT.md (fee bounds / health gates).
pub struct Subscription {
pub merchant: Address,
pub amount: i128,
pub interval: u64,
pub last_charged: u64,
pub active: bool,
pub paused: bool, // true if paused, false otherwise
pub token: Address, // SAC token used for this subscription
pub referrer: Option<Address>, // optional referral address
pub label: Symbol, // user-assigned label for this subscription
pub trial_duration: u64, // optional trial duration in seconds
pub created_at: u64, // timestamp of subscription creation
}Returned by live batch_charge and by get_batch_charge_estimate. Defined in contract/src/batch.rs (re-exported from lib.rs).
pub enum ChargeResult {
Charged, // live: funds transferred; estimate: would charge (no transfer)
Skipped, // interval has not elapsed
NoSubscription, // no subscription record
Inactive, // cancelled / inactive
Paused, // still paused
GracePeriodElapsed, // past the grace window
AllowanceInsufficient, // SAC allowance < gross `sub.amount`; appended (discriminant 6)
Charged,
Skipped,
NoSubscription,
Inactive,
Paused,
GracePeriodElapsed,
AllowanceInsufficient,
}| Variant | Meaning on batch_charge |
Meaning on get_batch_charge_estimate |
|---|---|---|
Charged |
Transfer succeeded | Precheck passed (or auto-resume short-circuit; no transfer) |
Skipped |
Interval not elapsed | Same (via precheck_charge) |
NoSubscription |
No record | Same |
Inactive |
Cancelled / inactive | Same |
Paused |
Still paused | Same |
GracePeriodElapsed |
Past grace window | Same |
New variants must be appended so off-chain parsers keep decoding 0–6. Do not confuse AllowanceInsufficient with ChargeSimResult::InsufficientAllowance or ContractError::InsufficientAllowance (8) — those are different symbols. |
| Variant | Meaning on batch_charge |
Meaning on get_batch_charge_estimate |
|---|---|---|
Charged |
Transfer succeeded | Precheck + allowance passed (no transfer) |
Skipped |
Interval not elapsed | Same (via precheck_charge) |
NoSubscription |
No record | Same |
Inactive |
Cancelled / inactive | Same |
Paused |
Still paused | Same |
GracePeriodElapsed |
Past grace window | Same |
AllowanceInsufficient |
Gross allowance too low; rest of batch continues | Same check; no transfer |
AllowanceInsufficient |
Allowance below gross amount; no transfer | Same precheck path as live charge |
This type is not the dry-run enum. Single-user simulation uses ChargeSimResult.
Returned only by simulate_charge. Defined in contract/src/charge_exec.rs.
pub enum ChargeSimResult {
WouldSucceed,
NotDue,
Inactive,
InsufficientAllowance,
GracePeriodElapsed,
ContractPaused,
SubscriptionPaused,
}| Variant | Meaning |
|---|---|
WouldSucceed |
Prechecks including SAC allowance would pass |
NotDue |
Ledger time is before next charge |
Inactive |
Missing subscription or inactive (no separate NoSubscription) |
InsufficientAllowance |
allowance(user, contract) < amount |
GracePeriodElapsed |
Past grace window |
ContractPaused |
Protocol paused (storage::is_contract_paused) |
SubscriptionPaused |
User pause, including unexpired pause_until |
simulate_charge never panics with a ContractError for these outcomes; they are return values.
Returned only by simulate_pay_per_use / simulate_pay_per_use_to. Defined in contract/src/charge_exec.rs.
pub enum PayPerUseSimResult {
WouldSucceed,
Inactive,
InsufficientAllowance,
ContractPaused,
SubscriptionPaused,
AmountMustBePositive,
AmountExceedsMaximum,
DailyLimitExceeded,
InvalidRecipient,
MerchantNotWhitelisted,
}| Variant | Meaning |
|---|---|
WouldSucceed |
Eligible: active, unpaused, within daily limit, allowance sufficient |
Inactive |
Missing subscription or inactive (no separate NoSubscription) |
InsufficientAllowance |
allowance(user, contract) < amount |
ContractPaused |
Protocol paused (storage::is_contract_paused) |
SubscriptionPaused |
Subscription is paused |
AmountMustBePositive |
amount <= 0 |
AmountExceedsMaximum |
amount > MAX_AMOUNT (per-call cap) |
DailyLimitExceeded |
daily_spent + amount > daily_limit |
InvalidRecipient |
pay_per_use_to recipient is the contract's own address |
MerchantNotWhitelisted |
Whitelist enabled and pay_per_use_to recipient not whitelisted |
simulate_pay_per_use never panics with a ContractError for these outcomes; they are return values.
pub struct ProtocolStats {
pub active_count: u64,
pub fee_bps: u32,
pub fee_collector: Option<Address>,
pub grace_period: u64,
pub whitelist_enabled: bool,
pub schema_version: u32,
pub contract_paused: bool,
}Returned by contract_health_check. There is no separate health-status enum: interpret is_healthy plus the component flags.
pub struct HealthReport {
pub is_healthy: bool,
pub contract_paused: bool,
pub token_configured: bool,
pub admin_configured: bool,
pub instance_ttl_ledgers: u32,
pub active_subscription_count: u64,
pub schema_version: u32,
pub fee_collector_set: bool,
pub global_volume_utilization_pct: u32,
pub pending_merchant_rev_count: u32,
}| Field | Type | Meaning |
|---|---|---|
is_healthy |
bool |
!contract_paused && token_configured && admin_configured. Does not include instance TTL. |
contract_paused |
bool |
Protocol pause flag |
token_configured |
bool |
Instance token is set |
admin_configured |
bool |
Admin is set |
instance_ttl_ledgers |
u32 |
Placeholder on-chain: hardcoded 100_000 (Soroban does not expose get_ttl() outside tests). Test/testutils: real instance TTL. Telemetry only; not used by is_healthy. |
active_subscription_count |
u64 |
Active subscription counter |
schema_version |
u32 |
Migration schema version |
fee_collector_set |
bool |
Protocol fee collector is set |
global_volume_utilization_pct |
u32 |
(accumulated * 100) / cap, clamped to ≤ 100; 0 if cap is 0. Cap is GlobalVolumeCapOverride or GLOBAL_MAX_VOLUME_PER_HOUR. |
pending_merchant_rev_count |
u32 |
Merchants in MerchantIndex with accumulated revenue (MerchantRevenue) > 0. This is a tracking metric, not a custodial balance — revenue settles directly into merchant wallets on every charge via SAC transfer_from. See MERCHANT-INTEGRATION.md § 5. |
How to interpret: treat is_healthy == false as “do not send production traffic” until pause, token, or admin flags are understood. instance_ttl_ledgers, fee_collector_set, and volume utilization are informational and do not enter the is_healthy formula. On-chain instance_ttl_ledgers is a fabricated constant, not a measurement. global_volume_utilization_pct uses effective_global_volume_cap (override when set, compile-time constant otherwise) — the same value charge enforcement uses (see limits.md — Global Hourly Volume Cap).
pub enum DataKey {
Subscription(Address),
Token,
Admin,
GracePeriod,
MerchantWhitelist(Address),
WhitelistEnabled,
MerchantFrozen(Address),
FeeCollector,
FeeBps,
PendingFee,
PendingAdmin,
ActiveCount,
MerchantRevenue(Address),
MerchantRevenueDay(Address, u64),
DailyLimit(Address),
DailySpent(Address),
Referral(Address),
SchemaVersion,
SubscriptionMeta(Address),
ChargeHistory(Address),
GlobalVolumeWindow,
ContractPaused,
MinInterval,
MerchantRevenueHistory(Address),
SubscriberIndex(u64),
SubscriberIndexSize,
MerchantSubCount(Address),
PendingGracePeriod,
}Returned by get_subscription_health. Provides composite health checks in one call for frontend UIs.
pub struct SubscriptionHealth {
pub active: bool,
pub charge_due: bool,
pub within_grace: bool,
pub has_sufficient_allowance: bool,
pub is_paused: bool,
pub trial_active: bool,
pub daily_limit_set: bool,
}| Field | Type | Meaning |
|---|---|---|
active |
bool |
Subscription is active (not cancelled) |
charge_due |
bool |
Current time ≥ next charge time (eligible for charging now) |
within_grace |
bool |
If overdue, still within grace period window |
has_sufficient_allowance |
bool |
Token allowance covers subscription amount |
is_paused |
bool |
Subscription is paused (user or admin pause) |
trial_active |
bool |
Currently in trial period (first charge not yet due) |
daily_limit_set |
bool |
User has configured a daily spending limit |
Returns default/zero values when no subscription exists (all fields false). See SUBSCRIBER-LIFECYCLE.md and docs/frontend/components.md for UI integration patterns.
Returned by get_daily_limit_status. Provides consistent snapshot of daily spending window from one storage read.
pub struct DailyLimitStatus {
pub limit: Option<i128>,
pub spent: i128,
pub day_start: Option<u64>,
pub remaining: Option<i128>,
}| Field | Type | Meaning |
|---|---|---|
limit |
Option<i128> |
Configured daily limit in stroops, or None if no limit set |
spent |
i128 |
Amount spent in current 24h window (0 if no spending yet) |
day_start |
Option<u64> |
Unix timestamp of current window start, or None if no spending yet |
remaining |
Option<i128> |
limit - spent (clamped ≥ 0), or None if no limit set |
See DAILY-LIMITS.md for spending-limit mechanics and TTL behavior.
Returned by get_contract_config. Snapshot of contract-wide configuration settings.
pub struct ContractConfig {
pub fee_bps: u32,
pub fee_collector: Option<Address>,
pub grace_period: u64,
pub min_interval: u64,
pub max_batch_size: u32,
pub global_volume_cap: i128,
pub whitelist_enabled: bool,
pub paused: bool,
pub schema_version: u32,
}| Field | Type | Meaning |
|---|---|---|
fee_bps |
u32 |
Protocol fee in basis points (0-10000); 100 bps = 1% |
fee_collector |
Option<Address> |
Address receiving protocol fees, or None if fee not configured |
grace_period |
u64 |
Seconds after due time before subscription lapses |
min_interval |
u64 |
Minimum allowed subscription interval (seconds) |
max_batch_size |
u32 |
Configured batch charge cap (default 50, ceiling 200) |
global_volume_cap |
i128 |
Effective hourly volume cap (override or default 50T stroops) |
whitelist_enabled |
bool |
Whether merchant whitelist enforcement is active |
paused |
bool |
Contract-wide pause flag (admin circuit breaker) |
schema_version |
u32 |
Migration schema version |
Useful for frontend config caching and keeper initialization.
Returned by batch_cancel. Encodes the outcome for each address in the batch.
pub enum CancelResult {
Cancelled,
NoSubscription,
AlreadyCancelled,
PausedCancelled,
MerchantFrozen,
}| Variant | Meaning |
|---|---|
Cancelled |
Subscription was active and cancelled successfully |
NoSubscription |
No subscription found for this address |
AlreadyCancelled |
Subscription already inactive (previously cancelled) |
PausedCancelled |
Subscription was paused; cancel succeeded but state was unusual |
MerchantFrozen |
Subscription's merchant was frozen; cancel succeeded but unusual state |
Wire encoding: discriminant u32 (0=Cancelled, 1=NoSubscription, 2=AlreadyCancelled, 3=PausedCancelled, 4=MerchantFrozen). Append-only invariant: new variants must be appended to preserve off-chain parser compatibility. See docs/cancel-results.md.
initialize(env: Env, token: Address, admin: Address)
| Name | Type | Description |
|---|---|---|
token |
Address |
SAC token used for subscription payments. |
admin |
Address |
Initial contract admin. |
Auth: none.
Returns: ().
Errors: ContractError::AlreadyInitialized.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source deployer --network testnet -- initialize --token <TOKEN_ADDRESS> --admin <ADMIN_ADDRESS>bump_instance_ttl(env: Env)
Permissionlessly refreshes the shared instance storage TTL. Keeper liveness probes may call it during read- or simulation-heavy periods. It does not inspect pause state, transfer funds, or mutate protocol state.
Auth: none.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ANY_KEY> --network testnet -- bump_instance_ttlsubscribe(env: Env, user: Address, merchant: Address, amount: i128, interval: u64, token: Address, trial_period: Option<u64>, referrer: Option<Address>)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and transaction signer. |
merchant |
Address |
Merchant receiving funds. |
amount |
i128 |
Recurring amount in stroops. |
interval |
u64 |
Billing interval in seconds. |
token |
Address |
Token contract used for this subscription. |
trial_period |
Option<u64> |
Optional delay before the first charge. |
referrer |
Option<Address> |
Optional referrer address. |
Auth: user.require_auth().
Returns: ().
Errors: ContractError::AmountMustBePositive, ContractError::IntervalMustBePositive, ContractError::MerchantNotWhitelisted, ContractError::ContractPausedError, ContractError::InvalidTokenAddress, ContractError::IntervalTooShort, ContractError::InsufficientAllowance.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- subscribe --user <USER_ADDRESS> --merchant <MERCHANT_ADDRESS> --amount 50000000 --interval 2592000 --token <TOKEN_ADDRESS>subscribe_with_metadata(env: Env, user: Address, merchant: Address, amount: i128, interval: u64, token: Address, trial_period: Option<u64>, referrer: Option<Address>, label: String)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and transaction signer. |
merchant |
Address |
Merchant receiving funds. |
amount |
i128 |
Recurring amount in stroops. |
interval |
u64 |
Billing interval in seconds. |
token |
Address |
Token contract used for this subscription. |
trial_period |
Option<u64> |
Optional delay before the first charge. |
referrer |
Option<Address> |
Optional referrer address. |
label |
String |
Subscription label, max 64 bytes. |
Auth: user.require_auth().
Returns: ().
Errors: same as subscribe(), plus ContractError::MetadataLabelTooLong.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- subscribe_with_metadata --user <USER_ADDRESS> --merchant <MERCHANT_ADDRESS> --amount 50000000 --interval 2592000 --token <TOKEN_ADDRESS> --label procharge(env: Env, user: Address)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber to charge. |
Auth: none. This is permissionless for keeper use.
Returns: ().
Errors: ContractError::NoSubscriptionFound, ContractError::SubscriptionNotActive, ContractError::SubscriptionPaused, ContractError::IntervalNotElapsed, ContractError::GracePeriodElapsed, ContractError::NotInitialized.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <KEEPER_KEY> --network testnet -- charge --user <USER_ADDRESS>Dry-run of a single charge() for user. No storage writes and no token transfer. Outcomes are ChargeSimResult variants, not panics.
simulate_charge(env: Env, user: Address) -> ChargeSimResult
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber to simulate. |
Auth: none.
Returns: ChargeSimResult.
Success behavior: evaluates contract pause, subscription presence, pause_until expiry (in memory only — does not persist auto-resume), due time, grace window, then SAC allowance(user, contract) vs amount. Returns WouldSucceed if all pass.
Error conditions: none as ContractError panics. Failure reasons are enum variants (ContractPaused, Inactive, SubscriptionPaused, NotDue, GracePeriodElapsed, InsufficientAllowance).
Distinguish from live charge and batch estimate:
charge / batch_charge |
simulate_charge |
get_batch_charge_estimate |
|
|---|---|---|---|
| Transfers | Yes | No | No |
| Storage writes | Yes on success / auto-resume | No (auto-resume is memory-only) | Yes if try_auto_resume fires |
| Contract pause | Enforced (panic / skip) | ContractPaused |
Ignored |
| Allowance | Required for success | Checked | Not checked |
| Return | void / ChargeResult |
ChargeSimResult |
Vec<ChargeResult> |
| Contract pause | charge and batch_charge panic ContractPaused (18) |
ContractPaused |
Ignored |
| Allowance | charge panics InsufficientAllowance (8); batch_charge returns AllowanceInsufficient |
InsufficientAllowance |
ChargeResult::AllowanceInsufficient |
| Return | void / Vec<ChargeResult> |
ChargeSimResult |
Vec<ChargeResult> |
Keeper ops still use live batch_charge; see KEEPER.md. Events for a real charge are in EVENTS.md.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- simulate_charge --user <USER_ADDRESS>Dry-run of a single pay_per_use() for user and amount. No storage writes and no token transfer. Outcomes are PayPerUseSimResult variants, not panics.
simulate_pay_per_use(env: Env, user: Address, amount: i128) -> PayPerUseSimResult
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber to simulate. |
amount |
i128 |
One-time amount to simulate. |
Auth: none.
Returns: PayPerUseSimResult.
Success behavior: evaluates contract pause, amount bounds, subscription presence, paused/inactive state, the daily spending limit (get_daily_limit / get_daily_spent), then SAC allowance(user, contract) vs amount. Returns WouldSucceed if all pass. Never writes spend tracking or other storage.
Dry-run of a single pay_per_use_to() for user, amount, and an explicit recipient. Same checks as simulate_pay_per_use, plus recipient validation (InvalidRecipient if recipient is the contract's own address; MerchantNotWhitelisted if the whitelist is enabled and recipient is not whitelisted). No storage writes.
simulate_pay_per_use_to(env: Env, user: Address, amount: i128, recipient: Address) -> PayPerUseSimResult
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber to simulate. |
amount |
i128 |
One-time amount to simulate. |
recipient |
Address |
Recipient that would receive the net payment. |
Auth: none.
Returns: PayPerUseSimResult.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- simulate_pay_per_use --user <USER_ADDRESS> --amount 1000000extend_subscription_ttl(env: Env, user: Address)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber whose TTL should be refreshed. |
Auth: none.
Returns: ().
Errors: none beyond storage access.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- extend_subscription_ttl --user <USER_ADDRESS>pay_per_use(env: Env, user: Address, amount: i128)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and signer. |
amount |
i128 |
One-time payment amount in stroops. |
Auth: user.require_auth().
Returns: ().
Errors: ContractError::AmountMustBePositive, ContractError::AmountExceedsMaximum, ContractError::NoSubscriptionFound, ContractError::SubscriptionNotActive, ContractError::SubscriptionPaused.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- pay_per_use --user <USER_ADDRESS> --amount 1000000Immediate pay-per-use charge that routes payment to an explicit recipient instead of the subscription's merchant. Shares the user's daily spending limit and allowance budget with pay_per_use.
pay_per_use_to(env: Env, user: Address, amount: i128, recipient: Address)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and transaction signer. |
amount |
i128 |
One-time payment amount in stroops. |
recipient |
Address |
Address receiving the net payment (after protocol fee if configured). |
Auth: user.require_auth().
Returns: ().
Errors:
| Condition | Code | Symbol |
|---|---|---|
amount <= 0 |
2 | AmountMustBePositive |
amount > MAX_AMOUNT |
15 | AmountExceedsMaximum |
No subscription for user |
4 | NoSubscriptionFound |
| Subscription is cancelled | 5 | SubscriptionInactive |
| Subscription is paused | 17 | SubscriptionPaused |
| Contract paused | 18 | ContractPaused |
| Allowance insufficient | 8 | InsufficientAllowance |
| Daily limit exceeded | 25 | DailyLimitExceeded |
recipient is the contract's own address |
32 | InvalidRecipient |
Whitelist enabled and recipient not whitelisted |
10 | MerchantNotWhitelisted |
Side Effects:
- Transfers
amountfromusertorecipient(after protocol fee split if configured). - Updates
recipient's merchant revenue tracking (not the subscription's merchant). - Increments user's daily spend counter.
- Emits
pay_per_useevent withrecipientin place ofsub.merchant.
Use cases:
Multi-merchant marketplaces, tipping, service routing. See MERCHANT-INTEGRATION.md § 4, INTEGRATION-GUIDE.md § pay_per_use_to, and MULTI-TOKEN.md.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- pay_per_use_to --user <USER_ADDRESS> --amount 1000000 --recipient <RECIPIENT_ADDRESS>cancel(env: Env, user: Address)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and signer. |
Auth: user.require_auth().
Returns: ().
Errors: ContractError::NoSubscriptionFound.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- cancel --user <USER_ADDRESS>pause(env: Env, user: Address)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and signer. |
Auth: user.require_auth().
Returns: ().
Errors: ContractError::NoSubscriptionFound, ContractError::SubscriptionNotActive.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- pause --user <USER_ADDRESS>Bounded user pause. The subscription auto-resumes on a later charge or batch_charge when ledger time >= expiry. Like indefinite pause, this sets paused = true and leaves active = true — pausing never deactivates a subscription (issue #1009; active = false is reserved for cancellation). The call also TTL-extends both the Subscription and PauseExpiry entries so they archive together.
pause_until(env: Env, user: Address, expiry: u64)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and signer. |
expiry |
u64 |
Unix ledger timestamp; must be strictly greater than current ledger time. |
Auth: user.require_auth().
Returns: ().
Success behavior: writes the subscription, stores pause expiry internally, emits paused (("paused", user)). See EVENTS.md.
Errors:
| Condition | Code | Symbol |
|---|---|---|
expiry <= now |
27 | InvalidPauseExpiry |
| No subscription | 4 | NoSubscriptionFound |
!sub.active |
16 | SubscriptionNotActive |
Pause-expiry read API: there is no public get_pause_expiry, get_pause, or paused_until contract method. Expiry is stored via internal storage::set_pause_expiry / storage::get_pause_expiry only.
For the full pause state model, auto-resume semantics, TTL considerations, and keeper/indexer guidance see architecture/pause-lifecycle.md.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- pause_until --user <USER_ADDRESS> --expiry <UNIX_TIMESTAMP>resume(env: Env, user: Address)
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber and signer. |
Auth: user.require_auth().
Returns: ().
Errors: ContractError::NoSubscriptionFound, ContractError::SubscriptionNotActive.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- resume --user <USER_ADDRESS>transfer_admin(env: Env, new_admin: Address)
| Name | Type | Description |
|---|---|---|
new_admin |
Address |
Proposed admin address. |
Auth: current admin only.
Returns: ().
Errors: none beyond auth.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- transfer_admin --new_admin <NEW_ADMIN_ADDRESS>accept_admin(env: Env)
Auth: proposed admin only.
Returns: ().
Errors: expect("no pending admin") if no proposal exists.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <NEW_ADMIN_KEY> --network testnet -- accept_adminis_contract_paused(env: Env) -> bool
Auth: none.
Returns: bool.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- is_contract_pausedget_admin(env: Env) -> Option<Address>
Auth: none.
Returns: Option<Address>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_adminget_token(env: Env) -> Option<Address>
Auth: none.
Returns: Option<Address>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_tokenpropose_upgrade(env: Env, new_wasm_hash: BytesN<32>)
Queues a WASM hash for the two-step upgrade flow. Auth: admin. The pending proposal is stored temporarily and refreshed to a 17,280-ledger TTL.
cancel_pending_upgrade(env: Env)
Clears the pending upgrade proposal. Auth: admin.
commit_upgrade(env: Env)
Commits the pending WASM hash and clears the proposal. Auth: admin. Panics
with NoPendingProposal when the proposal was cancelled or expired.
get_pending_upgrade(env: Env) -> Option<BytesN<32>>
Returns the pending upgrade hash, or None when no proposal exists. Auth: none.
get_subscription(env: Env, user: Address) -> Option<Subscription>
| Name | Type | Description |
|---|---|---|
user |
Address |
Subscriber address to look up. |
Auth: none.
Returns: Option<Subscription>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_subscription --user <USER_ADDRESS>Read-only view. Returns the Unix timestamp of the next scheduled charge for a user.
next_charge_at(env: Env, user: Address) -> Option<u64>
Parameters
| Name | Type | Description |
|---|---|---|
user |
Address |
The subscriber address to query. |
Auth: None.
Returns: Option<u64> — Some(last_charged + interval) if the subscription is active and not paused. Returns None when:
- No subscription exists for
user - The subscription is inactive (cancelled,
active == false) - The subscription is paused (
paused == true)
The returned timestamp does not change between charges; it reflects the scheduled billing time regardless of whether the charge has been triggered yet.
Storage read: DataKey::Subscription(user) in persistent storage.
See also: is_charge_due to test whether the charge window is open right now; get_subscription to inspect the full record.
CLI example
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- next_charge_at \
--user <USER_ADDRESS>Read-only predicate. Returns true when a subscriber has a charge due at the current ledger timestamp — that is, when calling charge(user) right now would succeed rather than panic.
is_charge_due(env: Env, user: Address) -> bool
Parameters
| Name | Type | Description |
|---|---|---|
user |
Address |
The subscriber address to test. |
Auth: None.
Returns: bool — true when all of the following conditions hold simultaneously:
- A subscription exists for
user - The subscription is active (
active == true) and not paused (paused == false) now >= next_charge_at— the billing interval has elapsednow <= next_charge_at + grace_period— the charge is still within the grace window (this condition is skipped whengrace_period == 0)
Returns false for any absent subscription, inactive or paused subscription, interval not yet elapsed, or expired grace window.
Storage read: DataKey::Subscription(user) in persistent storage; DataKey::GracePeriod in instance storage.
Usage for keepers: Poll is_charge_due for each subscriber before deciding whether to submit a charge() transaction, saving gas on calls that would revert.
See also: next_charge_at for the exact scheduled timestamp; get_grace_period for the contract-wide grace window; charge for the write operation.
CLI example
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- is_charge_due \
--user <USER_ADDRESS>Read-only view. Returns the Unix timestamp when a subscriber's trial period ends, or None if the subscriber is not currently in a trial or has no subscription.
get_trial_end(env: Env, user: Address) -> Option<u64>
Parameters
| Name | Type | Description |
|---|---|---|
user |
Address |
The subscriber address to query. |
Auth: None.
Returns: Option<u64> — Unix timestamp (seconds) of trial expiry when last_charged > now, otherwise None.
How trials work: When subscribe() is called with a trial_period of N seconds, the contract sets last_charged = now + N instead of now. This pushes the first eligible charge forward by the trial duration. get_trial_end returns Some(last_charged) while that timestamp is still in the future, indicating the subscriber is actively in a trial. Once the trial expires — that is, once last_charged <= now — this function returns None because the first charge window has opened. No separate storage key is used; the trial end is encoded directly in last_charged.
Returns None if no subscription exists for user.
Storage read: DataKey::Subscription(user) in persistent storage (reads last_charged field).
See also: subscribe for the trial_period parameter; next_charge_at which returns Some(last_charged + interval) once the trial ends.
CLI example
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- get_trial_end \
--user <USER_ADDRESS>propose_grace_period(env: Env, seconds: u64)
| Name | Type | Description |
|---|---|---|
seconds |
u64 |
Proposed grace period in seconds. |
Auth: admin only.
Returns: ().
Errors: ContractError::NoPendingProposal is not used here; seconds is validated in the helper.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- propose_grace_period --seconds 86400commit_grace_period(env: Env)
Auth: admin only.
Returns: ().
Errors: ContractError::NoPendingProposal.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- commit_grace_periodRead-only view. Returns the current contract-wide grace period in seconds.
get_grace_period(env: Env) -> u64
Auth: None.
Returns: u64 — grace period in seconds. Returns 0 when no grace period has been configured, which means charges must arrive exactly at or after next_charge_at with no tolerance window.
What the grace period means: After a billing interval elapses, keepers have a window of grace_period seconds in which to submit charge(). Concretely, charge() accepts a call only while now falls within [next_charge_at, next_charge_at + grace_period]. Calls that arrive after this window panic with "grace period elapsed". In batch_charge, users whose grace window has closed are returned as ChargeResult::GracePeriodElapsed without aborting the batch.
Storage read: DataKey::GracePeriod in instance storage. Defaults to 0 when the key is absent.
See also: propose_grace_period and commit_grace_period to change this value (admin, two-step); is_charge_due which incorporates the grace window into its result.
CLI example
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- get_grace_periodset_subscription_amount(env: Env, user: Address, new_amount: i128)
Auth: admin only.
Returns: ().
Errors: ContractError::NoSubscriptionFound, ContractError::AmountMustBePositive, ContractError::AmountExceedsMaximum, ContractError::ContractPausedError.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- set_subscription_amount --user <USER_ADDRESS> --new_amount 50000000set_subscription_interval(env: Env, user: Address, new_interval: u64)
Auth: admin only.
Returns: ().
Errors: ContractError::NoSubscriptionFound, ContractError::IntervalTooShort, ContractError::ContractPausedError.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- set_subscription_interval --user <USER_ADDRESS> --new_interval 604800set_min_interval(env: Env, seconds: u64)
Auth: admin only.
Returns: ().
Errors: aborts with the typed ContractError::IntervalMustBePositive (code 3) if seconds == 0, and with ContractError::NotInitialized (code 7) if no admin has been stored yet. seconds is validated before the admin guard, so an unconfigured contract still reports the invalid input.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- set_min_interval --seconds 3600Read-only view. Returns the minimum allowed subscription interval in seconds.
get_min_interval(env: Env) -> u64
Auth: None.
Returns: u64 — minimum interval in seconds. Default: 3600 (1 hour) when set_min_interval has never been called.
What this controls: When subscribe() or set_subscription_interval() is called, the requested interval is validated against this floor. Any attempt to create or update a subscription with interval < get_min_interval() will panic with ContractError::IntervalTooShort. The floor prevents subscriptions from being charged too frequently (e.g. every second), protecting both users and the network from spam.
Storage read: DataKey::MinInterval in instance storage. Falls back to the compile-time constant DEFAULT_MIN_INTERVAL = 3600 when the key is absent.
See also: set_min_interval to change this value (admin only); subscribe and set_subscription_interval which enforce it.
CLI example
soroban contract invoke \
--id <CONTRACT_ID> \
--network testnet \
-- get_min_intervaladd_merchant(env: Env, merchant: Address)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- add_merchant --merchant <MERCHANT_ADDRESS>See also: Merchant Integration Cookbook — Getting Started for whitelist request flow.
remove_merchant(env: Env, merchant: Address)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- remove_merchant --merchant <MERCHANT_ADDRESS>set_whitelist_enabled(env: Env, enabled: bool)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- set_whitelist_enabled --enabled trueget_whitelist_enabled(env: Env) -> bool
Auth: none.
Returns: bool (true by default if not set).
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_whitelist_enabledis_whitelist_enabled(env: Env) -> bool
Auth: none.
Returns: bool (true by default if not set).
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- is_whitelist_enabledis_merchant_whitelisted(env: Env, merchant: Address) -> bool
Auth: none.
Returns: bool.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- is_merchant_whitelisted --merchant <MERCHANT_ADDRESS>get_top_merchants_by_subs(env: Env, limit: u32) -> Vec<(Address, u32)>
Auth: none.
Returns: Vec<(Address, u32)> (top N merchants ranked by active subscriber count in descending order).
Panics: ContractError::BatchTooLarge if limit > 20. The 20 is caps::TOP_MERCHANTS_PAGE_SIZE; see limits.md.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_top_merchants_by_subs --limit 10freeze_merchant(env: Env, merchant: Address)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- freeze_merchant --merchant <MERCHANT_ADDRESS>unfreeze_merchant(env: Env, merchant: Address)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- unfreeze_merchant --merchant <MERCHANT_ADDRESS>bump_merchant_revenue_day(env: Env, merchant: Address, day: u64)
Auth: none.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- bump_merchant_revenue_day --merchant <MERCHANT_ADDRESS> --day 20000prune_merchant_revenue_days(env: Env, merchant: Address, days: Vec<u64>)
Auth: none in the wrapper.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- prune_merchant_revenue_days --merchant <MERCHANT_ADDRESS> --days '[20000,20001]'get_merchant_revenue_day(env: Env, merchant: Address, day: u64) -> i128
Auth: none.
Returns: i128.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_merchant_revenue_day --merchant <MERCHANT_ADDRESS> --day 20000is_merchant_frozen(env: Env, merchant: Address) -> bool
Auth: none.
Returns: bool.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- is_merchant_frozen --merchant <MERCHANT_ADDRESS>get_fee(env: Env) -> Option<(Address, u32)>
Auth: none.
Returns: Option<(Address, u32)>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_feepropose_fee(env: Env, collector: Address, bps: u32)
Auth: admin only.
Returns: ().
Errors: ContractError::InvalidFeeBps.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- propose_fee --collector <COLLECTOR_ADDRESS> --bps 100commit_fee(env: Env)
Auth: admin only.
Returns: ().
Errors: ContractError::NoPendingProposal (23), ContractError::FeeOutOfBoundsAtCommit (35) if pending bps is outside get_fee_bounds.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- commit_feeAdmin-only guardrails for future fee commits. Stored on instance as DataKey::MinFeeBps / DataKey::MaxFeeBps.
set_fee_bounds(env: Env, min_bps: u32, max_bps: u32)
| Name | Type | Description |
|---|---|---|
min_bps |
u32 |
Inclusive minimum bps for a pending fee at commit. |
max_bps |
u32 |
Inclusive maximum bps; must be <= 10_000. |
Auth: admin (admin::require_admin).
Returns: ().
Bounds semantics: propose_fee does not apply these bounds (it only rejects bps > 10_000 and an invalid collector). commit_fee rejects pending bps outside [min_bps, max_bps] with FeeOutOfBoundsAtCommit (35).
Errors: ContractError::InvalidFeeBounds (34) if min_bps > max_bps or max_bps > 10_000. Unauthorized callers fail admin auth.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- set_fee_bounds --min_bps 0 --max_bps 500get_fee_bounds(env: Env) -> (u32, u32)
Auth: none.
Returns: (min_bps, max_bps). Defaults to (0, 10000) when governance has not set explicit bounds.
Errors: none.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_fee_boundsOperational gate: MAINNET-DEPLOYMENT.md.
batch_charge(env: Env, users: Vec<Address>) -> Vec<ChargeResult>
Auth: none.
Returns: Vec<ChargeResult> — one entry per input address, in order.
A non-Charged result for one user never aborts the rest of the batch. Insufficient SAC allowance is ChargeResult::AllowanceInsufficient (not a transaction panic). When the batch includes at least one interesting non-success (NoSubscription, Inactive, Paused, GracePeriodElapsed, AllowanceInsufficient), the contract emits batch_charge_skips. All-charged or all-not-due (Skipped) batches emit no summary.
The public wrapper calls ensure_contract_not_paused first: a paused protocol panics ContractPaused (18) for the whole invoke (not a per-user result).
Errors: ContractError::BatchTooLarge (20) if users.len() exceeds get_max_batch_size (default 50, ceiling 200). get_max_batch_size is sourced from a single implementation in contract/src/caps.rs (the same key and default used by contract/src/batch.rs). Ordinary per-user outcomes are enum values. Full cap table: limits.md.
Keeper ops: KEEPER.md.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <KEEPER_KEY> --network testnet -- batch_charge --users '["<USER_A>","<USER_B>"]'Batch estimate (not a transfer). Returns Vec<ChargeResult> — the live batch enum — not ChargeSimResult.
get_batch_charge_estimate(env: Env, users: Vec<Address>) -> Vec<ChargeResult>
| Name | Type | Description |
|---|---|---|
users |
Vec<Address> |
Addresses to estimate. |
Auth: none.
Returns: one ChargeResult per input address, in order.
Success behavior: for each user, missing sub → NoSubscription. If paused, try_auto_resume may persist an auto-resume. Then precheck_charge runs. If that returns Ok, the estimate reads SAC allowance(user, contract) against gross sub.amount and returns Charged or AllowanceInsufficient.
Errors: ContractError::BatchTooLarge (20) if users.len() > 200 (hardcoded; not get_max_batch_size). Ordinary per-user outcomes are enum values, not panics. Note this is the shared ceiling, so it is higher than the default live charge cap: see limits.md.
Operational caveats (from lib.rs):
- Calls real
try_auto_resume, which writes the subscription, clears pause expiry, and emitssubscription_auto_resumed— not a pure view. - Does not check protocol pause (
is_contract_paused).simulate_chargereturnsContractPausedinstead. - Allowance is checked (same gross-amount test as live
batch_charge). A keeper file-header comment that says otherwise is stale. - Cap 200 vs live batch max (default 50 via
MaxBatchSize).
Use simulate_charge when you need a no-write, pause-aware single-user dry-run. Keepers that charge should still call batch_charge (KEEPER.md). Auto-resume event: EVENTS.md.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_batch_charge_estimate --users '["<USER_A>","<USER_B>"]'For a full comparison of estimate vs live semantics (transfers, auto-resume side-effects, protocol-pause handling, batch size cap differences, and result-enum mapping) see architecture/batch-estimate-vs-live.md.
get_active_count(env: Env) -> u64
Auth: none.
Returns: u64.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_active_countget_subscriber_count(env: Env) -> u64
Auth: none.
Returns: u64.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_subscriber_countget_subscriber_at(env: Env, index: u64) -> Option<Address>
Auth: none.
Returns: Option<Address>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_subscriber_at --index 0get_subscriber_page(env: Env, offset: u64, limit: u32) -> Vec<Address>
Auth: none.
Returns: Vec<Address>.
Shared scan implementation: get_subscriber_page, get_active_subscriber_page, and get_next_charge_batch all delegate their index-bounds lookup, SubscriberIndex slot reads, and tombstone skipping to a common internal helper (scan_subscriber_slots in subscriber_scan.rs). Each caller then applies its own per-address filter on the returned list. This means all three functions share identical tombstone-skip and out-of-bounds behavior.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_subscriber_page --offset 0 --limit 10View of active subscriber addresses from the append-only SubscriberIndex. Sibling get_subscriber_page does not filter on sub.active.
get_active_subscriber_page(env: Env, offset: u64, limit: u32) -> Vec<Address>
| Name | Type | Description |
|---|---|---|
offset |
u64 |
Index-slot cursor into SubscriberIndex, not “Nth active subscriber”. |
limit |
u32 |
Requested page size. Silently clamped to 50. 0 returns empty. |
Auth: none.
Returns: Vec<Address>. Empty when offset >= index_size or cap == 0. Length may be less than limit when many slots are missing or inactive; the scan continues until result.len() == cap or the index ends.
Pagination: increment offset by the number of index slots consumed, not by result.len(), if you need to walk the whole index. The implementation walks i from offset upward and pushes only addresses whose Subscription exists and sub.active is true.
Errors: none.
Related: ChargeResult / keeper paging still typically use batch_charge with an operator-supplied address list (KEEPER.md).
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_active_subscriber_page --offset 0 --limit 50Admin repair for a single stale subscriber-index slot. Looks up the occupant of index, refuses if that subscriber currently has an active subscription, then tombstones the slot so keepers skip it. Does not garbage-collect the rest of the index.
clear_subscriber_index_entry(env: Env, index: u64)
| Name | Type | Description |
|---|---|---|
index |
u64 |
Slot in the append-only SubscriberIndex. |
Auth: Contract admin only (require_admin).
Errors: ContractError::NoSubscriptionFound if index is out of range, empty, or already tombstoned. ContractError::CannotClearActiveSubscriber if the occupant still has an active subscription.
Event emitted
| Event name | Topic | Data |
|---|---|---|
subscriber_index_cleared |
("subscriber_index_cleared", user_address) |
index: u64 |
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet --source-account <ADMIN> -- clear_subscriber_index_entry --index 0get_merchant_revenue(env: Env, merchant: Address) -> i128
Auth: none.
Returns: i128.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_merchant_revenue --merchant <MERCHANT_ADDRESS>get_merchant_revenue_history(env: Env, merchant: Address, days: u32) -> Vec<i128>
Auth: none.
Returns: Vec<i128>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_merchant_revenue_history --merchant <MERCHANT_ADDRESS> --days 7clear_merchant_revenue_history(env: Env, merchant: Address)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- clear_merchant_revenue_history --merchant <MERCHANT_ADDRESS>get_merchant_subscriber_count(env: Env, merchant: Address) -> u64
Auth: none.
Returns: u64.
Alias: this is the canonical reader for DataKey::MerchantSubCount; it delegates to the single implementation in contract/src/merchant_stats.rs. get_merchant_sub_count is a thin alias over the same body.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_merchant_subscriber_count --merchant <MERCHANT_ADDRESS>get_merchant_sub_count(env: Env, merchant: Address) -> u32
Auth: none.
Returns: u32 — active subscriber count for merchant (same MerchantSubCount storage as get_merchant_subscriber_count, narrowed to u32).
Alias: thin delegation to get_merchant_subscriber_count; both read DataKey::MerchantSubCount through the same merchant_stats implementation, so the two can never disagree.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_merchant_sub_count --merchant <MERCHANT_ADDRESS>See also: Merchant Integration Cookbook — Monitoring Subscribers.
reset_merchant_revenue(env: Env, merchant: Address)
Auth: admin only.
Returns: ().
Note on Non-Custodial Revenue: Merchant revenue in PayFlow is non-custodial. Charges and pay-per-use operations transfer tokens directly from subscriber to merchant via SAC
transfer_from. The contract never holds merchant revenue funds;get_merchant_revenuemaintains an on-chain cumulative metric for merchant metrics and analytics.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- reset_merchant_revenue --merchant <MERCHANT_ADDRESS>set_daily_limit(env: Env, user: Address, limit: i128)
Auth: user.require_auth().
Returns: ().
Errors: ContractError::AmountMustBePositive.
CLI example:
See also: Daily Spending Limits Guide for a conceptual overview of the pay_per_use spending cap.
For a complete list of all error codes returned by the contract, see ERROR-CODES.md.
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- set_daily_limit --user <USER_ADDRESS> --limit 50000000remove_daily_limit(env: Env, user: Address)
Auth: user.require_auth().
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- remove_daily_limit --user <USER_ADDRESS>See the full reference entry below.
get_day_start(env: Env, user: Address) -> Option<u64>
Auth: none.
Returns: Option<u64> — Some(timestamp) while the current ~24h spend window is active, where timestamp is the ledger timestamp of the window's first pay_per_use / pay_per_use_to; None if no window has started or DataKey::DayStart(user) has expired. The window ends when that temporary entry's TTL expires, not at a fixed timestamp + 86400.
CLI example: See also: Daily Spending Limits Deep-Dive.
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_day_start --user <USER_ADDRESS>get_daily_limit_status(env: Env, user: Address) -> DailyLimitStatus
Auth: none.
Returns: DailyLimitStatus { limit: Option<i128>, spent: i128, day_start: Option<u64>, remaining: Option<i128> } — the values of get_daily_limit, get_daily_spent and get_day_start read together, plus remaining, which is None when no limit is set and max(limit - spent, 0) otherwise.
See also: Daily Spending Limits Deep-Dive.
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_daily_limit_status --user <USER_ADDRESS>get_referrer(env: Env, user: Address) -> Option<Address>
Auth: none.
Returns: Option<Address>.
Alias: get_referral and get_referrer are byte-identical readers of DataKey::Referral(user); get_referrer is the canonical implementation and get_referral is a thin delegation to it. Both names remain exported.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_referrer --user <USER_ADDRESS>migrate(env: Env, users: Vec<Address>)
Auth: none.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- migrate --users '["<USER_ADDRESS>"]'See the full reference entry below.
set_metadata(env: Env, user: Address, label: String)
Auth: user.require_auth().
Returns: ().
Errors: ContractError::MetadataLabelTooLong.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- set_metadata --user <USER_ADDRESS> --label proget_metadata(env: Env, user: Address) -> Option<String>
Auth: none.
Returns: Option<String>.
Alias: get_metadata and get_subscription_label are byte-identical readers of DataKey::SubscriptionMeta(user); get_metadata is the canonical implementation and get_subscription_label is a thin delegation to it. Both names remain exported.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_metadata --user <USER_ADDRESS>get_subscription_label(env: Env, user: Address) -> Option<String>
Auth: none.
Returns: Option<String>.
Alias: thin delegation to get_metadata; both read DataKey::SubscriptionMeta(user) through the same implementation.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_subscription_label --user <USER_ADDRESS>clear_metadata(env: Env, user: Address)
Auth: user.require_auth().
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- clear_metadata --user <USER_ADDRESS>get_charge_history(env: Env, user: Address) -> Vec<u64>
Auth: none.
Returns: Vec<u64>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_charge_history --user <USER_ADDRESS>get_protocol_stats(env: Env) -> ProtocolStats
Auth: none.
Returns: ProtocolStats.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_protocol_statspause_contract(env: Env)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- pause_contractunpause_contract(env: Env)
Auth: admin only.
Returns: ().
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <ADMIN_KEY> --network testnet -- unpause_contractset_initial_admin(env: Env, admin: Address)
Auth: none.
Returns: ().
Errors: panics if the admin is already set.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- set_initial_admin --admin <ADMIN_ADDRESS>Read-only snapshot of protocol health. Safe at any time: no auth, no storage writes. There is no health_check or get_health export.
contract_health_check(env: Env) -> HealthReport
Auth: none.
Returns: HealthReport.
Success behavior: builds the struct; does not panic on the normal path. is_healthy is true only when the contract is not paused and token and admin are set. Instance TTL is not part of that decision.
Errors: none defined.
Operational notes: on-chain instance_ttl_ledgers is a hardcoded 100_000 placeholder and must not be read as remaining TTL. Off-chain scripts/health-check.ts is a shallow RPC simulation of get_schema_version and get_active_count only — it does not call this method. See MAINNET-DEPLOYMENT.md.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- contract_health_checkclear_charge_history(env: Env, user: Address)
Auth: user.require_auth().
Returns: ().
Alias: clear_charge_history and prune_charge_history are byte-identical; clear_charge_history is the canonical implementation and prune_charge_history is a thin delegation to it. Both names remain exported.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- clear_charge_history --user <USER_ADDRESS>get_charge_history_ttl(env: Env, user: Address) -> u32
Auth: none.
Returns: In test/testutils builds, the persistent TTL of ChargeHistory(user), or 0 if the key is absent. On-chain / production always returns 0 because Soroban does not expose get_ttl() outside tests. Do not treat the production value as a live remaining-TTL measurement, and do not treat 0 as “history expired”.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_charge_history_ttl --user <USER_ADDRESS>get_charge_history_page(env: Env, user: Address, offset: u32, limit: u32, ascending: bool) -> Vec<u64>
Auth: none.
Returns: Vec<u64>.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --network testnet -- get_charge_history_page --user <USER_ADDRESS> --offset 0 --limit 12 --ascending falseChargeHistory(user) is a Vec<u64> of up to 12 charge timestamps, stored oldest → newest. Every successful charge() appends a timestamp; once the vector holds 12 entries, the next append drops entry 0 (the oldest) before pushing the new one. In other words, storage itself is the ring buffer — get_charge_history_page slices whatever is currently in it (in ascending or descending order). There is no separate "total charge count" stored anywhere; the longest history you can ever page through is 12 entries, because older charges are physically gone.
Slicing algorithm (from subscription_history.rs):
ordered_history = if ascending { history } else { history.reversed() }
effective_limit = min(limit, 12)
if offset >= ordered_history.len() { return [] } // empty, not an error
end = min(offset + effective_limit, ordered_history.len())
return ordered_history[offset..end]limit is silently capped at 12 — passing limit: 100 is safe and simply returns everything available. This 12 is a retention length (MAX_HISTORY), not a batch or page cap; see limits.md. offset is never validated against the history length beyond the empty-result check above; there is no IndexOutOfBounds-style error anywhere in this path.
ascendingparameter. Passingascending = truereturns records in oldest-to-newest order. Passingascending = falsereturns records in newest-to-oldest order (most recent first).
Ring buffer diagram — a subscriber with 14 total lifetime charges (c1..c14). Only the last 12 survive:
lifetime charges: c1 c2 c3 [c4 c5 c6 c7 c8 c9 c10 c11 c12 c13 c14]
↑ ↑ ↑ └──────────────── retained (12 slots) ────────────────┘
evicted (FIFO, oldest dropped first as new charges arrive)
ChargeHistory(user) in storage (index 0 = oldest):
index: 0 1 2 3 4 5 6 7 8 9 10 11
value: [c4, c5, c6, c7, c8, c9, c10, c11, c12, c13, c14] (len = 11 here, one slot short of the cap)
get_charge_history_page(user, offset=8, limit=3)
│
slices index [8..11) ─┐
▼
[c12, c13, c14]
Example 1 — fetch everything (offset=0, limit=12):
soroban contract invoke --id <CONTRACT_ID> --network testnet -- \
get_charge_history_page --user <USER_ADDRESS> --offset 0 --limit 12With a history of [c4..c14] (11 entries), this returns all 11 entries, oldest to newest. If the subscriber has fewer than 12 charges total, one call is always enough — you never need a second page.
Example 2 — most recent 5 records (no ascending param, so compute the offset):
const all = await getChargeHistoryPage(user, 0, 12); // at most 12 entries ever exist
const mostRecent5 = all.slice(-5); // last 5 = newest 5, since storage is oldest → newestEquivalently, on-chain: offset = max(0, total - 5), limit = 5. If total = 11, call get_charge_history_page(user, 6, 5) to get entries [6..11).
Example 3 — offset beyond the record count (returns empty, not an error):
soroban contract invoke --id <CONTRACT_ID> --network testnet -- \
get_charge_history_page --user <USER_ADDRESS> --offset 5 --limit 2For a subscriber with only 1 recorded charge, offset=5 >= len(1) so this returns []. This mirrors the covering test test_get_charge_history_page_offset_beyond_length.
Offset / limit reference table (history has 11 entries, c4..c14, oldest → newest):
offset |
limit |
Result | Notes |
|---|---|---|---|
0 |
12 |
[c4..c14] (11 items) |
limit capped at 12, but history only has 11 |
0 |
5 |
[c4, c5, c6, c7, c8] |
oldest 5 |
6 |
5 |
[c10, c11, c12, c13, c14] |
newest 5 (see Example 2) |
11 |
5 |
[] |
offset == len, empty result |
20 |
5 |
[] |
offset > len, empty result, no error |
9 |
100 |
[c13, c14] |
limit capped then clamped to remaining length |
Because storage never holds more than 12 entries, a single call with limit: 12 already returns everything. The loop below is still useful as the general-purpose pattern for infinite-scroll style UIs, and keeps working unmodified if the on-chain cap is ever raised. It calls the contract the same way getSubscription does — build, simulate, decode:
import {
Contract,
TransactionBuilder,
BASE_FEE,
nativeToScVal,
Address,
xdr,
} from "@stellar/stellar-sdk";
import { server, CONTRACT_ID, NETWORK_PASSPHRASE } from "./stellar";
import { ScValDecoder } from "./services/scval";
function addressVal(addr: string): xdr.ScVal {
return nativeToScVal(Address.fromString(addr), { type: "address" });
}
/** Fetches one page of charge timestamps via get_charge_history_page. */
async function getChargeHistoryPage(
user: string,
offset: number,
limit: number,
): Promise<number[]> {
const contract = new Contract(CONTRACT_ID);
const account = await server.getAccount(user);
const tx = new TransactionBuilder(account, {
fee: BASE_FEE,
networkPassphrase: NETWORK_PASSPHRASE,
})
.addOperation(
contract.call(
"get_charge_history_page",
addressVal(user),
nativeToScVal(offset, { type: "u32" }),
nativeToScVal(limit, { type: "u32" }),
),
)
.setTimeout(30)
.build();
const result = await server.simulateTransaction(tx);
if ("error" in result) throw new Error((result as { error: string }).error);
const retval = (result as { result?: { retval?: xdr.ScVal } }).result?.retval;
if (!retval) return [];
return retval.vec()?.map((v) => Number(ScValDecoder.decodeU64(v))) ?? [];
}
/**
* Loads the subscriber's full charge history by paging until an empty
* (or short) page comes back. Safe to call even though today's 12-record
* cap means it will always resolve after a single request.
*/
async function loadAllChargeHistory(user: string): Promise<number[]> {
const PAGE_SIZE = 12; // matches the contract's MAX_HISTORY cap
const all: number[] = [];
let offset = 0;
while (true) {
const page = await getChargeHistoryPage(user, offset, PAGE_SIZE);
all.push(...page);
if (page.length < PAGE_SIZE) break; // short page = no more records
offset += page.length;
}
return all;
}See INTEGRATION-GUIDE.md § 9 — Paginating Charge History for the same pattern in a full integration walkthrough.
transfer_subscription(env: Env, user: Address, new_user: Address)
Auth: user.require_auth().
Returns: ().
Errors: ContractError::ContractPausedError, ContractError::NoSubscriptionFound, ContractError::SubscriptionAlreadyActive.
CLI example:
soroban contract invoke --id <CONTRACT_ID> --source <USER_KEY> --network testnet -- transfer_subscription --user <USER_ADDRESS> --new_user <NEW_USER_ADDRESS>Administrative utilities for detecting and repairing corrupted subscription records after migrations or contract upgrades.
Read-only integrity check for a subscriber address.
validate_subscription(env: Env, user: Address) -> SubscriptionValidationReport
Returns SubscriptionValidationReport
| Field | Type | Description |
|---|---|---|
is_valid |
bool |
true when no inconsistencies are detected |
violations |
Vec<String> |
General integrity violations |
missing_records |
Vec<String> |
Missing auxiliary records (history, metadata, etc.) |
invalid_state_transitions |
Vec<String> |
Illegal active/paused/cancelled state combinations |
corrupted_references |
Vec<String> |
Broken merchant/token/referrer references |
Auth: None (read-only simulation).
Frontend: Exposed in the Admin Dashboard → Subscription Repair panel.
Repairs detected subscription inconsistencies for a user.
repair_subscription(env: Env, user: Address) -> u32
Auth: Contract admin only (require_admin).
Returns: Count of fixed inconsistencies (also emitted in the subscription_repaired event).
Event emitted
| Event name | Topic | Data |
|---|---|---|
subscription_repaired |
("subscription_repaired", user_address) |
fixed_inconsistencies: u32 |
Frontend authorization: The repair button is enabled only when the connected Freighter wallet matches the on-chain admin returned by get_admin.
All amounts are in stroops — the smallest unit of a Stellar token.
| Amount | Stroops |
|---|---|
| 1 XLM | 10,000,000 |
| 0.5 XLM | 5,000,000 |
| 0.0000001 XLM | 1 |
All intervals are in seconds.
| Interval | Seconds |
|---|---|
| 1 day | 86,400 |
| 1 week | 604,800 |
| 30 days | 2,592,000 |
All events can be indexed by listening to the Stellar RPC event stream for the FlowPay contract ID.
For a complete reference of all events with detailed schemas and examples, see EVENTS.md. For consumption patterns (polling, deduplication, reaction, reliability), see EVENT-DRIVEN-GUIDE.md.
| Event name | Topic | Data |
|---|---|---|
subscribed |
("subscribed", user_address) |
(merchant, amount, interval) |
charged |
("charged", user_address) |
(merchant, amount, timestamp) |
pay_per_use |
("pay_per_use", user_address) |
(merchant, amount) |
cancelled |
("cancelled", user_address) |
() |
paused |
("paused", user_address) |
() |
resumed |
("resumed", user_address) |
() |
referred |
("referred", user_address) |
referrer_address |
subscription_transferred |
("subscription_transferred", from_address, to_address) |
(merchant, amount, interval, token) |
subscription_repaired |
("subscription_repaired", user_address) |
fixed_inconsistencies: u32 |
subscriber_index_cleared |
("subscriber_index_cleared", user_address) |
index: u64 |
All error conditions are returned as ContractError values. Client SDKs can decode these programmatically. Each variant is identified by its u32 discriminant.
For the complete error codes reference with detailed recovery steps and prevention guidance, see ERROR-CODES.md.