Skip to content

multi: add unilateral-exit subsystem - #235

Closed
ellemouton wants to merge 15 commits into
mainfrom
oor-unroll-fixes
Closed

multi: add unilateral-exit subsystem#235
ellemouton wants to merge 15 commits into
mainfrom
oor-unroll-fixes

Conversation

@ellemouton

@ellemouton ellemouton commented Apr 2, 2026

Copy link
Copy Markdown
Member

Summary

This PR adds the client-side unilateral-exit runtime and the daemon
plumbing around it.

The subsystem is split into three packages with distinct
responsibilities:

  • unrollplan: pure dependency-resolution planner. Given a recovery
    proof, durable progress state, and current block height, it determines
    which proof transactions are ready to broadcast, which are blocked by
    unconfirmed parents, when the target becomes CSV-mature, and whether
    the sweep is needed. No I/O, no actors — callers own the durable State
    and the planner re-derives everything from first principles on each
    call.

  • txconfirm: generic shared actor for getting transactions confirmed
    on-chain. Not tied to unrolling — any subsystem that needs "get this
    tx confirmed with CPFP" can reuse it. Handles anchor detection, CPFP
    child construction, package relay with individual-broadcast fallback,
    periodic fee bumping (non-terminal on failure so the original broadcast
    can still confirm via the active watch), and subscriber fan-out for
    deduped confirmation/failure notifications.

  • unroll: durable per-target actor and thin registry. Each
    VTXOUnrollActor owns one target outpoint's lifecycle, delegates
    broadcasting to txconfirm, and drives the FSM through proof
    materialization → CSV wait → sweep. The UnrollRegistryActor manages
    job creation, dedup, boot restore, and terminal bookkeeping.

Supporting infrastructure added by earlier commits:

  • lib/recovery: immutable proof graph, session state, and
    serialization for representing the target VTXO's reachable on-chain
    ancestry
  • db: persisted unilateral-exit job store for tracking non-terminal
    and terminal unroll jobs, including sweep txid
  • daemonrpc: Unroll, GetUnrollStatus, and GetBalance.onchain_wallet
    RPCs
  • vtxo: ForceUnrollEvent in the VTXO lifecycle FSM and
    LazyChainResolver to bridge critical-expiry notifications into the
    unroll registry without a circular init dependency
  • chainsource/chainbackends/lwwallet: package submission support
    across all wallet backends

Daemon wiring:

  • Wallet adapters for LND, lwwallet, and btcwallet satisfying both
    txconfirm.Wallet and unroll.SweepWallet
  • Shared TxBroadcasterActor for CPFP package submission
  • UnrollRegistryActor with proof assembler and registry store
  • Non-terminal job restore on boot via RestoreNonTerminal
  • Critical-expiry routing: VTXO ExpiringNotification → lazy chain
    resolver → unroll registry
  • RPC: GetUnrollStatus queries active registry first, falls back to DB
    for historical/completed jobs

User-Visible Behavior

The client can now:

  • trigger unilateral exit for a VTXO via RPC or CLI
  • restore in-flight unroll jobs on restart
  • materialize and fee-bump recovery packages with CPFP
  • wait through CSV maturity
  • sweep recovered funds back into the client wallet
  • report unroll job status and sweep txid through GetUnrollStatus

Resilience

  • Fee-bump failures in txconfirm are non-terminal: the original
    broadcast is still live and the confirmation watch remains active.
    The FSM recovers to AwaitingConfirmation with an updated broadcast
    height so the next bump waits the full interval before retrying.
  • Sweep construction or broadcast failures are retried up to 3 times
    before the actor transitions to terminal failure.
  • A spend watch on the target outpoint detects external spends early,
    terminating the job with a clear "spent externally" reason instead
    of waiting for the sweep to fail with a cryptic missing-input error.

Validation

  • go test ./unrollplan/... ./txconfirm/... ./unroll/...
  • make lint-native
  • make itest icase=UnilateralExit (manual, round-born, OOR-derived)
    in the server repo against this client branch

