Pull-based recurring payments for Stellar.
Soroban contracts that let a merchant charge a subscriber on a schedule,
without the subscriber signing every payment.
Live demo: https://mmm-pay.github.io/payflow-frontend/ · Docs: https://mmm-pay.github.io/payflow-docs/
Stellar payments are push-only. The account holding the funds must sign every transfer. That is correct for a one-off payment and useless for a subscription: a merchant cannot bill a customer monthly without the customer signing a transaction every month, and no consumer does that.
Every subscription business on Stellar today solves this by holding customer funds off-chain or by asking for a custodial key. Both re-introduce exactly the trust the chain was supposed to remove.
Payflow supplies the missing primitive: a mandate. A subscriber authorizes a bounded, revocable standing charge once. After that, anyone can settle the payment when it comes due — and nobody can take more than the mandate allows.
merchant subscriber keeper (anyone)
│ │ │
│ create_plan │ deposit │
▼ ▼ │
┌──────────────┐ ┌──────────────┐ │
│ plan-registry│ │ vault │ │
│ │ │ holds funds │ │
│ price/period │ └──────┬───────┘ │
└──────┬───────┘ │ debit (only) │
│ get_plan │ │
▼ │ │
┌──────────────────────────────┴───────┐ │
│ subscription │◄──── charge ──────┘
│ mandates · schedule · fee split │
└──────────────────────────────────────┘
Three contracts, one direction of dependency:
| Contract | Owns | Depends on |
|---|---|---|
plan-registry |
Merchant plans: price, period, token | — |
vault |
Custody of subscriber funds | token (SEP-41) |
subscription |
Mandates, scheduling, fee split | plan-registry, vault |
Why the vault exists. A pull payment fails the moment a subscriber's wallet is empty at charge time. Separating custody from billing means subscribers fund ahead, and the biller debits a balance that is already committed. It also means custody logic can be audited on its own.
Why charge is open to anyone. The mandate is the authorization, so it
does not matter who triggers settlement. If the project's keeper goes offline,
the merchant — or the subscriber, or a stranger — can still settle. Billing does
not depend on us staying online. The caller signs only to receive the plan's
keeper tip, if it sets one; that signature gives it no power over the mandate.
A tip is paid out of the merchant's share, so it never raises what the
subscriber pays.
- The price is copied into the mandate at subscribe time. A merchant editing a plan later cannot reprice an open mandate.
max_chargescaps total collections.0means open-ended.cancelis unilateral and immediate; the merchant is not consulted.- A merchant can end a mandate (
end_mandate) when it stops offering the service. Ending can only stop future charges; it never takes anything. - Withdrawing from the vault starves any mandate. Funds are never locked.
- A keeper outage delays billing; it never accumulates a backlog that drains a
vault in one burst.
next_chargeadvances tonow + period, notnext_charge + period. - The protocol fee is capped at 10% (
MAX_FEE_BPS = 1000) and is integer basis points, frozen into each mandate when it is opened. An admin fee change only applies to new mandates. There is no floating point anywhere in the contracts. - A keeper tip is capped at 5% of the merchant's share (
MAX_KEEPER_BPS = 500) and comes out of that share, never out of the subscriber's payment. It is frozen into the mandate when it is opened. upgrademoves a mandate onto another plan of the same merchant and token. A more expensive plan collects the price difference for the part of the current period that is still ahead, prorated and rounded in the subscriber's favour; a cheaper plan takes effect at the next charge and never refunds anything. See ADR 0006.
Constructors, not initialize. Every contract receives its admin and
wiring as constructor arguments, so it is configured in the same transaction
that deploys it. A separate initialize call leaves a window in which anyone
can call it first and become admin.
Indexes that cannot be griefed. The lists of a merchant's plans, a
merchant's mandates and a subscriber's mandates are stored one ledger entry per
position plus a count, not as a single growing Vec. Opening a subscription
costs the same whether the merchant has one subscriber or a million, and nobody
can block a merchant by opening throwaway mandates until a list outgrows the
ledger's entry size limit.
Paged reads. merchant_plans, token_plans, merchant_mandates and subscriber_mandates
take start and limit and return at most 50 ids (MAX_PAGE), because one
transaction may touch at most 100 ledger entries. Each has a matching
*_count function, and next_plan_id tells a client how many plans exist.
Settled design choices are written up as short ADRs (context, decision,
consequences) in docs/adr/:
- 0001: Vault separate from billing
- 0002:
now + periodscheduling - 0003: Constructors instead of
initialize - 0004: Per-position indexes
- 0005: Permissionless
charge
Add a new ADR whenever a design choice changes.
You need Freighter (or another wallet supported by Stellar Wallets Kit) set to Testnet.
- Get test XLM. Copy your address from the wallet and open
https://friendbot.stellar.org/?addr=<your address>. - Fund your vault. In the demo, connect your wallet, open Account and deposit 5 XLM. The vault is the only balance a mandate can draw from, and you can withdraw it at any time.
- Subscribe. Open Subscribe, pick Demo Monthly (1 XLM every minute; the name predates the period), set a charge limit of 3 and confirm.
- Watch it bill. The first charge is due at once. A scheduled keeper settles due mandates about every 15 minutes, and Account shows the charges. Anyone can settle a due mandate, so a merchant does not have to wait for the keeper.
- Stay in control. Pause, resume or cancel from Account. Withdrawing your vault balance also stops further charges.
- Be the merchant. On Merchant, publish your own plan, charge due mandates yourself, or end a subscriber's mandate.
| Contract | ID |
|---|---|
plan-registry |
CDQ5EJTVXXX2CBMHWP3BGGUDHSK6D4I25IF5XXH3QGL2TNTBMJUOFKDB |
vault |
CBNQEWSYYQOKNX5KB62OFT6ID5TCVSWN4PS4AXAJK6IWUEXOLVN6DXII |
subscription |
CD5YZUJU6WLFIHGAV4J2BV42GCVZE5ZGDUGSW5CBRIA47TQHSZZW3XMM |
Settlement token: the native XLM Stellar Asset Contract
(CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC). The same ids, the admin and the ledger the suite was
deployed at are in deployments/testnet.env.
# prerequisites: Rust 1.96+, stellar-cli 27+
rustup target add wasm32v1-none
cargo test --all # 63 tests
stellar contract build # wasm -> target/wasm32v1-none/releaseDeploy your own suite:
stellar keys generate payflow-deployer --network testnet --fund
./scripts/deploy.sh testnet payflow-deployerThe script deploys in dependency order, passing each contract its configuration
as constructor arguments, grants the subscription contract debit rights on the
vault, and writes the ids to deployments/<network>.env.
Smoke-test a live deployment end to end:
./scripts/demo.sh testnet # reads deployments/testnet.envInstall the generated clients from npm:
npm install @mmm-pay/contracts @stellar/stellar-sdkUse the generated client with the testnet RPC to read contract state:
import { Networks } from "@stellar/stellar-sdk";
import { PlanRegistryClient } from "@mmm-pay/contracts";
const registry = new PlanRegistryClient({
contractId: "YOUR_PLAN_REGISTRY_ID",
networkPassphrase: Networks.TESTNET,
rpcUrl: "https://soroban-testnet.stellar.org",
});
const transaction = await registry.next_plan_id();
await transaction.simulate();
const nextPlanId = transaction.result.unwrap();The package also exports VaultClient and SubscriptionClient, with
contract-specific types under PlanRegistryTypes, VaultTypes and
SubscriptionTypes. Mutating methods return transactions that integrators
must authorize and submit with the Stellar SDK.
CI builds and verifies the bindings on contract changes. Publishing a GitHub
release publishes the matching version to npm; the repository needs an
NPM_TOKEN secret. The manual TypeScript bindings testnet smoke workflow
checks the live registry's next_plan_id read without a signing key.
To regenerate and check the package locally after changing a contract:
stellar contract build
./scripts/generate-bindings.sh
npm ci --prefix bindings
npm test --prefix bindingsVerify that a deployment runs the WASM built from this source:
./scripts/verify.sh testnet # fetches IDs from deployments/testnet.envThe verifier builds the contracts, fetches each deployed WASM, and compares SHA-256 hashes. It exits non-zero if any deployed contract differs.
contracts/
plan-registry/ merchant plan catalogue
vault/ subscriber custody
subscription/ mandates, scheduling, fee split
scripts/
deploy.sh ordered deploy + wiring
demo.sh end-to-end smoke test
verify.sh compare deployed WASMs with source
deployments/
testnet.env ids of the live testnet suite
Each contract follows the same file split: lib.rs (entry points), types.rs
(storage types), error.rs (contracterror), events.rs (contractevent),
test.rs.
| Repo | Role |
|---|---|
| payflow-contract | Soroban contracts (this repo) |
| payflow-backend | Event indexer + keeper that settles due mandates |
| payflow-frontend | Merchant dashboard and subscriber portal |
| payflow-docs | Protocol documentation |
Read CONTRIBUTING.md. Good first issues are labelled
good-first-issue. Changes are recorded in CHANGELOG.md. Every PR must pass cargo fmt --check, cargo clippy -D warnings, and cargo test --all.
Unaudited. Testnet only. Do not use with real funds. See SECURITY.md.
| Name | Role | Contact |
|---|---|---|
| Victor Adeleke | Lead maintainer | @titilope12 |
Apache-2.0. See LICENSE.