Skip to content

multi: unify all unroll triggers behind the VTXO manager - #909

Merged
Roasbeef merged 11 commits into
mainfrom
unify-unroll-through-vtxo-manager
Jul 10, 2026
Merged

multi: unify all unroll triggers behind the VTXO manager#909
Roasbeef merged 11 commits into
mainfrom
unify-unroll-through-vtxo-manager

Conversation

@Roasbeef

@Roasbeef Roasbeef commented Jul 9, 2026

Copy link
Copy Markdown
Member

In this PR, we unify every unilateral-exit ("unroll") trigger behind the
VTXO manager's single admission gate, so a coin that is exiting is always
persisted to VTXOStatusUnilateralExit (out of the live set) before the
unroll registry admits the job. This fixes a permanent wedge where a
cooperative send --onchain --sweep-all (or any cooperative consumption)
kept failing at the operator with:

join request invalid: invalid forfeit request for outpoint <op>:
forfeit VTXO is not live: status is unrolled_by_client

The root cause

The VTXO manager was designed as the single admission gate, but two of the
four unroll triggers went around it. Manual (da exit / RPC) and
critical-expiry flow manager → FSM → UnilateralExitState → chain resolver
→ registry. The other two did not:

  • Fraud (fraud/actor.go) Asked the unroll registry directly.
  • vHTLC recovery (vhtlcrecovery/coordinator) admitted the registry
    directly, and its materializer persisted the recovery target as
    VTXOStatusSpending.

Spending is status 7, and ListLiveVTXOs returns status < 3 OR status = 7. So the vHTLC recovery target was never flipped out of the live set:
on the next daemon restart the manager rehydrated it as a live actor, it
re-entered coin selection and sweep-all, and the operator rejected the whole
round because it already considered the coin unrolled_by_client. Every
retry re-included it, and it survived restarts. Fraud had the same shape
(the coin stayed live), and both were invisible to the #400 restart
orphan-recovery scan, which keys off UnilateralExit.

The change

