A Soroban smart contract for recurring paid subscriptions on the Pi Network. Merchants register services with configurable pricing and billing periods; subscribers pay using standard Stellar asset tokens via allowance-based recurring charges.
- Overview
- Architecture
- Data Model
- Contract API
- Payment Flow
- Access Control
- Events
- Error Codes
- Storage Layout & TTL
The contract implements a merchant-initiated pull-payment model:
- Merchant registers a service (name, price, billing period, optional trial, approval window).
- Subscriber subscribes to the service, optionally enabling auto-renewal and granting a token allowance to the contract for future charges.
- Merchant periodically calls
process(service_id, offset, limit)to batch-charge due subscribers viatransfer_fromagainst their approved allowances. - Subscriber can cancel, toggle auto-renewal, or extend their subscription at any time.
Key design properties:
- No charge drift --
next_charge_tsis always computed asold_next_charge_ts + period_secs, not from wall-clock time. - Paginated batch processing --
process()acceptsoffsetandlimitparameters to handle large subscriber sets across multiple transactions. - Graceful failure isolation -- failed charges and timestamp overflows disable auto-renewal (
auto_renew = false) for the individual subscription instead of reverting the entire batch. - Deduplication -- a subscriber cannot hold two active subscriptions to the same service simultaneously.
- Trial abuse prevention -- a subscriber who already used a free trial cannot re-subscribe without
auto_renew=true, preventing infinite free trials.
Subscriber Contract Token Contract Merchant
| | | |
|-- subscribe() --------->| | |
| |-- approve(N periods) ------> | |
| |-- transfer(price) ---------> |---> merchant |
| | | |
| |<-------------- process() ----| |
| |-- transfer_from(sub->merch)->| |
| | (for each due subscriber) | |
| | | |
|-- cancel() ------------>| sets auto_renew=false | |
Registered by a merchant. Represents a billable subscription offering.
| Field | Type | Description |
|---|---|---|
service_id |
u64 |
Auto-incrementing unique identifier |
merchant |
Address |
Merchant who owns the service |
name |
String |
Human-readable service name (must be non-empty) |
price |
i128 |
Amount charged per billing period (must be > 0) |
period_secs |
u64 |
Billing period duration in seconds (must be > 0) |
trial_period_secs |
u64 |
Free trial duration in seconds (0 = no trial) |
approve_periods |
u64 |
Number of billing periods the subscriber approves at once (must be > 0) |
is_active |
bool |
Whether new subscriptions can be created |
created_at |
u64 |
Ledger timestamp at creation |
Represents one subscriber's relationship to one service.
| Field | Type | Description |
|---|---|---|
sub_id |
u64 |
Auto-incrementing unique identifier |
subscriber |
Address |
Subscriber's address |
service_id |
u64 |
Reference to the parent service |
price |
i128 |
Price snapshot at subscription time |
period_secs |
u64 |
Period snapshot at subscription time |
trial_period_secs |
u64 |
Trial period snapshot at subscription time |
trial_end_ts |
u64 |
Timestamp when trial ends (0 if no trial) |
auto_renew |
bool |
If true, merchant can auto-charge via process(). If false, subscription expires at service_end_ts |
service_end_ts |
u64 |
Timestamp when access expires unless renewed |
next_charge_ts |
u64 |
Earliest timestamp for next process() charge |
created_at |
u64 |
Ledger timestamp at creation |
Returned by process() to report batch outcomes.
| Field | Type | Description |
|---|---|---|
charged |
u32 |
Number of subscriptions successfully charged |
failed |
u32 |
Number of subscriptions where payment or timestamp calculation failed |
skipped |
u32 |
Number of subscriptions not yet due, not found, or not auto-renewing |
total |
u32 |
Total number of subscriptions for the service |
fn __constructor(env: Env, admin: Address, token: Address)Initializes the contract. Sets the admin, token address, and zeroes the service/subscription ID counters. Called once at deployment.
fn register_service(
env: Env,
merchant: Address,
name: String,
price: i128,
period_secs: u64,
trial_period_secs: u64,
approve_periods: u64,
) -> Result<Service, ContractError>Creates a new subscription service.
Auth: merchant
Validation:
price > 0->InvalidPriceperiod_secs > 0->InvalidPeriodapprove_periods > 0->InvalidPeriodnamenon-empty ->InvalidServiceName
Effects:
- Stores
ServiceunderDataKey::Service(service_id) - Appends
service_idtoDataKey::MerchantServices(merchant) - Emits
srv_regevent
fn subscribe(
env: Env,
subscriber: Address,
service_id: u64,
auto_renew: bool,
) -> Result<Subscription, ContractError>Subscribes to a service. Behavior depends on trial period and auto_renew flag:
| Scenario | Immediate Payment | Token Approval |
|---|---|---|
No trial, auto_renew=true |
Yes (1 period) | approve_periods periods |
No trial, auto_renew=false |
Yes (1 period) | 1 period |
Trial, auto_renew=true |
No | approve_periods periods |
Trial, auto_renew=false |
No | None |
Auth: subscriber
Deduplication: Rejects with AlreadySubscribed if the subscriber has an active subscription to the same service, unless the previous subscription has expired and auto_renew=false.
Trial abuse prevention: If the subscriber previously used a free trial for this service, re-subscribing with auto_renew=false to a trial service is blocked. The subscriber must set auto_renew=true (committing to paid renewal after the trial).
Effects:
- Stores
SubscriptionunderDataKey::Sub(sub_id) - Updates index keys:
SubscriberSubs,ServiceSubs,SubServicePair - Calls
token.approve()beforetoken.transfer()(approval is pre-authorized) - Emits
sub,approve, and optionallylow_balevents
fn cancel(env: Env, subscriber: Address, sub_id: u64) -> Result<(), ContractError>Cancels auto-renewal. The subscription remains active until service_end_ts.
Auth: subscriber (must own the subscription)
Validation:
AlreadyCancelledifauto_renewis alreadyfalse
Effects:
- Sets
auto_renew = false - Emits
cancelevent with remaining seconds
fn toggle_auto_renew(
env: Env,
subscriber: Address,
sub_id: u64,
) -> Result<bool, ContractError>Toggles the auto_renew flag. When re-enabling (false -> true), refreshes the token approval.
Auth: subscriber (must own the subscription)
Validation:
SubscriptionExpiredif re-enabling on a fully expired subscription
Effects:
- Flips
auto_renew - If re-enabled: calls
token.approve()forapprove_periods - Emits
renewevent
fn extend_subscription(
env: Env,
subscriber: Address,
sub_id: u64,
) -> Result<Subscription, ContractError>Refreshes the token approval on an active subscription (useful when the original approval is about to expire).
Auth: subscriber (must own the subscription)
Validation:
SubscriptionExpiredifservice_end_ts <= now
Effects:
- Sets
auto_renew = true - Calls
token.approve()forapprove_periods - Emits
extendandapproveevents
fn process(
env: Env,
merchant: Address,
service_id: u64,
offset: u32,
limit: u32,
) -> Result<ProcessResult, ContractError>Batch-charges due subscriptions for a service. This is the core billing function.
Auth: merchant (must own the service via NotServiceOwner check)
Pagination: Soroban transactions have finite resource limits (read/write ledger entries and bytes per transaction). Large subscriber sets must be processed in batches using offset and limit. The returned ProcessResult.total indicates the total number of subscriptions, allowing the caller to determine how many batches are needed.
Example batch loop:
// Process 20 subscriptions per transaction
batch 1: process(merchant, service_id, 0, 20) -> total=55
batch 2: process(merchant, service_id, 20, 20)
batch 3: process(merchant, service_id, 40, 20) -> covers remaining 15
Processing logic for each subscription in the [offset, offset+limit) window:
- Skip if subscription not found in storage -> increment
skipped - Skip if
auto_renew == false(not auto-renewing) -> incrementskipped - Skip if
now < next_charge_ts(not yet due) -> incrementskipped - Attempt
token.try_transfer_from(subscriber -> merchant, price)- Success:
next_charge_ts += period_secs(on overflow: setsauto_renew = false, incrementsfailed, continues)service_end_ts = next_charge_ts- Emits
chargeevent - If was in trial period: emits
trl_endevent - Checks remaining allowance and balance; emits
low_alw/low_balif insufficient for next period - Increments
charged
- Failure:
- Sets
auto_renew = false - Emits
chg_failevent - Increments
failed
- Sets
- Success:
No charge drift guarantee: next_charge_ts and service_end_ts are advanced by exactly period_secs from their previous values, not from wall-clock time.
Failure isolation: Neither payment failures nor timestamp overflows revert the transaction. Each problematic subscription is individually marked as auto_renew = false and the batch continues processing the remaining subscriptions.
All query functions are read-only and are typically called via simulateTransaction (no gas cost). They do not bump storage TTLs -- TTL extension happens only in mutating functions (subscribe, process, cancel, toggle_auto_renew, extend_subscription, register_service).
fn get_service(env: Env, service_id: u64) -> Result<Service, ContractError>Returns service details. No auth required.
fn get_merchant_services(env: Env, merchant: Address) -> Vec<Service>Returns all services registered by a merchant. No auth required.
fn get_subscription(
env: Env,
caller: Address,
sub_id: u64,
) -> Result<Subscription, ContractError>Returns subscription details.
Auth: caller must be the subscriber or the merchant of the service.
fn get_subscriber_subs(env: Env, subscriber: Address) -> Vec<Subscription>Returns all subscriptions for a subscriber. Gracefully skips subscriptions that have expired from storage.
Auth: subscriber
fn get_merchant_subs(
env: Env,
merchant: Address,
service_id: u64,
) -> Result<Vec<Subscription>, ContractError>Returns all subscriptions for a service. Gracefully skips subscriptions that have expired from storage.
Auth: merchant (must own the service)
fn is_subscription_active(env: Env, subscriber: Address, service_id: u64) -> boolReturns true if a subscription exists and current_timestamp < service_end_ts. Returns false if the subscription or index entry is not found. No auth required.
fn upgrade(env: Env, new_wasm_hash: BytesN<32>)Replaces the contract WASM code.
Auth: admin
fn version(_env: Env) -> u32Returns the current contract version number (currently 1). No auth required.
The contract uses the Stellar token approve + transfer_from pattern for recurring charges:
- On
subscribe(),toggle_auto_renew(true), orextend_subscription(), the contract callstoken.approve()on behalf of the subscriber, granting the contract an allowance ofprice * approve_periods(using checked multiplication to prevent overflow). - The approval expiration ledger is calculated as:
current_ledger + ((period_secs * approve_periods + extra_secs) / 5), capped at the network's max TTL. For trial subscriptions,extra_secsincludestrial_period_secsto ensure the approval doesn't expire before the trial ends. - In the no-trial case,
token.approve()is called beforetoken.transfer()so the contract is pre-authorized. - On each
process()cycle, the contract callstoken.try_transfer_from()to pullpricefrom the subscriber to the merchant. - When the approval is running low (remaining allowance < price), a
low_alwevent is emitted to signal that the subscriber should callextend_subscription().
subscribe(auto_renew=true, trial=7d, period=30d)
|
|--- Day 0: No payment. Approval granted for N periods.
|
|--- Day 7 (trial ends): process() charges first period.
| Emits "trl_end" + "charge".
| service_end_ts = day 7 + 30d = day 37
| next_charge_ts = day 7 + 30d = day 37
|
|--- Day 37: process() charges second period.
| service_end_ts = day 67
| next_charge_ts = day 67
|
|--- Day 67: process() charges third period...
| Function | Requires Auth | Who Can Call |
|---|---|---|
register_service |
Yes | merchant |
subscribe |
Yes | subscriber |
cancel |
Yes | subscriber (own subscription) |
toggle_auto_renew |
Yes | subscriber (own subscription) |
extend_subscription |
Yes | subscriber (own subscription) |
process |
Yes | merchant (own service) |
get_subscription |
Yes | subscriber or merchant of the subscription's service |
get_subscriber_subs |
Yes | subscriber |
get_merchant_subs |
Yes | merchant (own service) |
get_service |
No | Anyone |
get_merchant_services |
No | Anyone |
is_subscription_active |
No | Anyone |
upgrade |
Yes | admin |
version |
No | Anyone |
All authenticated functions use require_auth() and verify the caller matches the expected role. Mismatches return ContractError::Unauthorized or ContractError::NotServiceOwner.
| Symbol | Description | Data |
|---|---|---|
srv_reg |
Service registered | Service struct |
sub |
Subscription created | (subscriber, service_id, sub_id) |
cancel |
Subscription cancelled | (subscriber, sub_id, service_id, remaining_secs) |
renew |
Auto-renewal toggled | (subscriber, sub_id, service_id, auto_renew) |
extend |
Subscription approval extended | (subscriber, sub_id, service_id) |
approve |
Token approval granted | (subscriber, service_id, amount, expiration_ledger, token) |
charge |
Successful payment | (subscriber, service_id, price) |
trl_end |
Trial period ended (first charge) | (subscriber, service_id, sub_id) |
chg_fail |
Payment or timestamp overflow failed | (subscriber, service_id, sub_id) |
low_alw |
Allowance insufficient for next period | (subscriber, service_id, remaining, required) |
low_bal |
Balance insufficient for next period | (subscriber, service_id, balance, required) |
upgrade |
Contract WASM upgraded | new_wasm_hash |
| Code | Name | Meaning |
|---|---|---|
| 1 | InvalidPrice |
price <= 0 |
| 2 | InvalidPeriod |
period_secs == 0 or approve_periods == 0 |
| 3 | AlreadySubscribed |
Active subscription exists for this (subscriber, service) pair, or subscriber is attempting to reuse a free trial without auto_renew |
| 4 | SubscriptionNotFound |
Subscription ID does not exist in storage |
| 5 | ServiceNotFound |
Service ID does not exist in storage |
| 6 | Unauthorized |
Caller is not the expected subscriber/merchant |
| 7 | AlreadyCancelled |
Subscription auto_renew is already false |
| 8 | TimestampOverflow |
Arithmetic overflow in timestamp or approval calculation |
| 9 | NotServiceOwner |
Merchant does not own the specified service |
| 10 | InvalidServiceName |
Service name is empty |
| 11 | SubscriptionExpired |
Cannot re-enable or extend a fully expired subscription |
| 12 | ServiceNotActive |
Service exists but is_active is false |
| Key | Storage Type | Value Type | Purpose |
|---|---|---|---|
Admin |
Instance | Address |
Contract administrator |
Token |
Instance | Address |
Token contract address |
NextServiceId |
Instance | u64 |
Service ID counter |
NextSubId |
Instance | u64 |
Subscription ID counter |
Service(u64) |
Persistent | Service |
Service data |
MerchantServices(Address) |
Persistent | Vec<u64> |
Service IDs by merchant |
Sub(u64) |
Persistent | Subscription |
Subscription data |
SubscriberSubs(Address) |
Persistent | Vec<u64> |
Subscription IDs by subscriber |
ServiceSubs(u64) |
Persistent | Vec<u64> |
Subscription IDs by service |
SubServicePair(Address, u64) |
Persistent | u64 |
Dedup index: (subscriber, service) -> sub_id |
| Parameter | Value | Approximate Duration |
|---|---|---|
INSTANCE_TTL_THRESHOLD |
17,280 | ~1 day (at 5s/ledger) |
INSTANCE_TTL_EXTEND |
518,400 | ~30 days |
PERSISTENT_TTL_THRESHOLD |
17,280 | ~1 day |
PERSISTENT_TTL_EXTEND_MIN |
518,400 | ~30 days (floor) |
Persistent storage TTL extend is computed dynamically based on the service's billing period:
ttl_extend = max(period_secs * 2 / SECS_PER_LEDGER, PERSISTENT_TTL_EXTEND_MIN)
This ensures data survives at least 2 full billing periods between process() calls. Without this, a quarterly subscription (90-day period) would lose its data after ~30 days of the fixed TTL, before the next process() call.
| Billing Period | TTL Extend | Approximate Duration |
|---|---|---|
| Monthly (30d) | 518,400 | ~30 days (floor) |
| Quarterly (90d) | 3,110,400 | ~180 days |
| Annual (365d) | 12,614,400 | ~730 days |
TTL bumps (bump_persistent, bump_instance) are performed only in mutating functions (subscribe, process, cancel, toggle_auto_renew, extend_subscription, register_service). Query functions do not bump TTLs because they are typically executed via simulateTransaction, where state changes are discarded.
This means persistent data stays alive as long as the service has active interactions (new subscriptions, recurring charges, cancellations). Data for fully inactive services (no subscribers, no process calls) may eventually expire from storage -- this is by design.