A production-grade, non-custodial recurring payments protocol built on Stellar's Soroban smart contract platform. Enables SaaS billing, creator subscriptions, and recurring donations directly on-chain — no custodial wallets, no pre-authorized transaction arrays.
SorobanPay
├── contracts/subscription/ Rust/Soroban smart contract
├── deploy/deploy.sh Automated testnet/mainnet deployment
├── frontend/ Next.js 14 TypeScript frontend
├── backend/audit-trail/ Backend cancellation audit trail design
└── Makefile Build, test, and clean targets
Three layers:
- Smart Contract —
SubscriptionProtocolSoroban contract withsubscribe,execute_payment, andcancelentry points. Uses persistent storage with TTL management and emits structured events for off-chain indexing. This is the sole source of truth for subscription state and payment execution — it never holds balances and requires a fresh auth signature on every call. - Frontend — Next.js 14 App Router + Freighter wallet integration + Tailwind CSS. Signs and submits transactions directly to Soroban RPC; handles no server-side logic.
- Backend (
backend/) — Optional off-chain service for event indexing, cancellation detection, payout summaries, and a merchant REST API. Read-only with respect to the chain — it pollsgetEvents()but never submits transactions. See docs/architecture.md for the full backend role definition. - Build & Deploy — GNU Makefile + bash deployment script with testnet/mainnet switching.
+------------------+ +---------------------+ +----------------+
| Subscriber | | Merchant | | Optional |
| (Freighter) |<------>| (Service Owner) |<------>| Backend/Indexer|
+--------+---------+ Web +----------+-----------+ API +--------+-------+
| Web | ^
| | | |
v v | |
+--------+--------+ +--------+--------+ | |
| Frontend | | Merchant Portal |---------------+ |
| (Next.js + TS) | | or Admin Panel | |
+--------+--------+ +-------------------+ |
| |
| contract ops |
v |
+--------+--------+ |
| Soroban Contract |------------------------------------------------------+
| subscribe() |
| execute_payment() |
| cancel() |
+--------+--------+
|
v
+--------+--------+
| Soroban Ledger |
| + PersistentStore |
| + SEP-41 Token |
+------------------+
Flow summary:
- Subscriber signs transactions via Freighter in the Next.js frontend.
- Frontend dispatches contract calls (
subscribe,cancel,execute_payment) through the Stellar RPC. - Soroban Contract executes on-chain, interacting with the SEP-41 Token for allowances/transfers and persisting state in the Soroban Ledger.
- Structured events emitted by the contract can be indexed by an optional backend for analytics, history, or notification triggers.
- Cancellation audit records are persisted off-chain by backend services after confirmed
canceltransactions because the contract does not emit cancellation events. - Merchant may use a dedicated portal or admin panel to trigger
execute_paymentand view subscription state.
Get SorobanPay running on Stellar testnet from a clean machine.
# Rust + wasm target
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustup target add wasm32-unknown-unknown
# Stellar CLI
cargo install --locked stellar-cli --features opt
# Node.js ≥ 18 → https://nodejs.org (or use nvm)git clone https://github.com/Chrisland58/SorobanPay.git
cd SorobanPay
make buildstellar keys generate alice --network testnet
stellar keys fund alice --network testnet
CONTRACT_ID=$(bash deploy/deploy.sh)
echo "Contract: $CONTRACT_ID"cd frontend
cp .env.example .env.local
# Edit .env.local — paste $CONTRACT_ID into NEXT_PUBLIC_CONTRACT_ID
npm install
npm run devOpen http://localhost:3000 in a browser with the Freighter extension installed and set to Testnet.
- In Freighter, switch to Testnet and fund your wallet via Friendbot.
- Open the app, enter a merchant address and amount, and click Subscribe.
- Approve the transaction in Freighter — the subscription is now live on-chain.
| Tool | Version | Install |
|---|---|---|
| Rust | stable | https://rustup.rs |
wasm32-unknown-unknown target |
— | rustup target add wasm32-unknown-unknown |
| Stellar CLI | ≥ 21.x | https://developers.stellar.org/docs/tools/stellar-cli |
| Node.js | ≥ 18.x | https://nodejs.org |
| Freighter browser extension | latest | https://www.freighter.app |
make buildCompiles the Rust contract to contracts/target/wasm32-unknown-unknown/release/soroban_subscription_contract.wasm using the --release profile (opt-level = "z", lto = true).
Override defaults at the command line:
make build TARGET_TRIPLE=<triple> PROFILE=<debug|release>Example — cross-compile for a different WASM target:
make build TARGET_TRIPLE=wasm32-unknown-unknown PROFILE=releaseThe Makefile exposes two override-friendly variables:
TARGET_TRIPLE— Rust compilation target (default:wasm32-unknown-unknown)PROFILE— Cargo profile name (default:release)
To add a new compilation target:
- Install the Rust target with
rustup target add <triple>. - Build with
make build TARGET_TRIPLE=<triple>. - The output artifact lands under
contracts/target/<triple>/<profile>/soroban_subscription_contract.wasm.
Example — add a native host build target:
make build TARGET_TRIPLE=x86_64-unknown-linux-gnu PROFILE=debugCaution: make test always runs via the native host (cargo test without --target). Do not set TARGET_TRIPLE for testing; WASM cross-targets cannot execute tests.
make testEquivalent to:
cargo test \
--manifest-path contracts/subscription/Cargo.tomlPrerequisites:
- Rust stable toolchain
wasm32-unknown-unknowntarget (rustup target add wasm32-unknown-unknown)
Runs the full test suite: unit tests (lifecycle, error paths, auth, events) and property-based tests (time-lock, double-payment prevention, balance invariant, and more).
make cleanRemoves all build artifacts from contracts/target/.
| Variable | Default | Description |
|---|---|---|
STELLAR_NETWORK |
testnet |
Target network: testnet or mainnet |
STELLAR_IDENTITY |
alice |
Stellar CLI identity alias to sign and pay fees |
# 1. Create identity (one-time)
stellar keys generate alice --network testnet
# 2. Fund via Friendbot (testnet only — free)
stellar keys fund alice --network testnet
# 3. Deploy
bash deploy/deploy.shThe contract address is printed to stdout. All diagnostic output goes to stderr. Save the address — you will need it for the frontend .env.local.
Mainnet requires a real funded account. There is no Friendbot.
# 1. Generate a mainnet identity (one-time)
stellar keys generate my-mainnet-id --network mainnet
# 2. Print the public key and fund it with real XLM (minimum ~2 XLM for base reserve + fee)
stellar keys address my-mainnet-id
# 3. Deploy
STELLAR_NETWORK=mainnet STELLAR_IDENTITY=my-mainnet-id bash deploy/deploy.shOn success the contract address is printed to stdout, e.g.:
CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
Capture it directly if needed:
CONTRACT_ID=$(STELLAR_NETWORK=mainnet STELLAR_IDENTITY=my-mainnet-id bash deploy/deploy.sh)
echo "Deployed: $CONTRACT_ID"| Symptom | Likely cause | Fix |
|---|---|---|
ERROR: Contract build failed |
Rust toolchain or wasm32 target missing |
Run rustup target add wasm32-unknown-unknown |
ERROR: WASM artifact not found |
Build produced no output | Check make build output; ensure opt-level = "z" is set in Cargo.toml |
ERROR: Contract deployment failed |
Identity not funded or CLI not configured | Fund the account; verify with stellar keys address <identity> |
ERROR: Unknown STELLAR_NETWORK value |
Typo in STELLAR_NETWORK |
Allowed values are exactly testnet or mainnet |
| Empty contract ID returned | RPC node unreachable or rate-limited | Retry; check RPC URL connectivity |
| Transaction fee too low (mainnet) | Surge pricing during congestion | Re-run; the script uses the Stellar CLI default fee which self-adjusts |
Freighter is the Stellar browser wallet the app uses for signing transactions.
- Install the extension for Chrome / Brave or Firefox.
- Open Freighter and create or import a wallet.
- Click the network selector in the top-right and choose Testnet (for local development) or Mainnet (for production).
- Fund your testnet wallet via Stellar Friendbot.
Mainnet note: Freighter defaults to Mainnet. Make sure the network in Freighter matches
NEXT_PUBLIC_NETWORK_PASSPHRASEin your.env.local, or transactions will be rejected.
Copy the example env file:
cp frontend/.env.example frontend/.env.localEdit frontend/.env.local:
# Contract address output by deploy.sh
NEXT_PUBLIC_CONTRACT_ID=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
# Testnet
NEXT_PUBLIC_RPC_URL=https://soroban-testnet.stellar.org
NEXT_PUBLIC_NETWORK_PASSPHRASE=Test SDF Network ; September 2015
# Mainnet (swap these two lines when deploying to mainnet)
# NEXT_PUBLIC_RPC_URL=https://mainnet.stellar.validationcloud.io/v1/<YOUR_KEY>
# NEXT_PUBLIC_NETWORK_PASSPHRASE=Public Global Stellar Network ; September 2015| Variable | Required | Description |
|---|---|---|
NEXT_PUBLIC_CONTRACT_ID |
✅ | Deployed contract address (C…) from deploy.sh |
NEXT_PUBLIC_RPC_URL |
✅ | Soroban RPC endpoint |
NEXT_PUBLIC_NETWORK_PASSPHRASE |
✅ | Must match the network Freighter is set to |
cd frontend
npm install
npm run devOpen http://localhost:3000. Freighter will prompt for connection on the first interaction.
cd frontend
npm run build
npm startcd frontend
npm run type-check| Symptom | Fix |
|---|---|
| "Wallet not connected" | Click the Freighter icon and approve the site connection |
| Transaction rejected — wrong network | Match the Freighter network with NEXT_PUBLIC_NETWORK_PASSPHRASE |
| "Insufficient balance" | Fund the account (Friendbot on testnet; real XLM on mainnet) |
| Freighter not detected | Ensure the extension is installed and the page is served over http://localhost or https:// |
| Function | Auth required | Description |
|---|---|---|
subscribe(subscriber, merchant, token, amount, interval) |
subscriber | Create or update subscription. Amount must be > 0, interval in [86400, 31536000] seconds. |
execute_payment(subscriber, merchant) |
merchant | Collect payment if interval has elapsed. Transfers tokens directly subscriber → merchant. |
cancel(subscriber, merchant) |
subscriber | Remove subscription from persistent storage. |
| Event | Topics | Data |
|---|---|---|
subscribe |
(symbol("subscribe"), subscriber, merchant) |
amount: i128 |
executed |
(symbol("executed"), subscriber, merchant) |
amount: i128 |
Events use three topics: a Symbol discriminant followed by two Address values. The data field is an i128 amount in stroops.
Quick decode example (TypeScript):
import { xdr, scValToNative } from "@stellar/stellar-sdk";
function decodeEvent(topic: string[], value: string) {
const [type, subscriber, merchant] = topic.map((t) =>
scValToNative(xdr.ScVal.fromXDR(t, "base64"))
);
const amount = BigInt(scValToNative(xdr.ScVal.fromXDR(value, "base64")));
return { type, subscriber, merchant, amount };
}See docs/events.md for the full event reference, RPC query examples, and Python decoding code.
| Code | Name | Trigger |
|---|---|---|
| 1 | AmountMustBePositive |
amount ≤ 0 in subscribe |
| 2 | IntervalTooShort |
interval < 86400 in subscribe |
| 3 | IntervalTooLong |
interval > 31536000 in subscribe |
| 4 | NoActiveSubscription |
No subscription found for (subscriber, merchant) pair |
| 5 | PaymentNotDue |
now < next_payment in execute_payment |
| 6 | Unauthorized |
Authorization check failed |
SorobanPay emits structured events via Soroban RPC for off-chain indexing. The contract publishes two core event types:
subscribe— Emitted when a subscription is created or updated. Signals the start of a recurring payment relationship.executed— Emitted after a successful payment transfer and timestamp advance. Confirms payment collection.
Cancellation Detection: The contract does not emit a cancellation event. Instead, off-chain indexers detect cancellations by the absence of executed events after a period exceeding the subscription interval.
| Component | Purpose |
|---|---|
| Event Sources | Soroban RPC's getEvents() endpoint (topics: event type, subscriber, merchant) |
| Storage | PostgreSQL, MongoDB, or time-series DBs for subscription state and payment history |
| Indexing Pattern | Pull-based polling with cursor-based pagination; event sourcing + CQRS for complex workflows |
| Resumability | Save RPC cursor in indexer_state to resume after failures |
Each event contains:
- Topics:
(symbol, subscriber_address, merchant_address)— enables filtering by party or event type - Data:
amount: i128— payment amount in token's smallest unit
For most SaaS and merchant dashboard use cases, a PostgreSQL-backed pull indexer is recommended. Characteristics:
- Poll Soroban RPC every 5–30 seconds for new events.
- Decode and persist to tables:
subscriptions,payments,indexer_state. - Detect cancellations via batch job: mark subscriptions inactive if no
executedevent in2 × interval. - Serve queries via REST/GraphQL API for merchant dashboards.
For high-volume payment streams, consider event sourcing + CQRS to maintain an immutable event log and multiple projections (subscription summary, revenue analytics, etc.).
For detailed guidance on event sources, storage options, indexing patterns, workflows, and error handling, see docs/architecture.md.
- Non-custodial: The contract never holds token balances. Transfers go directly
subscriber → merchantvia SEP-41transfer. - Per-invocation auth: Every entry point requires a fresh
require_auth()signature — no stored sessions. - Allowance model: Subscribers grant a SEP-41 allowance to the contract. Revoking allowance via
token.approve(contract_id, 0)prevents future payments regardless of on-chain subscription state. - Time-lock: Payment cannot be collected before
next_payment— enforced on-chain by the Soroban ledger timestamp. - TTL: Subscriptions have a ~30-day minimum and ~365-day maximum TTL. Each successful payment resets the 365-day clock.
We welcome contributions! Whether you want to report a bug, suggest an enhancement, or submit code changes, here's how to get started.
Bug Reports — If you've found a problem:
- Check existing issues to avoid duplicates
- Use the bug label
- Provide:
- Clear description of the issue
- Steps to reproduce (if applicable)
- Expected vs. actual behavior
- Environment details (OS, Node.js version, Rust version)
- Error messages or logs
Feature Requests — To suggest improvements:
- Use the enhancement label
- Describe the use case and expected behavior
- Include any relevant examples or references
Setting up locally:
# Clone the repository
git clone https://github.com/Chrisland58/SorobanPay.git
cd SorobanPay
# Install prerequisites (see Prerequisites section above)
# Build and test
make build
make test
# Frontend setup
cd frontend
npm install
npm run devSubmitting code:
- Create a feature branch:
git checkout -b fix/issue-numberorgit checkout -b feature/description - Write tests for new functionality
- Ensure all tests pass:
make test(contract) andnpm run type-check(frontend) - Run linters:
next lint(frontend) - Commit with clear, descriptive messages
- Push your branch and open a pull request
PR guidelines:
- Link the related issue (e.g., "Closes #189")
- Describe what changed and why
- Include any breaking changes
- Ensure CI/CD checks pass
| Label | Purpose |
|---|---|
bug |
Something isn't working |
enhancement |
New feature or improvement |
documentation |
Updates to docs or comments |
test |
Test coverage or test improvements |
contract |
Changes to the Soroban smart contract |
frontend |
Changes to the Next.js frontend |
deployment |
Changes to build or deploy scripts |
MIT