Related

@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 introduces a comprehensive unilateral exit (unrolling) subsystem for on-chain VTXO recovery, featuring a new state-machine-driven manager, a transaction broadcaster with CPFP support, and RPC/CLI interfaces for manual triggers. Feedback focuses on improving code maintainability and consistency, specifically by refactoring hardcoded transaction size estimates into constants, simplifying redundant error logging in the LND backend, and standardizing log formatting within the actor library.

Comment thread baselib/actor/durable_actor.go
Comment thread chainbackends/lnd.go Outdated
Comment on lines +222 to +231
var txErrors []string
for wtxid, txResult := range result.TxResults {
if txResult.Error == nil {
continue
}

txErrors = append(txErrors, fmt.Sprintf(
"wtxid=%s txid=%s error=%q",
wtxid, txResult.TxID, *txResult.Error))
}

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 error collection and sorting logic is redundant. Consider using a simpler approach to log errors directly without creating a slice of strings.

Comment thread unroller/executor.go Outdated
Comment on lines +319 to +322
// covers trees up to depth 10 (33+320 = 353 byte control
// block). Non-witness overhead is ~55 bytes; witness is
// discounted 4x, giving roughly 55 + (sig+script+cb)/4.
// Use 200 vbytes as a safe ceiling.

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 vbyte estimation logic is hardcoded. Consider moving this to a constant or a helper function to make it easier to update if the transaction structure changes.

@ellemouton
ellemouton force-pushed the oor-unroll-fixes branch 2 times, most recently from e515008 to 4273045 Compare April 7, 2026 02:13
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Apr 7, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 6m 43s —— View job


PR Review: Client: add unilateral-exit unroller runtime

Tasks:

  • Gather context and understand the request
  • Review core unroller subsystem (manager, job actor, FSM)
  • Review executor and package plans
  • Review proof assembler
  • Review broadcast/TxBroadcaster
  • Review chain monitor
  • Review daemon wiring and RPC
  • Review DB layer changes
  • Review CLI additions
  • Review tests
  • Check Gemini review comments
  • Post final review

This is a large, well-structured PR. The three-actor design (monitor / executor / manager+jobs) is clean, FSM state transitions are comprehensive with good test coverage, and the daemon wiring handles all three wallet modes. The fixes (PSBT rejection, sweep-as-local-spend, GetBalance) are sound. A few issues worth addressing below.


Medium — should fix before merge

1. Structured logging violation in executor.go:143

WarnS is called with a dynamically-built message string via fmt.Sprintf:

// executor.go:143
e.log.WarnS(ctx,
    fmt.Sprintf("Unroll node package execution failed: target=%s txid=%s mode=%s kind=%s height=%d",
        req.TargetOutpoint, plan.Txid, plan.Mode,
        plan.NodeKind, req.Height,
    ),
    err,
)

Per the codebase structured logging guide, log messages must be static strings with key-value pairs. Should be:

e.log.WarnS(ctx,
    "Unroll node package execution failed",
    err,
    "target", req.TargetOutpoint,
    "txid", plan.Txid,
    "mode", plan.Mode,
    "kind", plan.NodeKind,
    "height", req.Height,
)

Fix this →


2. Magic number should be a constant in executor.go:323 (also flagged by Gemini)

// executor.go:316-323
// Estimate sweep virtual size … Use 200 vbytes as a safe ceiling.
sweepVSize := int64(200)

The 200-vbyte estimate is well-justified in the comment, but using a named constant improves greppability and signals intent:

// In broadcast or executor package constants:
const sweepVSizeEstimate = int64(200)

Fix this →


3. finalizeTaprootScriptSpend silently swallows a wire-encoding error (proof_assembler.go:412)

// proof_assembler.go:406-415
witness := wire.TxWitness(witnessItems)
var buf bytes.Buffer
if err := psbt.WriteTxWitness(&buf, witness); err != nil {
    return  // ← silently bails, leaving FinalScriptWitness unset
}
in.FinalScriptWitness = buf.Bytes()

