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.
Deploy with init(admin) before creating subscriptions. The admin can set a per-deployment amount cap with set_max_amount; subscriptions above it return AmountExceedsLimit (error 18). subscribe accepts an optional grace period; failed collections record overdue_since, and anyone can call expire_subscription after the grace period.
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, lint, 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.
The diagram above is rendered from
docs/assets/architecture.svg. To edit it, open the file in draw.io or Excalidraw, or modify the SVG source directly.
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 events are emitted by the contract on every successful
cancelcall (symbol("cancel"), subscriber, merchant topics; unit data), allowing off-chain indexers to immediately detect and record cancellations without scanning storage changes. - Merchant may use a dedicated portal or admin panel to trigger
execute_paymentand view subscription state.
Demo GIF coming soon. The recording below will show: connecting Freighter → filling the subscription form → approving in Freighter → success card with transaction hash.
To record the GIF yourself:
- Run the frontend locally (
npm run devinfrontend/). - Record with Peek (Linux), LICEcap (macOS), or ScreenToGif (Windows).
- Compress to < 5 MB:
gifsicle -O3 --lossy=80 demo.gif -o docs/assets/demo.gif - Replace the notice above with
.
Video walkthrough coming soon. The planned video (5–10 min) will cover:
- Installing prerequisites
- Deploying the contract to Stellar testnet
- Configuring
frontend/.env.local- Creating your first subscription end-to-end
- Verifying the on-chain payment via Stellar Expert
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.
- Install and enable the Freighter wallet extension.
- Switch Freighter to Testnet and load a funded account.
- Connect Freighter in the app by clicking Connect Freighter Wallet.
- Ensure
NEXT_PUBLIC_CONTRACT_IDis set infrontend/.env.local. - Fill in the merchant address, token contract, amount, and interval.
- Submit the form and approve the transaction in Freighter.
- 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.
The deploy/k8s/ directory contains production-grade Kubernetes manifests for the three SorobanPay backend roles:
| Manifest | Workload | Replicas |
|---|---|---|
indexer-deployment.yaml |
Event indexer — polls Soroban RPC every 5 min | 1 (Recreate) |
api-deployment.yaml |
REST API — subscriptions, webhooks, admin, reports | 2–10 (HPA) |
webhook-worker-deployment.yaml |
Webhook worker — delivers merchant notifications | 2 (RollingUpdate) |
All three run the same sorobanpay/backend Docker image; the SERVICE_ROLE env var selects the active mode at startup.
| Tool | Install |
|---|---|
kubectl ≥ 1.28 |
https://kubernetes.io/docs/tasks/tools/ |
| A Kubernetes cluster | minikube, kind, EKS, GKE, AKS, etc. |
| nginx-ingress controller | kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.1/deploy/static/provider/cloud/deploy.yaml |
| cert-manager (TLS) | kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.15.1/cert-manager.yaml |
| metrics-server (HPA) | kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml |
# 1. Start minikube
minikube start --cpus=4 --memory=4096
# 2. Enable the nginx ingress addon
minikube addons enable ingress
# 3. Build the backend image inside minikube's Docker daemon
eval $(minikube docker-env)
docker build -t sorobanpay/backend:latest backend/
# 4. Set real secret values (do not commit these to source control)
kubectl create secret generic sorobanpay-secrets \
--from-literal=DATABASE_URL="postgresql://sorobanpay:sorobanpay@postgres:5432/sorobanpay?schema=public" \
--from-literal=WEBHOOK_SECRET="$(openssl rand -hex 32)" \
--from-literal=ADMIN_JWT_SECRET="$(openssl rand -hex 32)" \
-n sorobanpay --dry-run=client -o yaml > /tmp/sorobanpay-secrets.yaml
# Edit /tmp/sorobanpay-secrets.yaml if needed, then apply after the namespace:
# 5. Apply all manifests (namespace first, then the rest via kustomize)
kubectl apply -f deploy/k8s/namespace.yaml
kubectl apply /tmp/sorobanpay-secrets.yaml
kubectl apply -k deploy/k8s/
# 6. Verify the rollout
kubectl rollout status deployment/sorobanpay-api -n sorobanpay
kubectl rollout status deployment/sorobanpay-indexer -n sorobanpay
kubectl rollout status deployment/sorobanpay-webhook-worker -n sorobanpay
# 7. Check HPA
kubectl get hpa -n sorobanpay
# 8. Port-forward to test locally (bypasses Ingress)
kubectl port-forward svc/sorobanpay-api 8080:80 -n sorobanpay
curl http://localhost:8080/health# 1. Create the namespace
kubectl apply -f deploy/k8s/namespace.yaml
# 2. Populate secrets from your secret manager (example: plain kubectl)
kubectl create secret generic sorobanpay-secrets \
--from-literal=DATABASE_URL="postgresql://..." \
--from-literal=WEBHOOK_SECRET="$(openssl rand -hex 32)" \
--from-literal=ADMIN_JWT_SECRET="$(openssl rand -hex 32)" \
-n sorobanpay
# 3. Edit deploy/k8s/configmap.yaml — set CONTRACT_ID, RPC_URL, and API_BASE_URL
# 4. Edit deploy/k8s/api-service.yaml — replace api.sorobanpay.example.com with your domain
# 5. Apply everything
kubectl apply -k deploy/k8s/
# 6. Watch pods come up
kubectl get pods -n sorobanpay -wUse kustomize edit to pin a specific release without editing manifests by hand:
cd deploy/k8s
kustomize edit set image sorobanpay/backend=sorobanpay/backend:v1.2.3
kubectl apply -k .deploy/k8s/
├── namespace.yaml # sorobanpay namespace
├── configmap.yaml # Non-secret env vars (RPC_URL, CONTRACT_ID, …)
├── secrets.yaml # Placeholder secrets — replace with real values
├── indexer-deployment.yaml # Event indexer (1 replica, Recreate)
├── api-deployment.yaml # REST API (2 replicas min, HPA to 10)
├── webhook-worker-deployment.yaml # Webhook worker (2 replicas)
├── api-service.yaml # ClusterIP service + Ingress with TLS
├── hpa.yaml # HPA: CPU ≥ 70% or Memory ≥ 80%
├── postgres-statefulset.yaml # PostgreSQL (dev/CI only — use managed DB in prod)
├── redis-statefulset.yaml # Redis reference (not yet used — future roadmap)
└── kustomization.yaml # Kustomize root — applies all of the above
The provided secrets.yaml contains placeholder base64-encoded values and must never be applied as-is to a real cluster. Recommended approaches:
- External Secrets Operator (recommended): sync from AWS Secrets Manager, GCP Secret Manager, or HashiCorp Vault. Replace
secrets.yamlwith anExternalSecretCRD. - Sealed Secrets:
kubeseal --format yaml < secrets.yaml > secrets-sealed.yaml— safe to commit. kubectl create secret: generate secrets on-the-fly in your CI/CD pipeline, never touching disk.
See docs/security.md for full guidance on managing backend secrets.
All three deployments expose /health on port 3001. Kubernetes uses this endpoint for liveness, readiness, and startup probes. The health handler verifies:
- Soroban RPC reachability (
getHealth) - Contract address resolvability (
getContractData)
A pod will not receive traffic and will be restarted if either check fails consistently. See backend/src/routes/health.ts for the implementation.
Prometheus annotations are set on all pods:
prometheus.io/scrape: "true"
prometheus.io/port: "3001"
prometheus.io/path: "/metrics"
If you use the prometheus-operator, create a ServiceMonitor targeting the sorobanpay-api service. The Grafana dashboard in deploy/grafana/sorobanpay-dashboard.json can be imported directly.
| 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 |
Run make help to print all available targets with descriptions:
$ make help
SorobanPay — available make targets
------------------------------------
help Print all available targets with descriptions
build Compile the contract to WASM (uses TARGET_TRIPLE and PROFILE)
test Run contract unit and property tests on the native host (not WASM)
lint Check formatting (rustfmt --check) and run Clippy on the contract
coverage Run contract tests with llvm-cov; enforce COVERAGE_THRESHOLD
clean Remove all contract build artifacts from contracts/target/
test-frontend Run the Next.js Jest test suite (unit + coverage)
test-frontend-coverage Run the Next.js Jest suite with coverage report
Override variables:
TARGET_TRIPLE=<triple> Rust compilation target (default: wasm32-unknown-unknown)
PROFILE=<debug|release> Cargo profile (default: release)
COVERAGE_THRESHOLD=<n> Min line-coverage % (default: 95)
| Target | Description |
|---|---|
make help |
Print all targets with descriptions |
make build |
Compile contract to WASM |
make test |
Run contract unit and property tests |
make lint |
Check formatting and run Clippy |
make coverage |
Run tests with llvm-cov; enforce coverage threshold |
make clean |
Remove build artifacts |
make test-frontend |
Run the Next.js Jest test suite |
make test-frontend-coverage |
Run Jest with coverage report |
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 test-upgradeRuns the two-phase contract upgrade regression tests under the upgrade-test feature flag. Verifies that adding optional fields or new entry points does not break existing stored subscriptions. See docs/deployment.md §Contract Upgrades for the full upgrade guide.
# Requires: cargo install cargo-mutants --version "24.11.1" --locked
make mutation-testRuns cargo-mutants against the contract source. Target score: > 80%. The full mutation report is at docs/mutation-report.md. Mutation tests run in CI on the slow-tests branch protection rule.
make cleanRemoves all build artifacts from contracts/target/.
make lintRuns two checks in sequence:
rustfmt --check— verifies that every source file incontracts/subscription/is formatted according to the project'srustfmt.toml. Exits non-zero if any file would be reformatted; runcargo fmt --manifest-path contracts/subscription/Cargo.tomlto fix.cargo clippy -D warnings— runs the Clippy linter across all targets. All Clippy warnings are promoted to errors, so CI fails on any new lint finding.
Prerequisites:
rustup component add rustfmt clippyBoth components are included in the default rustup installation; the command above is a no-op if they are already present.
Fix formatting issues before committing:
cargo fmt --manifest-path contracts/subscription/Cargo.toml| 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-checkSymptom: "Wallet not connected" badge appears and the Submit button is disabled.
Steps to resolve:
- Click the Freighter extension icon in your browser toolbar.
- If the site is not listed under "Connected Sites", click Connect and approve the connection prompt.
- Reload the page — the badge should turn green.
Symptom: Freighter popup does not appear when the page loads.
Steps to resolve:
- Confirm the Freighter extension is installed (Chrome/Brave or Firefox — see Install Freighter).
- Make sure the page is served over
http://localhostorhttps://. Freighter blocks requests fromfile://origins. - Disable other wallet extensions temporarily — they can conflict with the Freighter injected API.
- Try a hard reload (
Ctrl+Shift+R/Cmd+Shift+R).
Symptom: Transaction rejected — "User declined" or signing popup dismissed.
Steps to resolve:
- Open Freighter and confirm the correct account is selected.
- Re-submit the form; Freighter will show the signing prompt again.
- If Freighter closes before you can sign, disable browser pop-up blockers for
localhost.
Symptom: Transaction rejected — wrong network.
Steps to resolve:
- Open Freighter → click the network name at the top-right.
- Select the network that matches
NEXT_PUBLIC_NETWORK_PASSPHRASEin your.env.local:- Testnet passphrase:
Test SDF Network ; September 2015 - Mainnet passphrase:
Public Global Stellar Network ; September 2015
- Testnet passphrase:
- Reload and retry.
Symptom: "Insufficient balance" error.
Steps to resolve:
- Testnet: fund your wallet at Stellar Friendbot.
- Mainnet: transfer at least 2 XLM to your account to cover the base reserve and transaction fee.
| Symptom | Fix |
|---|---|
| "Wallet not connected" badge | Open Freighter and approve the site connection |
| Signing popup never appears | Serve the app over http://localhost or https://; disable conflicting extensions |
| Transaction rejected — wrong network | Match Freighter's network selector to NEXT_PUBLIC_NETWORK_PASSPHRASE |
| "Insufficient balance" | Fund via Friendbot (testnet) or send XLM (mainnet) |
| Freighter not detected | Install the extension; page must be on http://localhost or https:// |
| Popup closes before signing | Disable pop-up blockers for localhost |
If NEXT_PUBLIC_CONTRACT_ID is not set or is blank, the app renders a "Contract not configured" warning card instead of the subscription form. This is the most common first-run issue.
Symptom: Yellow warning card titled "Contract not configured" appears where the form should be.
Fix:
-
Deploy the contract and capture the address:
CONTRACT_ID=$(bash deploy/deploy.sh) echo "Contract: $CONTRACT_ID"
-
Paste the address into
frontend/.env.local:NEXT_PUBLIC_CONTRACT_ID=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
-
Restart the dev server:
npm run dev
The warning card also displays the current values of RPC_URL, NETWORK_PASSPHRASE, and CONTRACT_ID to help you verify your environment.
When no wallet is connected the app shows a prompt card with:
- A link to install Freighter if the extension is not detected.
- A link to the Quick Start guide.
- A reminder to set
NEXT_PUBLIC_CONTRACT_IDin.env.local.
Connect Freighter and approve the site to dismiss this state.
Once the wallet is connected, a Payment History placeholder card is shown below the subscription form. This area will display executed payments and subscription activity once on-chain event indexing (polling getEvents()) is implemented. Until then it serves as a roadmap indicator.
The SubscriptionForm component reflects the wallet and transaction lifecycle through distinct visual states. Contributors should maintain these states when modifying the form.
| State | Trigger | UI indicator | Submit button |
|---|---|---|---|
| Disconnected | publicKey is null (Freighter not connected or not approved) |
Gray badge: "Disconnected" with dim dot | Disabled; yellow hint: "Connect your Freighter wallet to enable submission." |
| Connected / idle | publicKey is set, isSubmitting is false |
Green badge: "Connected" with green dot | Enabled: "Authorize Subscription" |
| Awaiting signature | isSubmitting is true (transaction sent to Freighter, waiting for user approval) |
Blue animated spinner + progress bar with label "Submitting transaction…" | Disabled: "Submitting…" with spinner |
| Success | successData is set after transaction confirmed |
Green SuccessCard with tx hash, summary, and next-steps guidance |
Hidden; replaced by "Create another subscription" button |
| Error | txError is set after a failed or rejected transaction |
Red alert box with error message and "Your form data has been preserved — review and retry." | Re-enabled; form data retained for correction |
Disconnected ──(connect Freighter)──► Connected/idle
Connected/idle ──(submit form)──► Awaiting signature
Awaiting signature ──(user approves)──► Success
Awaiting signature ──(user rejects / timeout / RPC error)──► Error
Error ──(fix form & resubmit)──► Awaiting signature
Success ──(click "Create another")──► Connected/idle
The frontend supports keyboard shortcuts for faster navigation and accessibility. Shortcuts are disabled when focus is inside any form field (<input>, <textarea>, <select>), so they never interfere with typing.
| Key | Action | Category |
|---|---|---|
? |
Open / close the keyboard shortcuts help modal | Interface |
N |
Scroll to and focus the new subscription form | Actions |
H |
Jump to the payment history section | Navigation |
M |
Jump to the merchant portal section | Navigation |
D |
Jump to the dashboard section | Navigation |
Esc |
Close the shortcuts help modal | Interface |
Three ways to access the shortcuts reference:
- Keyboard: Press
?(Shift + /) from anywhere on the page. - Mouse / touch: Click the
?button fixed at the bottom-right corner of the screen. - Tab order: The
?button is in the page's normal tab sequence and can be activated with Enter or Space.
- All interactive elements that have a corresponding shortcut carry an
aria-keyshortcutsattribute (e.g.,aria-keyshortcuts="n"on the Connect Wallet button). - The help modal uses
role="dialog",aria-modal="true", and a labelled title for screen readers. - Focus is trapped inside the modal while it is open and restored to the previously focused element on close.
- A visually-hidden
aria-liveregion announces navigation actions to screen readers.
| File | Purpose |
|---|---|
src/hooks/useKeyboardShortcuts.ts |
Registers hotkeys via react-hotkeys-hook, exports SHORTCUT_DEFINITIONS and SECTION_IDS |
src/components/ShortcutsHelpModal.tsx |
Accessible modal component that renders the shortcuts reference |
src/app/page.tsx |
Mounts the hook and modal; adds section landmark IDs and aria-keyshortcuts attributes |
| Function | Auth required | Description |
|---|---|---|
subscribe(subscriber, merchant, token, amount, interval, strict, grace_period) |
subscriber | Create or update subscription. Amount ∈ (0, 10¹⁸], interval ∈ [86400, 31536000] seconds. |
execute_payment(subscriber, merchant, token) |
merchant | Collect payment if interval has elapsed. Transfers tokens directly subscriber → merchant (after protocol fee split if configured). |
cancel(subscriber, merchant, token) |
subscriber | Remove subscription from persistent storage. Revoke SEP-41 allowance to block future collections. |
update_subscription(subscriber, merchant, new_amount, new_interval) |
subscriber | Modify amount and/or interval in-place. Preserves next_payment — no billing-cycle reset. |
transfer_subscription(subscriber, old_merchant, new_merchant) |
subscriber + old_merchant | Atomically reassign subscription to a new merchant. Both parties must authorize. |
batch_execute_payment(merchant, token, subscribers) |
merchant | Collect payments from up to 50 subscribers in one transaction. Fee split applied per subscriber. |
get_subscription(subscriber, merchant, token) |
(none — read-only) | Return Some(SubscriptionData) if an active subscription exists, or None if it does not. |
get_subscription_count(merchant) |
(none — read-only) | Return the number of active subscriptions indexed for a given merchant. Returns 0 if none. |
| Parameter | Type | Valid range | Description |
|---|---|---|---|
amount |
i128 | (0, 10¹⁸] | Payment per interval in token's smallest unit (stroops). Must be strictly positive and ≤ 1,000,000,000,000,000,000. |
interval |
u64 | [86400, 31536000] | Seconds between payments. Minimum 1 day (86400 s), maximum 365 days (31536000 s). |
fee_bps |
u32 | [0, 500] | Protocol fee in basis points. 0 = no fee, 500 = 5% max. Set by admin via set_protocol_fee. |
grace_period |
Option | [0, ∞) | Optional seconds after payment due date before subscription can expire. Default: 0 (no grace period). |
strict |
bool | {true, false} | When true, rejects subscription if subscriber's SEP-41 allowance < amount. When false, issues a warning event. |
subscribe — authorize 100 tokens every 30 days:
stellar contract invoke \
--id $CONTRACT_ID --source alice --network testnet \
-- subscribe \
--subscriber GABC...ALICE \
--merchant GXYZ...MERCHANT \
--token CABC...USDC \
--amount 100 \
--interval 2592000 \
--strict false \
--grace-period 0import { Contract, nativeToScVal, Address } from "@stellar/stellar-sdk";
const op = contract.call(
"subscribe",
new Address(subscriber).toScVal(),
new Address(merchant).toScVal(),
new Address(tokenAddress).toScVal(),
nativeToScVal(100n, { type: "i128" }),
nativeToScVal(2592000n, { type: "u64" }),
nativeToScVal(false, { type: "bool" }),
nativeToScVal(null), // no grace period
);
// Expected: subscription stored, `subscribe` event emitted, first payment collectable immediately.
// Error cases:
// - AmountMustBePositive (code 1) if amount ≤ 0
// - AmountTooLarge (code 9) if amount > 10^18
// - IntervalTooShort (code 2) if interval < 86400
// - IntervalTooLong (code 3) if interval > 31536000
// - SelfSubscription (code 10) if subscriber == merchant
// - InsufficientAllowance if strict=true and allowance < amountsubscribe with grace period — allow up to 7 days late payment before expiry:
stellar contract invoke \
--id $CONTRACT_ID --source alice --network testnet \
-- subscribe \
--subscriber GABC...ALICE \
--merchant GXYZ...MERCHANT \
--token CABC...USDC \
--amount 5000 \
--interval 2592000 \
--strict true \
--grace-period 604800// grace_period = 604800 = 7 days in seconds
// If payment fails, subscription enters overdue state.
// After grace_period elapses, expire_subscription can be called to remove it.
const op = contract.call(
"subscribe",
new Address(subscriber).toScVal(),
new Address(merchant).toScVal(),
new Address(tokenAddress).toScVal(),
nativeToScVal(5000n, { type: "i128" }),
nativeToScVal(2592000n, { type: "u64" }),
nativeToScVal(true, { type: "bool" }),
nativeToScVal(604800n, { type: "u64" }), // 7 days grace period
);execute_payment — merchant collects a due payment (with protocol fee):
stellar contract invoke \
--id $CONTRACT_ID --source merchant-key --network testnet \
-- execute_payment \
--subscriber GABC...ALICE \
--merchant GXYZ...MERCHANT \
--token CABC...USDCconst op = contract.call(
"execute_payment",
new Address(subscriber).toScVal(),
new Address(merchant).toScVal(),
new Address(tokenAddress).toScVal(),
);
// Expected:
// - If protocol fee is 0%: 100 tokens → merchant
// - If protocol fee is 2.5% (250 bps):
// fee = 100 * 250 / 10_000 = 2 tokens
// merchant receives 98 tokens
// fee_collector receives 2 tokens
// - next_payment advanced by interval
// - `executed` event emitted with amount and payment_nonce
//
// Error cases:
// - NoActiveSubscription (code 4) if subscription not found
// - PaymentNotDue (code 5) if now < next_payment
// - TransferFailed (code 7) if subscriber balance < amount
// - SubscriptionPaused (code 12) if subscription is pausedexecute_payment — error case recovery:
// Scenario: subscriber has insufficient balance; payment fails
const result = await server.simulateTransaction(tx);
// Returns error: TransferFailed (code 7)
// Subscription remains ACTIVE with overdue_since timestamp set
// Merchant can retry execute_payment once subscriber adds balance
// After grace_period expires, admin or anyone can call expire_subscription
// If subscriber adds balance before grace period:
const retryOp = contract.call("execute_payment", ...);
// Second attempt succeeds, overdue_since is cleared, next_payment advancedcancel — subscriber terminates the agreement:
stellar contract invoke \
--id $CONTRACT_ID --source alice --network testnet \
-- cancel \
--subscriber GABC...ALICE \
--merchant GXYZ...MERCHANT \
--token CABC...USDC
# IMPORTANT: Also revoke the SEP-41 allowance to prevent future collections
stellar contract invoke \
--id $TOKEN_CONTRACT_ID --source alice --network testnet \
-- approve \
--from GABC...ALICE \
--spender $CONTRACT_ID \
--amount 0// Step 1: Remove subscription from contract
const op = contract.call(
"cancel",
new Address(subscriber).toScVal(),
new Address(merchant).toScVal(),
new Address(tokenAddress).toScVal(),
);
// Expected: subscription removed, `cancel` event emitted
// Step 2: Revoke allowance for added security
const tokenClient = new token.Client(env, tokenAddress);
tokenClient.approve(subscriber, contractAddress, BigInt(0));
// This is an additional layer of protection—even if the subscription
// re-appears due to a bug, no tokens can be transferred without new approval.
// Error case:
// - NoActiveSubscription (code 4) if subscription not found → idempotent, safe to retryupdate_subscription — modify terms without resetting billing cycle:
stellar contract invoke \
--id $CONTRACT_ID --source alice --network testnet \
-- update_subscription \
--subscriber GABC...ALICE \
--merchant GXYZ...MERCHANT \
--new-amount 150 \
--new-interval 1209600import {
Contract,
nativeToScVal,
Address,
} from "@stellar/stellar-sdk";
const op = contract.call(
"update_subscription",
new Address(subscriber).toScVal(),
new Address(merchant).toScVal(),
nativeToScVal(150n, { type: "i128" }), // new amount
nativeToScVal(1209600n, { type: "u64" }), // new interval (14 days)
);
// Expected:
// - Amount changed from 100 to 150
// - Interval changed from 30 days to 14 days
// - next_payment is PRESERVED — subscriber NOT charged immediately
// - `updated` event emitted with old and new values
// - TTL extended to ~365 days
//
// Typical use cases:
// - Subscriber upgrades plan: 100/mo → 150/mo
// - Subscriber downgrades plan: 100/mo → 50/mo
// - Annual to monthly billing: 1200/yr → 100/mo
//
// Error cases:
// - NoActiveSubscription (code 4) if subscription not found
// - AmountMustBePositive (code 1) if new_amount ≤ 0
// - AmountTooLarge (code 9) if new_amount > 10^18
// - IntervalTooShort (code 2) if new_interval < 86400
// - IntervalTooLong (code 3) if new_interval > 31536000batch_execute_payment — collect from multiple subscribers in one call:
stellar contract invoke \
--id $CONTRACT_ID --source merchant-key --network testnet \
-- batch_execute_payment \
--merchant GXYZ...MERCHANT \
--token CABC...USDC \
--subscribers GABC...SUB1 GDEF...SUB2 GHIJ...SUB3const op = contract.call(
"batch_execute_payment",
new Address(merchant).toScVal(),
new Address(tokenAddress).toScVal(),
new Vec(env, [
new Address("GABC...SUB1").toScVal(),
new Address("GDEF...SUB2").toScVal(),
new Address("GHIJ...SUB3").toScVal(),
]),
);
// Expected return: Vec<(Address, bool)> = [
// (GABC...SUB1, true), // payment successful
// (GDEF...SUB2, false), // payment skipped: not due or insufficient balance
// (GHIJ...SUB3, true), // payment successful
// ]
//
// Cost benefits:
// - One auth check covers all subscribers
// - Fee split applied per subscriber
// - Protocol fee deducted from each payment
// - Reduced total transaction fee vs. 3 individual calls
//
// Constraints:
// - Maximum 50 subscribers per batch
// - Empty subscribers list returns EmptyBatch error
// - Batch is partial-success: failed subscribers do not block others
//
// Error cases:
// - EmptyBatch if subscribers.is_empty()
// - BatchTooLarge if subscribers.len() > 50transfer_subscription — reassign subscription to new merchant (atomic):
stellar contract invoke \
--id $CONTRACT_ID --source alice --network testnet \
-- transfer_subscription \
--subscriber GABC...ALICE \
--old-merchant GXYZ...OLD_MERCHANT \
--new-merchant GNEW...NEW_MERCHANTconst op = contract.call(
"transfer_subscription",
new Address(subscriber).toScVal(),
new Address(oldMerchant).toScVal(),
new Address(newMerchant).toScVal(),
);
// Expected:
// - Subscription removed from old merchant's index
// - Subscription added to new merchant's index
// - All state preserved: token, amount, interval, next_payment
// - Atomic: either both changes commit or neither does
// - `subscription_transferred` event emitted
//
// Typical use cases:
// - Merchant key rotation: old_key → new_key
// - Business acquisition: subscriber's vendors merge
// - Account consolidation: old_merchant → admin account
//
// Authorization:
// - Both subscriber AND old_merchant must sign
// - Neither party alone can reassign the subscription
//
// Error cases:
// - NoActiveSubscription (code 4) if (subscriber, old_merchant) pair not found
// - SameMerchant if old_merchant == new_merchant (no-op)
// - SelfSubscription (code 10) if subscriber == new_merchant
// - SubscriptionAlreadyExists if (subscriber, new_merchant) pair already has active subscriptionget_subscription — read active subscription state without auth:
stellar contract invoke \
--id $CONTRACT_ID --network testnet \
-- get_subscription \
--subscriber GABC...ALICE \
--merchant GXYZ...MERCHANT \
--token CABC...USDCimport {
Contract,
SorobanRpc,
TransactionBuilder,
Networks,
Address,
scValToNative,
} from "@stellar/stellar-sdk";
const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
const contract = new Contract(CONTRACT_ID);
// Build a read-only simulation — no signing required.
const account = await server.getAccount(anyPublicKey);
const tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET })
.addOperation(
contract.call(
"get_subscription",
new Address(subscriber).toScVal(),
new Address(merchant).toScVal(),
new Address(tokenAddress).toScVal(),
)
)
.setTimeout(30)
.build();
const sim = await server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationSuccess(sim) && sim.result) {
const raw = scValToNative(sim.result.retval);
if (raw === null) {
console.log("No active subscription for this pair.");
} else {
// raw is an object matching SubscriptionData:
// {
// token: Address,
// amount: i128,
// interval: u64,
// next_payment: u64,
// is_paused: boolean,
// grace_period: u64,
// overdue_since: Option<u64>,
// payment_nonce: u32,
// }
console.log("Subscription:", raw);
console.log("Amount (stroops):", raw.amount);
console.log("Interval (seconds):", raw.interval);
console.log("Next payment:", new Date(Number(raw.next_payment) * 1000));
console.log("Overdue since:", raw.overdue_since ? new Date(Number(raw.overdue_since) * 1000) : "N/A");
console.log("Is paused:", raw.is_paused);
}
}
// Expected: returns the SubscriptionData struct or null (None) if no subscription exists.
// No wallet connection or signature needed — safe to call from any read-only context.get_subscription_count — enumerate active subscriptions for a merchant:
stellar contract invoke \
--id $CONTRACT_ID --network testnet \
-- get_subscription_count \
--merchant GXYZ...MERCHANTconst tx = new TransactionBuilder(account, { fee: "100", networkPassphrase: Networks.TESTNET })
.addOperation(
contract.call(
"get_subscription_count",
new Address(merchantAddress).toScVal(),
)
)
.setTimeout(30)
.build();
const sim = await server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationSuccess(sim) && sim.result) {
const count = scValToNative(sim.result.retval) as number;
console.log(`Merchant has ${count} active subscriber(s).`);
}
// Expected: u32 count of active subscriptions indexed for the merchant.
// Returns 0 when the merchant has no subscribers or the index has expired from temporary storage.When a protocol fee is configured via set_protocol_fee(admin, fee_bps, fee_collector), every execute_payment call splits the payment:
fee_bps = 250 (2.5%)
amount = 100 tokens
fee = amount × fee_bps / 10_000 = 100 × 250 / 10_000 = 2 tokens (integer division)
merchant_amount = amount - fee = 100 - 2 = 98 tokens
Transfer 1: subscriber → merchant for 98 tokens
Transfer 2: subscriber → fee_collector for 2 tokens
Key points:
- Fee is capped at 500 bps (5%) — admin cannot extract more than 5% per transaction.
- Fee rounds down; amounts < 200 tokens at 50 bps yield zero fee.
- Each transfer triggers a
fee_collectedevent. - Subscriber's SEP-41 allowance must cover the full
amount; the contract handles the split internally. batch_execute_paymentapplies the same fee split per subscriber.
| Error Code | Name | Trigger | Recovery |
|---|---|---|---|
| 1 | AmountMustBePositive |
amount ≤ 0 in subscribe or update_subscription |
Correct amount to be > 0 and resubmit |
| 2 | IntervalTooShort |
interval < 86400 (< 1 day) |
Set interval ≥ 86400 seconds |
| 3 | IntervalTooLong |
interval > 31536000 (> 365 days) |
Set interval ≤ 31536000 seconds |
| 4 | NoActiveSubscription |
Attempting payment/query on non-existent pair | Call subscribe to create; or check pair is correct |
| 5 | PaymentNotDue |
execute_payment called before now ≥ next_payment |
Wait until next_payment or check timestamp |
| 6 | Unauthorized |
Invalid or missing signature on restricted entry point | Ensure correct account signs via Freighter or CLI |
| 7 | TransferFailed |
Subscriber has insufficient balance for payment | Subscriber must deposit tokens and retry; or cancel |
| 9 | AmountTooLarge |
amount > 10^18 |
Reduce amount ≤ 1,000,000,000,000,000,000 |
| 10 | SelfSubscription |
subscriber == merchant in subscribe |
Use different addresses for subscriber and merchant |
| 12 | SubscriptionPaused |
execute_payment on paused subscription before resume time |
Wait until pause expires or cancel |
For the full parameter reference and additional administrative functions see docs/contract-api.md.
| Event | Topics | Data | Condition |
|---|---|---|---|
subscribe |
(symbol("subscribe"), subscriber, merchant, token) |
amount: i128 |
Always on success |
executed |
(symbol("executed"), subscriber, merchant, token) |
amount: i128 |
Successful transfer |
payment_transfer_failure |
(symbol("payment_transfer_failure"), subscriber, merchant) |
amount: i128 |
Insufficient balance detected before transfer |
cancel |
(symbol("cancel"), subscriber, merchant) |
() |
Always on success |
Events use a Symbol discriminant as the first topic. The data field is an i128 amount in stroops (or () for cancel).
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.
Current contract version:
1.0.0
Schema version:1
Entry point:get_version()→"1.0.0"|get_schema_version()→1
Soroban contracts are immutable once deployed. A contract address is forever bound to the WASM bytecode uploaded at deployment time. "Upgrading" the contract always means deploying a new contract address and migrating clients to it — there is no in-place code replacement.
The following are guaranteed stable across all v1.x.x releases:
| Surface | Stability guarantee |
|---|---|
Entry point names (subscribe, execute_payment, cancel, batch_execute_payment, get_subscription) |
Stable — never removed or renamed in v1 |
| Parameter order for all entry points | Stable — positional args will not shift |
| Parameter types for all entry points | Stable — Address, i128, u64, bool types are locked |
| Error code numbers (1–17) | Stable — codes will not be reassigned |
Event topic structure (subscribe, executed, cancel, payment_transfer_failure) |
Stable — topic 0 discriminant and topic order are locked |
SubscriptionData field names returned by get_subscription |
Stable — fields may be added but never removed |
| SEP-41 token interface assumptions | Stable — balance, transfer, allowance signatures are fixed |
A minor bump (e.g. 1.0.0 → 1.1.0) signals additive, backwards-compatible changes:
- New entry points — additional functions on the same contract.
- New optional parameters — added at the end of an existing entry point's parameter list. Existing callers that pass positional arguments to the functions up to the current last parameter are unaffected.
- New event types — off-chain indexers must ignore unknown event topics gracefully.
- New error codes — higher-numbered codes may be added; existing codes 1–17 remain.
- New
SubscriptionDatafields — the struct may gain new fields; off-chain code must not assume a fixed field count.
Minimum required change for existing clients after a minor bump: none. Existing transactions continue to work without modification.
A major bump (1.x.x → 2.0.0) means a breaking change. Examples that would force a major bump:
- Removing or renaming an entry point.
- Changing the type of an existing parameter.
- Reordering parameters of an existing entry point.
- Removing or reassigning an error code.
- Changing the topic structure of an existing event.
- Changing the storage key scheme in a way that invalidates existing persistent entries.
Major versions always require:
- Deploying a new contract address.
- Running the migration path described in docs/versioning.md.
- Coordinating client upgrades (frontend, backend, off-chain indexers).
Follow this checklist when extending the contract interface:
- Add new entry point at the bottom of
#[contractimpl] impl SubscriptionProtocol. Never reorder existing functions (the XDR ABI is position-independent but reordering is a footgun for tooling). - Use a new, distinct function name. Never overload an existing entry point.
- Append new parameters after all existing ones if extending an existing function signature. Never insert parameters in the middle — that shifts positional offsets for every existing caller.
- Register new error codes with unused numbers (currently ≥ 18). Never reuse a retired code.
- Emit new events with a new
Symboltopic discriminant. Off-chain consumers that filter for known topics will silently ignore unknown events — this is safe. - Increment
CONTRACT_VERSIONincontracts/subscription/src/storage.rs:- New entry point → bump minor:
"1.0.0"→"1.1.0". - New fields on
SubscriptionData→ bump minor and incrementCURRENT_SCHEMA_VERSION. - Breaking change → bump major:
"1.x.x"→"2.0.0".
- New entry point → bump minor:
- Update
docs/contract-api.mdwith the new entry point's full parameter table, error cases, and CLI/TypeScript examples. - Update
CHANGELOG.mdunder the[Unreleased]heading.
SubscriptionData is stored as XDR on the ledger. Because Soroban serializes #[contracttype] structs by field order, append-only additions are safe on a fresh deployment but would silently corrupt reads from existing entries on an upgraded contract. The safe procedure is:
- Only add fields at the end of the struct definition in
storage.rs. - Increment
CURRENT_SCHEMA_VERSION(currently1→2). - Provide a
migrate(admin)migration path that reads existing entries under the old schema and rewrites them with default values for the new field, if entries must survive across deployments. - For a fresh deployment (new contract address, no existing state), no migration is needed — all entries will be created with the full new struct from day one.
The frontend (frontend/src/lib/transaction_builder.ts) calls subscribe with five positional ScVal arguments. When the contract interface changes:
| Change type | Frontend impact | Required action |
|---|---|---|
| New entry point added | None | No frontend change needed unless the new feature is surfaced in UI |
New optional parameter appended to subscribe |
None — existing five-arg call still valid | Pass new arg only when the UI exposes it |
| Parameter type change | Breaking — ScVal encoding must be updated | Bump major version; update buildAndSubmitSubscribe |
| Parameter removed | Breaking | Bump major version; remove from builder |
| New event type emitted | None for transaction builder | Backend indexer must handle unknown event topics |
To verify client–contract compatibility at runtime, call the read-only version helpers before submitting a transaction:
import { Contract, SorobanRpc } from "@stellar/stellar-sdk";
const server = new SorobanRpc.Server(rpcUrl);
const contract = new Contract(contractId);
// Read version string from on-chain entry point
const versionResult = await server.simulateTransaction(
buildVersionQueryTx(contract, account, networkPassphrase)
);
// Expected: "1.0.0"
const version = scValToNative(versionResult.result?.retval);
const [major] = String(version).split(".").map(Number);
if (major !== 1) {
throw new Error(`Unsupported contract version: ${version}. This client requires v1.x.x.`);
}See docs/versioning.md for the full migration guide, including how to run migrate(admin) after deploying a schema-version bump.
Soroban charges fees based on CPU instructions, memory bytes, and ledger entry reads/writes. All three entry points are computationally O(1) — they touch a fixed number of storage entries and make no loops — but they differ meaningfully in cost because execute_payment crosses into an external token contract.
Operations performed:
- 1
require_authonsubscriber - 5 input validations (amount bounds, interval bounds, timestamp guard)
- 1 persistent storage write (
SubscriptionDatastruct, ~5 fields) - 1 TTL extension (
extend_ttlon the same entry) - 1 event publish (
subscribe, 4 topics + i128 data)
This is a pure write with no cross-contract calls. Expect roughly 50,000–150,000 CPU instructions under normal conditions. The dominant cost is the auth verification and the persistent storage write (ledger entry write fee).
Budget guidance:
- Inclusion fee: standard (100 stroops is usually sufficient on testnet; 1,000–10,000 stroops on mainnet during normal congestion)
- Resource fee: set
instructionsto at least 150,000 andwrite_bytesto at least 300 - The Stellar CLI and SDKs can simulate the transaction first (
simulateTransaction) to get exact values
Operations performed:
- 1
require_authonmerchant - 1 persistent storage read
- 1 ledger timestamp read
- 1 cross-contract
balancecall on the SEP-41 token contract - 1 cross-contract
transfercall on the SEP-41 token contract (the most expensive operation) - 1 persistent storage write (updated
next_payment) - 1 TTL extension
- 1 event publish (
executedorpayment_transfer_failure, depending on outcome)
The two cross-contract calls — especially transfer, which itself performs auth checks, balance reads, and two storage writes inside the token contract — are what make this the most expensive entry point. Soroban charges for every instruction executed within invoked contracts, not just the top-level caller.
Budget guidance:
- Resource fee: set
instructionsto at least 500,000 andwrite_bytesto at least 500 - Always run
simulateTransactionbefore broadcasting — the simulation returns exactinstructions,readBytes, andwriteBytesvalues - If the subscriber has insufficient balance, the contract returns
TransferFailedearly (after thebalanceread but beforetransfer) and emitspayment_transfer_failure. This path is slightly cheaper than a successful transfer since the token'stransferis never invoked
Operations performed:
- 1
require_authonsubscriber - 1 persistent storage
hascheck (read) - 1 persistent storage
remove - 1 event publish (
cancel, 2 topics + unit data)
No cross-contract calls, no writes to new keys. Removing a persistent entry reduces ledger size, which may earn a small rent refund. This is the cheapest of the three entry points.
Budget guidance:
- Resource fee: set
instructionsto at least 50,000 andwrite_bytesto at least 100 - In practice the
simulateTransactionresult will likely be even lower
execute_payment > subscribe > cancel
(cross-contract (write + (read +
transfer) TTL extend) remove)
Never hardcode fee values for production. Always simulate:
# Simulate a subscribe call and inspect the fee breakdown
stellar contract invoke \
--id <CONTRACT_ID> \
--network testnet \
--simulate-only \
-- subscribe \
--subscriber <SUBSCRIBER_ADDRESS> \
--merchant <MERCHANT_ADDRESS> \
--token <TOKEN_ADDRESS> \
--amount 1000000 \
--interval 86400Or via the JavaScript SDK:
import { SorobanRpc, TransactionBuilder, Networks } from "@stellar/stellar-sdk";
const server = new SorobanRpc.Server("https://soroban-testnet.stellar.org");
// Build the transaction, then simulate before signing
const simResult = await server.simulateTransaction(tx);
if (SorobanRpc.Api.isSimulationSuccess(simResult)) {
console.log("Min resource fee:", simResult.minResourceFee); // in stroops
console.log("CPU instructions:", simResult.transactionData.resources().instructions());
console.log("Write bytes:", simResult.transactionData.resources().writeBytes());
}The minResourceFee from simulation is the floor. Add a 10–25% buffer on instructions for safety — network-level variance (e.g., host version upgrades) can shift costs slightly between simulation and submission.
subscribe and execute_payment both call extend_ttl to keep the subscription entry alive:
- Minimum TTL: ~30 days (518,400 ledgers at 5 s/ledger)
- Maximum TTL: ~365 days (6,307,200 ledgers)
The TTL extension adds a rent fee proportional to the number of ledgers being extended and the size of the entry. For most subscriptions the entry is small (~200 bytes), so rent is a minor fraction of the total fee. If a subscription entry expires (TTL reaches zero) before cancel is called, it will be evicted from the ledger; a new subscribe call will recreate it.
Failed calls that return a ContractError (e.g., PaymentNotDue, NoActiveSubscription, TransferFailed) still consume fees for the work performed up to the point of the error. The transaction is included in the ledger as a failed invocation. Budget accordingly:
| Scenario | Fee relative to success |
|---|---|
execute_payment → PaymentNotDue |
~10–20% of full cost (only auth + storage read before early return) |
execute_payment → TransferFailed |
~60–80% of full cost (balance cross-contract call completed, transfer skipped) |
subscribe → validation error |
~10–15% of full cost (auth + validation only, no write) |
cancel → NoActiveSubscription |
~10% of full cost (auth + storage has check only) |
| 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 |
| 7 | TransferFailed |
Insufficient subscriber balance at payment time |
| 8 | InvalidTimestamp |
Ledger timestamp is zero or would overflow |
| 9 | AmountTooLarge |
amount > 10¹⁸ in subscribe |
| 10 | SelfSubscription |
subscriber == merchant in subscribe |
| 11 | InvalidTokenAddress |
token is the contract's own address in subscribe |
| 12 | SubscriptionPaused |
Payment attempted while a subscription is paused |
SorobanPay emits structured events via Soroban RPC for off-chain indexing. The contract publishes four 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.payment_transfer_failure— Emitted when a payment attempt fails due to insufficient subscriber balance. The subscription remains active and is eligible for retry.cancel— Emitted after a subscription is successfully removed. Provides an explicit, reliable signal for off-chain indexers to mark the relationship as ended.
| 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[, token_address])— enables filtering by party or event type - Data:
amount: i128(or()forcancel) — 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. - Use
cancelevents to immediately mark subscriptions inactive; usepayment_transfer_failureevents to flag subscriptions for retry logic. - 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.
Soroban persistent storage entries are not kept forever. The Soroban host tracks a Time-To-Live (TTL) for every persistent entry measured in ledgers, not wall-clock seconds. When the TTL reaches zero the entry expires and any read of that key returns None — the subscription record is effectively gone.
A subscription is stored as a single persistent entry keyed by (subscriber, merchant). If that entry expires between payment cycles the next call to execute_payment will return ContractError::NoActiveSubscription, even though the subscriber never cancelled. For monthly (30-day) or annual (365-day) billing intervals this is a real operational risk without deliberate TTL management.
SorobanPay prevents this with an extend_ttl call every time a subscription is written:
subscribe— sets or resets the TTL when a subscription is created or updated.execute_payment— extends the TTL after each successful payment transfer.
Neither cancel nor failed payment attempts touch the TTL, since cancel removes the entry entirely and a failed payment should not silently keep a problematic record alive.
| Constant | Ledgers | Approximate wall-clock time |
|---|---|---|
MIN_TTL_LEDGERS |
518 400 | ~30 days (30 × 24 × 60 × 60 ÷ 5 s/ledger) |
MAX_TTL_LEDGERS |
6 307 200 | ~365 days (365 × 24 × 60 × 60 ÷ 5 s/ledger) |
The extend_ttl(key, threshold, max) call works as follows: if the entry's remaining TTL is already above threshold (MIN_TTL_LEDGERS), the host does nothing — avoiding unnecessary fee spend. Otherwise it bumps the TTL up to max (MAX_TTL_LEDGERS). The net effect is that every active subscription is always guaranteed at least ~30 ledger-days of remaining lifetime, and at most ~365 days are ever charged.
subscribe() ──────────────────────────────────────► TTL = MAX (~365 days)
│
execute_payment() ──────────────────────► TTL reset to MAX (~365 days)
│
execute_payment() ──────────────────────► TTL reset to MAX (~365 days)
│
(no activity for > 365 days)
│
subscription entry expires ────────────► reads return None
│
execute_payment() ──────────────────────► ContractError::NoActiveSubscription
For yearly billing (interval = 31 536 000 s = 365 days) the storage TTL is refreshed on each payment, so an active annual subscription is never at risk of expiry. A subscription that goes a full year without a successful payment (e.g., the subscriber consistently has insufficient balance) will expire naturally once the 365-day TTL window is exhausted. This is intentional: stale, non-paying subscriptions are automatically garbage-collected by the Soroban host rather than accumulating permanently on-chain.
The TTL constants assume a 5-second average ledger close time, which is the Stellar mainnet target. If the network sustains a faster or slower close time for an extended period the effective wall-clock durations will drift. The ledger counts remain authoritative; the "30 days" and "365 days" labels are approximations.
SorobanPay is designed around three core principles: non-custody, per-invocation authorization, and time-locked collection. This section summarises the on-chain security model. The full reference — including the authorization audit, circuit-breaker runbook, backend secrets management, and known limitations — is in docs/security.md.
The contract never holds token balances. Every payment transfer goes directly subscriber → merchant via the SEP-41 transfer() call. There is no treasury address, no escrow wallet, and no contract-level balance to drain. A compromised contract instance cannot move tokens it does not hold.
Every entry point calls require_auth() as its first statement — before any storage reads, logging, or cross-contract calls. The Soroban host, not application logic, enforces this: a missing or invalid signature aborts the entire transaction before any code executes.
| Entry point | Who must authorize |
|---|---|
subscribe |
subscriber |
execute_payment |
merchant |
batch_execute_payment |
merchant |
cancel |
subscriber |
get_subscription, get_version |
(no auth — read-only) |
Subscribers grant a SEP-41 allowance to the contract address. The contract's execute_payment calls token.transfer(subscriber, merchant, amount) using that allowance. Revoking the allowance with token.approve(contract_address, 0) immediately prevents all future collections — regardless of whether the on-chain subscription record still exists. This gives subscribers a unilateral, no-contract-call emergency stop.
SorobanPay supports an optional on-chain protocol fee configured by the contract admin via set_protocol_fee(admin, fee_bps, fee_collector).
Fee split mechanics:
When fee_bps > 0, every execute_payment call splits the payment into two transfers:
fee = amount * fee_bps / 10_000 (integer division — rounds down)
merchant_amount = amount - fee
transfer 1: subscriber → merchant for merchant_amount
transfer 2: subscriber → fee_collector for fee
When fee_bps = 0 (the default) only one transfer is made and behavior is identical to the no-fee baseline.
Constraints and abuse prevention:
| Constraint | Value |
|---|---|
Maximum fee_bps |
500 (5 %) |
set_protocol_fee requires |
admin signature |
| Fee config stored | instance storage (upgradeable by admin only) |
The 500 bps cap prevents admin abuse: even a compromised admin key cannot extract more than 5 % of any payment. The subscriber's allowance model (see below) remains the unilateral emergency stop — revoking the SEP-41 allowance blocks all transfers regardless of fee configuration.
Integer division truncation: fee rounds down toward zero. For example, 1 token at 50 bps yields fee = 0 (the merchant receives the full token). The first non-zero fee at 50 bps occurs at 200 tokens (200 * 50 / 10_000 = 1).
Events: a fee_collected event is emitted after each successful fee transfer, with topics (symbol("fee_collected"), subscriber, merchant, fee_collector) and data fee_amount: i128.
execute_payment checks now >= next_payment using the Soroban ledger timestamp before attempting any transfer. The timestamp is set by network validators and cannot be manipulated by the transaction submitter. Merchants cannot collect payments early or double-collect within a billing window.
Subscription records are persistent storage entries with a TTL of ~30 days minimum and ~365 days maximum. Each successful payment resets the clock to the maximum. Entries that expire (after ~365 days of no successful payments) are garbage-collected by the Soroban host and cannot be paid against — stale, non-paying subscriptions do not accumulate on-chain indefinitely. See Storage TTL for full semantics.
subscribe validates all inputs before touching storage, including self-subscription prevention (subscriber == merchant), amount bounds (0 < amount ≤ 10¹⁸), interval bounds (86400 ≤ interval ≤ 31536000), and timestamp overflow guards. See Error codes for the full list.
The optional off-chain backend polls getEvents() but never submits token transfers. If the backend is compromised, an attacker can read subscription state and payment history — they cannot move tokens or modify on-chain subscriptions.
For guidance on storing backend secrets safely (database credentials, RPC API keys, webhook secrets), see docs/security.md.
| Document | Description |
|---|---|
| docs/faq.md | Frequently asked questions for integrators |
| docs/deployment.md | Production deployment guide (mainnet, Docker, Kubernetes, monitoring) |
| docs/saas-integration-guide.md | End-to-end SaaS billing integration guide with Node.js examples |
- SaaS billing — See docs/saas-integration-guide.md for a complete walkthrough: contract deployment, event indexing, webhooks, plan changes, cancellations, and revenue reporting.
- Creator subscriptions — Fans grant a one-time allowance; creators collect recurring payments on-chain without custodial wallets.
- Recurring donations — DAOs and nonprofits accept on-chain pledges with configurable intervals (daily to annual).
cd frontend
npm run storybookOpens Storybook at http://localhost:6006. Stories are available for all UI components including SubscriptionForm, SuccessCard, WalletBadge, skeleton loaders, and error boundary fallback. Each story includes accessibility checks via the axe-core panel.
Build a static Storybook:
cd frontend
npm run storybook:buildWe 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:
make lint(contract) andnext 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 |
dependencies |
Dependency updates (Dependabot) |
security |
Security advisories and vulnerability fixes |
major-update |
Major-version bump requiring manual review |
Dependabot is configured to open pull requests for outdated dependencies every Monday:
| Ecosystem | Directory | Schedule | Grouping |
|---|---|---|---|
| npm | frontend/ |
Weekly (Monday) | @stellar/* grouped into one PR |
| npm | backend/ |
Weekly (Monday) | — |
| Cargo | contracts/subscription/ |
Weekly (Monday) | — |
| GitHub Actions | / |
Monthly | — |
Merge policy:
- Patch and minor updates — automatically approved and squash-merged once all CI checks pass. No manual action required.
- Major updates — opened as a PR with the
major-updatelabel and left for manual review. CI must still pass before merge. - GitHub Actions updates — automatically approved and squash-merged (Actions use immutable tag or SHA pins; breaking changes do not follow semver).
Weekly security scanning (OPS-121):
A separate security-audit workflow runs every Monday at 04:00 UTC independently of Dependabot PRs:
npm audit --audit-level=highin bothfrontend/andbackend/cargo auditincontracts/subscription/
If any HIGH or CRITICAL advisory is found, the workflow fails and automatically opens a GitHub issue labelled security + dependencies so the team is alerted immediately. Audit reports are uploaded as workflow artifacts for detailed inspection.
Responding to security issues:
- Check the opened issue for the advisory details and CVE link.
- For npm: run
npm audit fixin the relevant directory, or pin to a safe version manually. - For Cargo: update the crate version in
Cargo.toml, runcargo update, and commit the updatedCargo.lock. - If no fix exists yet, add an
[advisories]ignore entry inaudit.tomlwith a written justification and a link to the upstream issue. - Close the GitHub issue once the advisory is resolved.
| Guide | Description |
|---|---|
| Soroban Events API | Comprehensive guide to all contract events: topics, payloads, integration examples |
| Storage TTL and Subscription Lifetime | Complete guide to storage TTL management, subscription lifecycle, and cost implications |
| Storage TTL Management | Detecting at-risk entries, extending TTL programmatically, alert thresholds |
| Network Configuration | Testnet vs. mainnet side-by-side, common mistakes, switching guide |
| Backend API Cookbook | 8 recipes: auth, subscriptions, webhooks, CSV export, MRR, TTL health |
| Release Process | Versioning rules, release note template, changelog process, step-by-step checklist |
| Freighter Troubleshooting | Connection issues, signing failures, rejected transactions, contract errors, diagnostic checklist |
| Changelog | Version history following Keep a Changelog format |
MIT