A production-ready Soroban smart contract that combines a time-locked cliff with linear token streaming for long-term contributor retention on the Stellar network.
Coming from standard Drips? See the comparison guide for a feature table, cancel behaviour details, and migration instructions.
Setting up a local development environment? See the Developer Onboarding Guide for step-by-step instructions.
Have a question? Check the FAQ for common answers about stream lifecycle, claiming, token support, and fees.
Standard Drips streams begin releasing tokens immediately. This contract adds a mandatory cliff period before any tokens can be claimed, ensuring contributors remain aligned with the project before unlocking value.
For the formal lifecycle model, transition table, and error-to-state mapping, see docs/flows.md.
Token Flow
──────────────────────────────────────────────────────────────────────
Ledger: start_ledger cliff_ledger end_ledger
│ │ │
Tokens: │ [locked] │ ← instant catch-up claim → │ ← linear drip ──┤
│ │ │
- Sponsor deposits the full allocation upfront into the contract vault.
- Recipient cannot claim anything until
cliff_ledgeris reached. - At the cliff, all tokens accrued since
start_ledgerare released instantly. - Remaining tokens continue to drip linearly per ledger until
end_ledger.
.
├── Cargo.toml # Package manifest & dependencies
├── Makefile # Build / test / lint / mutants helpers
├── README.md
├── .cargo-mutants.toml # Mutation testing exclusions & config
├── .gitignore
├── docs/
│ ├── architecture.md # Full-stack system architecture & Mermaid diagrams
│ └── mutation/
│ └── report.md # Mutation testing results
├── scripts/
│ ├── deploy.sh # Build + optimize + deploy to testnet
│ ├── invoke_create.sh # CLI helper: create_vesting_stream
│ └── invoke_claim.sh # CLI helper: claim_vested
└── src/
├── lib.rs # Crate root & module declarations
├── contract.rs # Contract entry-points (public API)
├── types.rs # VestingSchedule & DataKey types
├── error.rs # VestingError enum (contracterror)
├── events.rs # Structured event helpers
├── storage.rs # Persistent storage read/write/TTL helpers
└── tests/
├── mod.rs # Shared test env helpers
├── token_helper.rs # SAC token creation & minting
├── test_create.rs # Stream creation tests
├── test_claim.rs # Claim / vesting logic tests
├── test_cancel.rs # Cancellation & refund tests
├── test_views.rs # Read-only view function tests
├── test_edge_cases.rs # Boundary & integration scenarios
├── test_clawback.rs # Clawback compliance tests (#317)
├── test_drain.rs # Drain expired stream tests (#316)
└── test_min_deposit.rs # Minimum deposit validation tests (#314)
A comprehensive full-stack architecture diagram, data flow sequences (creation, claim, cancel), backend service component breakdowns, and persistent storage layout diagrams are documented in docs/architecture.md.
For the full event schema with field types, topic discriminators, and XDR/JSON examples for indexers, see docs/events.md.
flowchart TD
UI["Web Application (UI)"] -->|"Simulate & Sign"| Wallet["Stellar Wallet"]
Wallet -->|"Submit Transaction"| RPC["Soroban RPC Node"]
RPC -->|"Execute Host Call"| Contract["VestingDrips Contract"]
Indexer["Backend Event Indexer"] -->|"Poll Events"| Horizon["Horizon API"]
Indexer -->|"Persist Activity"| DB[("PostgreSQL DB")]
UI -->|"Query Indexed Data"| API["Backend API Server"]
API --> DB
Key design decisions (storage layout, rate type, cliff math, error codes, TTL strategy) are documented in docs/adr/.
For information about reporting vulnerabilities and our security policy, please see SECURITY.md.
Terraform-managed AWS infrastructure (ECS, RDS, VPC, IAM). Configuration lives in terraform/.
A scheduled GitHub Actions workflow runs
terraform plan -detailed-exitcode daily at 02:00 UTC against the selected
environment's remote state, authenticating to AWS with OIDC (no static access keys).
workflow_dispatch allows an on-demand run against staging or production. The
outcome is classified as clean, drift, or error:
- Drift — one workflow-managed issue (labels
infrastructure+drift) is created or updated in place with the plan excerpt and a link to the full plan artifact, and Slack#opsis alerted with the plan summary. - Clean — workflow-managed drift issues are commented on and closed; manually opened issues are never touched.
- Error — the run fails, open drift issues stay open, and Slack
#opsis alerted.
State lives in a per-environment S3 bucket with DynamoDB locking, AES-256 encryption, versioning, public access blocked, and MFA delete. Setup, backend migration, stale-lock handling, and state recovery are documented in Terraform Bootstrap.
| Runbook | Purpose |
|---|---|
| Terraform Bootstrap | Remote state bootstrap, backend migration, stale locks, state recovery, MFA delete |
| Drift Reconciliation | How to evaluate, approve, or reject detected drift |
| Emergency Override | Manual infrastructure changes with required post-hoc Terraform update |
| RDS Restore | Database snapshot restore procedure |
| Backup Restore Verification | Weekly automated proof that backups restore; run one on demand |
| Disaster Recovery | Full system recovery scenarios |
| Backfill Stream Events | Replay Horizon events into stream_events after indexer downtime or decoder fix |
See the full runbooks index for all operational procedures.
pub fn create_vesting_stream(
env: Env,
sponsor: Address, // must sign; pays the deposit
recipient: Address, // beneficiary
token: Address, // SAC token contract
rate: i128, // tokens per ledger (> 0)
cliff_duration: u32, // ledgers until cliff
total_duration: u32, // total stream length (> cliff_duration)
) -> Result<u32, VestingError> // newly allocated stream_idStream IDs start at 0 for new recipients and increase per recipient. Existing deployments read their legacy single schedule as stream ID 0; newly created streams receive subsequent IDs. Validates that rate × total_duration ≥ min_deposit (configurable, default 100).
cliff_duration must be at least 1; a zero-length cliff is rejected with
InvalidCliffDuration (code 12) because it provides no lockup guarantee.
pub fn claim_vested(
env: Env,
recipient: Address,
stream_id: Option<u32>, // None claims all active streams
) -> Result<i128, VestingError>Returns the total amount transferred across the selected stream(s). Streams use their own token; pass Some(stream_id) to claim one stream or None to claim all claimable streams.
pub fn cancel_stream(
env: Env,
sponsor: Address,
recipient: Address,
stream_id: u32,
) -> Result<(), VestingError>Cancels the stream. If the cliff has passed, the recipient keeps accrued tokens; the sponsor receives the remainder. If the cliff has not passed, the full deposit is refunded to the sponsor.
pub fn clawback_stream(
env: Env,
sponsor: Address, // original stream funder; must sign
recipient: Address,
stream_id: u32,
reason: String, // compliance reason (max 256 chars)
) -> Result<(), VestingError>Compliance clawback: the original sponsor recovers all remaining tokens in the vault, bypassing cliff state. Only available on tokens that support the SAC clawback flag. Emits StreamClawedBack event with the reason string.
pub fn drain_expired_stream(
env: Env,
caller: Address, // any address; no auth required
recipient: Address,
) -> Result<(), VestingError>Permissionless cleanup of a fully expired stream. Available to any caller after end_ledger + 6,307,200 ledgers (~1 year) have elapsed. Transfers remaining tokens to the original sponsor. Emits StreamDrained event.
pub fn set_min_deposit(
env: Env,
admin: Address, // must sign
min_deposit: i128, // new minimum total deposit (must be > 0)
) -> Result<(), VestingError>Updates the minimum total deposit threshold in instance storage. Default is 100 tokens.
| Function | Returns |
|---|---|
get_schedule(recipient) |
Option<VestingSchedule> |
get_stream_ids(recipient) |
Vec<u32> — active stream IDs in ascending order |
get_schedule_by_id(recipient, stream_id) |
Option<VestingSchedule> |
claimable_amount(recipient) |
i128 — 0 if cliff not reached |
get_claimable_batch(recipients) |
Vec<(Address, i128)> — preserves input order; unknown recipients return 0 |
is_cliff_passed(recipient) |
bool |
get_min_deposit() |
i128 — current minimum deposit threshold |
| Code | Name | Meaning |
|---|---|---|
| 1 | ScheduleNotFound |
No active schedule for the recipient |
| 2 | CliffNotReached |
Ledger is still before cliff_ledger |
| 3 | InvalidDuration |
total_duration ≤ cliff_duration |
| 4 | InvalidRate |
rate ≤ 0 (checked before deposit arithmetic); or fee_bps > 500 |
| 5 | DepositOverflow |
Arithmetic overflow computing total deposit |
| 6 | ScheduleAlreadyExists |
A conflicting schedule already exists |
| 7 | NothingToClaim |
Claimable amount is zero at current ledger |
| 8 | StreamNotExpired |
end_ledger has not yet been reached |
| 9 | TransferFailed |
Token transfer failed |
| 10 | DrainDelayNotExpired |
The 1-year drain delay after end_ledger has not passed |
| 11 | InvalidRecipient |
sponsor and recipient are the same address |
| 12 | InvalidCliffDuration |
cliff_duration is zero; a cliff must have positive length |
| 13 | AlreadyInitialized |
initialize has already been called |
| 14 | RecipientNotAllowed |
Recipient not on the configured allowlist |
| 15 | StreamPaused |
Claim attempted on a paused stream |
| 16 | BatchTooLarge |
Batch size exceeds the maximum of 20 |
| 17 | RateTooLow |
rate > 0 but rate × total_duration < min_deposit (total deposit is below the configured floor) |
| 18 | NotInitialized |
initialize has not yet been called |
| 19 | InvalidSegments |
Variable-rate segments are invalid (empty, out-of-order, or bad rate) |
| 20 | MetadataTooLong |
metadata exceeds 256 UTF-8 bytes |
| 21 | Unauthorized |
Caller is not the contract admin or original sponsor |
| 22 | DepositBelowMinimum |
Overflow check passed but rate × total_duration < min_deposit; total deposit is below the configured minimum |
| 23 | StreamAlreadyPaused |
Stream is already paused |
| 24 | StreamNotPaused |
resume_stream called on a non-paused stream |
| 25 | VersionOverflow |
Version counter has reached u32::MAX |
| 26 | ClawbackNotSupported |
Token does not support the SAC clawback flag |
| 27 | InvalidToken |
token is not a valid SAC; the try_balance probe failed |
| 27 | InvalidToken |
Token address is not a valid SAC contract |
- Rust with
wasm32-unknown-unknowntarget - Stellar CLI (
stellar)
rustup target add wasm32-unknown-unknownmake buildmake testCI also runs the contract test suite through Soroban's WASM runner so the contract is exercised in the same target it is deployed to.
stellar keys generate default --network testnet --fund
./scripts/deploy.sh defaultexport VESTING_CONTRACT=<contract-id>
export SPONSOR=default
export RECIPIENT=<G...>
export TOKEN=<C...>
export RATE=10
export CLIFF_DURATION=17280 # ~1 day at 5s/ledger
export TOTAL_DURATION=172800 # ~10 days
./scripts/invoke_create.sh- Auth: Both
create_vesting_stream(sponsor) andclaim_vested/cancel_stream(respective callers) userequire_auth(). - Same-address guard:
create_vesting_streamrejects calls wheresponsor == recipient, returningInvalidRecipient(error 11) immediately before any token transfer. This prevents a sponsor from vesting tokens to themselves and bypassing the cliff lock. - Overflow protection: All arithmetic uses checked_* operations, returning
DepositOverflowon failure. - Overflow boundary: The maximum valid deposit rate for a given duration is
i128::MAX / total_duration; one unit above that returnsDepositOverflow. - Multiple streams: Each recipient can have concurrent streams, identified by an auto-incremented
stream_id. - TTL management: Persistent storage entries are bumped on every read/write (~60-day window) to prevent expiry of active streams.
- No admin backdoor: The contract has no owner/admin key; only the original sponsor can cancel.
A Software Bill of Materials (SPDX 2.3 JSON) is generated for every release and attached as sbom.spdx.json. License scanning runs on every pull request and blocks merges if a dependency carries a copyleft or unapproved license.
See docs/sbom.md for the full policy, allowed license list, and instructions for adding new dependencies.
- CHANGELOG.md — Project-level changes and release history.
- API Changelog — Contract and backend API changes for integrators.
MIT
This project follows the Contributor Covenant 2.1.