We make the manager own the exit for all four triggers. ForceUnrollRequest
now carries the trigger and an optional exit-policy identity, threaded
string-typed through ForceUnrollEvent and ExpiringNotification into the
chain-resolver bridge, which converts them back into unroll.StartTrigger /
unroll.ExitPolicyKind at the one seam where both packages are in scope
(they ride string-typed because unroll already imports vtxo, so the real
types can't be referenced without a cycle). The manager can now
handleForceUnroll a VTXO with no live actor by re-materializing it from the
persisted descriptor, which is what lets it own the vHTLC recovery target
(materialized store-level, never a normal coin). vHTLC recovery now hands off
to the manager's ForceExit seam, and the materializer persists the target
as UnilateralExit, so it is out of the live set and inside the restart
scan where an exiting coin belongs.

We also re-sequence boot recovery so the policy-bearing vHTLC restore admits
before the generic no-policy orphan scan: the LazyChainResolver buffers
the restore's admissions and replays them when the resolver is wired, and the
registry is first-writer-wins on exit policy, so a refund target must land its
policy first.

Review-driven hardening

An adversarial review of the "manager now owns the recovery target" topology
surfaced two failure-leg bugs, both fixed here:

  • A recoverable unroll failure (the #602 no-footprint rollback) would
    relive the recovery-only target into the live coin set, turning a
    swap-contract output into spendable balance. We thread the job's exit
    policy onto ExitOutcomeNotification and hold recovery-only targets in
    exit instead of reliving them.
  • The no-policy orphan scan could permanently claim a vHTLC target under the
    standard timeout policy if restore failed for it once. We hand the scan the
    durable exit policy of every non-terminal recovery target so it re-admits
    under the right policy regardless of interleaving.

See each commit message for the detailed reasoning w.r.t the incremental
changes.

Testing

Unit coverage for the metadata threading, the manager admission gate
(spawn-from-descriptor + absent/terminal guards), the idempotent
UnilateralExitState re-admission, the chain-resolver mapper, the recovery
coordinator handoff, and both review fixes (recovery-only hold on recoverable
failure, policy-carrying orphan scan). The full end-to-end vHTLC-refund →
unroll → sweep-all regression is a follow-up in swapdk-server, where a real
swap can be driven.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the unilateral exit flow by routing vHTLC recovery and fraud escalation through the VTXO manager's single admission gate, ensuring consistent state transitions and preventing recovery-only targets from being incorrectly relived as spendable liquidity. A potential nil pointer dereference was identified in the recovery coordinator service when checking the unroll status, which should be resolved by adding a defensive nil check.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread vhtlcrecovery/coordinator/service.go Outdated
Comment on lines 512 to 514
if !status.Found {
return fmt.Errorf("unroll admission returned without visible " +
"status")
return nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If s.unroll.GetStatus returns nil, nil (which is a common pattern in Go to represent a missing or uninitialized status, especially in test mocks), checking !status.Found directly will cause a nil pointer dereference panic. Adding a nil check on status ensures robust defensive programming.

Suggested change
if !status.Found {
return fmt.Errorf("unroll admission returned without visible " +
"status")
return nil
}
if status == nil || !status.Found {
return nil
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 07b39cb. Good catch, and it applied to both GetStatus read sites, not just this one: the sibling reconcileLoaded path dereferenced the status the same way. Guarded both with status == nil || !status.Found and added a coordinator test that drives RestoreNonTerminal against a nil-status source to pin it (it panics without the guard, which is how I caught the second site).

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f34bfd5545

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread darepod/server.go
// witness against a vHTLC taproot tree would never sweep.
ensureReq := &unroll.EnsureUnrollRequest{
Outpoint: op,
Trigger: unroll.TriggerRestart,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve fraud trigger in orphan recovery

When a fraud spend is forced through the VTXO manager, there is now a crash/drop window after the VTXO status is committed to UnilateralExit but before the best-effort chain-resolver Tell creates the registry row. On restart this orphan scan re-admits every such target as TriggerRestart, so a fraud-triggered target loses TriggerFraudSpend; the unroll FSM only applies the fraud checkpoint deferral/backstop logic for TriggerFraudSpend (see shouldSubmitReadyFrontier), so recipient fraud recovery can resume with the wrong behavior. Persist/recover the original fraud trigger or avoid routing fraud orphans through this generic restart admission.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed the window is real: routing fraud through the manager commits UnilateralExit before the best-effort chain-resolver Tell, so a crash in that gap leaves an orphan the restart scan re-admits as TriggerRestart, dropping TriggerFraudSpend.

Two things worth pinning down on impact before deciding the fix:

  1. It's a lost timing optimization, not a fund/liveness bug. Under a non-fraud trigger shouldSubmitReadyFrontier returns true, so the FSM submits the ready checkpoint immediately instead of deferring it to the fraud backstop deadline. That's a premature but safe broadcast: the exit still completes and the recipient still recovers the output; what's lost is the deferral (hold the checkpoint until near the CSV deadline). There's no path where the earlier broadcast helps an attacker.

  2. This is a byproduct of fixing the larger bug. Before this PR a fraud target never left the live set at all (it stayed Live), so it was never a UnilateralExit orphan-scan candidate. The window only exists because fraud now correctly goes out of the live set through the manager.

On the fix: a faithful "recover the trigger" needs new durable fraud state. Unlike the vHTLC recovery policy (which the orphan scan already recovers from the recovery store via recoveryExitPolicies), the fraud watcher keeps its watch set in memory and has no store the scan can consult, so preserving the trigger means persisting fraud-forced exits durably and joining them in the scan.

Given the impact is a lost deferral optimization in a narrow crash window with no fund risk, my inclination is to document this as a known limitation consistent with the async-admission trade-off rather than add a durable fraud-trigger store in this PR. I'm getting a second opinion on that call and will follow up here either way.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second opinion is in and concurs: keep it documented, don't fix in this PR. The key confirmations from tracing the FSM: losing TriggerFraudSpend degrades to the same immediate-checkpoint-submission that manual and critical-expiry (the deadline-sensitive path) already use; the CSV delay is relative to checkpoint confirmation so earlier confirmation only starts the sweep clock earlier (never misses a deadline); checkpoints are the recipient's own pre-signed exit lineage, so publishing them reveals nothing and enables no attacker spend. And the fraud watcher can't paper over it on restart — it rebuilds watches from live descriptors, and an orphaned target is UnilateralExit, so it's never re-watched.

Documented the degraded behavior and the faithful follow-up at the re-admission site in e22f731 (recoverOrphanedUnrollJobs): the minimal correct fix is to stamp the trigger onto the VTXO row in the same tx that flips it to UnilateralExit and read it back off the descriptors the scan already lists — atomic, no new store, but a schema change and cleanly separable.

Comment thread vtxo/manager.go
Comment on lines +836 to +839
if err != nil {
res := fn.Err[ManagerResp](
fmt.Errorf("load vtxo for force-unroll: %w", err),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat missing VTXO as a declined force-unroll

In production VTXOPersistenceStore.GetVTXO returns an error on a miss (wrapped sql.ErrNoRows, and the interface documents “Returns error if not found”), so this new absent-actor path never reaches the descriptor == nil branch. A manual/fraud force for an outpoint the wallet no longer tracks now surfaces an internal load vtxo for force-unroll error instead of the intended Accepted:false, Reason:"no such vtxo"; handle the not-found error here before returning a hard failure.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 314cc51, a bit more thoroughly than a local not-found check. Rather than teach the manager about sql.ErrNoRows, the store now returns a typed domain sentinel vtxo.ErrVTXONotFound on a miss (wrapping sql.ErrNoRows in the chain so the other call sites that still test for it keep working), and spawnForceUnrollActor matches the sentinel to return Accepted:false, Reason:"no such vtxo". That keeps the domain manager off a persistence-layer detail and makes the decline read correctly for an outpoint the wallet no longer tracks. Added a db-store test asserting the sentinel (and back-compat with sql.ErrNoRows) plus a manager test for the wrapped-error path.

@Roasbeef
Roasbeef force-pushed the unify-unroll-through-vtxo-manager branch from 1b2113e to b8ec48f Compare July 9, 2026 23:24
@Roasbeef

Roasbeef commented Jul 9, 2026

Copy link
Copy Markdown
Member Author

Added the faithful end-to-end regression for this fix in swapdk-server#214 (draft, stacked on this PR). It drives an out-swap into refund-without-receiver recovery, restarts the client daemon, and runs a real send --onchain --sweep-all, asserting the exiting recovery target is held in UNILATERAL_EXIT and never offered as a forfeit input. Against the pre-fix client it reproduces the operator wedge; against this branch it passes (~52s). That PR's client submodule should be re-pinned to the merged commit once this lands.

@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 3m 14s —— View job


Review: unify unroll triggers behind the VTXO manager

I read the full diff (git diff 3fd9eb6..HEAD, 21 files) plus the surrounding vtxo, unroll, fraud, and vhtlcrecovery context and package invariants.

  • Gather context (read changed files, diff vs base)
  • Analyze metadata threading & admission gate
  • Assess the three bot findings
  • Post review

Overall this is a clean, well-reasoned consolidation. The single-admission-gate topology is the right shape, the string-typed trigger/policy threading to avoid the unroll↔vtxo cycle is tasteful, the spawnForceUnrollActor terminal/absent guards are careful, and the two review-driven failure-leg fixes (recovery-only hold, policy-carrying orphan scan) are correct. Test coverage is strong. Comments are excellent — they explain the why at every seam.

My findings below, roughly in priority order.

🔴 1. Fraud trigger is lost in the orphan-recovery scan (agrees with Codex P1)

This PR introduces a new crash window that didn't exist for fraud before. Previously fraud called unroll.EnsureUnroll(TriggerFraudSpend) directly, creating the registry record in one step. Now fraud flows manager → status-flip to UnilateralExit → best-effort Tell → registry UpsertRecord. A crash/drop between the status commit and the registry write leaves the coin in UnilateralExit with no registry record.

On restart, recoverOrphanedUnrollJobs (darepod/server.go:5548) re-admits every such orphan under unroll.TriggerRestart. But the fraud-specific safety logic keys off the trigger:

  • unroll/fsm_logic.go:179 — rejects a zero CSV delay only under TriggerFraudSpend.
  • unroll/fsm_logic.go:573shouldSubmitReadyFrontier returns early unless job.Trigger == TriggerFraudSpend.

So a fraud target that hit the crash window resumes with the wrong checkpoint-deferral/backstop behavior. Note the PR already solved the exactly analogous problem for the exit policy by threading recoveryExitPolicies into the scan — but the fraud trigger has no equivalent carry-through, and the fraud watcher may not re-fire the escalation on restart once the ancestor spend has already confirmed.

Options: (a) persist/recover the original trigger the way you now recover the exit policy (e.g. a trigger map fed into the orphan scan), or (b) keep fraud orphans out of the generic TriggerRestart admission. Fix this →

🟡 2. Missing VTXO surfaces an internal error instead of a clean decline (agrees with Codex P2)

In spawnForceUnrollActor (vtxo/manager.go:835) the descriptor == nil branch that returns Accepted:false, Reason:"no such vtxo" is effectively dead in production. The interface documents "Returns error if not found" (vtxo/interfaces.go:462), and the concrete db.VTXOPersistenceStore.GetVTXO (db/vtxo_store.go:192-195) wraps q.GetVTXO's sql.ErrNoRows into the returned error. So a manual da exit (or fraud force) for an outpoint the wallet no longer tracks now returns a hard load vtxo for force-unroll error instead of the intended graceful "no such vtxo" decline — a behavior regression vs. the old absent-actor path.

Suggest handling not-found before the hard-error return:

descriptor, err := m.cfg.Store.GetVTXO(ctx, outpoint)
if errors.Is(err, sql.ErrNoRows) {
        res := fn.Ok[ManagerResp](&ForceUnrollResponse{
                Accepted: false, Reason:,
        })
        return nil, &res
}
if err != nil { ... }

(If GetVTXO ever returns (nil, nil), keep the existing descriptor == nil guard as a belt-and-suspenders.)

🟢 3. Defensive nil-status check (agrees with Gemini, low priority)

vhtlcrecovery/coordinator/service.go:512 dereferences status.Found right after GetStatus. In production ActorUnrollRegistry.GetStatus never returns (nil, nil) on err == nil, so this can't panic against the real registry — but the narrow UnrollRegistry interface exists precisely so tests can substitute mocks, and a mock returning (nil, nil) would panic. The status == nil || !status.Found guard Gemini suggests is cheap and matches the "not visible yet → return nil" intent already in place. Optional hardening.

Notes / things I checked that look correct

  • The recovery-only hold in recoverExitedVTXO (vtxo/manager.go) correctly gates on req.ExitPolicyKind.Valid(), which is true only for the two vHTLC kinds — a standard-timeout or empty kind falls through and relives normally, so manual/critical-expiry coins aren't accidentally trapped in exit. 👍
  • Sourcing policyKind from the child's terminal message (b.exitPolicyKind()) rather than the possibly-evicted r.pending record (unroll/registry.go:1006), and not stamping the ref-less message kind onto the persisted record, is a nice subtle fix.
  • The boot re-sequencing (restore-before-orphan-scan + LazyChainResolver buffering + first-writer-wins policy) is sound, and belt-and-suspenders with the policy-carrying scan.

Given finding #1 opens a real (if narrow) correctness gap for fraud recovery that this PR itself introduces, I'd treat it as a blocker or an explicit follow-up before merge; #2 is a small but user-visible RPC regression worth a quick fix; #3 is optional.
· unify-unroll-through-vtxo-manager

Roasbeef added 7 commits July 9, 2026 17:19
In this commit, we widen the ForceUnroll path so a forced unilateral
exit can carry two pieces of metadata end-to-end: the trigger that
started it (manual, critical expiry, fraud spend) and an optional
exit-spend policy identity (a vHTLC refund names its own policy). This
is the groundwork for routing fraud and vHTLC recovery through the VTXO
manager's admission gate rather than letting them admit the unroll
registry behind the manager's back.

We add these as a string-typed UnrollTrigger enum and an
fn.Option[ExitPolicy] on actormsg.ForceUnrollRequest, mirror them onto
the vtxo ForceUnrollEvent and ExpiringNotification, and populate them in
every FSM arm that escalates to UnilateralExitState. They ride
string-typed on the vtxo/actormsg side on purpose: unroll already
imports vtxo, so the real unroll.StartTrigger / unroll.ExitPolicyKind
can't be referenced here without a cycle. The darepod chain-resolver
bridge converts them back at the seam where both packages are in scope.

We also make UnilateralExitState re-emit the ExpiringNotification on a
duplicate ForceUnrollEvent instead of silently self-looping. A first
admission can be lost before the registry records it (a crash between
the status flip and the registry UpsertRecord), so an idempotent
re-admission lets a restart re-drive the exit under the same trigger and
policy. The registry dedups against a live record, so a redundant
re-admit is harmless.
In this commit, we teach Manager.handleForceUnroll to re-materialize a
VTXO actor from its persisted descriptor when there is no live actor for
the outpoint, then drive the ForceUnrollEvent through it carrying the
request's trigger and exit policy.

This is what lets the manager own the exit for triggers whose target is
not a normal live coin. The vHTLC recovery target is materialized
directly in the store and never had a manager actor, and any exiting
VTXO that a restart left out of the live-recovery set (UnilateralExit is
excluded from ListLiveVTXOs) has no actor either. Rather than let those
callers admit the unroll registry behind the manager's back, the manager
spawns the actor from the descriptor and runs the same transition every
other trigger uses.

A missing descriptor reports "no such vtxo" and a terminal descriptor
reports "already terminal" instead of spawning an actor that would
immediately reap itself, so the caller can tell a real transition apart
from a no-op.
In this commit, we point the recipient fraud watcher at the VTXO manager
instead of the unroll registry. When a watched ancestor of a tracked OOR
VTXO is seen spent on-chain, the watcher now Asks the manager to force
the affected target into unilateral exit under TriggerFraudSpend, and
the manager owns the state transition and starts the durable unroll job
through its chain-resolver seam.

Before this, fraud admitted the unroll registry directly, so the VTXO
was never flipped to UnilateralExit: it stayed live in the store, leaked
back into the live set on the next restart, and was invisible to the
#400 orphan-recovery scan. Converging on the manager fixes both: the
coin is persisted out of the live set the moment fraud fires, and the
restart scan covers it.

A declined transition (the coin is already terminal, or the wallet no
longer tracks it) is logged rather than surfaced as a hard error: the
fraud watch has done all it can, and failing would only wedge escalation
for the other targets sharing the ancestor.
In this commit, we route vHTLC recovery through the VTXO manager and
wire the chain-resolver bridge that converts a manager exit notification
into a registry admission, completing the unification of all four unroll
triggers (manual, critical expiry, fraud, vHTLC) behind the manager's
single admission gate.

The recovery coordinator now hands off to the manager's ForceExit seam
instead of admitting the unroll registry itself, carrying the recovery
row's exit policy so the registry records the refund policy rather than
the standard timeout. The materializer persists the recovery target
directly into VTXOStatusUnilateralExit rather than Spending: Spending is
returned by ListLiveVTXOs (status < 3 OR status = 7), so a Spending
target leaked back into the live set on restart and poisoned sweep-all,
which offered the already-exiting coin as a forfeit and got the whole
round rejected with "forfeit VTXO is not live: status is
unrolled_by_client". This is the root cause of the reported wedge.

Admission is asynchronous now, so the coordinator no longer reads the
registry record back for a synchronous policy check on the happy path.
It keeps a best-effort guard: a visible record under a different policy
still fails the recovery closed, while a not-yet-visible record is left
to the registry's own validation and the restart re-drive.

On the darepod side, the chain-resolver bridge maps the threaded
trigger and exit policy back into unroll.StartTrigger /
unroll.ExitPolicyKind (an empty trigger stays critical expiry, matching
the auto-expiry default). We also restore in-flight recovery jobs before
the generic orphan scan: restore drives the policy-bearing admission,
which the LazyChainResolver buffers and replays to the registry the
instant it is wired, and the registry is first-writer-wins on exit
policy, so the no-policy orphan scan must run after that replay or a
refund target would silently exit under the standard timeout.
In this commit, we fix a fund-safety regression the unification
introduced: a recoverable unroll failure would relive a vHTLC recovery
target into the live coin set. Because the manager now owns an actor for
the recovery target and persists it as VTXOStatusUnilateralExit (both
new in this series), the darepo-client#602 recovery edge
(ExitOutcomeRecoverable rolls a no-footprint failure back to LiveState)
now fires for it: the recovery output, which is a swap-contract output
and not spendable liquidity, would become a live wallet coin, inflating
balance and re-poisoning sweep-all. Before the series it was safe
because the target was Spending with no manager actor, so both the
store-path guard and boot reconciliation skipped it.

We thread the unroll job's exit policy onto ExitOutcomeNotification (and
ExitOutcomeResolution for the boot-reconcile path), sourced from the
registry record in notifyVTXOExit and from the persisted unilateral-exit
job in resolveExitOutcome. recoverExitedVTXO then refuses to relive a
target whose policy is a known non-standard (recovery-only) policy,
holding it in UnilateralExit instead. A clean refund failure is the
owning recovery subsystem's job to retry or terminal-fail; the manager
must not resurrect the coin as spendable in the meantime.
In this commit, we close a first-writer-wins hole in the boot orphan
scan. The registry keeps whichever exit policy admits a target first and
ignores later requests' policies. recoverOrphanedUnrollJobs re-admits
every unilateral-exit VTXO under TriggerRestart with no policy, and
vHTLC recovery targets are now in that scan (they are UnilateralExit,
not Spending). If RestoreNonTerminal failed for a target on a prior boot
(a transient error leaves it exiting on disk with no registry record),
the no-policy scan would permanently claim it as a standard timeout
exit, and a standard witness against a vHTLC taproot tree never sweeps.

We hand the scan the durable exit policy of every non-terminal recovery
target, indexed by outpoint, so it re-admits a refund target under the
right policy even when it is the one that creates the record. The
restore-before-scan ordering stays as belt-and-suspenders. We also pin
the hand-maintained actormsg exit-policy enum to the canonical
vhtlcrecovery constants with a mirror test, since actormsg can't import
vhtlcrecovery without a cycle and a silent drift would break the
round-trip.
In this commit, we source the exit policy the registry hands to the VTXO
manager on a terminal unroll from the child's UnrollTerminatedMsg rather
than from the registry's in-memory pending record.

The prior F1 fix read record.ExitPolicyKind out of r.pending in
handleTerminated. That cache is legitimately evicted the moment a
child's async terminal persist completes (handlePersistRecordResult
drops the entry once the store write lands), so a recovery-only vHTLC
target can reach handleTerminated with no cached record at all. When
that happens the kind arrives empty, the manager's Valid() guard misses,
and the target gets relived to live: the exact darepo-client#602 relive
bug the F1 fix was meant to close, just through a narrower window (one
flaky admission-refinement persist under load).

The child already knows its policy authoritatively from its own durable
state via exitPolicyKind(), so we stamp it onto the terminal message and
prefer it in handleTerminated. The record stays accurate for persistence
and the manager sees the real kind whether or not the cache survived.
@Roasbeef
Roasbeef force-pushed the unify-unroll-through-vtxo-manager branch from b8ec48f to 75f7d0a Compare July 10, 2026 00:20
Roasbeef added 3 commits July 9, 2026 17:24
In this commit, we update the per-package agent docs for the packages
the unified-unroll change touches so they describe the flow as it now
works: every unilateral-exit trigger (manual, critical expiry, fraud,
vHTLC recovery) goes through the VTXO manager's admission gate.

The fraud watcher and the vHTLC recovery coordinator docs now describe
forcing the exit through the VTXO manager (VTXOManagerRef / the
ExitAdmitter ForceExit seam) rather than talking to the unroll registry
directly. The lib/actormsg, vtxo, and unroll docs pick up the trigger
and exit-policy fields that ride the ForceUnroll path, the manager
spawning an absent actor to force-unroll it, the recovery-only
hold-in-exit on a recoverable failure, and the child-stamped
ExitPolicyKind on the terminal handoff. The darepod doc picks up the
policy-carrying boot ordering, the expiring-to-unroll bridge, and the
recovery materializer persisting unilateral-exit.
In this commit, we give VTXOStore.GetVTXO a domain-level miss sentinel
so callers stop reaching for the persistence-layer sql.ErrNoRows. The
store translates a row miss into vtxo.ErrVTXONotFound (keeping
sql.ErrNoRows in the error chain so the call sites that still test for
it keep working while they migrate), and the VTXO manager matches the
sentinel instead.

This fixes a real papercut on the force-unroll path: the manager's
spawn-from-descriptor step treated a GetVTXO miss as a nil descriptor,
but the store signals a miss with an error, not a nil. A manual or fraud
force for an outpoint the wallet no longer tracks therefore surfaced an
internal "load vtxo for force-unroll" error instead of the intended
declined ForceUnrollResponse{Accepted: false, Reason: "no such vtxo"}.
Matching the sentinel makes the decline read correctly, and it keeps the
manager off a database/sql detail it had no business knowing.
In this commit, we nil-check the unroll status the coordinator reads
back after forcing a recovery exit. Both the post-force policy-conflict
guard and the status-reconcile path dereferenced the GetStatus result
directly, so a status source that returns a nil status with no error
would panic rather than read as "no record yet".

A nil-with-no-error is a legitimate shape now that admission is
asynchronous through the VTXO manager: the registry record may not be
visible yet. We treat it the same as a not-found record, leaving the
recovery active for the registry's own validation and the restart
re-drive instead of crashing the service.
@Roasbeef

Copy link
Copy Markdown
Member Author

Review round addressed

@gemini-code-assist @chatgpt-codex-connector thanks for the passes — here's what landed in response, on top of a rebase onto the latest main (which picked up the repo-wide docs sweep) and a reconciliation of the touched packages' AGENTS.md/CLAUDE.md.

Fixed:

  • Nil unroll status (vhtlcrecovery/coordinator)07b39cb. Nil-checked the GetStatus result. It applied to both read sites, not just the flagged one: the sibling reconcileLoaded path dereferenced it the same way. Guarded both and added tests that drive each path against a nil-status source (they panic without the guard).

  • Missing VTXO surfaced as a hard error (vtxo/db)314cc51. Rather than teach the manager about sql.ErrNoRows, the store now returns a typed domain sentinel vtxo.ErrVTXONotFound on a miss (wrapping sql.ErrNoRows in the chain so downstream callers that still test for it keep working), and spawnForceUnrollActor matches the sentinel to return the intended Accepted:false, Reason:"no such vtxo" decline. Keeps the domain manager off a persistence detail.

Documented, not fixed (P1: fraud trigger in orphan recovery)e22f731. Confirmed the window is real: a fraud exit that crashes in the gap between the UnilateralExit status flip and the registry admission re-admits on restart as TriggerRestart, losing TriggerFraudSpend. But the impact is a lost timing optimization, not a fund/liveness bug: the FSM broadcasts the ready checkpoint immediately instead of deferring it to the recipient's backstop window (shouldSubmitReadyFrontier) — earlier fees, same funds outcome, no missed deadline (CSV is relative to checkpoint confirmation), and no attacker advantage (checkpoints are the recipient's own pre-signed exit lineage). It's also a byproduct of the fix itself: pre-PR a fraud target never left the live set, so it was never an orphan candidate. A faithful fix needs new durable state (the fraud watcher holds its watch set in memory, unlike the vHTLC recovery policy the scan already recovers from the recovery store), so I documented the degraded behavior at the re-admission site and named the same-transaction trigger-stamp as the separable follow-up. This got a second independent review that concurred with keeping it documented.

Full reasoning is in the inline replies on each thread. PTAL when you get a chance.

@Roasbeef

Copy link
Copy Markdown
Member Author

@claude thanks for the thorough review — mapping your three findings to what landed (all on top of a rebase onto latest main + a docs reconciliation of the touched packages):

🟡 #2 — missing VTXO surfaces an internal error (your P2 / Codex P2): fixed in 314cc51. Went a step past the local errors.Is(err, sql.ErrNoRows) you sketched: rather than teach the domain manager about a persistence error, the store now returns a typed vtxo.ErrVTXONotFound sentinel (wrapping sql.ErrNoRows in the chain so the ~8 downstream callers that still test for it keep working), and spawnForceUnrollActor matches the sentinel to return the graceful Accepted:false, Reason:"no such vtxo" decline. Kept the nil-descriptor branch as the belt-and-suspenders you noted. db + manager tests added.

🟢 #3 — defensive nil-status (your Gemini agreement): fixed in 07b39cb. Guarded it — and it turned out to apply to both GetStatus read sites, not just line 512: the sibling reconcileLoaded path dereferenced it the same way. Tests drive each path against a nil-status mock (they panic without the guard).

🔴 #1 — fraud trigger lost in orphan recovery (your P1 / Codex P1): took the "explicit follow-up" path you offered, documented in e22f731. You're right that this PR introduces the window and that it's the exact analogue of the exit-policy carry-through — but the trigger has no durable home the way the policy does (the fraud watcher's watch set is in-memory, and it can't re-fire since the orphaned target is no longer live). Crucially the impact is bounded to a lost timing optimization, not correctness: under TriggerRestart the FSM broadcasts the ready checkpoint immediately instead of deferring to the backstop window — earlier fees, same funds outcome, no missed deadline (CSV is relative to checkpoint confirmation), no attacker advantage (checkpoints are the recipient's own pre-signed exit lineage). So rather than block on a schema change, I documented the degraded behavior at the re-admission site and named the same-transaction trigger-stamp (stamp the trigger onto the VTXO row in the tx that flips it to UnilateralExit, read it back off the descriptors the scan already lists) as the faithful follow-up. A second independent review concurred with keeping it documented rather than fixed in this PR.

Full per-thread reasoning is in the inline replies. If you still read #1 as a hard blocker over the "premature-but-safe broadcast" framing, say so and I'll reconsider the same-tx stamp here.

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 1m 22s —— View job


Re-review of the fix round


Read all three commits plus unroll/fsm_logic.go to trace the fraud path myself. Verdict up front: #2 and #3 are correctly resolved; #1 is fine as documented — not a hard blocker. The premature-but-safe framing holds up under my own trace, so I withdraw the blocker flag.

🟡 #2ErrVTXONotFound sentinel (314cc51) — ✅ resolved, and better than what I sketched

Returning a typed domain sentinel from the store instead of teaching the manager sql.ErrNoRows is the right call — it keeps the domain layer off a persistence detail. I checked the specifics:

  • db/vtxo_store.go:194 wraps with %w: %w, so both vtxo.ErrVTXONotFound and sql.ErrNoRows stay in the chain — the ~8 legacy errors.Is(err, sql.ErrNoRows) call sites keep working during migration. Correct double-wrap.
  • The commit also migrated the two in-package call sites (isPersistedSpent, customForfeitInputIsSynthetic) to the sentinel, so manager.go no longer imports database/sql. Clean.
  • The descriptor == nil branch is kept as belt-and-suspenders with an accurate comment. Good.

🟢 #3 — nil-status guard (07b39cb) — ✅ resolved at both sites

Confirmed the guard landed on both dereferences — ensureUnroll (:512) and the sibling reconcileLoaded (:556) — with a test in e22f731 pinning the reconcile path independently through GetRecoveryStatus. Catching the second site is exactly the kind of thing that only surfaces when you write the panicking test. 👍

🔴→documented #1 — fraud trigger lost in orphan recovery (e22f731)

I traced the FSM to check your "lost timing optimization, not correctness" framing against the code, and it holds:

  • shouldSubmitReadyFrontier (fsm_logic.go:571) returns true for any non-TriggerFraudSpend trigger → under TriggerRestart the ready checkpoint broadcasts immediately instead of deferring to deferred.DeadlineHeight. That's the whole behavioral delta.
  • The deferral is a fee/timing optimization (hold the checkpoint until near the backstop). Losing it means an earlier broadcast. Since CSV is relative to checkpoint confirmation, an earlier confirmation only starts the sweep clock earlier — strictly can't miss a deadline, and can't help an attacker since checkpoints are the recipient's own pre-signed exit lineage. Agreed on all three.
  • Critically, the exit still completes: the coin is in UnilateralExit, so the orphan scan does re-admit and drive it to completion. No stuck coin, no fund risk — this is a degraded-timing leg, not a liveness gap.

One footnote I verified while I was in there: losing the trigger also bypasses the zero-CSV fraud rejection (fsm_logic.go:179). But under TriggerRestart a zero-CSV config just yields the immediate broadcast that's already the restart default, so there's no new correctness hole there either — just worth being aware the loud misconfig guard is silently skipped on the re-admit leg.

So: documenting it is the right call for this PR. The doc comment at the re-admission site accurately names both the effect and the faithful fix (same-tx trigger stamp on the VTXO row, read back off the listed descriptors — atomic, no new store, schema change). That's a clean, separable follow-up. I'd only ask that the follow-up be tracked as an issue rather than living solely in the code comment, so it doesn't get lost.

Nice work on the round — the store-level sentinel and the second nil-guard site are both genuinely better than the local patches originally suggested.
· unify-unroll-through-vtxo-manager

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the unilateral exit and vHTLC recovery paths to route all exits (manual, critical-expiry, fraud, and vHTLC recovery) through the VTXO manager's single admission gate. This ensures the manager owns the state transition and persists the target into unilateral exit before starting the durable unroll job, preventing recovery-only targets from being mistakenly relived as spendable wallet liquidity. Feedback on the changes highlights a potential nil pointer dereference in the unroll registry if the pending record has been evicted, as well as a missing nil check for the target materializer during service initialization.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread unroll/registry.go
Comment on lines +1007 to +1009
if policyKind == "" {
policyKind = record.ExitPolicyKind
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

If the pending record has been evicted (record is nil) and the incoming req.ExitPolicyKind is empty (e.g., for a standard timeout exit), executing policyKind = record.ExitPolicyKind will result in a nil pointer dereference and panic. We should defensively check if record is not nil before accessing its fields.

Suggested change
if policyKind == "" {
policyKind = record.ExitPolicyKind
}
if policyKind == "" && record != nil {
policyKind = record.ExitPolicyKind
}

Comment on lines +187 to +189
if cfg.Exiter == nil {
return nil, fmt.Errorf("exit admitter is required")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The cfg.TargetMaterializer is not checked for nil in NewService. If it is nil, any subsequent method calls on s.targetMaterializer will result in a nil pointer dereference panic. We should defensively validate that cfg.TargetMaterializer is not nil during service initialization.

	if cfg.Exiter == nil {
		return nil, fmt.Errorf("exit admitter is required")
	}
	if cfg.TargetMaterializer == nil {
		return nil, fmt.Errorf("target materializer is required")
	}

In this commit, we close out the final review round. We add a test that
drives the status-reconcile path (GetRecoveryStatus, which joins the
durable row with unroll status without going through escalation) against
a nil status source, pinning the second half of the nil-status guard
independently of the escalation path.

We also document, at the orphan-recovery re-admission, that a
fraud-forced exit orphaned in the crash gap between the VTXO status flip
and the registry admission re-admits as TriggerRestart and so loses its
fraud checkpoint deferral. The effect is a premature but safe checkpoint
broadcast (earlier fees, same funds outcome, no missed deadline), and
the comment names the same-transaction trigger-stamp as the faithful
follow-up shape should we ever want to close the gap.
@Roasbeef
Roasbeef force-pushed the unify-unroll-through-vtxo-manager branch from e22f731 to afc7b33 Compare July 10, 2026 02:01
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude appreciate the re-review and the independent FSM trace — good to have the premature-but-safe framing confirmed from your own read rather than taking my word for it. Nice catch on the zero-CSV guard being bypassed on the re-admit leg too; agree it collapses to the restart default and opens no new hole, but worth naming.

Filed the follow-up as #914 (the same-tx trigger-stamp fix) so it doesn't live only in the code comment, and linked the issue from the comment at the re-admission site in afc7b33. That closes out all three findings — thanks again for the thorough pass.

@claude

claude Bot commented Jul 10, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 17s —— View job


Wrapping up

Thanks — that closes the loop cleanly. Tracking the same-tx trigger-stamp as #914 is exactly the right home for it: the code comment at the re-admission site names the degraded behavior, and now the issue carries the faithful fix so it won't get lost.

To recap where the three findings landed:

Nothing outstanding from my side. LGTM. 👍
· unify-unroll-through-vtxo-manager

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: e22f731e11

ℹ️ About Codex in GitHub

Your team has set up Codex to 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 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Roasbeef
Roasbeef merged commit 1ced0ea into main Jul 10, 2026
18 checks passed
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.

1 participant