Skip to content

lib/recovery + unrollplan: recovery proof graph and pure planner (2/5) - #261

Merged
Roasbeef merged 7 commits into
mainfrom
unroll-02-plan
Apr 22, 2026
Merged

lib/recovery + unrollplan: recovery proof graph and pure planner (2/5)#261
Roasbeef merged 7 commits into
mainfrom
unroll-02-plan

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

Summary

Part 2 of 5 in the stacked split of #235. Adds two pure library
layers that the unroll subsystem builds on:

  • 40582calib/recovery: add recovery proof, session, and state packages. The immutable recovery proof graph (Proof, Node,
    NodeKind) with topological layering and parent/child tracking. The
    recovery Session that tracks per-node broadcast/confirmation state
    and computes CSV maturity. SessionState with custom JSON marshaling
    for map[chainhash.Hash] keys. Proof binary codec for checkpoint
    persistence. Tree path extraction helpers. Comprehensive unit tests.

  • d8aa488unrollplan: add pure dependency-resolution planner.
    Given a recovery proof, durable progress state, and current block
    height, answers 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.
    Validates state consistency against the proof graph before planning.

Forward-port from the original branch

The original commits imported the legacy lib/scripts helper package,
which main removed (PR #215). This branch uses lib/arkscript.AnchorPkScript
/ AnchorOutput instead. The fixup was folded back into Elle's
original commits so each commit compiles standalone — authorship is
preserved on both commits.

Stack

# Branch PR Scope
1/5 unroll-01-prep #260 preparatory fixes + infra
2/5 unroll-02-plan this PR lib/recovery + unrollplan
3/5 unroll-03-txconfirm (to come) txconfirm actor
4/5 unroll-04-core (to come) vtxo + db + rpc + unroll/
5/5 unroll-05-wire (to come) daemon wiring + CLI

Supersedes #235.

Authorship

Both commits authored by @ellemouton.

Test plan

  • go test ./lib/recovery/... ./unrollplan/...
  • go vet ./...
  • go build ./cmd/...
  • CI: full unit + lint

@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 recovery proof system and a planner for unilateral-exit execution. It includes logic for building transaction graphs, tracking confirmation status, and calculating CSV maturity. Feedback focuses on improving efficiency by using byte comparisons for hash sorting and avoiding redundant map lookups for layer indices. Additionally, a validation check is suggested to ensure consistency between target confirmation status and the presence of a confirmation height.

Comment thread lib/recovery/proof.go
Comment on lines +400 to +404
func sortHashes(hashes []chainhash.Hash) {
sort.Slice(hashes, func(i, j int) bool {
return hashes[i].String() < hashes[j].String()
})
}

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

Sorting hashes by their string representation is inefficient as it requires hex encoding each hash twice per comparison. Using bytes.Compare on the raw bytes is significantly faster and achieves the same deterministic result.

Suggested change
func sortHashes(hashes []chainhash.Hash) {
sort.Slice(hashes, func(i, j int) bool {
return hashes[i].String() < hashes[j].String()
})
}
func sortHashes(hashes []chainhash.Hash) {
sort.Slice(hashes, func(i, j int) bool {
return bytes.Compare(hashes[i][:], hashes[j][:]) < 0
})
}

Comment thread lib/recovery/session.go Outdated
Comment on lines +257 to +277
for _, layer := range s.proof.layers {
for _, txid := range layer {
state := s.txStates[txid]

switch state {
case TxStateBroadcasted:
awaiting = append(awaiting, txid)

case TxStateConfirmed:
continue

case TxStatePending:
missingParents, err := s.missingParents(txid)
if err != nil {
return nil, err
}

layerIndex, err := s.proof.Layer(txid)
if err != nil {
return nil, 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.

medium

The topological layer index is already available from the outer loop iteration. Calling s.proof.Layer(txid) inside the loop performs an unnecessary map lookup for every pending transaction. Using the loop index directly is more efficient.

Suggested change
for _, layer := range s.proof.layers {
for _, txid := range layer {
state := s.txStates[txid]
switch state {
case TxStateBroadcasted:
awaiting = append(awaiting, txid)
case TxStateConfirmed:
continue
case TxStatePending:
missingParents, err := s.missingParents(txid)
if err != nil {
return nil, err
}
layerIndex, err := s.proof.Layer(txid)
if err != nil {
return nil, err
}
for i, layer := range s.proof.layers {
for _, txid := range layer {
state := s.txStates[txid]
switch state {
case TxStateBroadcasted:
awaiting = append(awaiting, txid)
case TxStateConfirmed:
continue
case TxStatePending:
missingParents, err := s.missingParents(txid)
if err != nil {
return nil, err
}
layerIndex := i

Comment thread unrollplan/planner.go Outdated
Comment on lines +137 to +143
if s.TargetConfirmHeight != nil && !confirmedTxidSetContains(
confirmed, proof.TargetOutpoint().Hash,
) {

return fmt.Errorf("target confirm height set " +
"without confirmed target")
}

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 validation logic should enforce that if the target transaction is confirmed, a TargetConfirmHeight must be provided. This ensures consistency and prevents potential issues in the Plan method when calculating CSV maturity, which currently returns an error if the height is missing for a confirmed target.

Suggested change
if s.TargetConfirmHeight != nil && !confirmedTxidSetContains(
confirmed, proof.TargetOutpoint().Hash,
) {
return fmt.Errorf("target confirm height set " +
"without confirmed target")
}
targetConfirmed := confirmedTxidSetContains(
confirmed, proof.TargetOutpoint().Hash,
)
if s.TargetConfirmHeight != nil && !targetConfirmed {
return fmt.Errorf("target confirm height set " +
"without confirmed target")
}
if targetConfirmed && s.TargetConfirmHeight == nil {
return fmt.Errorf("target confirmed without " +
"target confirm height")
}

@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: 58c2e1f91e

ℹ️ 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 lib/recovery/session.go
Comment on lines +163 to +164
txStates := make(map[chainhash.Hash]TxState, len(proof.nodes))
for txid := range proof.nodes {

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 Guard NewSession from nil proof input

NewSession dereferences proof.nodes before any validation, so a nil proof causes an immediate panic and process crash on construction (for example, if proof loading fails but construction is still attempted). This constructor is part of the public recovery API, so it should fail in a controlled way instead of panicking.

Useful? React with 👍 / 👎.

Comment thread lib/recovery/state_json.go Outdated
Comment on lines +72 to +79
if js.FailedTxid != nil {
hash, err := parseHash(*js.FailedTxid)
if err != nil {
return fmt.Errorf("failed_txid %q: %w",
*js.FailedTxid, err)
}

s.FailedTxid = &hash

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 Clear stale FailedTxid on JSON unmarshal

UnmarshalJSON only sets s.FailedTxid when failed_txid is present, but never clears it when the field is absent. If a SessionState object is reused across decodes, an old failure txid can leak into a newer state and incorrectly mark the restored session as failed or inconsistent.

Useful? React with 👍 / 👎.

@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@Roasbeef
Roasbeef changed the base branch from unroll-01-prep to main April 18, 2026 02:34
@claude

claude Bot commented Apr 18, 2026

Copy link
Copy Markdown

Claude Code is working…

I'll analyze this and get back to you.

View job run

@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented Apr 18, 2026

Copy link
Copy Markdown

Claude finished @Roasbeef's task in 2m 10s —— View job


PR Review: lib/recovery + unrollplan (2/5)

Tasks:

  • Gather context and understand request
  • Read changed files (proof.go, session.go, state.go, state_json.go, planner.go, tests)
  • Analyze code for bugs, correctness, style
  • Post review feedback

Overall Assessment

Solid, well-structured implementation. The separation between the immutable proof graph (Proof), per-session tracking (Session), durable state (SessionState), and the pure stateless planner (Planner) is clean and correct. The test coverage is comprehensive. A few issues merit attention before merging.


Bugs / Correctness

1. State.Validate missing reverse check — TargetConfirmHeight required when target is confirmed (unrollplan/planner.go:137)

The current validation rejects TargetConfirmHeight != nil when the target is unconfirmed, but does not reject a confirmed target without a height. If the caller sets ConfirmedTxids to include the target but leaves TargetConfirmHeight == nil, Validate passes but Plan will error inside csvInfoAt with a less useful message. The symmetric check is missing:

targetConfirmed := confirmedTxidSetContains(
    confirmed, proof.TargetOutpoint().Hash,
)
if s.TargetConfirmHeight != nil && !targetConfirmed {
    return fmt.Errorf("target confirm height set " +
        "without confirmed target")
}
if targetConfirmed && s.TargetConfirmHeight == nil {
    return fmt.Errorf("target confirmed without " +
        "target confirm height")
}

There's also no test for this second case. Fix this →


2. NewSession panics on nil proof (lib/recovery/session.go:162)

NewSessionFromState (state.go:29) correctly guards proof == nil, but NewSession dereferences proof.nodes immediately at line 163 with no guard. A nil proof causes a panic rather than a graceful error:

func NewSession(proof *Proof) (*Session, error) {
    if proof == nil {
        return nil, fmt.Errorf("proof cannot be nil")
    }
    // ...
}

(This also changes the signature to return (*Session, error) for consistency with NewSessionFromState.) Fix this →


3. UnmarshalJSON leaks stale FailedTxid (lib/recovery/state_json.go:72)

If a SessionState value with a non-nil FailedTxid is reused as the target of UnmarshalJSON for a JSON document that omits failed_txid, the old pointer is left in place:

// current code only sets FailedTxid when present:
if js.FailedTxid != nil {
    hash, err := parseHash(*js.FailedTxid)
    ...
    s.FailedTxid = &hash
}
// missing: s.FailedTxid = nil in the else branch

In practice json.Unmarshal is typically called on a zero-value struct, but the Unmarshaler contract doesn't guarantee that. Add an explicit else { s.FailedTxid = nil }. Fix this →


4. MarkConfirmed allows silently skipping TxStateBroadcasted (lib/recovery/session.go:217)

MarkConfirmed transitions any state (including TxStatePending) directly to TxStateConfirmed with no validation. MarkBroadcasted enforces that parents are confirmed before broadcast, but MarkConfirmed enforces nothing. If external observation (e.g. re-org handling) is an intentional use case, this should be documented. If not, a guard matching MarkBroadcasted's approach would prevent accidental misuse.


Performance

5. sortHashes uses String() instead of bytes.Compare (lib/recovery/proof.go:400, unrollplan/planner.go:571)

Both copies of sortHashes sort by hex-encoded string representation, causing two heap allocations per comparison. bytes.Compare on the raw 32-byte slice is faster and equally deterministic:

func sortHashes(hashes []chainhash.Hash) {
    sort.Slice(hashes, func(i, j int) bool {
        return bytes.Compare(hashes[i][:], hashes[j][:]) < 0
    })
}

Same fix applies to the Txid.String() comparisons in sortFrontier and sortBlocked in planner.go. Fix this →


6. Redundant Layer() map lookup in SnapshotAt (lib/recovery/session.go:274)

The outer loop already iterates s.proof.layers with an index, but the code calls s.proof.Layer(txid) for a second map lookup to get the same value:

// Before:
for _, layer := range s.proof.layers {
    for _, txid := range layer {
        ...
        layerIndex, err := s.proof.Layer(txid) // redundant

// After:
for i, layer := range s.proof.layers {
    for _, txid := range layer {
        ...
        layerIndex := i // already known

Fix this →


7. allProofTxidsConfirmed triggers a second deep copy of proof.Layers() (unrollplan/planner.go:394)

Plan calls p.proof.Layers() once for iteration (line 284) and allProofTxidsConfirmed calls it again internally (line 397). Each call to Layers() deep-copies every layer slice. The already-computed layers slice should be threaded through rather than re-derived.


Minor / Style

8. errors.New vs fmt.Errorf("%s", ...) for error reconstruction (lib/recovery/state.go:69)

// Current:
session.lastError = fmt.Errorf("%s", state.LastError)

// Cleaner:
session.lastError = errors.New(state.LastError)

fmt.Errorf with %s creates an intermediate string needlessly. errors.New is the idiomatic choice when restoring an error from a string.


9. Blank assignment _ = confirmHeight is unnecessary (lib/recovery/state.go:151)

The variable confirmHeight is declared (line 136) and the switch checks hasConfirmHeight, but confirmHeight itself is never used in the TxStateConfirmed arm. The blank assignment _ = confirmHeight on line 151 is a workaround for the unused variable. Restructuring to use _, hasConfirmHeight := state.ConfirmHeights[txid] instead would eliminate the need for both the variable and the blank:

_, hasConfirmHeight := state.ConfirmHeights[txid]

Missing Test Cases

  • TestPlanInvalidStateConfirmedTargetWithoutHeight: no test exercises the case where target is in ConfirmedTxids but TargetConfirmHeight == nil (follows from bug lib: start adding lib helpers #1 above).
  • JSON round-trip for SessionState: MarshalJSON/UnmarshalJSON are exercised indirectly only; a dedicated round-trip test with a failed session would make the stale-pointer issue immediately visible.
  • NewSession(nil) error path: once the nil guard is added (bug multI: add initial repo scaffolding #2), a test should verify it.

Summary

# Severity File Issue
1 Bug unrollplan/planner.go:137 Missing reverse validation: confirmed target requires TargetConfirmHeight
2 Bug lib/recovery/session.go:162 NewSession panics on nil proof
3 Bug lib/recovery/state_json.go:72 UnmarshalJSON doesn't clear stale FailedTxid
4 Design lib/recovery/session.go:217 MarkConfirmed silently skips broadcast state
5 Perf proof.go:400, planner.go:571 sortHashes uses String() instead of bytes.Compare
6 Perf lib/recovery/session.go:274 Redundant Layer() map lookup in SnapshotAt
7 Perf unrollplan/planner.go:394 Double proof.Layers() deep-copy in Plan
8 Style lib/recovery/state.go:69 errors.New vs fmt.Errorf("%s", ...)
9 Nit lib/recovery/state.go:151 Unnecessary blank assignment _ = confirmHeight

ellemouton and others added 3 commits April 21, 2026 13:49
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.
Replace JSON-based SessionState codec with a TLV codec. The prior
JSON path went through chainhash.NewHashFromStr which zero-pads
short hex inputs, so distinct JSON keys could collapse to one
Go map entry (parser-differential; last-write-wins). Raw 32-byte
hashes eliminate that attack surface and match the canonical
on-disk form used elsewhere in the repo.

Add a Proof binary codec (proof_codec.go) so callers can
checkpoint the immutable graph alongside the SessionState; this
fulfills the "proof binary codec for checkpoint persistence"
promise in the PR description.

Switch optional pointer fields (FailedTxid, Snapshot.CSV,
Snapshot.FailedTxid) to fn.Option for consistent nil-safety and
idiomatic WhenSome / UnwrapOrErr / UnwrapOrFail handling.

Both codecs carry a version byte so future wire-format changes
can be migrated explicitly rather than silently re-interpreting
older blobs. Duplicate keys, wrong-length hashes, unknown
versions, and trailing bytes all fail the decode loudly.
Strip json:"..." tags from unrollplan.State and SweepState and
add a dedicated TLV codec (state_codec.go) so the planner state
gets the same adversarial-input guarantees as the recovery-side
codec: raw 32-byte hashes, duplicate-key rejection, version byte,
and length-mismatch failures.

Move optional fields (TargetConfirmHeight, SweepState.Txid,
SweepState.ConfirmHeight, Snapshot.CSV) from nil-able pointers
to fn.Option. Callers now use WhenSome / UnwrapOrErr /
UnwrapOrFail instead of nil checks, which removes the
can-I-deref-this ambiguity and plays cleanly with
MapOptionZ-style error forwarding.

csvInfoAt and validateSweepState take fn.Option[int32] directly;
Plan no longer needs copyInt32Ptr.
Add rapid property-based tests for the SessionState, unrollplan.State,
and Proof TLV codecs covering round-trip equivalence and canonical
encoding. The tests shrink to minimum counterexamples on failure,
giving us adversarial-input coverage without a hand-enumerated fuzz
corpus.

Add focused unit tests for the state-machine guards landed in the
preceding fixup commits (MarkConfirmed parent-confirmed / height
negative / re-confirm-at-different-height, MarkFailed overwrite
rejection, ComputeMaturityHeight overflow, sweep state CSV maturity
and txid-collision checks).

Add a concurrency test that hammers Session from multiple goroutines;
combined with -race it validates the sync.RWMutex added for H-3.

Coverage: lib/recovery 43% -> 85%, unrollplan 47% -> 86%.
Per-package documentation for the two new recovery packages, plus an
entry in ARCHITECTURE.md's Layer 1 table. Documents the invariants that
are not self-evident from the code: the csvDelay unit + MaxCSVDelay
cap, MarkConfirmed's state-machine guards, the sweep ordering rules,
and the canonical TLV encoding.
Expand core files with walkthrough-style comments explaining the
mental model, algorithmic choices, and non-obvious invariants.

lib/recovery:
- Add package-level doc.go with the unilateral-exit data model and
  key invariants.
- NodeKind and Node: explain metadata-vs-planning split and the
  unsigned/signed-tx invariance.
- Proof: document the redundancy between parents/children/layers/
  layerByTxid and which hot-path query each serves.
- NewProof: five-stage validation narrative with rationale for
  duplicate rejection, in-graph-only edges, reachability, and the
  Kahn-style layering.
- buildLayers: Kahn's algorithm commentary plus cycle detection
  side-effect explanation.
- Session: caller-driven state machine with textual transition
  diagram; concurrency rules; why internal helpers assume the lock.
- MarkBroadcasted / MarkConfirmed: per-guard explanation of why each
  rejection exists (references the C/H findings from the PR review).
- SnapshotAt: layer-walk classification narrative.
- ComputeMaturityHeight: signed-overflow and unsigned-to-signed
  overflow reasoning.
- SessionState / validateSessionState: persistence philosophy, the
  "mirror every invariant" rule, ordered-checks rationale.
- proof_codec.go: refactor Node encoding to use Node.Record() +
  nested TLVs so per-Node fields are forward-extensible.

unrollplan:
- Expand doc.go with the mental model, no-I/O design, Validate
  symmetry with recovery, and TLV on-disk notes.
- Planner / NewPlanner / Proof accessor: document the
  statelessness rationale.
- Plan: per-bucket classification narrative and the "validate on
  every call" trade-off.
- Validate: ordered-checks list with cost annotations.
- validateSweepState: three-state lifecycle diagram.
- Migrate hashSetFromSlice / ensureDisjoint /
  confirmedTxidSetContains / allProofTxidsConfirmed /
  missingParentsFromSet / ensureParentsConfirmed /
  validateSweepState from map[chainhash.Hash]struct{} to
  fn.Set[chainhash.Hash] for idiomatic Contains/Intersect usage.
@Roasbeef
Roasbeef merged commit 1e0efc3 into main Apr 22, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants