Soroban smart contracts for Stellabill — prepaid USDC subscription billing on the Stellar network. This repository contains the on-chain logic for recurring payments, subscriber vaults, and merchant payouts.
For a runnable off-chain example, see the reference event indexer, which captures Soroban contract events in a local SQLite database.
- What’s in this repo
- Prerequisites
- Local setup
- Build, test, and deploy
- Contributing (open source)
- Project layout
- License
A single Soroban contract that implements a prepaid subscription vault for recurring USDC billing:
| Concept | Description |
|---|---|
| Subscriber | User who holds a subscription; funds are held in the contract (vault) for that subscription. |
| Merchant | Recipient of recurring payments; can withdraw accumulated USDC. |
| Subscription | Agreement between subscriber and merchant: amount, billing interval, status (active/paused/cancelled), and prepaid balance. |
Main capabilities (current / planned):
| Method | Signature | Auth | Docs |
|---|---|---|---|
version |
version(env: Env) -> u32 |
— | — |
init |
init(env: Env, token: Address, admin: Address, min_topup: i128) |
— | lifecycle |
create_subscription |
create_subscription(env: Env, subscriber: Address, merchant: Address, amount: i128, interval_seconds: u64, expiration: Option<u64>, usage_enabled: bool) -> u32 |
subscriber | lifecycle |
deposit_funds |
deposit_funds(env: Env, subscription_id: u32, subscriber: Address, amount: i128) |
subscriber | lifecycle |
charge_subscription |
charge_subscription(env: Env, subscription_id: u32) |
admin | lifecycle |
batch_charge |
batch_charge(env: Env, subscription_ids: Vec<u32>) -> BatchChargeResult |
admin | lifecycle |
cancel_subscription |
cancel_subscription(env: Env, subscription_id: u32, authorizer: Address) |
subscriber or merchant | lifecycle |
pause_subscription |
pause_subscription(env: Env, subscription_id: u32, authorizer: Address) |
subscriber or merchant | lifecycle |
resume_subscription |
resume_subscription(env: Env, subscription_id: u32, authorizer: Address) |
subscriber or merchant | lifecycle |
withdraw_merchant_funds |
withdraw_merchant_funds(env: Env, merchant: Address, amount: i128) |
merchant | lifecycle |
get_subscription |
get_subscription(env: Env, subscription_id: u32) -> Subscription |
— | — |
Types:
Subscription
| Field | Type | Description |
|---|---|---|
subscriber |
Address |
Owner of the subscription; must auth create and deposit. |
merchant |
Address |
Recipient of charges. |
amount |
i128 |
Charge per interval (in token base units). |
interval_seconds |
u64 |
Minimum time between charges. |
last_payment_timestamp |
u64 |
Ledger timestamp of last successful charge. |
status |
SubscriptionStatus |
Lifecycle state; see state machine below. |
prepaid_balance |
i128 |
Current balance; increased by deposit, decreased by charge. |
expiration |
Option<u64> |
Optional ledger timestamp after which the subscription is treated as expired. None means no expiry. |
usage_enabled |
bool |
Usage-billing flag; reserved for future use. |
SubscriptionStatus — Active, Paused, Cancelled, InsufficientBalance, GracePeriod.
See subscription_lifecycle.md for the full state machine and transition rules.
Error (selected variants — see docs/errors.md for the canonical table with numeric codes):
| Variant | Category | When |
|---|---|---|
Unauthorized |
Auth | Required signer or admin mismatch. |
NotFound |
Not found | Subscription id or resource is missing. |
NotInitialized |
Not found | Contract has not been initialized. |
InvalidAmount |
Invalid args | Amount is zero or negative. |
InvalidStatusTransition |
State | Lifecycle transition not permitted from current status. |
NotActive |
State | Operation requires Active subscription. |
SubscriptionExpired |
State | Subscription has passed its expiration timestamp. |
IntervalNotElapsed |
State | Charge attempted before interval has elapsed. |
InsufficientBalance |
Accounting | Vault balance is insufficient. |
BelowMinimumTopup |
Accounting | Deposit is below the configured min_topup threshold. |
Documentation: Subscription lifecycle and state machine — states, transitions, on-chain representation, and invariants. Error codes — canonical error taxonomy with numeric codes and retry guidance.
Implementation status:
contracts/subscription_vault/src/lib.rscurrently exposes only theversion()stub while the full implementation is rewritten on a future branch. The method table and type definitions above reflect the intended API as methods land. See the inline doc-comment inlib.rsfor context.
- Rust (latest stable, e.g. 1.75+):
https://rustup.rs
rustup default stable - Soroban CLI:
https://developers.stellar.org/docs/tools/soroban-cli/install
Used to build WASM and run tests/deploy. - Stellar / Soroban basics:
https://developers.stellar.org/docs
Optional but helpful for contributing.
git clone https://github.com/YOUR_ORG/0b33.git
cd 0b33(Replace YOUR_ORG with the actual org or user.)
- Install Rust:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | shthenrustup default stable. - Install Soroban CLI per the official install guide.
rustc --version
cargo --version
soroban --versioncargo build
cargo testFrom the repo root, this builds the workspace and runs the contract unit tests (including subscription_vault tests in contracts/subscription_vault/src/test.rs).
soroban contract buildThis produces the WASM under target/ for deployment to Stellar (e.g. testnet/mainnet via Soroban CLI or your CI/CD).
Set up the repository pre-commit hook to enforce code formatting and linting on staged files before every commit:
./scripts/install_git_hooks.shThe installer is idempotent — safe to run repeatedly. It:
- Sets the executable bit on all scripts under
.githooks/. - Runs
git config core.hooksPath .githooks(no global config changes). - Smoke-tests the hook to confirm it exits 0.
After installation, every
git commitautomatically runs:
| Check | Command | Scope |
|---|---|---|
| Formatting | cargo fmt --check |
staged .rs files only |
| Linting | cargo clippy --all-targets -- -D warnings |
full workspace |
Bypass options:
git commit --no-verify # standard Git flag, no logging
SKIP_PRE_COMMIT=1 git commit # env-var bypass, prints a warning to stderrVerify the installation is correct without modifying anything:
./scripts/install_git_hooks.sh --checkNote:
cargo clippyalways runs against the full workspace (not only staged files) because clippy operates at crate granularity. A change in one file can surface a lint in a test that imports it, so the broader scope is intentional.
| Task | Command |
|---|---|
| Build workspace | cargo build |
| Run tests | cargo test |
| Build contract WASM | soroban contract build |
| Install Git hooks | ./scripts/install_git_hooks.sh |
| Verify hook installation | ./scripts/install_git_hooks.sh --check |
| One-command local deploy | ./scripts/deploy_local.sh |
| Run with Soroban CLI (e.g. testnet) | See Stellar docs for soroban contract deploy and invoke. |
scripts/deploy_local.sh automates the full workflow:
building the contract, starting a local Soroban network (via Docker
stellar/quickstart), deploying, initializing, and running a smoke test.
./scripts/deploy_local.sh # full flow
./scripts/deploy_local.sh --skip-build # reuse existing WASM
./scripts/deploy_local.sh --skip-smoke # deploy + init only
./scripts/deploy_local.sh --help # full option referencePrerequisites: rust, soroban/stellar CLI, docker, and curl (all checked at startup).
The script creates three test identities (admin, subscriber, merchant),
wraps the native Stellar asset as a test token, and validates the deployment
by creating, funding, and charging a subscription.
scripts/generate_error_table.py is a code-generation utility that produces the canonical error-code cross-reference table by:
- Parsing
contracts/subscription_vault/src/types.rsto extract error variants and numeric codes. - Grepping all
.rssource files to identify which contract entrypoints emit each error. - Splicing the generated table into the "## Entrypoint Cross-Reference" section of
docs/errors.md.
Usage:
python scripts/generate_error_table.py # Generate and update docs/errors.md in-place
python scripts/generate_error_table.py --check # Check mode: fail if docs/errors.md would change (used by CI)The generator is deterministic; when run without changes to the error enum or emitting code, it produces no output diff. The generated table is committed to the repository as the single source of truth for error codes and recommended client retry handling.
We welcome contributions from the community. Here’s how to get started and how we work.
- Read this README and the Stellar / Soroban docs.
- Check GitHub Issues for “good first issue” or “help wanted” labels.
- If you want to change behavior or add a feature, open an issue first so we can align on design.
- Fork the repo on GitHub and clone your fork.
- Create a branch from
main(or default branch):
git checkout -b feature/your-featureorfix/your-fix. - Set up locally as in Local setup. Run
./scripts/install_git_hooks.shto enable pre-commit checks (cargo fmt --checkandcargo clippy). Runcargo testandcargo buildto ensure everything passes. (Note: pre-commit hooks can be bypassed when necessary viagit commit --no-verify). - Make changes in small, logical commits. Keep messages clear (e.g. “Add admin check to charge_subscription”, “Fix subscription id overflow”).
- Run tests and build before pushing:
cargo test && cargo buildand, if you touch contract interface,soroban contract build. - Push to your fork and open a Pull Request against the upstream
main(or default branch).
- Scope: One logical change per PR when possible (easier review and atomic history).
- Tests: New behavior should be covered by unit tests in the contract crate; existing tests must stay green.
- Docs: If you add or change a public function or type, update the README or inline docs as needed.
- Description: Use the PR description to explain the “why” and how to verify (e.g. steps or test commands).
- Rust: Follow common Rust style (
cargo fmt,cargo clippy). Nounwrap()in contract logic without a clear justification; preferResultand explicit errors. - Soroban: Use
Envfor storage and auth; keep contract functions narrow and well-documented. - Security: Any change that touches auth, token transfers, or admin rights will get extra review. When in doubt, open an issue first.
- Questions: Open a GitHub Discussion or an issue with the “question” label.
- Bugs: Open an issue with steps to reproduce, environment (Rust/Soroban versions), and logs if relevant.
- Ideas: Use Discussions or an issue with “enhancement” so we can track and discuss.
We expect all contributors and maintainers to be respectful and inclusive. By participating, you agree to uphold a constructive and professional environment. Specific CoC details (if any) will be linked in the repo (e.g. CODE_OF_CONDUCT.md or in the GitHub community guidelines).
0b33/
├── Cargo.toml # Workspace root; lists contract crates
├── Cargo.lock # Locked dependencies (reproducible builds)
├── README.md # This file
├── .gitignore
├── docs/ # Contract documentation
│ ├── subscription_lifecycle.md # Subscription lifecycle, state machine, on-chain representation
│ ├── subscription_state_machine.md
│ ├── batch_charge.md
│ ├── billing_intervals.md
│ ├── topup_estimation.md
│ └── safe_math.md
└── contracts/
└── subscription_vault/ # Prepaid subscription vault contract
├── Cargo.toml
└── src/
├── lib.rs # Contract logic and types
└── test.rs # Unit tests
See the LICENSE file in this repository (add one if not present). Contributions are accepted under the same license.