Skip to content

design: settle upgradeability and pausing — non-upgradeable, create_stream-only pause - #37

Merged
jayteemoney merged 1 commit into
mainfrom
design/upgradeability-and-pause
Aug 15, 2026
Merged

design: settle upgradeability and pausing — non-upgradeable, create_stream-only pause#37
jayteemoney merged 1 commit into
mainfrom
design/upgradeability-and-pause

Conversation

@jayteemoney

@jayteemoney jayteemoney commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Closes #33architecture.md open questions 4 and 5, and threat-model T1 (the highest-severity entry in the model) and T5.

Three decisions, which turned out to be one

  1. Non-upgradeable. No upgrade function in the source, so no key to compromise, coerce, or lose. Only a contract can replace its own Wasm, so an absent function is permanent immutability rather than a policy needing enforcement.
  2. create_stream is the only pausable entry point. withdraw, cancel, approve_milestone, and TTL extension never are — a pause cannot reach a stream that already exists. It auto-expires after 30 days and can be renounced.
  3. cancel on a non-cancelable stream is permitted with the sender's and the recipient's authorization. One authorization rule. Not a new entry point, not a new settlement rule.

The timelock recommendation was wrong, on arithmetic

T1 originally offered a second acceptable answer — upgradeable behind a multisig plus a timelock long enough for recipients to exit. That half is withdrawn. It borrows a vault pattern that doesn't survive contact with streaming: a timelock protects only what a user can withdraw during it, and a stream's defining property is that most of the money isn't withdrawable yet.

Four-year vest, attacker picks the announcement day, recipient reacts perfectly:

Timelock Announced day 0 Announced day 365 Announced day 730
7 days 0.5% 25.5% 50.4%
30 days 2.1% 27.0% 52.0%
90 days 6.2% 31.1% 56.1%

A timelock long enough to actually protect a stream has to outlast the stream — which is non-upgradeability plus a key someone can be compelled to use. Gated tranches can't be rescued at all, and a recipient inside a cliff rescues nothing.

Decision 3 exists because of decision 1

Migration off an immutable contract means each stream unwinds and re-creates. A non-cancelable stream couldn't unwind at all — and that dead end is identical to T3's (approver vanishes, nobody can move the funds). Two unrelated causes producing one dead end was the signal that the dead end was the defect.

cancelable = false now means the sender cannot cancel unilaterally, not nobody can cancel. The recipient's guarantee is unchanged — they gained an option that requires their own signature.

Stated as a limit rather than a win: this makes stranded funds movable without deciding who deserves them. Rule 4 still returns the unapproved tranche to the sender, so a recipient who did the work is being asked to sign it away and may reasonably refuse. T3 moves Unmitigated → Partially mitigated, not Mitigated.

The best case for pausing withdrawals, and the invariant that answers it

T5's original sentence survives but was under-argued. The real counter-case: the contract's token balance is pooled, so an accrual bug over-crediting stream A pays it out of B's deposit — a race where fast recipients drain slow ones.

Answered by an invariant rather than a key: assert payout <= total - withdrawn per stream, one comparison on an entry already loaded. This is only sound because #32 made total the measured balance delta rather than the requested amount.

Recorded in behaviour.md and SECURITY.md: the conservation invariant would not catch cross-stream drain — it's a closure check across the whole contract and balances either way. Same lesson as the day-20 scenario in #26, in a new place.

Two consequences found by tracing dependents

  • ttl-strategy.md proposed storing tunable TTL thresholds "mutable by whatever admin/governance process the upgradeability question settles." That process is now none — so thresholds derive from max_ttl() at call time, which that document already recommended as better practice.
  • architecture.md's "there is no global admin over user funds" needed amending rather than deleting. A pauser exists; it has no power over funds in any existing stream. Precision beats a clean sentence that has quietly stopped being true.

Acceptance criteria

  • Upgradeability decided, counter-argument addressed rather than skipped
  • Pause decided, scoped by entry point (five-row table)
  • Interaction worked through — three directions, only one expected
  • architecture.md open questions 4 and 5 replaced by the decisions
  • threat-model.md T1 and T5 updated; T1's second recommendation amended as wrong
  • "No global admin" claim amended precisely
  • SECURITY.md scope updated — a new class 6 for attacks on the limits themselves

On prior art

