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
└── 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. - Frontend — Next.js 14 App Router + Freighter wallet integration + Tailwind CSS.
- 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.
- 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 |
- 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