Soroban smart contracts powering StreamGive, a recurring/streaming donation platform for verified NGOs on Stellar.
For how these contracts fit with the backend and frontend — and how a donation flows end to end — see docs/ARCHITECTURE.md. For who these contracts defend against, what the admin can and cannot do, and which risks are knowingly accepted, see docs/THREAT_MODEL.md.
ngo-registry— on-chain NGO application, verification, and registrydonation-vault— streaming donation vault (create / withdraw / cancel / modify streams)
Admin changes use a two-step handshake:
- The current admin calls
propose_admin(new_admin), which records the pending administrator without changing the active admin. - The proposed address calls
accept_admin()to complete the transfer. - Either side can abort the pending transfer by calling
cancel_admin_proposal()before acceptance; the active admin remains unchanged.
Only the current admin can propose or cancel a transfer, and only the pending administrator can accept it. The current admin continues to control admin-gated operations until acceptance succeeds.
The workspace Cargo.toml's [profile.release] sets several non-default
flags. Soroban's resource-fee model charges per byte of the deployed wasm
and per CPU instruction executed, so a smaller, more predictable binary
isn't just nice-to-have — it directly lowers what every invocation of
these contracts costs:
| Setting | Value | Why |
|---|---|---|
opt-level |
"z" |
Optimizes for binary size over speed — wasm size drives upload and storage fees. |
lto |
true |
Whole-program link-time optimization, trimming dead code and shrinking the binary further. |
codegen-units |
1 |
A single codegen unit gives the optimizer the whole crate to work with, trading build time for smaller output. |
panic |
"abort" |
Drops unwinding tables and landing pads; Soroban traps on panic and can't unwind across the host boundary anyway. |
strip |
"symbols" |
Strips symbol/debug info from the deployed artifact — of no use on-chain, pure size cost otherwise. |
debug |
0 |
No debug info emitted for release builds, same rationale as strip. |
debug-assertions |
false |
Standard release behavior — keeps hot paths free of debug-only checks. |
overflow-checks |
true |
Kept on in release, contrary to the Rust default — these contracts move token balances, and a silently wrapped i128 is far worse than the small extra cost of a checked op. |
Change these with care: relaxing opt-level, lto, or strip grows the
deployed wasm and raises fees, while turning overflow-checks off would
let balance arithmetic wrap silently.
donation-vault has an admin-gated pause / unpause pair — an
emergency brake for when something is wrong. pause only flips a flag in
the instance storage: no funds are moved, so every balance stays exactly
where it was and there is nothing to unwind when the pause is lifted.
While the vault is paused, every entry point that moves tokens or changes
touching storage or requiring any auth:
Note that pausing does not stop time-based accrual. A stream's
pending_accrual keeps growing while the vault is paused, so a stream
paused for a week still owes a week of accrual once the pause is lifted.
That accrual is claimable via withdraw as soon as the vault is
unpaused.
| Entry point | While paused |
|---|---|
create_stream |
Rejected |
withdraw |
Rejected |
top_up |
Rejected |
modify_rate |
Rejected |
cancel_stream |
Still works — settles and refunds as usual |
withdraw being on that list is the point of the brake: it is the only
path that pays tokens straight out of the vault, so a pause triggered by a
suspected vulnerability has to close it or an attacker could simply drain
funds while the rest of the contract is frozen.
cancel_stream is deliberately left open. It is the one path that returns
money to a donor, so keeping it available means a pause can never trap a
donor's unspent deposit. The read-only views (admin, pending_admin,
get_stream, stream_count, pending_accrual, paused, treasury,
fee_bps) and extend_stream also keep working, since none of them can
move funds, and unpause is of course still reachable.
Soroban contracts use bounded Time-To-Live (TTL) for on-chain state retention:
- Instance storage (contract admins, configuration, pause flags) is bumped
to 30 days (
518,400ledgers) on every state-changing call. - Persistent storage (each individual
Streamrecord indonation-vaultand eachNgorecord inngo-registry) has an independent 90-day TTL (1,555,200ledgers) that must be maintained per entry.
If a stream has no activity (withdrawals, top-ups, rate modifications) or an NGO entry receives no updates for 90 consecutive days, its TTL expires and the network archives the entry.
Archived entries cannot be read or modified by normal contract calls (get_stream,
withdraw, verification lookups, etc. will fail) until a Soroban state restoration
transaction is submitted and network restoration fees are paid. Long-running streams
with low drip rates or infrequent withdrawals are especially at risk if left untouched.
To protect idle entries from archival without moving funds, modifying balances, or requiring admin credentials, both contracts provide permissionless keep-alive entry points that anyone (donors, NGOs, keeper bots, or indexers) can invoke:
donation-vault::extend_stream(stream_id): Extends the persistent storage TTL ofDataKey::Stream(stream_id)back to 90 days. Can be called at any time, requires no authorization, and remains accessible even while the vault is paused.ngo-registry::touch_ngo(owner): Refreshes both the registry instance TTL (to 30 days) and the NGO's persistent storage TTL (DataKey::Ngo(owner)) back to 90 days without altering registration status. Requires no authorization.
For full key specifications, bump thresholds, and archival lifecycle details, see docs/STORAGE.md.
- streamgive-backend — indexer & API
- streamgive-frontend — donor & NGO web app
- streamgive-docs — documentation
Run the full test suite for all contracts from the workspace root:
cargo test --workspaceTo run tests for a single contract:
cargo test -p donation-vault
cargo test -p ngo-registryNotable coverage:
donation-vault'smathmodule unit-tests the streaming accrual calculation (accrued) directly: zero/negative rate, zero balance, zero elapsed time, capping at the remaining balance, and saturating instead of overflowing/panicking neari128::MAX.- It also includes a deterministic grid-based invariant sweep
(
invariants_hold_across_a_grid_of_inputs) that checks, across a matrix of rates, balances, and elapsed durations, that accrual is always non-negative, never exceeds the remaining balance, and is monotonically non-decreasing as elapsed time (or rate) grows — a stand-in for property-based testing over the streaming math's edge cases.
CI (see .github/workflows/ci.yml) runs
cargo fmt --check, cargo clippy, a wasm32v1-none release
build, a wasm binary size check (see
scripts/check-wasm-size.sh), and
cargo test --workspace on every push and pull request.
Apache-2.0 permits reuse and modification while providing an explicit patent license and clear contributor protections. That makes it a practical default for contracts intended to be integrated by wallets, applications, and other open-source projects.
The contracts move token balances and calculate payouts with i128. A wrapped
balance could silently corrupt funds, so release builds keep overflow-checks
enabled and return explicit arithmetic errors where the contract can handle
the failure.
Soroban contracts run in a constrained WebAssembly environment. no_std
keeps the deployed artifact small and avoids bringing operating-system
facilities that are unavailable on-chain.
Persistent storage is retained per key. A stream that is never touched can
expire independently of the vault instance, so state-changing calls and the
permissionless extend_stream entry point refresh the specific stream that
needs to remain available.
The admin can configure cancel_grace_ledgers so indexers have additional
time to observe and process a cancellation. Cancelling a stream retains its
record for the normal stream TTL plus that configured grace period; a value of
zero keeps the default retention period.
Each contract exposes its failures as a #[contracterror] enum Error,
returned as Result<_, Error> from every fallible entry point. Clients see
the numeric code below (e.g. a failed try_withdraw surfacing Error(5)).
| Code | Error | Meaning |
|---|---|---|
| 1 | AlreadyInitialized |
init was already called; the vault already has an admin. |
| 2 | NotInitialized |
init has not been called yet, so there is no admin to act as. |
| 3 | StreamNotFound |
No stream exists for the given stream id. |
| 4 | InvalidAmount |
deposit or rate passed to create_stream, the amount passed to top_up, or the new_rate passed to modify_rate was zero or negative. |
| 5 | NothingToWithdraw |
The stream has accrued nothing since its last checkpoint. |
| 6 | ContractPaused |
The admin has paused the vault; see Pausing for what still works. |
| 7 | FeeTooHigh |
set_fee_bps was called with a value above the 10% (1,000 bps) cap. |
| 8 | NoPendingAdmin |
accept_admin was called without a prior (or already-completed) propose_admin. |
| 9 | ArithmeticOverflow |
A stream's balance/withdrawn or the stream-id counter would overflow. |
| 10 | InvalidTreasury |
set_treasury was called with the vault's own address. |
| 10 | SelfStream |
create_stream was called with the same address as both donor and ngo, which would stream the donor's own deposit back to them. |
| 11 | StreamCancelled |
top_up or modify_rate was called on a stream that cancel_stream has already closed out. |
| 9 | ArithmeticOverflow |
A stream balance, withdrawn total, or stream ID would exceed its integer range. |
| 10 | DepositTooLow |
create_stream was called with a deposit below the admin-configured minimum. |
| 11 | AlreadyPaused |
pause was called when the vault was already paused. |
| 12 | AlreadyUnpaused |
unpause was called when the vault was already active. |
| 13 | SelfStream |
create_stream was called with the same address as both donor and ngo. |
| 14 | StreamCancelled |
top_up or modify_rate was called on a stream that cancel_stream has already closed out. |
| 15 | InvalidAdmin |
propose_admin was called with the current admin instead of a different address. |
| Code | Error | Meaning |
|---|---|---|
| 1 | AlreadyInitialized |
init was already called; the registry already has an admin. |
| 2 | NotInitialized |
init has not been called yet, so there is no admin to act as. |
| 3 | AlreadyRegistered |
register was called for an address that already has an entry. |
| 4 | NotRegistered |
No registry entry exists for the given owner address. |
| 5 | AlreadyVerified |
An approved NGO cannot change its name. |
| 6 | NameTooLong |
register was called with a name longer than 200 bytes. |
| 7 | NotVerified |
revoke_ngo was called on an NGO that isn't currently verified. |
| 8 | NoPendingAdmin |
accept_admin was called without a pending admin proposal. |
Early development.
Apache-2.0 — see LICENSE.