Cited where I could verify it, marked TODO(maintainer) where I couldn't. The web tooling was unavailable throughout this work, and a Sablier citation from memory isn't good enough for a document a grant application might lean on.

Verification

markdownlint-cli2 clean (17 files, 0 issues). All internal links and anchors resolved with a local checker — the only hit is the pre-existing GitHub-relative path in PULL_REQUEST_TEMPLATE.md, which resolves correctly on GitHub. Timelock table computed in Python, not estimated.

Next in Track A: nothing — this was the last of #17#32#33. Tracks B and C on #34 remain unclaimed.

…tream-only pause

Answers #33, which is architecture.md open questions 4 and 5 and threat-model
T1 (the highest-severity entry in the model) and T5.

Three decisions that turned out to be one:

1. Non-upgradeable. No upgrade function, so no key to compromise or be coerced
   into using. Only a contract can replace its own Wasm, so an absent function
   is permanent immutability rather than a policy needing enforcement.

2. create_stream is the only pausable entry point. withdraw, cancel,
   approve_milestone and TTL extension never are, so a pause cannot reach a
   stream that already exists. It auto-expires after 30 days and can be
   renounced.

3. cancel on a non-cancelable stream is permitted with the sender's AND the
   recipient's authorization. One auth rule, no new entry point, no new
   settlement math.

The threat model's fallback recommendation — upgradeable behind a multisig and
a timelock — is withdrawn as wrong, and the reason is arithmetic rather than
principle. A timelock protects only what a user can withdraw during it, and a
stream's defining property is that most of the money isn't withdrawable yet.
Against a four-year vest, a 30-day timelock lets a perfectly attentive
recipient rescue 2.1% if the attacker announces early — and the attacker picks
when to announce. Gated tranches can't be rescued at all; a recipient inside a
cliff rescues nothing. A timelock long enough to actually protect a stream must
outlast the stream, which is non-upgradeability plus a key someone can be
compelled to use.

Decision 3 exists because of decision 1. Migration off an immutable contract
means each stream unwinds and re-creates, and a non-cancelable stream could not
unwind at all. That dead end is identical to T3's — approver vanishes, nobody
can move the funds — and two unrelated causes producing one dead end was the
signal that the dead end was the defect. cancelable=false now means the sender
cannot cancel unilaterally, not that nobody can. The recipient's guarantee is
unchanged; they gained an option requiring their own signature.

Honest limit recorded rather than glossed: mutual cancel makes stranded funds
movable without deciding who deserves them. Rule 4 still returns the unapproved
tranche to the sender, so a recipient who did the work may reasonably refuse.
T3 goes Unmitigated -> Partially mitigated, not Mitigated.

The strongest case for pausing withdrawals is stronger than T5 originally
allowed and is now recorded: the contract's token balance is pooled, so an
accrual bug over-crediting one stream pays it out of another's deposit. It is
answered by an invariant rather than a key — assert payout <= total - withdrawn
per stream, one comparison on an entry already loaded. This is only sound
because #32 made `total` the measured balance delta rather than the requested
amount. Also noted in behaviour.md and SECURITY.md: the conservation invariant
would NOT catch cross-stream drain, since it is a closure check across the
whole contract and balances either way. Same lesson as the day-20 scenario in
PR #26, in a new place.

Two consequences found by tracing dependents rather than by looking for them:

- ttl-strategy.md proposed storing tunable TTL thresholds "mutable by whatever
  admin/governance process the upgradeability question settles". That process
  is now none, so the thresholds must be derived from max_ttl() at call time —
  which that document already recommended as better practice anyway.
- architecture.md's "there is no global admin over user funds" needed amending
  rather than deleting. A pauser role now exists; it has no power over funds in
  any existing stream. Precision beats a clean sentence that has quietly
  stopped being true.

Prior art is cited where I could verify it and marked TODO(maintainer) where I
could not — the web tooling was unavailable during this work, and a Sablier
citation from memory is not good enough for a doc a grant application might
lean on.

Closes #33.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZDKaiY2UJbvbZCKeqy4cg
@jayteemoney
jayteemoney merged commit d14122f into main Aug 15, 2026
3 checks passed
@jayteemoney
jayteemoney deleted the design/upgradeability-and-pause branch August 15, 2026 19:43
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.

design: upgradeability and emergency pause — settle open questions 4 and 5

1 participant