diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e966282..29eba68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,11 @@ jobs: - uses: actions/checkout@v4 - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + uses: dtolnay/rust-toolchain@master with: + toolchain: "1.93.0" components: rustfmt + targets: wasm32-unknown-unknown - name: Cache cargo registry and build uses: Swatinem/rust-cache@v2 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..b31f730 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,68 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: "1.93.0" + components: rustfmt + targets: wasm32-unknown-unknown + + - name: Cache cargo registry and build + uses: Swatinem/rust-cache@v2 + + - name: Validate tag matches Cargo.toml version + run: | + TAG_VERSION="${GITHUB_REF_NAME#v}" + CARGO_VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag version ($TAG_VERSION) does not match Cargo.toml version ($CARGO_VERSION)" + exit 1 + fi + + - name: Run tests + run: cargo test + + - name: Build deterministic WASM + run: | + docker build --platform linux/amd64 \ + -f docker/Dockerfile.build \ + -o artifacts . + + - name: Install git-cliff + uses: kenji-miyake/setup-git-cliff@v2 + + - name: Generate changelog + id: changelog + run: | + TAG_COUNT=$(git tag -l 'v*' | wc -l) + if [ "$TAG_COUNT" -le 1 ]; then + git-cliff --tag "$GITHUB_REF_NAME" > RELEASE_NOTES.md + else + git-cliff --latest > RELEASE_NOTES.md + fi + + - name: Create GitHub Release + env: + GH_TOKEN: ${{ github.token }} + run: | + gh release create "$GITHUB_REF_NAME" \ + --title "$GITHUB_REF_NAME" \ + --notes-file RELEASE_NOTES.md \ + artifacts/streampay_contracts.optimized.wasm \ + artifacts/streampay_contracts.checksum diff --git a/.gitignore b/.gitignore index 035c614..e578533 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ .env .idea *.iml -docs/ +artifacts/ +docs/superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3526a28 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,31 @@ +# Changelog + +All notable changes to streampay-contracts are documented here. + +## [0.1.0] - 2026-03-24 + +### Added + +- Add StreamPay Soroban contract and tests + +### CI + +- Add fmt, build, test workflow +- Pin Rust toolchain to 1.93.0 +- Add git-cliff configuration for changelog generation +- Add deterministic WASM build Dockerfile +- Add release workflow for tagged WASM builds + +### Documentation + +- Add README and contributor onboarding +- *(contracts)* Add release process design spec +- *(contracts)* Address spec review findings +- *(contracts)* Add release process implementation plan +- *(contracts)* Add release process guide + +### Fixed + +- *(contracts)* Move soroban-sdk testutils to dev-dependencies + + diff --git a/Cargo.toml b/Cargo.toml index 88fc675..dce5e34 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,4 +12,7 @@ default = [] testutils = ["soroban-sdk/testutils"] [dependencies] +soroban-sdk = "22.0" + +[dev-dependencies] soroban-sdk = { version = "22.0", features = ["testutils"] } diff --git a/README.md b/README.md index 7d8fe74..7a5d8f9 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,29 @@ On every push/PR to `main`, GitHub Actions runs: Ensure all three pass before merging. +## Releases + +Tagged releases follow [semver](https://semver.org/). Each release includes an optimized WASM artifact and SHA-256 checksum. + +See [docs/RELEASE.md](docs/RELEASE.md) for the full release process, including how to verify WASM builds. + ## Project structure ``` streampay-contracts/ ├── src/ -│ └── lib.rs # Contract and tests +│ └── lib.rs # Contract and tests +├── docker/ +│ └── Dockerfile.build # Deterministic WASM builder +├── .github/workflows/ +│ ├── ci.yml # Format, build, test +│ └── release.yml # Tagged release workflow +├── docs/ +│ └── RELEASE.md # Release process guide +├── cliff.toml # Changelog generator config +├── rust-toolchain.toml # Pinned Rust version ├── Cargo.toml -├── .github/workflows/ci.yml +├── CHANGELOG.md └── README.md ``` diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..f4e3aff --- /dev/null +++ b/cliff.toml @@ -0,0 +1,45 @@ +[changelog] +header = """ +# Changelog + +All notable changes to streampay-contracts are documented here.\n +""" +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +footer = "" +trim = true + +[git] +conventional_commits = true +filter_unconventional = true +split_commits = false +commit_parsers = [ + { message = "^.*!:", group = "Breaking Changes" }, + { message = "^feat", group = "Added" }, + { message = "^fix", group = "Fixed" }, + { message = "^doc", group = "Documentation" }, + { message = "^refactor", group = "Changed" }, + { message = "^ci", group = "CI" }, + { message = "^release", skip = true }, + { message = "^chore", skip = true }, +] +protect_breaking_commits = true +filter_commits = false +tag_pattern = "v[0-9].*" +skip_tags = "" +ignore_tags = "" +topo_order = false +sort_commits = "oldest" diff --git a/docker/Dockerfile.build b/docker/Dockerfile.build new file mode 100644 index 0000000..1401e3d --- /dev/null +++ b/docker/Dockerfile.build @@ -0,0 +1,31 @@ +ARG RUST_VERSION=1.93.0 +FROM rust:${RUST_VERSION}-slim-bookworm AS builder + +# Install dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + pkg-config \ + libssl-dev \ + binaryen \ + && rm -rf /var/lib/apt/lists/* + +# Install stellar-cli (pinned version) +ARG STELLAR_CLI_VERSION=25.2.0 +RUN cargo install --locked stellar-cli --version ${STELLAR_CLI_VERSION} + +# Add WASM target +RUN rustup target add wasm32-unknown-unknown + +WORKDIR /build +COPY . . + +# Build the optimized WASM +RUN stellar contract build --release \ + && wasm-opt -Oz \ + target/wasm32-unknown-unknown/release/streampay_contracts.wasm \ + -o streampay_contracts.optimized.wasm \ + && sha256sum streampay_contracts.optimized.wasm > streampay_contracts.checksum + +# Output stage — copies artifacts out via docker build -o +FROM scratch AS artifacts +COPY --from=builder /build/streampay_contracts.optimized.wasm / +COPY --from=builder /build/streampay_contracts.checksum / diff --git a/docs/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000..b2566d6 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,93 @@ +# Release Process + +Guide for cutting releases of streampay-contracts. + +## Pre-release Checklist + +- [ ] All tests pass (`cargo test`) +- [ ] Version bumped in `Cargo.toml` +- [ ] `soroban-sdk` testutils feature is in `[dev-dependencies]`, not `[dependencies]` +- [ ] Changes are merged to `main` + +## Semver Bump Rules + +| Bump | When | +|------|------| +| **Major** | Breaking entry point changes (renamed/removed functions, changed params) OR storage-migration-required changes | +| **Minor** | New entry points, new features backward-compatible with existing streams | +| **Patch** | Bug fixes, doc changes, or any non-breaking change that alters the compiled WASM hash | + +Every on-chain-distinguishable build (different WASM hash) gets at minimum a patch bump. + +## Cutting a Release + +1. **Bump version** in `Cargo.toml`: + ```toml + version = "X.Y.Z" + ``` + +2. **Commit the version bump**: + ```bash + git add Cargo.toml + git commit -m "release(contracts): vX.Y.Z" + ``` + +3. **Tag the release**: + ```bash + git tag vX.Y.Z + ``` + +4. **Push to main with tag**: + ```bash + git push origin main --tags + ``` + +5. **CI takes over** — the release workflow: + - Validates the tag matches `Cargo.toml` + - Runs tests + - Builds a deterministic WASM via Docker + - Generates a changelog entry with `git-cliff` + - Creates a GitHub Release with the `.wasm` and `.checksum` attached + +## Verifying a WASM Build + +Anyone can verify that a release artifact is reproducible: + +```bash +# Clone the repo at the release tag +git checkout vX.Y.Z + +# Build with Docker (use --platform on Apple Silicon) +docker build --platform linux/amd64 -f docker/Dockerfile.build -o artifacts . + +# Compare checksums +cat artifacts/streampay_contracts.checksum +# Should match the .checksum file from the GitHub Release +``` + +The canonical hash is produced on `linux/amd64`. On Apple Silicon, the `--platform linux/amd64` flag is required for matching hashes. + +## Version Sources + +These must stay in sync on every release: + +- `Cargo.toml` `version` field +- Git tag (`vX.Y.Z`) + +## Troubleshooting + +### Tag version doesn't match Cargo.toml +The release workflow validates that the git tag matches the version in `Cargo.toml`. If they diverge, the workflow fails. Fix by deleting the incorrect tag and re-tagging: + +```bash +git tag -d vX.Y.Z +git push origin :refs/tags/vX.Y.Z +# Fix Cargo.toml, commit, then re-tag +git tag vX.Y.Z +git push origin main --tags +``` + +### Docker build fails +- Ensure Docker is installed and running +- Check that `docker/Dockerfile.build` references valid pinned versions +- Try `docker build --no-cache` to rebuild from scratch diff --git a/docs/collateral.md b/docs/collateral.md new file mode 100644 index 0000000..65e7d6d --- /dev/null +++ b/docs/collateral.md @@ -0,0 +1,196 @@ +# Collateral & Lockup Escrow Design + +> **Status:** Draft · **Issue:** #51 · **Author:** *(contributor name)* +> **Scope:** Design documentation only — no MVP code changes unless explicitly approved. + +## 1. Motivation + +StreamPay enables continuous payment streaming between a payer and recipient. +The current contract (`v0.1.0`) holds a `balance` field inside each `StreamInfo` +struct that decrements as the stream settles. This balance is the *only* economic +guarantee backing the stream. + +For **high-risk flows** — large-value B2B payments, cross-border payroll, +protocol-to-protocol composability — a bare stream balance is insufficient: + +| Risk | Current Mitigation | Gap | +|------|--------------------|-----| +| Payer tops up less than committed | None — stream drains to zero and halts | No penalty or pre-commitment | +| Recipient depends on future flow | Trust assumption on payer solvency | No collateral backing | +| Stream used as collateral by third party | Not possible — streams are IDs, not addresses | Requires factory pattern (Phase 2) | +| Dispute between payer and recipient | No on-chain arbitration | No escrow release mechanism | + +This document proposes collateral and lockup escrow patterns that close these +gaps in a phased approach aligned with the storage migration roadmap +(`docs/factory-pattern.md`). + +## 2. Escrow Models + +### 2.1 Lockup Deposit (Phase 1 — Singleton) + +A **lockup deposit** is additional funds the payer locks at stream creation time, +held separately from the stream's operational balance. The lockup is returned to +the payer only when the stream completes its full committed duration or is +mutually cancelled. + +**Mechanism (conceptual — no code change in MVP):** + +1. `create_stream()` accepts an optional `lockup_amount: i128` parameter. +2. `lockup_amount` is transferred to the contract and stored alongside the stream. +3. On `settle_stream()` after `end_time`: lockup is released back to payer. +4. On premature `stop_stream()` by payer: lockup is forfeited to recipient as + compensation (partial or full, per policy). +5. On mutual cancellation (future entry point): lockup returned to payer minus + any pro-rata penalty. + +**Storage impact:** One additional `i128` field per `StreamInfo`. Compatible with +the planned persistent-storage migration — no architectural change needed. + +``` +┌─────────────────────────────────────────┐ +│ StreamInfo (v2) │ +├─────────────────────────────────────────┤ +│ payer: Address │ +│ recipient: Address │ +│ rate_per_second: i128 │ +│ balance: i128 ← operational │ +│ lockup_amount: i128 ← NEW: escrow │ +│ start_time: u64 │ +│ end_time: u64 │ +│ is_active: bool │ +└─────────────────────────────────────────┘ +``` + +**When to use:** Medium-risk flows where payer commitment assurance is needed but +full escrow isolation is overkill (e.g., freelancer payroll, recurring SaaS fees). + +### 2.2 Full Escrow with Release Conditions (Phase 1 — Singleton) + +A **full escrow** locks the *entire* stream value upfront. The stream's +`balance` equals the total committed payout. Settlement releases funds to the +recipient progressively; the payer cannot withdraw uncommitted funds until the +stream concludes or is cancelled under defined conditions. + +**Mechanism (conceptual):** + +1. `create_stream()` requires `initial_balance >= rate_per_second * duration`. +2. Contract enforces that `balance` cannot be reduced by the payer except through + settlement. +3. A `release_policy` enum governs cancellation: + - `NonRefundable` — payer forfeits remaining balance to recipient on cancel. + - `ProRata` — settled amount to recipient, remainder to payer. + - `Arbitrated` — third-party address must co-sign cancellation. + +**Storage impact:** One `release_policy` enum field per stream. Fits singleton +persistent storage. + +**When to use:** High-risk flows — large B2B payments, cross-border payroll +where recipient needs hard guarantees. + +### 2.3 Composable Collateral via Factory (Phase 2) + +When the factory pattern is adopted (see `docs/factory-pattern.md` §Phase 2), +each stream becomes its own contract address. This unlocks: + +- **Stream-as-collateral:** A lending protocol can accept a stream contract + address as collateral, query its remaining value, and liquidate on default. +- **Transferable escrow:** Stream ownership (payer/recipient roles) can be + reassigned, enabling invoice factoring. +- **Multi-party escrow:** Milestone-based releases where an oracle or DAO + triggers partial settlements. + +**Dependency:** Requires factory pattern deployment. Out of scope for Phase 1. + +## 3. Risk Analysis + +### 3.1 Threat Model + +| # | Threat | Severity | Affected Model | Mitigation | Residual Risk | +|---|--------|----------|----------------|------------|---------------| +| T1 | **Payer drains lockup via re-entrancy** — malicious token callback re-enters `settle_stream` to double-claim | Critical | 2.1, 2.2 | Soroban's execution model is single-threaded per invocation; no re-entrancy possible in current runtime. Validate on future SDK upgrades. | Low — runtime guarantee, not code-level. | +| T2 | **Lockup griefing** — payer creates stream with lockup but never starts it, locking recipient expectation | Medium | 2.1 | Add expiry: if stream not started within `max_start_delay` ledgers, lockup auto-refunds to payer and stream is archived. | Low with expiry. | +| T3 | **Oracle manipulation (Arbitrated policy)** — compromised arbitrator co-signs fraudulent cancellation | High | 2.2 (Arbitrated) | Require M-of-N multi-sig for arbitrated releases. Recommend time-locked dispute window before release. | Medium — depends on arbitrator trust model. | +| T4 | **Collateral valuation drift (Phase 2)** — stream's remaining value drops below collateral requirements between oracle updates | High | 2.3 | Lending protocol must implement health-factor checks with sufficient margin. StreamPay exposes `get_stream_info()` for real-time queries — no stale oracle needed for on-chain value. | Medium — protocol-level, not StreamPay-level. | +| T5 | **Lockup amount overflow** — `lockup_amount + balance` exceeds `i128::MAX` | Low | 2.1, 2.2 | Validate `lockup_amount + balance` does not overflow at `create_stream()`. Use checked arithmetic. | Negligible with validation. | +| T6 | **Denial-of-service via dust lockups** — attacker creates thousands of minimum-lockup streams to bloat storage | Medium | 2.1, 2.2 | Enforce minimum lockup threshold. Persistent storage TTL auto-expires inactive streams. | Low with TTL + minimum. | +| T7 | **Front-running cancellation** — payer cancels stream just before a large settlement to recover lockup | High | 2.1, 2.2 | `stop_stream()` must settle all accrued value *before* processing cancellation. Lockup forfeiture on unilateral payer cancel. | Low with settle-first invariant. | +| T8 | **Phantom lockup (accounting-only gap)** — `lockup_amount` field is set but no actual token transfer occurs, giving false collateral guarantees | High | 2.1, 2.2 | Current contract is accounting-only; token integration is a separate workstream. `lockup_amount` MUST NOT be trusted as collateral until token custody is implemented. Document this limitation prominently. | High until token transfers land. | + +### 3.2 Security Invariants + +The following invariants MUST hold for any collateral/escrow implementation: + +1. **Settle-before-cancel:** Any cancellation path must first settle all accrued + value to the recipient. No path may allow the payer to recover funds that + have already been earned by elapsed time. + +2. **Lockup isolation:** Lockup funds are not part of the settleable balance. + `settle_stream()` draws from `balance` only; `lockup_amount` is released or + forfeited exclusively through terminal stream events (completion, cancellation, + expiry). + +3. **No unilateral recipient withdrawal of lockup:** The recipient receives + lockup funds only through defined forfeiture rules, never by direct claim. + +4. **Arithmetic safety:** All operations on `balance`, `lockup_amount`, and + `rate_per_second * elapsed` use checked arithmetic. Overflow panics the + transaction (Soroban default) rather than wrapping. + +5. **Authorization consistency:** Lockup and escrow operations follow the same + authorization model as existing entry points (see `docs/factory-pattern.md` + §5 — Authorization model subsection). `create_stream` requires payer auth; `settle_stream` + remains permissionless; cancellation requires payer auth (or multi-sig for + arbitrated policy). + +## 4. Phase Roadmap + +| Phase | Milestone | Escrow Capability | Dependency | +|-------|-----------|-------------------|------------| +| **0 (current)** | Singleton + instance storage | None — `balance` only | — | +| **1a** | Persistent storage migration | Lockup deposit field added to `StreamInfo` | `docs/factory-pattern.md` §5 (Recommended Architecture) | +| **1b** | Release policy enum | Full escrow with `NonRefundable` / `ProRata` / `Arbitrated` | Phase 1a | +| **2** | Factory pattern | Composable collateral — stream-as-address | Factory deployment | + +### Graduation Triggers (Phase 1 → Phase 2) + +Collateral features graduate from singleton to factory when: + +- A partner protocol requests stream-address composability for lending/collateral. +- Lockup usage exceeds 30% of active streams (signal of high-risk flow demand). +- The factory pattern is deployed and battle-tested for non-collateral streams. + +These align with the graduation triggers in `docs/factory-pattern.md` §6.2 (Graduation triggers). + +## 5. Decision Log + +| Date | Decision | Rationale | +|------|----------|-----------| +| 2026-03-24 | Collateral is documentation-only for MVP | Risk analysis complete; no code until Phase 1a persistent storage lands. Avoids premature abstraction. | +| 2026-03-24 | Lockup is a separate field, not part of `balance` | Isolation invariant — prevents settle logic from touching escrowed funds. Simpler to audit. | +| 2026-03-24 | Three release policies (NonRefundable, ProRata, Arbitrated) | Covers spectrum from no-trust to partial-trust flows without over-engineering. | +| 2026-03-24 | Phase 2 composable collateral deferred to factory | Streams must be contract addresses for third-party collateral use. Singleton IDs are not composable. | + +## 6. Open Questions + +- [ ] Should `lockup_amount` be denominated in the stream's token or a separate + stablecoin? (Affects cross-asset risk.) +- [ ] What is the minimum lockup threshold to prevent dust griefing? (Needs gas + cost analysis on Stellar.) +- [ ] Should the `Arbitrated` release policy support on-chain dispute evidence + (e.g., hash of off-chain ruling)? +- [ ] How should lockup interact with `archive_stream()`? (Auto-refund on + archive, or require explicit claim?) +- [ ] Token custody: `lockup_amount` is currently accounting-only (no token + transfers). When token integration lands, lockup must require actual token + custody before being treated as collateral. (See T8 in threat model.) + +## 7. References + +- [StreamPay Factory Pattern Design](factory-pattern.md) — storage architecture + and Phase 2 factory roadmap. +- [Soroban Storage Docs](https://soroban.stellar.org/docs/storage) — persistent + vs. instance storage semantics. +- [Sablier V2 Protocol](https://docs.sablier.com/) — prior art for on-chain + payment streaming with lockups. +- [Superfluid Protocol](https://docs.superfluid.finance/) — real-time finance + streaming patterns. diff --git a/docs/factory-pattern.md b/docs/factory-pattern.md new file mode 100644 index 0000000..52bd021 --- /dev/null +++ b/docs/factory-pattern.md @@ -0,0 +1,310 @@ +# Factory Pattern Design: Factory-Deployed Children vs Singleton Multi-Stream + +**Issue:** #46 — Stream factory pattern: design document +**Status:** Proposal +**Author:** StreamPay Contributors +**Date:** 2026-03-24 + +--- + +## 1. Problem Statement + +StreamPay currently stores all payment streams in Soroban **instance storage**. +Instance storage is a single ledger entry shared across the entire contract +instance — every stream's data is bundled together. + +This creates three scaling concerns: + +| Concern | Detail | +|---------|--------| +| **Storage ceiling** | Instance storage has a practical size limit (~64 KB). Each `StreamInfo` is ~200 bytes, giving a ceiling of roughly 300 concurrent streams before storage pressure. | +| **TTL coupling** | Renewing the contract instance TTL renews *all* streams, including inactive or fully-settled ones. The contract pays to keep dead data alive. | +| **Blast radius** | A storage-level bug or corrupt entry affects the entire contract state — there is no per-stream isolation. | + +### Current storage layout + +``` +Instance Storage (single ledger entry): + Symbol("next_id") → u32 + (Symbol("stream"), 1u32) → StreamInfo + (Symbol("stream"), 2u32) → StreamInfo + … + (Symbol("stream"), Nu32) → StreamInfo +``` + +## 2. Approaches Evaluated + +Three architectures were considered. + +### 2.1 Singleton with Instance Storage (status quo) + +Keep the current single contract. All streams remain in instance storage under +composite keys `("stream", id)`. + +**Pros:** +- Simplest model — already implemented and tested. +- Cheapest at low stream counts (one ledger entry, one TTL renewal). +- One upgrade touches all streams. + +**Cons:** +- ~300-stream ceiling before instance storage pressure. +- TTL renewal is all-or-nothing. +- No per-stream isolation. + +### 2.2 Factory + Child Contracts + +A factory contract deploys a new child contract per stream. The factory +maintains a registry mapping `stream_id → contract_address`. + +**Pros:** +- Full isolation — each stream is a separate contract with independent storage + and TTL. +- Streams are first-class contract addresses, composable with other protocols + (e.g., a stream used as collateral). +- Per-stream upgrade control (with proxy pattern or re-deploy). + +**Cons:** +- Highest cost per stream — contract deploy is ~100 000 stroops plus per- + contract storage overhead. +- Significant complexity: WASM upload, `env.deployer()`, registry, cross- + contract auth delegation. +- Upgrade coordination is harder — existing children keep old logic unless + individually re-deployed. + +### 2.3 Singleton with Persistent Storage (recommended) + +Keep the single contract but migrate stream data from instance storage to +**persistent storage**. Each stream becomes its own ledger entry with an +independent TTL. + +**Pros:** +- Per-stream ledger entries with independent TTLs — active streams stay alive, + inactive ones can expire. +- Practically unlimited scaling (no shared-entry size limit). +- Minimal code change — swap storage accessor, add TTL extension calls. +- Upgrade path unchanged — one contract upgrade covers all streams. + +**Cons:** +- Slightly higher per-entry cost (persistent storage rate vs instance rate). +- Shared contract logic — a logic bug still affects all streams (same as 2.1). +- Streams are IDs, not contract addresses — not directly composable. + +## 3. Trade-off Matrix + +| Criterion | Instance (2.1) | Persistent (2.3) ★ | Factory (2.2) | +|---|---|---|---| +| Stream isolation | None — shared entry | Per-stream entries, shared logic | Full — separate contracts | +| TTL management | All-or-nothing | Per-stream TTL | Per-contract TTL | +| Upgrade path | One upgrade, all streams | One upgrade, all streams | Per-child upgrade or re-deploy | +| Deploy cost | One contract | One contract | Factory + WASM + per-stream deploy | +| Storage cost | Cheapest at <50 streams | Slightly higher (persistent rate) | Highest (contract overhead) | +| Scaling ceiling | ~300 streams | Practically unlimited | Practically unlimited | +| Implementation complexity | Lowest (current code) | Low (storage swap + TTL) | High (deployer, registry, auth) | +| Failure blast radius | All streams | All streams (shared logic) | Single stream | +| Composability | Streams are IDs | Streams are IDs | Streams are contract addresses | + +## 4. Recommendation + +**Adopt Approach 2.3 — Singleton with Persistent Storage** as the primary +architecture for StreamPay v1. + +### Rationale + +1. StreamPay is at v0.1.0 — the contract surface is small and the singleton + model is proven by existing tests. +2. Moving from instance to persistent storage removes the scaling bottleneck + with a minimal diff. +3. Per-stream TTL management is the single highest-value improvement for + production readiness. +4. The factory pattern adds deployer/registry/cross-contract complexity that is + not justified at the current stage. + +### Decision framework — "choose this when…" + +| Choose… | When… | +|---------|-------| +| Singleton (Instance) | Prototyping, <50 concurrent streams, simplicity is the priority. | +| **Singleton (Persistent) ★** | Production use, moderate scale, per-stream TTL without factory complexity. | +| Factory + Children | Per-stream upgradability, multi-tenant isolation, or streams as first-class composable addresses. | + +## 5. Recommended Architecture + +### Target storage layout + +``` +Instance Storage: + Symbol("next_id") → u32 ← small, always needed + +Persistent Storage: + (Symbol("stream"), 1u32) → StreamInfo ← independent ledger entry, own TTL + (Symbol("stream"), 2u32) → StreamInfo ← independent ledger entry, own TTL + … + (Symbol("stream"), Nu32) → StreamInfo ← each is its own ledger entry +``` + +### What changes + +- `env.storage().instance()` → `env.storage().persistent()` for all stream + read/write operations. +- Add `env.storage().persistent().extend_ttl()` calls on stream mutation so + active streams stay alive (see TTL strategy below). +- Add `env.storage().instance().extend_ttl()` on every mutating call to keep + the contract instance itself alive (instance storage still holds `next_id`). +- Add an optional `archive_stream()` entry point to let payers explicitly + remove fully-settled stream data via `env.storage().persistent().remove()`. +- `next_id` stays in instance storage (small, always needed). + +### What stays the same + +- All existing entry points (`create_stream`, `start_stream`, `stop_stream`, + `settle_stream`, `get_stream_info`, `version`) — identical signatures, + identical behavior. +- `StreamInfo` struct — no changes. +- Stream ID scheme — auto-incrementing `u32`. +- Single contract deployment model. + +### Key behaviors + +| Behavior | Detail | +|----------|--------| +| TTL per stream | `create_stream()`, `start_stream()`, `stop_stream()`, and `settle_stream()` extend the target stream's TTL. `get_stream_info()` is read-only and does not extend TTL. Inactive streams that are never touched naturally expire. | +| Instance TTL | Every mutating entry point also calls `env.storage().instance().extend_ttl()` to keep the contract itself alive. | +| Storage cost | ~200 bytes per stream at the persistent storage rate. Callers (or a keeper) extend TTLs as needed. | +| Deletion | Settled/completed streams can be explicitly archived via `env.storage().persistent().remove()` or left to expire via TTL. | +| Migration | StreamPay is pre-production (v0.1.0) — no on-chain migration needed; clean deploy. | + +### TTL strategy + +Soroban's `extend_ttl(threshold, extend_to)` API extends the entry's TTL to +`extend_to` ledgers only if the current TTL is below `threshold`. Recommended +defaults: + +| Constant | Value | Rationale | +|----------|-------|-----------| +| `STREAM_TTL_THRESHOLD` | 17 280 ledgers (~1 day) | Extend before the last day of life. | +| `STREAM_TTL_EXTEND` | 518 400 ledgers (~30 days) | Reasonable default; active streams refresh on every interaction. | +| `INSTANCE_TTL_THRESHOLD` | 17 280 ledgers (~1 day) | Keep contract instance alive. | +| `INSTANCE_TTL_EXTEND` | 518 400 ledgers (~30 days) | Match stream TTL. | + +These can be tuned per deployment. The key invariant: any stream that is +actively used (started, stopped, or settled) will never expire unexpectedly. + +### Authorization model + +| Entry point | `require_auth` | Notes | +|-------------|---------------|-------| +| `create_stream` | payer | Payer authorizes stream creation and initial funding. | +| `start_stream` | payer | Only payer can activate the stream. | +| `stop_stream` | payer | Only payer can deactivate the stream. | +| `settle_stream` | **none** | Intentionally permissionless — allows recipients, keepers, or bots to trigger settlement. **Note:** this also extends the stream's TTL, meaning anyone can keep a stream alive. This is acceptable because settlement only moves funds toward the recipient and extending TTL preserves the recipient's ability to claim. | +| `get_stream_info` | none | Read-only, no state changes, no TTL extension. | +| `archive_stream` (new) | payer | Payer can remove settled stream data. Stream must be inactive (is_active = false) **and** have zero balance (balance = 0) before archival — protects recipient entitlements. | +| `version` | none | Read-only. | + +## 6. Future Factory Architecture (Phase 2) + +When graduation triggers are hit (see §6.2), the factory architecture can be +layered on top. + +### 6.1 Factory design + +``` +┌─────────────────────┐ +│ StreamPayFactory │ +│ │ +│ - deploy_stream() │──deploys──▶ ┌──────────────────┐ +│ - registry: Map< │ │ StreamPayChild │ +│ u32, Address> │ │ │ +│ - wasm_hash │ │ - payer │ +│ - next_id │ │ - recipient │ +└─────────────────────┘ │ - rate, balance │ + │ │ - start/stop/ │ + │ │ settle │ + ├──deploys──▶ └──────────────────┘ + │ ┌──────────────────┐ + └──deploys──▶ │ StreamPayChild │ + │ … │ + └──────────────────┘ +``` + +**Factory contract responsibilities:** + +- Store uploaded WASM hash for the child contract template. +- `deploy_stream()` — deploy child via `env.deployer()`, register in map, + return `(stream_id, contract_address)`. +- `get_stream_address(stream_id)` — look up child contract address. +- `upgrade_child(stream_id, new_wasm_hash)` — admin-gated, upgrade a specific + child. + +**Child contract:** + +- Same streaming logic as the singleton, but for a single stream. +- No `next_id`, no multi-stream storage — just its own `StreamInfo`. +- Callable directly by contract address (composable with other protocols). + +### 6.2 Graduation triggers + +Consider migrating to the factory pattern when any of these conditions is true: + +- StreamPay needs per-stream access control beyond payer authorization. +- Streams need to be independently composable contract addresses (e.g., used as + collateral in lending protocols). +- Per-stream upgrade control is required (e.g., regulatory or compliance + reasons). +- Contract logic diverges for different stream types (e.g., linear vs + cliff-vesting streams). + +### 6.3 Migration strategy (Phase 1 → Phase 2) + +1. Deploy the factory contract alongside the existing singleton. +2. New streams are created through the factory; existing streams remain in the + singleton until fully settled. +3. No big-bang migration — dual-path operation until the singleton drains. + +## 7. Testing Strategy + +| Test | Purpose | +|------|---------| +| Existing test suite | Must continue to pass — storage layer is an implementation detail. | +| TTL extension | Verify `env.ledger().with_mut()` scenarios: stream TTL extends on access. | +| Expired stream access | Verify that accessing an expired stream returns a clear error (e.g., `"stream not found"` panic, consistent with current behavior) rather than undefined state. | +| `archive_stream()` | Verify payer can remove settled stream data. | +| Factory PoC (optional) | Minimal branch validating `env.deployer()` flow for future reference. | + +**Coverage target:** 95 %+ on touched contract code, per project guidelines. + +## 8. Security Considerations + +- **TTL expiry:** A stream expiring while active could make unsettled balance + inaccessible. Note: the current contract performs balance accounting only (no + token transfers yet). Once token transfers are added, this becomes a funds-at- + risk concern. Mitigation: all mutating operations extend TTL; `settle_stream` + is permissionless so recipients/keepers can always trigger it; integrators + should monitor TTL and extend proactively. +- **Archive authorization:** Only the payer (or an admin) should be able to + archive a stream. The recipient must be able to settle before archival. +- **Storage exhaustion:** Persistent storage is not free — ensure documentation + warns deployers about ongoing TTL renewal costs. +- **Upgrade atomicity:** A contract upgrade applies to all streams + simultaneously. Test upgrade scenarios to ensure in-flight streams are not + corrupted by struct layout changes. + +## 9. Scope: Phase 1 Excludes + +The following are explicitly **not** in scope for the persistent storage +migration (Phase 1): + +- **Token transfers** — the contract remains accounting-only; token integration + is a separate workstream. +- **Event emission** — events for stream lifecycle are desirable but deferred to + a follow-up issue. +- **Factory deployment** — Phase 2; see §6. + +## 10. Open Questions + +1. Should `archive_stream()` require the stream to be fully settled, or allow + the payer to force-close with a final settlement? +2. Should the TTL defaults (30-day extend, 1-day threshold) be configurable per + deployment, or hardcoded as constants? +3. Should the contract emit events on stream creation, settlement, and archival + for indexer consumption? (Deferred from Phase 1 but worth deciding early.) diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..8529e8f --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.93.0" +targets = ["wasm32-unknown-unknown"] +components = ["rustfmt"]