If WriteTxWitness fails (malformed witness stack), the function returns without setting FinalScriptWitness. The caller then tries psbt.Extract again and returns a ErrUnrollProofInvalid with an opaque "not fully finalized" message — obscuring the actual root cause. Either propagate or log the encoding error:

if err := psbt.WriteTxWitness(&buf, witness); err != nil {
    // Encoding failed; caller will detect the missing
    // FinalScriptWitness and return an appropriate error.
    log.Debugf("finalizeTaprootScriptSpend: encode witness: %v", err)
    return
}

Fix this →


4. Redundant double-loop in chainbackends/lnd.go:221 (also flagged by Gemini)

// lnd.go:221-253
var txErrors []string
for wtxid, txResult := range result.TxResults { ... }
sort.Strings(txErrors)

// Then a second full iteration for debug logging:
for wtxid, txResult := range result.TxResults { ... DebugS(...) }

The first loop builds a sorted error slice for the error message. The second loop re-iterates the same map for debug logging. These can be merged:

// Single pass: collect errors and emit debug log together.
for wtxid, txResult := range result.TxResults {
    if txResult.Error != nil {
        txErrors = append(txErrors, fmt.Sprintf("wtxid=%s …", …))
    }
    b.logger(ctx).DebugS(ctx, "Package tx result", …)
}
sort.Strings(txErrors)

Fix this →


Low — nits

5. fmt.Errorf("%s", ...) should be errors.New(...) (fsm_transitions.go:74, :128)

Both occurrences construct an error from a plain string:

// fsm_transitions.go:74
err := job.RecordNodeFailure(
    evt.Txid, fmt.Errorf("%s", evt.Reason),
)

fmt.Errorf("%s", s) is equivalent to errors.New(s) with an unnecessary allocation. Use errors.New(evt.Reason) in both places.


6. isPackageSubmissionUnsupported string match is too broad (broadcast/broadcaster.go:588)

return strings.Contains(err.Error(), "not supported")

