Skip to content

feat: MVP — contract on testnet, dashboard, and every open issue closed - #39

Merged
jayteemoney merged 8 commits into
mainfrom
feat/mvp
Aug 16, 2026
Merged

feat: MVP — contract on testnet, dashboard, and every open issue closed#39
jayteemoney merged 8 commits into
mainfrom
feat/mvp

Conversation

@jayteemoney

@jayteemoney jayteemoney commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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 reaches create_stream and nothing else, expires after 30 days, and can be renounced. cancel takes the recipient's signature alongside the sender's when cancelable = false.

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 every call site folds over — a milestone cannot mean one thing to withdraw and another to cancel.

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_expiry pointed 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

  1. The first deploy came up with pauser == null. Setup was a callable initialize, 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.
  2. RPC getEvents returns 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.
  3. #[contractevent] emits snake_case topics. StreamCreated publishes as stream_created; matching on the Rust type name silently dropped every event.
  4. The meter labelled a cancelled stream's remainder "Unstreamed" — that money went back to the sender.
  5. pnpm bindings destroyed the workspace on every run, repointing the package at an unbuilt ./dist and pinning a second stellar-sdk. Two SDK copies mean two incompatible u64/i128 aliases, surfacing as "number is not assignable to bigint" somewhere unrelated.
  6. initialize consumed 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.md is 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_stream throughout; I had implemented touch. The docs were written first and are the specification, so the code was what was wrong — renamed and redeployed.

Verification

Check Result
Contract tests 75/75
cargo clippy (wasm lib + host tests) clean
cargo fmt clean
markdownlint 0 issues, 25 files
Links + anchors 0 broken, 26 files
Web typecheck / lint / build clean
Docs site build + Mermaid renders, verified in-browser
End-to-end on testnet conserves value exactly
create → withdraw → approve → cancel
withdrawn 301,750,000 + refund 494,305,557 + remaining 203,944,443
  = 1,000,000,000 deposited

Not 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.md is a leaf nothing imports.

The indexer and the Trustless Work integration remain unbuilt and are marked as such wherever they appear.

jayteemoney and others added 8 commits August 16, 2026 08:48
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

docs: survey existing payment-streaming work on Soroban and EVM

1 participant