feat: safe harbour sweep activation - #796
Conversation
Codecov Report❌ Patch coverage is @@ Coverage Diff @@
## feat/bridge-sweep-prereq-str-3937 #796 +/- ##
=====================================================================
+ Coverage 87.71% 88.39% +0.67%
=====================================================================
Files 257 258 +1
Lines 32477 32786 +309
=====================================================================
+ Hits 28488 28980 +492
+ Misses 3989 3806 -183
... and 9 files with indirect coverage changes 🚀 New features to boost your workflow:
|
4bc54c5 to
231957c
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 231957c7ee
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
try_register_deposit is the single chokepoint that admits a DRT and registers its DepositSM and GraphSMs, so gate it on the registry latch right after the envelope pre-filter. The gate is best-effort by design: a DRT admitted before the node latches is caught by the sweep once the deposit reaches Deposited, which is what keeps the check a cheap synchronous read on the block path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds the SafeHarbourAbort event and its transition, mirroring the user takeback: Created and GraphGenerated move to Aborted and emit the existing DepositRequestTakenBack graph-teardown signal. The window ends at DepositNoncesCollected, where this operator's partial signature is already gossiped and cannot be recalled; later deposits finish and are swept instead. No on-chain takeback exists for this abort, so the signal carries the deposit-request txid. Replays against an aborted deposit are duplicates (the per-block scan re-emits until the deposit leaves the safe window); every other state is classified explicitly so a future state cannot silently inherit a fatal arm. The abort can also pre-empt graph generation, so a late GraphAvailable signal reaching an aborted deposit is classified as a non-fatal rejection instead of falling through to the fatal invalid-event arm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…bour scan After each buried block is processed, enumerate the registry's deposits while the safe-harbour latch is set: Deposited deposits are seeded with SweepRequested carrying the frozen safe-harbour descriptor, and safe-window deposits (Created/GraphGenerated) with SafeHarbourAbort. The seeds flow through the same applicator batch as block events, so duties and persistence follow the normal path. Re-running every block is the retry mechanism: the deposit SM classifies replays as duplicates, so emission is idempotent until each deposit leaves its scanned state. Mid-signing deposits (partial already gossiped) are left to finish and become sweepable at Deposited, and an Assigned deposit whose deadline lapses reverts to Deposited and becomes sweepable on its own. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ddress The flagship safe-harbour sweep scenario: N completed deposits, a real Defcon1 council tx, then one single-input sweep tx per deposit paying the frozen address exactly deposit - fee - anchor, with the operator-keyed CPFP anchor present at dust value. The shared helpers (activation via dev-cli + latch wait, sweep-tx wait and shape assertions, fee constants pinned to the Rust helpers) live in utils/safe_harbour.py for the remaining sweep tests to reuse. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A DRT confirmed after every operator latches must never be admitted as a deposit. A pre-activation deposit first proves the pipeline works, then the post-activation DRT is watched across its burial and an observation window on every operator. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Holds one operator down so the deposit deterministically pins in Created (pausing block production would not work: graph generation and signing progress off-chain), then activates. The live operators must abort the deposit, and the held-down operator must converge after restart by either refusing the replayed DRT or aborting the admitted deposit — the halt gate is best-effort by design, so both outcomes are legal, but completing the deposit is not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sweep is N-of-N, so one offline operator must stall it: the deposit UTXO stays unspent across an observation window while the operator is down. After the restart the operator latches from the ASM tip and joins the round via nagging, and the sweep confirms with the expected shape. No new bridge code is involved — this pins the nag/retry and latch recovery paths working together. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r is active The deposit SM stops emitting withdrawal duties once it enters the sweep flow, but that leaves the switch-over window and the graph SMs (which have no sweep flow of their own) uncovered. Drop is_withdrawal_path() duties in the pipeline dispatch loop — the loop holds the registry, the duty dispatcher does not — so no user is fronted and no claim advances towards a payout while deposits are being swept. The defensive duties (contest, counterproofs, slash, unstaking burn) never match the taxonomy and always dispatch: they are the only funds defense when a rogue operator stalls the sweep and fires its pre-signed claim path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-block scan now seeds SweepRequested from all withdrawal states (Assigned, Fulfilled, PayoutDescriptorReceived, PayoutNoncesCollected, CooperativePathFailed) in addition to Deposited. The deposit UTXO is a fixed N-of-N key-path output that withdrawal progress never touches, so the sweep entry already accepts every one of these states — this wires the trigger. Whatever payout progress the source carried is abandoned; a racing payout is resolved on chain, whichever tx spends the outpoint first. Deposits already in the sweep flow are skipped rather than re-seeded so routine scans do not log a duplicate per deposit per block. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The assignee is held down when the assignment lands, pinning the deposit in Assigned with no fulfillment possible. Activation must sweep the deposit out of Assigned on the live operators, and the restarted assignee must join the stalled N-of-N round instead of fulfilling: the mux orders safe-harbour state ahead of the replayed assignment, so the fulfillment duty is suppressed at dispatch. Asserts the sweep shape and that the assignee's log contains no fulfillment submission. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Activation lands after the fulfillment confirms, while the cooperative payout round is in flight. The winner is intentionally unspecified: the test asserts the deposit outpoint is spent by exactly one transaction — the sweep (full shape checked) or the payout — and that every operator converges with no pending withdrawal and a live RPC afterwards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scenario the duty-suppression carve-out exists for: a claimer goes mute after posting its claim (stalling the N-of-N sweep) and the safe harbour activates. The watchtower contest, bridge-proof timeout, and slash must all proceed with the latch set — extraction through the pre-signed claim path costs the claimer its stake. Once the claimer returns, the stalled sweep completes to the frozen address, proving the funds never left through the claim path. Mirrors the bridge-proof-timeout slashing flow with activation inserted after the claim confirms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
process_graph_available sent every state past Created (except the abort-tolerated Aborted) to InvalidEvent via a catch-all. GraphMessage is not softened, so a hit is fatal: the orchestrator raises an InvariantViolation that takes the node down, and the failure recurs on restart because the unpersisted batch rolls back and nagging redelivers the partial that re-emits the signal. No current sequence reaches the arm — the graph SM emits GraphAvailable exactly once (final graph partial), and both abort paths tear the graphs down in the same atomic batch — so this is hardening: the arm's safety rests on invariants maintained elsewhere, and the same catch-all pattern already caused a real fleet crash when the ASM replayed assignments into the sweep states. States past Created hold the full graph set, so a late link there is a replayed signal (duplicate); the match is exhaustive so a future state must be classified rather than silently inherit a fatal arm. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…test The halt test's only unique content was a post-activation DRT and an observation window; its pre-activation sanity deposit duplicated the flagship's deposits, and the flagship's sweep-confirmation waits already span the window. Broadcasting the DRT right after activation and asserting it is never admitted once the sweeps confirm covers the same contract with one fewer environment spin-up, and the combined scenario is the activation contract in one place: halt new custody, sweep existing deposits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The liveness test was mechanically a subset of the assigned test — operator down at activation, sweep completes after restart via nagging and latch recovery — differing only in its stall assertion and in sweeping from Deposited rather than Assigned. The from-Deposited path is covered by the unassigned flagship, so asserting the stall (deposit UTXO unspent while the assignee is down) before the restart in the assigned test preserves the full contract with one fewer environment spin-up. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
CI runs one parallel job per directory under tests/, and safe_harbour had grown into the largest group. Keep the activation semantics (detection, halt + unassigned sweep, safe-window abort) in safe_harbour/ and move the in-flight withdrawal interplay (assigned sweep, sweep-vs-payout race, defensive duties) to safe_harbour_withdrawal/, which also groups the tests that need the checkpoint/assignment machinery. Group discovery picks up the new directory automatically. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
wait_until_bridge_proof_timedout polls pendingWithdrawalInfo, which reports None once the scan sweeps the deposit out of the withdrawal states — within a couple of blocks of the latch — so the phase check could never succeed under safe harbour and timed out in CI. The contest-proof connector spend already proves the timeout on-chain (only the proof-timeout tx can spend it with the claimer down), and the slash wait covers the graph-side tracking, since the slash only fires after the graphs process the timeout. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
231957c to
ac41bd9
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ac41bd94d7
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
…very The mux prioritizes buried blocks over ASM events, so the activation block was processed before the latch was set and the scan only ran on the next buried block — sweeps and aborts lagged activation by a block interval, unbounded if block production stalls, and a latch recovered from persistence waited the same way after a restart. Latch first, then run the scan through the regular applicator/persist/dispatch stages: a first activation falls through to the stages it previously skipped, startup seeds the scan once when a persisted latch is recovered, and the per-block scan remains the retry. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rajil1213
left a comment
There was a problem hiding this comment.
Overall looks good.
This still needs rebasing, and perhaps more importantly, updating the orchestrator design doc to include the latch as part of the pipeline.
bridge-sm, the rpc-server monitoring tests and the safe-harbour scan each hand-rolled the same two connectors before calling DepositTx::new. Expose build_test_deposit_tx behind tx-graph's existing test_utils feature and point all three at it. The fixture lives in tx-graph rather than test-utils because test-utils depends only on primitives; hosting a DepositTx there would invert that against tx-graph's dev-dependency on it. Two incidental changes: the monitoring fixture is now deterministic (it drew fresh random keys per call, and nothing reads the tx), and the recovery delay is uniformly 1008 -- it only shapes the deposit-request tapscript, since the Normal spend path always sequences as Sequence::MAX. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5a215c8f04
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Description
Stacked on #756 . Wires the persisted safe-harbour latch to bridge behaviour; the sweep machinery it drives landed in the base branch.
try_register_deposit(best-effort by design; anything admitted late is caught by the sweep).SafeHarbourAborttransition forCreated/GraphGenerated(no partial gossiped yet), reusing the takeback graph-teardown signal. Later pre-Depositedstates finish and are swept.SweepRequested(with the frozen address) forDepositedand all withdrawal states, andSafeHarbourAbortfor the safe window. Re-emission per block is the retry mechanism; the SM classifies replays as duplicates.Also hardens
process_graph_available: late graph links are now non-fatal (duplicate/rejected) instead of a pipeline-killingInvalidEvent: defense in depth against the catch-all pattern that previously caused a fleet crash via assignment replays.Tests
Unit tests per transition plus six functional tests across two CI groups:
safe_harbour/(detection, halt + unassigned sweep with exact payout/anchor values, safe-window abort) andsafe_harbour_withdrawal/(assigned-deposit sweep with fulfillment suppression, sweep-vs-payout race, contest/slash ladder under an active latch).Type of Change
Notes to Reviewers
Checklist
Related Issues
STR-3938, STR-3939