Skip to content

About

Soroban contracts for Payflow: pull-based recurring payments on Stellar.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Payflow — Contracts

Pull-based recurring payments for Stellar.
Soroban contracts that let a merchant charge a subscriber on a schedule, without the subscriber signing every payment.

CI Coverage Rust soroban-sdk License Network

Live demo: https://mmm-pay.github.io/payflow-frontend/ · Docs: https://mmm-pay.github.io/payflow-docs/


The problem

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.

How it works

  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.

Guarantees for the subscriber

  • The price is copied into the mandate at subscribe time. A merchant editing a plan later cannot reprice an open mandate.
  • max_charges caps total collections. 0 means open-ended.
  • cancel is 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_charge advances to now + period, not next_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.
  • upgrade moves 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.

Design notes

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.

Architecture decision records

Settled design choices are written up as short ADRs (context, decision, consequences) in docs/adr/:

Add a new ADR whenever a design choice changes.

Try it in five minutes

You need Freighter (or another wallet supported by Stellar Wallets Kit) set to Testnet.

  1. Get test XLM. Copy your address from the wallet and open https://friendbot.stellar.org/?addr=<your address>.
  2. 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.
  3. Subscribe. Open Subscribe, pick Demo Monthly (1 XLM every minute; the name predates the period), set a charge limit of 3 and confirm.
  4. 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.
  5. Stay in control. Pause, resume or cancel from Account. Withdrawing your vault balance also stops further charges.
  6. Be the merchant. On Merchant, publish your own plan, charge due mandates yourself, or end a subscriber's mandate.

Deployed — Stellar Testnet

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.

Quick start

# prerequisites: Rust 1.96+, stellar-cli 27+
rustup target add wasm32v1-none

cargo test --all          # 63 tests
stellar contract build    # wasm -> target/wasm32v1-none/release

Deploy your own suite:

stellar keys generate payflow-deployer --network testnet --fund
./scripts/deploy.sh testnet payflow-deployer

The 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.env

TypeScript clients

Install the generated clients from npm:

npm install @mmm-pay/contracts @stellar/stellar-sdk

Use 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 bindings

Verify that a deployment runs the WASM built from this source:

./scripts/verify.sh testnet # fetches IDs from deployments/testnet.env

The verifier builds the contracts, fetches each deployed WASM, and compares SHA-256 hashes. It exits non-zero if any deployed contract differs.

Repository layout

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.

Related repositories

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

Contributing

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.

Security

Unaudited. Testnet only. Do not use with real funds. See SECURITY.md.

Maintainers

Name Role Contact
Victor Adeleke Lead maintainer @titilope12

Contributors

License

Apache-2.0. See LICENSE.

About

Soroban contracts for Payflow: pull-based recurring payments on Stellar.

Topics

Resources

Contributing

Security policy

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages