The contract on the blockchain
One Soroban contract carries the money logic, and one instance serves each seller. It is a prepaid credit ledger for many small charges, and it runs the recurring mandates as well, so a seller’s buyers, balances and subscriptions live in one place that the seller reconciles.
It runs on Stellar testnet as CD3GESMYMJ3MNWNSKS6P7TEDHL5HYEWSGTFX7A3ENDB5MXTQ5TED7PSI. Its source and full design are public, and the code it runs can be fetched from the network and hashed against the Wasm the public CI builds.
The prepaid ledger
The contract holds no USDC. It records each buyer’s prepaid credit and the seller’s earned revenue; a separate treasury account holds the deposited USDC. Keeping the funds out of the contract is deliberate: a defect in the contract’s logic cannot move USDC it does not hold.
A deposit is one invocation. deposit(owner, amount, deposit_id) moves the USDC from the buyer to the treasury through the SAC and credits the buyer, together, under the buyer’s own signature. The contract does not take anyone’s word that a deposit happened: it performs the transfer itself, and if the transfer fails, nothing is credited. Each deposit_id is processed once, so a deposit cannot be credited twice. A withdrawal is the mirror image: one invocation that debits the credit and moves the USDC from the treasury to the destination.
Because the treasury’s key could still move USDC without calling the contract, the contract keeps two totals on-chain, liabilities (every buyer’s balance) and revenue (charged, not yet paid out), so that anyone can compare them with what the treasury holds. The observer in the software around the signature does that continuously, and on testnet the comparison is recorded from the network.
A charge cannot be applied twice, and the way it is refused is worth noting. Each charge carries the seller’s 32-byte identifier and the last ledger in which it may settle. The contract records every charge it settles, with its outcome, in TEMPORARY storage that lives until shortly after that last ledger, and a charge whose identifier is recorded is a Duplicate and never debits again, even if the buyer has since added funds. By the time a record expires, the charge it records is past its last ledger and refused as Expired, so a replay is refused at any time. TEMPORARY storage also keeps the rent bounded: a record costs rent once, for a window, not forever.
The batching behaviour matters for the same reason. charge_batch settles up to 98 charges and returns one outcome per entry: Charged, InsufficientBalance, AboveLimit, AboveDailyLimit, UnknownAccount, Duplicate or Expired. One bad entry does not revert the others, so one underfunded buyer cannot fail everyone else’s charges in the same batch. Only a malformed batch reverts as a whole. And while a record lives, it also answers, from the contract itself, what happened to a charge whose transaction’s fate is unknown, which is what lets the worker resolve a lost submission without guessing.
The ceiling of 98 is not arbitrary. Stellar limits a transaction to 200 ledger-entry writes. A charge to a distinct buyer writes two entries, the account and the record, so 98 charges write 196, plus the contract instance and the operator’s nonce: 198. At 100 the network refuses the transaction.
Ceilings the operator cannot exceed
The contract enforces the limits that bound the operator’s authority over a prepaid balance:
- Per charge. A charge above
max_chargeis refused. - Per buyer, per day. A charge that would take one account past its daily limit is refused as
AboveDailyLimit. - Per seller, per day. A charge that would take all accounts together past the seller’s daily limit is refused the same way.
- Minimum deposit. A deposit below
min_depositis refused, so a flood of dust deposits cannot cost the operator more in fees than it brings in.
The day is derived by the contract itself from ledger time, and the counts start again each day without any operator action. Each account’s count lives in the account entry a charge already writes, so the limits add no ledger writes to a full batch.
An over-limit charge does not go through, and the refusal is not advisory: inside a batch the entry is refused with its reason, on its own the call reverts. So a compromised or buggy gateway cannot drain a prepaid balance faster than those numbers allow, and reducing that exposure means changing numbers on-chain rather than trusting Fermah’s software.
Access control and upgradeability
Five roles. The four the contract is created with, admin, operator, seller and treasury, must be four distinct addresses, because each authorises something the others must not be able to do alone:
| Role | Authorises |
|---|---|
| Buyer | its own deposit, its side of withdraw, its mandate and its revocation |
| Treasury | its side of withdraw, and paying out revenue |
| Operator | charge, charge_batch, charge_recurring_batch |
| Seller | its side of paying out revenue |
| Admin | pause, unpause, the limits, upgrade, and every role rotation |
A rotation needs both the admin’s authorisation and the new holder’s, so a role cannot be handed to an address nobody controls, and after set_operator the previous operator can no longer charge. On testnet the admin is an account that needs two of its three keys for any change, each signing on its own, and one key alone is refused before anything is sent.
The contract is upgradeable via update_current_contract_wasm, which is a real trade-off worth naming rather than glossing: it means a defect can be corrected without migrating any account, and it means the admin holds power over what every balance means. That is why the admin is a multisig kept apart from the keys that operate day to day, and why every administrative change is visible. A pause, a limit change and a rotation each emit an event, an upgrade emits the network’s own system event naming the old and the new code, and the observer raises an alert on each, including for code other than what the operator expects. On testnet the contract was upgraded in place with two of the three keys, balances untouched, and the running code hashes to the Wasm built by the public CI.
The money invariants are not only unit-tested. A property-based test using proptest runs random sequences of deposits, charges, withdrawals, mandates, recurring charges and changes of day against the contract, and after every step compares it with an independent model written from the documented rules rather than from the contract’s code: every balance, wallet and allowance, liabilities equal to the sum of balances, revenue, and the treasury’s USDC. The contract tests sign with real Ed25519 keys and have the Soroban host verify every authorisation.
Recurring charges via SAC approve
The recurring mandates live in the same contract, and their design is simpler by construction.
A subscriber signs one authorisation for authorize_recurring, which records the mandate and, in the same signed tree, calls the SAC’s approve to grant the contract an allowance: up to the mandate’s total, expiring at a specific ledger. From then on the operator charges each period with charge_recurring_batch, up to 35 per transaction, the most that fit Stellar’s 16 KB of contract events per transaction.
What the subscriber signs, once, is the whole of what the operator may later do:
authorize_recurring(owner, mandate_id, amount, period_secs, cycles, live_until)
└─ usdc.approve(
from: owner,
spender: ledger_contract, // only this contract's code may pull
amount: amount × cycles, // the mandate's total
expiration_ledger: live_until // authority dies at this ledger
)The contract checks every charge against the mandate: the period has started and has not ended, it was not already charged, the amount is within the mandate, and the mandate has not expired. The SAC itself refuses a pull past expiration_ledger, so no Fermah code needs to enforce it — the protocol does.
Only the subscriber can create or cancel their own mandate, since each carries the subscriber’s authorisation entry. revoke_recurring sets the allowance back to zero and works even while the contract is paused, so a subscriber’s way out never depends on the admin. On testnet, a subscriber holding 0 XLM authorised a mandate, was charged two periods , saw an early charge and a charge after the mandate ended both refused on-chain, and revoked back to zero.