"not supported" is a very common phrase that could match unrelated errors (e.g., a fee estimation backend that doesn't support a certain confirmation target). Consider being more specific, e.g. matching chainsource.ErrPackageSubmissionNotSupported as a sentinel error rather than string-matching, or at least matching a more specific substring like "package submission not supported".


7. notifyNodeSuccess sends the full accumulated records slice to deduped waiters (executor.go:158)

// handleNodePackages loop:
records = append(records, *record)
e.notifyNodeSuccess(ctx, plan.Txid, records)  // ← records grows on each iteration

notifyNodeSuccess sends the entire records slice (all plans executed so far, not just this plan's record) to deduped waiters for plan.Txid. If a single ExecuteNodePackagesRequest contains N plans and all have deduped waiters, waiters for plan[0] receive N records, waiters for plan[1] receive N-1 records, etc. This appears harmless only if RecordNodePackage is idempotent for unknown txids — worth confirming and/or documenting. The cleaner fix is to pass only record to notifyNodeSuccess.


8. TxBroadcaster is stateful but undocumented as single-threaded only

TxBroadcaster.usedFeeOutpoints and lastFeeHeight are mutated on every Submit call without a mutex. This is safe today because the broadcaster is only called from the single-threaded UnrollPackageExecutor actor, but there's no comment or lint guard making this clear. A short package-level doc note would prevent accidental concurrent reuse.


Fixes included in this PR

The three bug-fix additions are solid:

  • Non-finalized PSBT rejection (extractFinalizedTx): the new code correctly errors rather than silently returning an unsigned tx. The finalizeTaprootScriptSpend helper fills the gap for standard psbt.Finalize not knowing about taproot script-path spends — good.
  • Sweep-as-local-spend (handleSpendObserved + targetSpendBelongsToActiveSweep): correctly checks the spending txid against the proof graph and active sweep txid before declaring fraud. The Failed state case in targetSpendBelongsToActiveSweep correctly handles the window where a sweep confirms after the job transitions to Failed.
  • GetBalance.OnchainWalletConfirmedSat: all three wallet modes (LND, lwwallet, btcwallet) are covered.

@ellemouton
ellemouton force-pushed the oor-unroll-fixes branch 2 times, most recently from 2443488 to 49d2e1b Compare April 7, 2026 02:56
@ellemouton

Copy link
Copy Markdown
Member Author

Addressed review comments from both Claude and Gemini:

Claude review (comment #4196073160):

# Issue Status
1 Structured logging violation in executor.go (WarnS with fmt.Sprintf) Fixed — static message + slog key-value pairs
2 Magic number 200 in executor.go Fixed — extracted to estimatedSweepVBytes constant
3 finalizeTaprootScriptSpend silently swallows encoding error Fixed — now returns error, caller propagates
4 Redundant double-loop in chainbackends/lnd.go Fixed — merged into single pass with errors.Join
5 fmt.Errorf("%s", ...)errors.New(...) in fsm_transitions.go Fixed — all 4 occurrences
6 isPackageSubmissionUnsupported string match too broad Not fixed — would need sentinel error through chain backend interface
7 notifyNodeSuccess sends accumulated records slice Not fixed — harmless if RecordNodePackage is idempotent
8 TxBroadcaster undocumented as single-threaded only Fixed — added concurrency safety doc comment

Gemini inline comments: All three addressed (structured logging, error collection, vbyte constant).

Additional cleanup:

  • Moved ForceUnrollEvent from round package to vtxo package where it belongs
  • Fixed TxID.String() usage in chainbackends/lnd.go

@ellemouton
ellemouton force-pushed the oor-unroll-fixes branch 4 times, most recently from f79d018 to 858864c Compare April 7, 2026 09:46
@ellemouton
ellemouton marked this pull request as ready for review April 7, 2026 09:49
@ellemouton
ellemouton force-pushed the oor-unroll-fixes branch 2 times, most recently from d76eb1f to 66f0cbf Compare April 7, 2026 16:49
@ellemouton ellemouton changed the title Client: add unilateral-exit unroller runtime multi: add unilateral-exit subsystem Apr 8, 2026
@ellemouton
ellemouton force-pushed the oor-unroll-fixes branch 5 times, most recently from e24372a to 7384186 Compare April 10, 2026 07:52
The round FSM saves VTXOs to the store before the VTXO manager
sends VTXOCreatedNotification. Previously, CommitmentTxID,
BatchExpiry, and CreatedHeight were left zero in the first save
and filled in asynchronously by the manager's second upsert.
This created a race: callers reading the VTXO between the two
saves would see incomplete metadata.

Fix: add CommitmentTxID, BatchExpiry, and CreatedHeight fields
to ClientVTXO and populate them from the BoardingConfirmed event
before SaveVTXOs. The first write is now complete, making the
manager's upsert a harmless no-op.
Add a Log option to DurableActorConfig so actors can surface warning
and error logs through the caller's logger instead of the context
fallback. Log message type and delivery ID on Tell failures so
silent nack loops become visible.

Add WarnS calls to TransactionExecutor for begin, body, commit, and
retry-exhaustion failures so DB transaction errors are observable
without debug-level logging.
@ellemouton
ellemouton force-pushed the oor-unroll-fixes branch 3 times, most recently from a6868ca to c4a1daf Compare April 10, 2026 08:16
Add SubmitPackageRequest/Response to chainsource with backend
forwarding. Add SubmitPackage to LNDBackend with optional
PackageSubmitter interface (backed by bitcoind RPC). Add Esplora
package relay support to lwwallet chain backend.

Add PackageSubmitter field to LNDBackendFromLndClientConfig and
daemon Config for wiring.

Add 15-second timeout to LndClientChainNotifier's
RegisterConfirmationsNtfn to prevent hangs under heavy block load.
Add 10-second timeout to ConfActor's backend registration.

Export GetLNDClientConn, BitcoindRPCUser, BitcoindRPCPass from the
harness. Add BitcoindPackageSubmitter for itest package submission
via direct bitcoind JSON-RPC. Export WalletKit() on BoardingBackend.
Add ForceUnrollEvent so manual unroll requests route through the
VTXO actor's FSM rather than bypassing it with direct DB writes.
LiveState handles ForceUnrollEvent by transitioning to
UnilateralExitState and emitting ExpiringNotification through the
chain resolver seam, converging manual and automatic triggers.

Add ForceUnrollRequest/Response to the VTXO manager admission
types and TestLiveStateForceUnroll unit test.
Add Unroll RPC for triggering unilateral exit by outpoint and
GetUnrollStatus RPC for querying job progress. Add UnrollJobStatus
enum and request/response messages for both endpoints.
Add onchain_wallet_confirmed_sat field to GetBalanceResponse and
populate it from the backing wallet (LND, lwwallet, or btcwallet)
so clients can see confirmed on-chain funds including sweep proceeds.
Add unilateral_exit_jobs migration, sqlc queries, and persistence
store for manager-facing unroll job control-plane rows. Provides
UpsertJob, GetJob, ListNonTerminalJobs, and MarkJobTerminal.
Add the immutable recovery proof graph (Proof, Node, NodeKind) with
topological layering and parent/child tracking. Add the recovery
Session that tracks per-node broadcast/confirmation state and computes
CSV maturity. Add SessionState with custom JSON marshaling for
map[chainhash.Hash] keys. Add proof binary codec for checkpoint
persistence. Add tree path extraction helpers.

Includes comprehensive unit tests for proof construction, session
lifecycle, multi-branch graphs, and state round-trip serialization.
Pure computation layer that answers: given a recovery proof, durable
progress state, and current block height, which proof transactions
are ready to broadcast, which are blocked, and when is the target
CSV-mature for sweeping?

No I/O, no actors — callers own the durable State and the planner
re-derives everything from first principles on each call. Validates
state consistency against the proof graph before planning.

Key types: Planner, State, Snapshot, TxFrontier, CSVInfo, SweepState.
Generic shared actor that deduplicates confirmation requests by txid
and ensures transactions confirm on-chain. Any subsystem that needs
"get this tx confirmed" can use it.

Features:
- Automatic anchor detection and CPFP child construction
- Package relay with individual-broadcast fallback
- Periodic fee bumping (non-terminal on failure so the original
  broadcast can still confirm via the active watch)
- Subscriber fan-out for deduped confirmation/failure notifications
- protofsm-based lifecycle: New → Broadcasting → AwaitingConfirmation
  → (FeeBumping loop) → Confirmed/Failed

Key types: TxBroadcasterActor, CPFPBroadcaster, EnsureConfirmedReq.
Per-target durable actor managing the full unilateral-exit lifecycle:
proof assembly, transaction materialization via txconfirm, CSV
maturity wait, sweep construction and confirmation.

Features:
- UnrollRegistryActor: thin registry, dedup by outpoint, boot restore
- VTXOUnrollActor: durable FSM per target, delegates to txconfirm
- Sweep retry (up to 3 attempts before terminal failure)
- Spend watch on target outpoint for early external-spend detection
- Proof assembly from VTXO descriptors and OOR artifacts
- Checkpoint persistence after every FSM transition

Key types: UnrollRegistryActor, VTXOUnrollActor, LocalProofAssembler.
Add FinalizePsbtDirect for signing and finalizing PSBTs via
btcwallet without going through LND's WalletKit RPC layer.

Add WaitForSync which blocks until btcwallet's sync height
reaches the Esplora chain tip, closing the race between the
chain source actor's confirmation events and btcwallet's
asynchronous block processing pipeline.
Add NewWalletAddress and ListWalletUnspent methods on Server so
the root itest harness can request backing-wallet addresses and
enumerate confirmed UTXOs across all wallet backends (LND,
lwwallet, btcwallet).
Initialize the unroll subsystem during daemon startup:

- Build wallet adapter (LND, lwwallet, btcwallet) satisfying both
  txconfirm.Wallet and unroll.SweepWallet
- Create shared TxBroadcasterActor for CPFP package submission
- Create UnrollRegistryActor with proof assembler and registry store
- Restore non-terminal jobs on boot via RestoreNonTerminal
- Wire critical-expiry path: VTXO ExpiringNotification → lazy chain
  resolver → unroll registry (solves circular init dependency)

RPC integration:
- Unroll RPC routes through VTXO manager → chain resolver → registry
- GetUnrollStatus queries active registry first, falls back to DB
  for historical jobs
Add 'darepocli unroll --outpoint <txid:index>' to trigger unilateral
exit and 'darepocli unroll status --outpoint <txid:index>' to query
job progress.
@Roasbeef

Copy link
Copy Markdown
Member

Replacing this PR with a 5-branch stack so each layer can be reviewed in isolation. All commits preserve @ellemouton as the git author.

# Branch PR Scope
1/5 unroll-01-prep #260 preparatory fixes + package-submission infra
2/5 unroll-02-plan #261 lib/recovery + unrollplan
3/5 unroll-03-txconfirm #262 txconfirm actor
4/5 unroll-04-core #263 vtxo + db + rpc + unroll/
5/5 unroll-05-wire #264 daemon wiring + CLI

The split also pulled in main's drift: lib/scriptslib/arkscript migration, migration renumber 000007_unilateral_exit_store000008_unilateral_exit_store (main's 000007_utxo_audit_log took the slot), Descriptor.OwnerKeyClientKey, and proto/sqlc regenerations. All fixups were folded back into Elle's original commits so each commit compiles standalone.

I'll close this PR once the stack is reviewed; keeping it open for now as a cross-reference.

@litbot-9000

Copy link
Copy Markdown
Collaborator

@Roasbeef: review reminder
@ellemouton, remember to re-request review from reviewers when ready

Roasbeef added a commit that referenced this pull request Apr 24, 2026
In this commit, we switch the timeout-path sweep transaction from
version 2 to the canonical Ark v3 (TRUC) version defined in
`lib/tx/arktx`. CSV-relative timelocks work for any version >= 2, so
the sequence-based CSV check is unaffected; this is purely a TRUC-
compatibility fix.

The shared `txconfirm` CPFP broadcaster gained a TRUC gate in 0c0f211
("txconfirm: gate Submit on v3 (TRUC) parents") after PR #235 landed,
but the unroll sweep builder was not updated at the same time. The
net effect is that every unilateral-exit sweep fails on its first
broadcast attempt with:

    broadcast: parent transaction must be v3 (TRUC) for CPFP
    broadcast: got version 2, want 3

And the failure mode is subtle: the FSM still transitions
AwaitingSweepBroadcast -> AwaitingSweepConfirmation because that
transition is driven by the `Ask` reply, not the txconfirm
notification. The subsequent `TxFailed` that txconfirm fires gets
dropped on a "mailbox full" error because the unroll actor's mailbox
is saturated with `HeightObservedMsg` deliveries from the chainsource
block subscription. So the FSM never learns the sweep failed and
`GetUnrollStatus` sits at `UNROLL_JOB_STATUS_UNSPECIFIED` forever.

We re-use `arktx.TxVersion` instead of re-hardcoding the constant so
the unroll sweep and the rest of the Ark transaction graph stay in
lockstep if the canonical Ark version ever changes.

Validated against the three unilateral-exit itests on darepo
(`TestUnilateralExitManualStartSingleParentTree`,
`TestUnilateralExitRoundBornCompletion`,
`TestUnilateralExitOORDerivedCompletion`); all three now run sweep
broadcast -> confirmation -> completion end-to-end.
@Roasbeef

Copy link
Copy Markdown
Member

Merged in via this series: #235 (comment)

@Roasbeef Roasbeef closed this Apr 24, 2026
ellemouton pushed a commit that referenced this pull request May 22, 2026
ci: fix nightly doc-gardening workflow failures
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants