What the system cannot do
The claims worth making about a payment system are the negative ones. Not what it does, which is a matter of engineering, but what it is unable to do even when its own software is wrong, which is a matter of where the enforcement sits.
Each property below names its enforcer, because a property enforced by Fermah’s code and one enforced by the chain are different kinds of promise. The second survives Fermah having a bug. The first does not. The full threat model, party by party and key by key, is published with the code .
Funds move only against the user’s signature
Enforced by Soroban’s require_auth and the SAC, not by Fermah.
Moving a user’s USDC out of their wallet requires an authorisation entry that user signed, binding the contract, the function, every argument, and a single-use nonce. Fermah can submit one or not submit it. It cannot construct one, because that needs the user’s key, and it cannot modify one, because the signature would no longer verify. The Soroban runtime verifies the signature and checks the nonce against its own on-chain storage. Fermah’s software being wrong does not weaken any of it.
A deposit is the transfer and the credit in one invocation, so there is no credit without the user’s USDC arriving, and no USDC taken without the credit. A withdrawal needs the user’s signature for the amount and destination. And a recurring charge can only pull what a mandate the user signed allows, period by period.
On the prepaid path the funds are in the treasury after the deposit, and that is custody; what bounds Fermah afterwards is the next property.
Charges are bounded by numbers on the chain
Enforced by the ledger contract.
A charge cannot exceed the user’s on-chain balance, cannot exceed max_charge, and, once daily limits are set, cannot take one user or the seller as a whole past their daily limit. Inside a batch an over-limit entry is refused with its reason, rather than reverting everyone else’s charges; on its own it reverts. Either way no balance moves past the limit, and the day boundary that resets the counts is derived by the contract from ledger time, not supplied by the caller.
So a compromised operator key or a buggy gateway cannot drain a prepaid balance faster than those numbers allow, and reducing that exposure means changing numbers on-chain, with two of the admin’s three keys, rather than trusting Fermah’s software.
A subscription cannot outlive what the user approved
Enforced by the ledger contract and by the SAC itself.
A recurring charge is at most the mandate’s amount, at most once per period, never before a period starts or after it ends, and never after the mandate’s end ledger, at which point the SAC stops honouring the allowance on its own. A new mandate replaces the allowance rather than adding to it. And revoke_recurring sets the allowance to zero even while the contract is paused, so a user’s way out never depends on Fermah or the admin.
The terms of a payment cannot be altered in flight
Enforced by Soroban’s authorisation entry structure, which binds the full invocation tree.
A Soroban authorisation entry covers the contract address, the function name, and every argument. For a deposit, that is the seller’s contract, the amount and the deposit identifier, and under it the transfer to the seller’s own treasury. For a withdrawal, the amount and the destination. For a mandate, the amount, the period, the number of periods and the end ledger. A submitter that changes any of them produces a transaction whose authorisation no longer matches the user’s signature, and the Soroban runtime rejects it. The gateway checks the same thing earlier, comparing every returned entry byte for byte with the one it prepared, so an altered entry never costs a fee.
This is structurally stronger than the EVM version, where binding application-level terms required a second EIP-712 signature over a separate struct. On Soroban, one signature covers everything.
A duplicate request cannot double-charge
Enforced in two independent places, one of them on-chain.
The gateway records every charge with the seller’s idempotency key. A repeated key returns the original charge without debiting again, even if the balance is now too low for a new one, and the same key with a different amount is refused. Two simultaneous requests with the same key do not race: the gateway locks the user’s row before it looks the key up, so the second waits for the first to commit and then finds it.
The contract is the second place, and it does not depend on the first. A charge’s on-chain identifier is derived from the idempotency key, and the contract records every identifier it settles and refuses it again. So a batch sent twice, a worker restarted mid-flight, or a database restored from an old backup still cannot settle the same charge twice. On testnet, a whole batch of 98 charges sent a second time landed with every entry refused as a duplicate.
The system’s beliefs are continuously checked against the chain
Enforced by an observer that holds no key and cannot change a balance.
Postgres is an admission view and the chain is the truth. The observer matches every contract event against the gateway’s records and reconciles the treasury’s USDC against what the contract owes, the contract’s totals against its events, and both against the database. What it finds is appended as a finding no role can edit, and raised as an alert.
A charge on-chain that the gateway never admitted is treated as what it looks like, a compromised operator key rather than a bug: a critical unknown_charge finding within a minute, which was drilled on testnet by charging a buyer with the operator’s key outside the gateway. The response is a pause with the admin’s keys, which stops the contract from moving money while the observer keeps watching and users can still revoke their mandates.
Fermah’s own spending is bounded
Enforced by the contract, by Postgres row locks, and by a floor on the fee account.
Every deposit, withdrawal, mandate and revocation costs the operator a fee whatever its amount, so each is bounded:
- A minimum deposit, on-chain, so a deposit’s fee stays a small share of it.
- Daily quotas per user and per seller deployment, on new buyers, deposits, withdrawals and mandate changes, counted in the same transaction as the insert with the user’s row locked, so N concurrent requests cannot all pass a quota that fits one of them. With the defaults, a deployment’s deposits cost at most about 130 XLM a day, whatever the number of buyers.
- A cap on any user’s transaction, 1 XLM of resource fee by default. A smart wallet whose own code makes its transactions expensive is refused before anything is sent, and gets at most two restores per request.
- A cap on inclusion fees, so congestion cannot make one envelope arbitrarily expensive.
- A floor on the fee account. Below it the worker sends nothing new and only finishes the work already in flight.
On testnet, a flood of deposits was drilled until the deployment’s quota refused one and the alert fired.
Testnet money and mainnet money do not share a process
Enforced by process separation, and checked at startup.
A seller deployment is pinned to one network and its keys carry that network in their prefix. A gateway serves one network and refuses to start unless its RPC endpoint reports that network’s passphrase. So a testnet key reaches a process that only knows testnet buyers, and no field in a request crosses that boundary.
Signing keys get the same treatment. Before building any transaction, the worker has each key sign a random payload and checks the signature against the account the key claims, and it checks every later signature again before it records or sends anything. A key wired to the wrong account, or a remote signer answering for the wrong key, signs nothing that is used.
Every privileged action leaves a record that cannot be erased
Enforced by the chain, and by Postgres.
On-chain, every pause, limit change and role rotation emits a contract event, and every upgrade emits the network’s own system event naming the old and the new code. None of them can be made without two of the admin’s three keys, and none of them can be hidden: the observer reports each one, and raises a critical alert when the contract runs code other than the operator expects.
Off-chain, a recorded transaction’s envelope and hashes cannot be changed by the process that wrote them, a final outcome cannot be left, and both the observer’s findings and an operator’s resolution of a quarantined charge are append-only for every database role, the owner included. History can be appended to and read. It cannot be edited.