feat: MVP — contract on testnet, dashboard, and every open issue closed - #39
Merged
Conversation
The first code in the repo. Implements every decision Phase 0 settled, with the behaviour specs turned into 75 passing tests. Entry points: create_stream, withdraw, approve_milestone, cancel, touch, plus pause/unpause/transfer_pauser/renounce_pauser. Design decisions made real: - No upgrade function exists. Not gated, not admin-guarded — absent (#33). - Pause reaches create_stream and nothing else, expires after 30 days, and can be renounced permanently (#33). - cancel takes the recipient's authorization alongside the sender's when cancelable=false, settling under identical rules (#33). - create_stream stores the measured balance delta, never the requested amount (#32). - Milestone state is monotonic; Met is terminal; double approval is a silent no-op with auth still checked first (#17, #32). - Milestones carry an optional deadline resolving to a party named at creation (#38). Constrained to >= end so expiry never resolves a still-accruing tranche. - Stream ids are a monotonic counter (architecture.md open question 1). - No TTL constant is compiled in; thresholds derive from max_ttl() at call time, since #33 left no admin who could retune a stored one. Structure worth noting: accrual.rs is pure functions over a Stream and a timestamp, so the property tests hammer the maths directly. Milestone state, deadline, and expiry policy collapse into one Resolution enum that every call site folds over — a milestone cannot mean one thing to withdraw and another to cancel. Events use #[contractevent], so each carries a named map rather than a positional tuple and appears in the generated interface. Adding a field later cannot shift what an existing consumer parses. Two invariants are asserted separately because they catch different things: value conservation (deposit == withdrawn + refunded + remaining) is a closure check that balances even if one stream were paid from another's deposit, so per-stream solvency (payout <= total - withdrawn) is checked on its own. The contract's token balance is pooled; that assertion is what keeps streams isolated, and it is the reason a withdrawal pause isn't needed. One bug caught by the tests: initialize consumed stream id 0, so the first real stream was id 1. Fixed by arming the counter rather than drawing from it. Verification: 75/75 tests pass, clippy clean on both targets, cargo fmt clean, 49,139-byte wasm. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… doing it
Live at CC3XU5QBQE4HSZGIBKV72AHMWFV6756AWRBH7A23FSFOXUG6YE65Z7FO.
Deploying found two problems the unit tests could not:
1. Setup was a callable `initialize`, and the first deploy proved it — the
contract came up with `pauser == null` because the constructor args were
silently ignored. On a contract that can never be upgraded, whoever calls
`initialize` first holds the pauser role permanently. Moved to
`__constructor`, which runs inside the deploy transaction, so the window
does not exist.
2. `create_stream` took a full `Milestone`, meaning callers had to supply a
`state` field whose only legal value was `Unmet`. Now it takes
`MilestoneSpec`, which has no such field: the bad request is unrepresentable
rather than validated. One error case deleted rather than documented.
Error codes 1 and 2 are left unused rather than reassigned, so a code never
changes meaning between builds.
End-to-end run against testnet with real XLM via its SAC — create, describe,
withdraw, approve, preview_cancel, cancel — conserves value exactly:
withdrawn 301,500,000 + refund 494,722,223 + remaining 203,777,777
= 1,000,000,000 deposited
deployments.json records superseded addresses rather than deleting them. The
contract is non-upgradeable, so a new version is always a new address, and
anyone holding a stream on an old one needs to be able to find it.
75/75 tests pass, clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Next.js 16 + Tailwind 4, reading the live testnet contract and driving every entry point from the UI: create, withdraw, approve, cancel. Design notes worth keeping: - Accrual animates locally between polls rather than by polling per second. lib/stream.ts is a faithful port of accrual.rs — same clamping, same multiply-before-divide, same end-of-stream special case, same three-way milestone resolution — in BigInt, because stroop amounts pass Number.MAX_SAFE_INTEGER around 900,000 XLM. It is a projection, never a source of truth: anything the user acts on comes from the contract. - The clock is anchored to ledger time, not the browser's. It keeps the offset rather than the absolute value, so a resync corrects drift without the number jumping backwards every poll. - The deposit meter is the one real chart. Palette is validated slots from the data-viz reference — all six checks pass in both modes. Light-mode aqua sits below 3:1 on the light surface, so the relief rule applies: every segment carries a direct label, and identity is never colour alone. - Wallet modules are listed explicitly rather than allowAllModules(), which drags in WalletConnect and Trezor — a large dependency tree declaring peers this app doesn't satisfy, for signing paths nothing here uses. Three bugs found by running it against the real chain rather than by reading: 1. RPC getEvents scans a bounded span per request and returns an EMPTY PAGE PLUS A CURSOR when nothing matches in that chunk — not an error, and not the events further along the requested range. A single wide startLedger query therefore reported "no activity" for a contract with plenty. Now follows cursors, bounded by a page budget. 2. #[contractevent] derives its topic from the struct name in snake_case, so StreamCreated publishes as stream_created. Matching on the Rust type name silently dropped every event — the filter never fired and nothing errored. 3. The meter labelled a cancelled stream's remainder "Unstreamed". That money went back to the sender when accrual froze; the label implied it was still on its way to the recipient. Also closes #10: logo, banner and 1280x640 social preview committed as SVG sources under assets/. The mark is the mechanism rather than a decoration — three streams with the middle one interrupted by a gate, the outer two passing through, which is the design's actual claim. logo.svg and banner.svg use currentColor so one file serves both GitHub themes; the social card commits to a dark ground because a social embed has no inherited colour to take. Verified: typecheck, lint and production build all clean; dashboard exercised in-browser against the live contract. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…#11 #13 Closes the remaining open issues and brings every document in line with the fact that the code now exists. #2 — docs/comparison.md. The survey found the README's own claim was half wrong, and it was the convenient half. Both Soroban-native streaming projects were described as "hackathon-scale"; both are actively developed, pushed within a week of the survey, with substantial contributor programmes. Corrected in the README rather than quietly dropped. What did hold up is narrower: none of the four projects surveyed implements approver-gated milestones — Sablier's "tranched" streams unlock on a clock, not a signature. LlamaPay is recorded as a genuinely different model rather than a weaker one: open-ended with debt instead of full escrow, which is strictly better for payroll capital efficiency and strictly worse for the guarantee milestone work needs. Every claim carries a link and a checked-on date. Sablier V2 also turns out to have shipped its core non-upgradeable with an admin that cannot touch user streams — near-identical to #33's decision, reached independently. That resolves the TODO(maintainer) left in upgradeability-and-pause.md with a verified citation instead of a recollection. #3 — three use-case pages with concrete parameters and a required "what can still go wrong" section. The interesting part is that grants and vesting want `on_expiry` pointed in OPPOSITE directions: a grant should resolve to the recipient, because the funder chose the committee and should carry the risk of it failing; a performance-gated vest should resolve to the sender, because a target that pays out when nobody looked is not a target. That is exactly why #38 refused to hardcode a default. #11 — .github/labels.yml with four namespaces, applied to the repo. Every pre-existing ad-hoc label was migrated onto the new taxonomy before the duplicate was deleted, so no issue lost meaning. One difficulty tier above `good first issue`, not three: the boundary between "medium" and "hard" is not one anybody applies consistently. #13 — VitePress over the flattened docs/. Chosen over Docusaurus because it renders the markdown already in this repo, in place; Docusaurus wants frontmatter on every page and its own directory shape. Mermaid renders (verified in-browser). The one link VitePress cannot check — ../README, which resolves correctly on GitHub — is handled in config rather than by bending the markdown, because #13 requires links to work in both places. Pages workflow present and deliberately not enabled: workflow_dispatch only, no push trigger. All six action SHAs verified against the GitHub API. Also in this commit, found while doing the above: - The docs specify `bump_stream` throughout; the contract implemented `touch`. The docs were written first and are the specification, so the code was what was wrong. Renamed and redeployed. - `pnpm bindings` was destroying the workspace on every run: `stellar contract bindings --overwrite` rewrites the package.json to point at an unbuilt ./dist and pin its own stellar-sdk copy. Two SDK copies mean two incompatible sets of u64/i128 aliases, and the failure surfaces somewhere unrelated as "number is not assignable to bigint". Now generated through scripts/generate-bindings.mjs, which rewrites the manifest deterministically. - The README banner needed splitting in two. A README image renders inside an <img>, which has no inherited text colour, so the single currentColor asset resolved to black and vanished on GitHub's dark theme. Also dropped the <style> block: GitHub sanitises it out of SVGs in markdown, which would have stripped every font declaration. - Docs truth pass: "None of it is implemented", "nothing is built", "Phase 0 of 8" and the open fee-on-transfer question were all false as of this branch. Verified: 75/75 contract tests, clippy clean, markdownlint clean across 25 files, 0 broken links or anchors across 26 files, docs site builds, web app typechecks/lints/builds, and a full create→withdraw→approve→cancel run against the new deployment conserves value exactly: 301,750,000 + 494,305,557 + 203,944,443 = 1,000,000,000. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The docs already had CI; the code did not. Adds two workflows. contract.yml — fmt, tests, clippy on both targets with -D warnings, a real Wasm build, and a size report that fails past 200 KB. Clippy has to run twice because the testutils feature does not build for wasm and the lib does not build for the host with --all-targets. The contract is non-upgradeable, so this is the last cheap place to catch a regression. web.yml — regenerates the bindings rather than trusting a committed copy, which makes the job a live check that deployments.json still points at a contract that answers. Then typecheck, lint, build, and the docs site. The Stellar CLI version is pinned rather than "latest": a CLI upgrade can change the shape of the generated bindings, and that should appear as a deliberate diff rather than turning an unrelated PR red. I guessed v23.1.4 first; checking the API showed latest is v27.1.0, which is also what this was developed against. All nine pinned action SHAs verified against the GitHub API by dereferencing annotated tags — not taken from the version comments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A pre-existing `lib/` rule, meant for build output, was excluding apps/web/lib/ — nine files holding the accrual port, the contract boundary, the wallet layer, the event decoder and both hooks. The app's entire non-component source. Nothing local noticed, because the files were on disk and everything resolved. It took CI type-checking a clean checkout to surface it, as `Cannot find module '@/lib/wallet'` plus a cascade of implicit-any errors from types that could no longer be inferred. That is the first thing the new web workflow caught, one commit after adding it. Removed the rule rather than negating it: nothing in this repo emits to lib/, and a pattern that hides source is worse than an unignored directory that is never created. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phases 0, 1, 2 and 5 are done and on testnet. Phases 3 (indexer) and 4 (the SDK convenience layer) were SKIPPED rather than completed, and the table says so rather than leaving them looking pending — the dashboard folds RPC's event log directly, which works at this scale and is honest about its limit. Phase 5 shipped ahead of its position in the order, because a contract nobody can drive is hard to evaluate. It also came out differently from the plan: roles are not separate views. The interface shows the actions your connected address is actually entitled to on each stream, which removed the "which view am I meant to be in" question rather than answering it. One Phase 5 item is deliberately left unticked: the clawback warning. The threat model accepts T6 and T7 *on the condition* that the SDK and dashboard disclose them, so that acceptance is not honest until the warning exists. Recorded as a gap rather than quietly dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3 tasks
This was referenced Aug 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Takes StelFlow from a documentation repo to a working product, and closes every open issue.
Live on testnet:
CBUWKI66…NRL7· 75 contract tests · dashboard drives every entry point.Closes #2, #3, #10, #11, #13, #33, #34, #38.
The contract
Every Phase 0 decision implemented, with
docs/behaviour.md's scenarios turned into 75 passing tests. No upgrade function exists — not gated, absent. The pause reachescreate_streamand nothing else, expires after 30 days, and can be renounced.canceltakes the recipient's signature alongside the sender's whencancelable = false.accrual.rsis pure functions over aStreamand a timestamp, so the property tests hammer the maths directly. Milestone state, deadline and expiry policy collapse into oneResolutionenum every call site folds over — a milestone cannot mean one thing towithdrawand another tocancel.Two invariants are asserted separately, because they catch different things. Value conservation (
deposit == withdrawn + refunded + remaining) is a closure check: it balances even if one stream were paid out of another's deposit. The contract's token balance is pooled, so per-stream solvency (payout <= total - withdrawn) is checked on its own. That second assertion is the reason a withdrawal pause isn't needed.#38 — milestone deadlines, decided because #33 made it now-or-never
A non-upgradeable contract cannot retrofit a recovery path onto streams already created. Deferring the decision was deciding it, in the direction that can't be reversed.
The interesting finding: grants and vesting want
on_expirypointed in opposite directions. A grant should resolve to the recipient — the funder chose the committee, so the funder carries the risk of it failing. A performance-gated vest should resolve to the sender — a target that pays out because nobody looked is not a target. That is precisely why it is a term agreed at creation rather than a protocol default.Bugs found by running it, not by reading it
pauser == null. Setup was a callableinitialize, and on a contract that can never be upgraded, whoever calls it first holds the role permanently. Moved to__constructor, which runs inside the deploy transaction.getEventsreturns an empty page plus a cursor when nothing matches in the span it scanned — not an error, and not the events further along the range. A single wide query reported "no activity" for a contract with plenty.#[contractevent]emits snake_case topics.StreamCreatedpublishes asstream_created; matching on the Rust type name silently dropped every event.pnpm bindingsdestroyed the workspace on every run, repointing the package at an unbuilt./distand pinning a second stellar-sdk. Two SDK copies mean two incompatibleu64/i128aliases, surfacing as "number is not assignable to bigint" somewhere unrelated.initializeconsumed stream id 0, so the first real stream was id 1.#2 — the survey found the README was wrong
The README called Soroban's streaming projects "hackathon-scale". Both are actively developed, pushed within a week of the survey, with substantial contributor programmes. Corrected rather than quietly dropped.
What held up is narrower: none of the four surveyed implements approver-gated milestones — Sablier's "tranched" streams unlock on a clock, not a signature. LlamaPay is recorded as a genuinely different model rather than a weaker one.
Sablier V2 also shipped its core non-upgradeable with an admin that cannot touch user streams — near-identical to #33's conclusion, reached independently. That resolved a
TODO(maintainer)with a citation instead of a recollection.Docs
All markdown now lives in one folder,
docs/, flattened. Two exceptions, both functional:README.mdis the repo landing page, and.github/templates are only read from.github/.A truth pass corrected every page that still said "None of it is implemented", "nothing is built", "Phase 0 of 8", and the fee-on-transfer question that #32 settled.
The docs won a naming dispute with the code. They specify
bump_streamthroughout; I had implementedtouch. The docs were written first and are the specification, so the code was what was wrong — renamed and redeployed.Verification
cargo clippy(wasm lib + host tests)cargo fmtNot done, deliberately
#12 (Spanish README) is left open for a human. The issue says "Native or fluent speakers only. A machine translation of a technical doc reads as machine-translated and does more harm than no translation." I am machine translation. It does not block anything —
README.es.mdis a leaf nothing imports.The indexer and the Trustless Work integration remain unbuilt and are marked as such wherever they appear.