Skip to content

unroll: durable per-target unroll subsystem (4/5) - #263

Merged
Roasbeef merged 6 commits into
mainfrom
unroll-04-core
Apr 23, 2026
Merged

unroll: durable per-target unroll subsystem (4/5)#263
Roasbeef merged 6 commits into
mainfrom
unroll-04-core

Conversation

@Roasbeef

Copy link
Copy Markdown
Member

Summary

Part 4 of 5 in the stacked split of #235. The unilateral-exit
subsystem proper: durable per-target actor + registry, VTXO FSM
integration, RPC surface, DB store, plus a small GetBalance
addition so recovered funds are visible after sweep.

Commits (in order):

  • 555cd6bmulti: add ForceUnrollEvent to VTXO lifecycle.
    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. Adds
    ForceUnrollRequest/Response to the VTXO manager admission
    types and TestLiveStateForceUnroll.

  • 6439f20daemonrpc: add Unroll and GetUnrollStatus RPCs.

  • 2a541c4darepod: surface onchain wallet balance in GetBalance RPC. Adds onchain_wallet_confirmed_sat so clients can
    see confirmed on-chain funds including sweep proceeds.

  • 3c7dd66db: add unilateral exit job store. Adds
    unilateral_exit_jobs migration (now 000008 — 000007 was taken by
    utxo_audit_log on main), sqlc queries, and persistence store.
    Provides UpsertJob, GetJob, ListNonTerminalJobs,
    MarkJobTerminal.

  • fe4d49bunroll: add durable per-target unroll actor and registry. UnrollRegistryActor (thin registry, dedup by outpoint,
    boot restore). VTXOUnrollActor (durable FSM per target, delegates
    to txconfirm, proof assembly, CSV wait, sweep). Sweep retry (up
    to 3 attempts before terminal failure). Spend watch on target
    outpoint for early external-spend detection. Checkpoint persistence
    after every FSM transition.

Forward-port from the original branch

  • Migration renumber: the original 000007_unilateral_exit_store
    was bumped to 000008 to avoid collision with main's
    000007_utxo_audit_log. LatestMigrationVersion is now 8.
  • lib/scriptslib/arkscript: unroll/sweep.go was
    rewritten to use arkscript.NewVTXOSpendInfoFromPolicy +
    SpendInfo.BuildSignDescriptor + arkscript.VTXOTimeoutSpendWitness
    (the legacy scripts.NewVTXOSpendInfo took a prebuilt tapscript;
    the arkscript equivalent derives it from policy keys, which requires
    the descriptor to expose ClientKey.PubKey, OperatorKey, and
    RelativeExpiry).
  • Descriptor.OwnerKeyDescriptor.ClientKey: main renamed
    the field (70906ba). Callers in sweep.go and unroll/actor_test.go
    were updated accordingly.
  • Proto regeneration: daemon.pb.go conflicts were resolved by
    re-running make rpc after merging the .proto file; the
    regenerated output is the committed version.

All fixups were folded back into Elle's original commits so each
commit compiles standalone.

Stack

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

Supersedes #235.

Authorship

All five commits authored by @ellemouton.

Test plan

  • go test ./unroll/... ./db/... ./vtxo/...
  • 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 implements a new unilateral-exit (unroll) subsystem using a durable actor pattern and a finite state machine to manage on-chain recovery. It introduces new RPCs, database migrations for job tracking, and integration with the VTXO manager. Feedback includes correcting the GetBalance logic to accumulate balances across multiple wallet backends instead of overwriting them. Recommendations were also made to enhance the robustness of the unroll actor by adding defensive checks for proof nodes, treating missing transactions during state reissues as errors to avoid stalls, and adopting non-blocking communication in the registry actor to prevent potential hangs.

Comment thread darepod/rpc_server.go Outdated
return
}

resp.OnchainWalletConfirmedSat = int64(walletBalance.Confirmed)

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 OnchainWalletConfirmedSat field is overwritten by each wallet backend. If multiple backends are active (e.g., LND and a lightweight wallet), the balance will only reflect the last one processed. It should accumulate the balances instead.

Suggested change
resp.OnchainWalletConfirmedSat = int64(walletBalance.Confirmed)
resp.OnchainWalletConfirmedSat += int64(walletBalance.Confirmed)

Comment thread darepod/rpc_server.go Outdated
return
}

resp.OnchainWalletConfirmedSat = int64(confirmed)

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 balance should be accumulated to support multiple wallet backends correctly.

Suggested change
resp.OnchainWalletConfirmedSat = int64(confirmed)
resp.OnchainWalletConfirmedSat += int64(confirmed)

Comment thread darepod/rpc_server.go Outdated
return
}

resp.OnchainWalletConfirmedSat = int64(confirmed)

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 balance should be accumulated to support multiple wallet backends correctly.

Suggested change
resp.OnchainWalletConfirmedSat = int64(confirmed)
resp.OnchainWalletConfirmedSat += int64(confirmed)

Comment thread unroll/actor.go
Comment thread unroll/actor.go Outdated
Comment on lines +685 to +687
}

pkScript := append(

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 a proof node is missing during reissue, the actor will skip it and potentially get stuck in the AwaitingMaterialization state forever because the required confirmation will never arrive. It's safer to return an error here, similar to how EnsureReadyTransactions is handled.

if !ok {
					return fmt.Errorf("proof node %s missing", txid)
				}

Comment thread unroll/actor.go Outdated
Comment on lines +712 to +714
continue
}

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 the sweep transaction is missing during reissue while the FSM is in a sweep-related state, the actor will be stuck. This indicates a corrupted checkpoint or inconsistent state and should be treated as an error.

if b.sweepTx == nil {
				return fmt.Errorf("sweep transaction missing")
			}

Comment thread unroll/registry.go
Comment on lines +581 to +583
resp, err := child.Ref().Ask(
ctx, &GetStateRequest{},
).Await(ctx).Unpack()

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 registry actor performs a blocking Ask call to child actors. Since the registry runs on a single goroutine, if a child actor is unresponsive or deadlocked, the entire registry will hang, preventing status queries or new unroll requests for other VTXOs. Consider using a non-blocking approach or a shorter timeout with a retry mechanism.

@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: 29613fa181

ℹ️ 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 daemonrpc/daemon.proto
Comment on lines +91 to +93
rpc Unroll (UnrollRequest) returns (UnrollResponse);

// GetUnrollStatus returns the current status of an unroll job for the

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 Implement newly declared unroll RPC handlers

Declaring Unroll/GetUnrollStatus in the public service contract without server implementations makes both endpoints return Unimplemented at runtime (the daemon server only has the embedded UnimplementedDaemonServiceServer fallback, and there are no concrete RPCServer.Unroll or RPCServer.GetUnrollStatus methods in darepod). This advertises functionality clients can call but cannot use.

Useful? React with 👍 / 👎.

Comment thread unroll/registry.go
Comment on lines +262 to +267
if child, ok := r.active[req.Outpoint]; ok {
return fn.Ok[RegistryResp](&EnsureUnrollResp{
ActorID: child.Ref().ID(),
Created: false,
})
}

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 Deduplicate EnsureUnroll against persisted jobs

handleEnsure only checks r.active, so once a child is removed from active (for example after terminal notification) or while a snapshot is only in pending/store, a repeated EnsureUnrollRequest will spawn a new actor for the same outpoint instead of returning Created=false. That breaks outpoint-level deduplication and can retrigger recovery attempts while overwriting control-plane history for an already-tracked target.

Useful? React with 👍 / 👎.

@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@code review this

@claude

claude Bot commented Apr 22, 2026

Copy link
Copy Markdown

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


PR #263 Review — unroll: durable per-target unroll subsystem (4/5)

  • Gather context and plan review
  • Read core unroll actor and registry
  • Read FSM types and logic
  • Read DB store and migrations
  • Read VTXO integration (transitions, events, manager)
  • Read RPC server changes
  • Read proof assembler, sweep, and snapshot
  • Post review feedback

Overall the architecture is clean and well thought out — the FSM-per-target actor, the thin registry over it, and the convergence of manual/automatic unroll paths on ExpiringNotification are all good design choices. The test coverage looks solid. Several issues of varying severity are worth addressing before merge.


High — Correctness Bugs

1. darepod/rpc_server.go:274,285,296 — Balance accumulation overwrites instead of accumulates

The three WhenSome closures for LND, lwWallet, and btcwWallet all assign (=) OnchainWalletConfirmedSat. In a configuration where more than one backend is active, only the last non-erroring one's balance is reported — the others are silently discarded. Should be +=:

resp.OnchainWalletConfirmedSat += int64(walletBalance.Confirmed)
// ...
resp.OnchainWalletConfirmedSat += int64(confirmed)
// ...
resp.OnchainWalletConfirmedSat += int64(confirmed)

Fix this →

2. unroll/actor.go:683-685ReissueInFlightTransactions silently skips missing proof nodes

case *ReissueInFlightTransactions:
    for _, txid := range evt.Txids {
        node, ok := b.proof.Node(txid)
        if !ok {
            continue  // ← silent skip
        }

If a proof node isn't found during restart reissue, continue means the txconfirm subscription is never (re)registered, and the actor stalls in AwaitingMaterialization indefinitely — no block epoch, no confirmation, no recovery. The EnsureReadyTransactions arm (line 667-679) correctly returns an error on !ok. This arm should too:

if !ok {
    return fmt.Errorf("proof node %s missing on reissue", txid)
}

Fix this →

3. unroll/actor.go:712-714ReissueSweepConfirmation silently skips nil sweep tx

case *ReissueSweepConfirmation:
    if b.sweepTx == nil {
        continue  // ← actor stalls in AwaitingSweepConfirmation forever
    }

If the actor restores to AwaitingSweepConfirmation state (sweep was broadcast before crash) but the checkpoint's SweepTx is nil (corrupted or incomplete persistence), the ReissueSweepConfirmation outbox event is skipped. The actor then sits in AwaitingSweepConfirmation with no pending txconfirm subscription and can never advance. This indicates corrupted state and should be a hard error:

if b.sweepTx == nil {
    return fmt.Errorf(
        "sweep tx missing in ReissueSweepConfirmation state",
    )
}

Fix this →


Medium — Correctness / Reliability

4. unroll/registry.go:262-268handleEnsure deduplication only covers r.active

After a child terminates and is removed from r.active, a repeat EnsureUnroll for the same outpoint will spawn a fresh actor and overwrite the terminal record (including its sweep txid and failure reason). The check should extend to r.pending and the persistent store:

func (r *registryBehavior) handleEnsure(ctx context.Context,
    req *EnsureUnrollRequest) fn.Result[RegistryResp] {

    if child, ok := r.active[req.Outpoint]; ok {
        return fn.Ok[RegistryResp](&EnsureUnrollResp{
            ActorID: child.Ref().ID(), Created: false,
        })
    }

    // Also deduplicate against pending (terminal or not).
    if record, ok := r.pending[req.Outpoint]; ok {
        return fn.Ok[RegistryResp](&EnsureUnrollResp{
            ActorID: record.ActorID, Created: false,
        })
    }

    // Check the durable store for previously persisted terminal jobs.
    existing, err := r.cfg.Store.GetRecord(ctx, req.Outpoint)
    if err == nil && existing != nil {
        return fn.Ok[RegistryResp](&EnsureUnrollResp{
            ActorID: existing.ActorID, Created: false,
        })
    }
    // ...
}

Fix this →

5. vtxo/transitions.goSpendingState silently rejects ForceUnrollEvent

SpendingState.ProcessEvent falls through to default: for ForceUnrollEvent, returning an error. Since handleForceUnroll in the manager uses Tell (fire-and-forget), this error is never surfaced to the RPC caller, which receives Accepted: true. The VTXO stays in SpendingState and the force-unroll is silently lost.

The fix is to handle ForceUnrollEvent in SpendingState (same transition as ExpiryStatusCritical in that state — escalate to UnilateralExitState with the appropriate outbox). Alternatively, the manager could check the actor's current state before sending the event and return Accepted: false with a descriptive message when the VTXO is being spent.


Low / Polish

6. unroll/snapshot.go:23wire.MsgTx JSON serialization is fragile

actorCheckpointWire.SweepTx *wire.MsgTx is persisted via json.Marshal. wire.MsgTx has no JSON struct tags; byte slice fields (PkScript, witness stack items, etc.) are base64-encoded by encoding/json, which does round-trip correctly — but the encoding is not explicitly specified and relies on reflection. The rest of the planner state uses a proper TLV codec. Consistency would suggest serializing the sweep tx via wire.MsgTx.Serialize() to []byte and storing the raw bytes as a JSON-encoded []byte field, rather than marshaling the struct.

7. daemonrpc/daemon.proto:102Unroll/GetUnrollStatus RPCs not yet implemented

The proto declares both RPCs, but no RPCServer.Unroll or RPCServer.GetUnrollStatus method exists in darepod/rpc_server.go. Callers will get UNIMPLEMENTED until part 5/5 lands. This is acknowledged in the PR description (wiring is in part 5), but it's worth flagging so CI doesn't ship an intermediate binary that advertises capability it doesn't have if part 5 is delayed.

8. darepod/rpc_server.go:299-301TotalConfirmedSat proto comment is stale

The proto comment on total_confirmed_sat says "sum of all confirmed balances" but the implementation only sums boarding_confirmed_sat + vtxo_balance_sat, excluding the new onchain_wallet_confirmed_sat. Either update the comment to clarify the exclusion is intentional, or include OnchainWalletConfirmedSat in the sum.

9. unroll/registry.go:489-495 — Dead code branch

inFlight, ok := r.persisting[req.Outpoint]
if ok && sameRegistryRecord(inFlight, record) {
    return fn.Ok[RegistryResp](&RegistryAckResp{})  // ← branch A
}
if ok {
    return fn.Ok[RegistryResp](&RegistryAckResp{})  // ← branch B
}

Branch A's condition is a strict subset of branch B. Both return the same value. The two can be collapsed to a single if ok { return ... } (or branch B can be dropped). This reads like the original intent was for branch A to do something different (e.g., skip vs. re-enqueue) that didn't make it into the final code.

10. unroll/actor.go:272 — Missing TxOut length guard in ensureNodeConfirmed

ConfirmationPkScript: append(
    []byte(nil), node.Tx.TxOut[0].PkScript...,
),

node.Tx is checked for nil (line 271-273), but TxOut[0] panics on an empty slice. recovery.Proof construction should guarantee non-empty outputs, but a belt-and-suspenders check (len(node.Tx.TxOut) == 0) would guard against malformed proof nodes crashing the actor.

11. unroll/sweep.go:19estimatedSweepVBytes = 200 undocumented

The constant is conservative (actual tapscript CSV spend is ~120-150 vbytes), but there's no comment explaining its derivation. A brief note on how it was estimated (key path vs script path spend, witness stack composition) helps reviewers trust the constant and maintainers update it if the spend path changes.


Confirmed / Agree with Prior Reviewers

  • Gemini line 273 (ensureNodeConfirmed nil+length guard) — agree, noted above as item 10.
  • Gemini line 688 (missing node error in ReissueInFlightTransactions) — agree, this is issue multI: add initial repo scaffolding #2 above.
  • Gemini line 715 (nil sweep tx in ReissueSweepConfirmation) — agree, this is issue chainbackend+chainsource: add chainsource actor and backend impl #3 above.
  • Gemini line 583 (blocking Ask on child actors in registry) — agree it's a liveness risk, but the pattern is used throughout the codebase; a timeout context on child queries would be a meaningful mitigation without a full async rewrite.
  • Codex line 102 (Unroll/GetUnrollStatus unimplemented) — agree, noted as item 7.
  • Codex line 267 (dedup gap in handleEnsure) — agree, this is issue multi: stage boarding actor client  #4 above.

@Roasbeef
Roasbeef force-pushed the unroll-04-core branch 2 times, most recently from ddc71a1 to 88cc739 Compare April 22, 2026 04:05
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.
@Roasbeef

Copy link
Copy Markdown
Member Author

@claude review this

@codex review this

@claude

claude Bot commented Apr 22, 2026

Copy link
Copy Markdown

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


PR #263 Review — unroll: durable per-target unroll subsystem (4/5)

  • Gather context and plan review
  • Read core unroll actor and registry
  • Read FSM types and logic
  • Read DB store and migrations
  • Read VTXO integration (transitions, events, manager)
  • Read RPC server changes
  • Read sweep, snapshot, and proof assembler
  • Post review feedback

The architecture is solid: FSM-per-target actor with TLV checkpointing, dedup registry, convergent manual/automatic exit paths, and safe-by-construction sweep tx reuse. The correctness issues flagged in previous reviews have largely been addressed in the current branch HEAD. One high-severity bug remains open, plus a few low/polish items.


Status of Previous Review Findings

Several issues flagged by Gemini, Codex, and the earlier Claude review have been resolved in the current code:

Prior finding Status
ReissueInFlightTransactions silently continue on missing node ✅ Fixed — now return fmt.Errorf(...) (actor.go:739)
ReissueSweepConfirmation silently continue on nil sweepTx ✅ Fixed — now return fmt.Errorf(...) (actor.go:776)
handleEnsure dedup only covers r.active ✅ Fixed — now checks r.pending then Store.GetRecord (registry.go:262-291)
TxOut[0] panic on empty outputs ✅ Fixed — safeTxOutPkScript guards every access (actor.go:295-306)
snapshot.go JSON serialization of wire.MsgTx ✅ Not an issue — uses TLV + wire.MsgTx.Serialize (snapshot.go:106-114)
SpendingState silently rejects ForceUnrollEvent ✅ Not an issue — handled at transitions.go:863, produces proper UnilateralExitState transition
Dead code branch in handlePersistRecordResult ✅ Not present — current implementation is clean
Blocking Ask on child actors in registry ✅ Considered acceptable given codebase-wide pattern; terminal detection is correct

High — Remaining Correctness Bug

darepod/rpc_server.go:274,285,296 — Balance assignment overwrites instead of accumulates

All three WhenSome closures for LND, lwWallet, and btcwWallet assign with =:

// Line 274
resp.OnchainWalletConfirmedSat = int64(walletBalance.Confirmed)
// Line 285
resp.OnchainWalletConfirmedSat = int64(confirmed)
// Line 296
resp.OnchainWalletConfirmedSat = int64(confirmed)

In any configuration with more than one active wallet backend, only the last non-erroring one's balance is reported. Should be += in all three places. Fix this →


Low / Polish

1. darepod/rpc_server.go:299-301TotalConfirmedSat excludes new field

resp.TotalConfirmedSat = resp.BoardingConfirmedSat +
    resp.VtxoBalanceSat

The proto field name implies a total confirmed balance, but OnchainWalletConfirmedSat is now excluded. Either include it in the sum or rename/comment it to clarify the exclusion is intentional (the on-chain wallet balance may be intentionally tracked separately as a distinct funding source).

2. unroll/sweep.go:18estimatedSweepVBytes = 200 has no derivation note

The constant is intentionally conservative (actual tapscript CSV timeout-path spend is ~120-150 vbytes with a 2-item witness stack). A brief comment noting the spend-path composition (input: 1 × P2TR tapscript, witness: sig + script + control block; output: 1 × P2TR) would help maintainers validate the number if the spend path changes.

3. unroll/sweep.go:120-126 — No dust-threshold floor on sweep output

The guard if sweepValue <= 0 catches catastrophic underflow but not outputs below the standard relay dust floor (~330 sat for P2TR). A sweep with a value between 1 and 329 sats would pass construction here but be rejected by mempool policy. In practice VTXOs are much larger than this, but a defensive check (sweepValue < dustThreshold) with a descriptive error would make the failure mode explicit rather than leaving it to txconfirm to surface.

4. daemonrpc/daemon.proto:102Unroll/GetUnrollStatus advertised but unimplemented

Acknowledged in the PR description as intentional (wiring lands in 5/5). Worth noting for the release gate: if 5/5 is delayed, any binary that ships this proto will advertise two RPCs that return UNIMPLEMENTED.


Confirmed Good

  • Sweep tx reuse / persist-before-broadcast invariant (actor.go:243-287): startSweep skips buildSweepTx when b.sweepTx is already set, and persists it before txconfirm.Ask. Crash recovery restores the same txid; txconfirm's dedup makes the re-submit a no-op. Well-designed.
  • handleForceUnroll in manager (manager.go:294-364): Uses Ask with prior/new state comparison to distinguish "accepted and transitioning" from "already terminal self-loop", returning Accepted: false, Reason: "already terminal" for the latter. Clean.
  • ForceUnrollEvent FSM coverage: Handled in all four non-terminal states (LiveState, PendingForfeitState, SpendingState, ForfeitingState); terminal states self-loop. Convergent ExpiringNotification + VTXOStatusUpdate + VTXOTerminatedNotification outbox shape in each case.
  • TLV message codec discipline: Every durable mailbox message uses hand-written TLV encode/decode with pinned record types. Snapshot uses TLV + wire.MsgTx.Serialize. No JSON in the persistence path.
  • Registry dedup trail: handleEnsure checks r.activer.pendingStore.GetRecord, so a repeat request after terminal cleanup returns the historical ActorID rather than spawning a fresh actor.
  • safeTxOutPkScript helper: Every TxOut[i].PkScript access in the actor goes through this bounds-checked helper, converting out-of-range proof artifacts to retryable errors.
  • Test coverage: actor_test.go (1721 lines), registry_test.go (766 lines), snapshot_test.go (534 lines), state_snapshot_test.go (190 lines), messages_test.go (129 lines). TLV codec round-trips and FSM lifecycle cases are well covered.

@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: e19f519977

ℹ️ 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 on lines +489 to +493
func (UnimplementedDaemonServiceServer) Unroll(context.Context, *UnrollRequest) (*UnrollResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method Unroll not implemented")
}
func (UnimplementedDaemonServiceServer) GetUnrollStatus(context.Context, *GetUnrollStatusRequest) (*GetUnrollStatusResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetUnrollStatus not implemented")

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 Implement Unroll RPC handlers before advertising methods

Unroll/GetUnrollStatus are now part of the generated service surface, but the server implementation does not define these methods (the codebase only embeds UnimplementedDaemonServiceServer), so both RPCs currently return codes.Unimplemented at runtime. This makes the newly published API contract unusable for any client generated from this proto until concrete handlers are added.

Useful? React with 👍 / 👎.

Comment thread unroll/registry.go
Comment on lines +326 to +329
r.pending[req.Outpoint] = cloneRegistryRecord(record)
r.requestPersist(req.Outpoint, 0)

return fn.Ok[RegistryResp](&EnsureUnrollResp{

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 Persist new unroll records before returning Created=true

handleEnsure returns success immediately after enqueueing asynchronous persistence, which creates a crash window where a child actor has started but no control-plane record is durable yet. On restart, RestoreNonTerminal only reads Store.ListNonTerminalRecords, so jobs created in that window are skipped and won’t be resumed automatically.

Useful? React with 👍 / 👎.

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.
Roasbeef added a commit that referenced this pull request Apr 23, 2026
Close the client-side drift that let Board and SendVTXO pay the
legacy flat terms.MinOperatorFee regardless of the operator's
runtime fee schedule. Under a non-zero schedule the legacy flat
fee either silently over-paid (when the dynamic quote was cheaper,
stashing the excess as operator profit) or got rejected server-side
with ErrOperatorFeeTooLow (when the dynamic quote was higher,
blocking refresh / boarding entirely).

 - rpc_fees.quoteOperatorFee is a new private helper that calls
   the operator's EstimateFee RPC and returns the TotalFeeSat
   the server will accept. Back-compat: on a zero schedule
   EstimateFee returns zero and the flow reduces to the pre-fee
   path.

 - rpc_server.Board now asks the wallet for the confirmed
   boarding balance via the existing GetBoardingBalanceRequest,
   quotes the dynamic fee for that amount, and passes the quote
   through BoardRequest.MinOperatorFee in place of the legacy
   static value. If the quote fails the handler logs and falls
   back to terms.MinOperatorFee so boarding stays possible in a
   degraded operator connection.

 - rpc_server.SendVTXO quotes the dynamic fee for the summed
   recipient amount (isBoarding=false), with the same
   log-and-fall-back failure behavior.

The wallet handlers (handleBoard, handleSendVTXOs) already
consume the fee via the request message; no wallet code change is
needed.

Part of issue #263.
Roasbeef added a commit that referenced this pull request Apr 23, 2026
Add a substring-based mapper (mapFeeError) that recognizes the
four load-bearing server-side fee rejection sentinels
(ErrVTXOBelowMinViable, ErrOperatorFeeTooLow, boarding-too-small,
EstimateFee-no-calculator) and rewrites them as terse actionable
CLI output instead of a raw gRPC status. The original server
message is kept as parenthetical context so operators can still
grep the server logs when needed.

The board and send-inround subcommands now run their daemon RPC
error through mapFeeError before falling back to the generic
wrap. Substring matching beats status-code matching because the
daemon sanitizes upstream gRPC statuses via proxyUpstreamError,
erasing the original error code.

A focused test suite in fee_errors_test.go pins the exact
substrings we depend on, so a future server-side message change
fails the CLI test rather than silently routing around the
mapper.

Part of issue #263.
Roasbeef added a commit that referenced this pull request Apr 23, 2026
Five rapid-driven invariants covering the client-side fee
reconciliation helper that every round's FeePaidMsg emission
depends on:

 1. Non-negative: the helper clamps below zero, so every draw
    must yield fee >= 0. A regression that returned a negative
    value would surface as a silently-dropped FeePaidMsg (the
    ledger actor rejects negatives), making the bug hard to
    trace.

 2. Fee = inputs - outputs when outputs <= inputs. The ledger
    emission path depends on this identity to reconcile with
    the server-booked operator_fee.

 3. Monotone in boarding: adding a positive boarding input must
    never decrease the fee. A regression that double-counted or
    dropped a boarding input would violate this.

 4. Monotone in owned output: adding a positive owned output
    must never increase the fee (symmetric to 3).

 5. Empty-intents identity: zero intents produces a zero fee.
    The baseline case that every round's first iteration
    traverses.

The random-intent generator drawIntentsAndOwned caps per-entry
amounts at 1B sat so the running sums can't overflow int64 for
realistic slice lengths.

Part of issue #263.
Roasbeef added a commit that referenced this pull request Apr 23, 2026
…nt match)

Three focused unit tests that lock in the client↔server fee
round-trip contract from issue #263 Phase C.4:

 - round/refresh_psbt_size_test.go: asserts PSBT cardinality
   (forfeit count, owned-output count, leave count) stays
   constant across fee-enabled vs fee-disabled schedules; only
   per-output VALUES move. A rapid property extends this across
   random (forfeit amount, fee amount) draws and asserts
   computeClientOperatorFee returns exactly `forfeit - owned`.

 - darepod/congestion_roundtrip_test.go: a bufconn-backed
   fakeArkService returns canned EstimateFee replies, and the
   client's quoteOperatorFee helper is driven through a
   baseline → congestion-bumped sequence. The test asserts the
   helper returns the TotalFeeSat the operator reported
   verbatim (no local caching). A second case pins the
   nil-serverConn guard so the helper surfaces a clean
   Unavailable error instead of panicking.

 - darepod/rpc_fees_e2e_test.go: verifies the daemon's
   EstimateFee RPC proxies every field (liquidity, on-chain,
   margin, total, effective rate, min viable, below-dust-flag)
   verbatim to the CLI caller, and asserts the CLI-facing
   EstimateFee number matches the internal quoteOperatorFee
   number for the same inputs — the invariant that keeps the
   CLI confirmation prompt aligned with the actual booking.

Part of issue #263.
Roasbeef added a commit that referenced this pull request Apr 23, 2026
Add a substring-based mapper (mapFeeError) that recognizes the
four load-bearing server-side fee rejection sentinels
(ErrVTXOBelowMinViable, ErrOperatorFeeTooLow, boarding-too-small,
EstimateFee-no-calculator) and rewrites them as terse actionable
CLI output instead of a raw gRPC status. The original server
message is kept as parenthetical context so operators can still
grep the server logs when needed.

The board and send-inround subcommands now run their daemon RPC
error through mapFeeError before falling back to the generic
wrap. Substring matching beats status-code matching because the
daemon sanitizes upstream gRPC statuses via proxyUpstreamError,
erasing the original error code.

A focused test suite in fee_errors_test.go pins the exact
substrings we depend on, so a future server-side message change
fails the CLI test rather than silently routing around the
mapper.

Part of issue #263.
Roasbeef added a commit that referenced this pull request Apr 23, 2026
Five rapid-driven invariants covering the client-side fee
reconciliation helper that every round's FeePaidMsg emission
depends on:

 1. Non-negative: the helper clamps below zero, so every draw
    must yield fee >= 0. A regression that returned a negative
    value would surface as a silently-dropped FeePaidMsg (the
    ledger actor rejects negatives), making the bug hard to
    trace.

 2. Fee = inputs - outputs when outputs <= inputs. The ledger
    emission path depends on this identity to reconcile with
    the server-booked operator_fee.

 3. Monotone in boarding: adding a positive boarding input must
    never decrease the fee. A regression that double-counted or
    dropped a boarding input would violate this.

 4. Monotone in owned output: adding a positive owned output
    must never increase the fee (symmetric to 3).

 5. Empty-intents identity: zero intents produces a zero fee.
    The baseline case that every round's first iteration
    traverses.

The random-intent generator drawIntentsAndOwned caps per-entry
amounts at 1B sat so the running sums can't overflow int64 for
realistic slice lengths.

Part of issue #263.
Roasbeef added a commit that referenced this pull request Apr 23, 2026
…nt match)

Three focused unit tests that lock in the client↔server fee
round-trip contract from issue #263 Phase C.4:

 - round/refresh_psbt_size_test.go: asserts PSBT cardinality
   (forfeit count, owned-output count, leave count) stays
   constant across fee-enabled vs fee-disabled schedules; only
   per-output VALUES move. A rapid property extends this across
   random (forfeit amount, fee amount) draws and asserts
   computeClientOperatorFee returns exactly `forfeit - owned`.

 - darepod/congestion_roundtrip_test.go: a bufconn-backed
   fakeArkService returns canned EstimateFee replies, and the
   client's quoteOperatorFee helper is driven through a
   baseline → congestion-bumped sequence. The test asserts the
   helper returns the TotalFeeSat the operator reported
   verbatim (no local caching). A second case pins the
   nil-serverConn guard so the helper surfaces a clean
   Unavailable error instead of panicking.

 - darepod/rpc_fees_e2e_test.go: verifies the daemon's
   EstimateFee RPC proxies every field (liquidity, on-chain,
   margin, total, effective rate, min viable, below-dust-flag)
   verbatim to the CLI caller, and asserts the CLI-facing
   EstimateFee number matches the internal quoteOperatorFee
   number for the same inputs — the invariant that keeps the
   CLI confirmation prompt aligned with the actual booking.

Part of issue #263.
ellemouton and others added 2 commits April 22, 2026 19:57
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 unroll/CLAUDE.md documenting the durable per-target unroll subsystem
alongside the registry, FSM events, persist-before-broadcast contract,
TLV-encoded durable messages, and safe TxOut indexing.

Update vtxo, lib/actormsg, and db docs to reflect the ForceUnrollEvent
handling in SpendingState / ForfeitingState, the handleForceUnroll
Tell→Ask change and new ForceUnrollResponse.Reason field, and the
unilateral_exit_jobs migration landed by this series.
@Roasbeef
Roasbeef merged commit 0c680ad into main Apr 23, 2026
16 checks passed
ellemouton pushed a commit that referenced this pull request May 22, 2026
Close the gap where every integration test ran against a zero fee
schedule, leaving the fee-aware code paths in rounds, sweeps, OOR,
refresh, and the classifier uncovered at integration level.

The forcing function inverts today's default: the itest harness and
systest E2EHarness now install harness.DefaultItestFeeSchedule() (5%
annual rate, 100 sat margin, 30% dust-ratio cap, 10-block refresh
floor) before arkd starts, and opt-out helpers (WithZeroFeeSchedule
on itest, DisableFees on systest) preserve the pre-#263 behaviour
for one focused regression test. Every existing itest that asserts
a specific VTXO balance now runs under a non-zero schedule and
must audit the expected post-fee amount accordingly (B.2/B.3 in
issue #263).

Additional scaffolding:
 - harness.RestartArkd mirrors RestartClientDaemon so the persistence
   regression test can stop/start arkd against the same data dir.
 - config.FeesConfig grows a MinRefreshDeltaBlocks field so the
   canonical schedule can set a sub-production refresh-fee floor;
   server_fees.scheduleFromConfig now copies the new field.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
Close a round-trip gap in GetFeeSchedule / UpdateFeeSchedule:
fees.Schedule carries a MinRefreshDeltaBlocks field (the d_min
liquidity-fee floor) but the proto FeeScheduleParams omitted it,
so admin callers could not read or write that field. Without it
the restart-persistence regression in itest/fees_hotreload_test.go
could not prove that the full schedule survives a daemon reboot.

Regenerated via make rpc. The handler wiring in adminrpcserver.go
is updated in a follow-up commit.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
UpdateFeeSchedule previously mutated the in-memory Calculator only,
silently reverting any runtime fee change on the next boot. The
fee_schedule_history table and its InsertFeeScheduleHistory /
ListFeeScheduleHistory sqlc queries were already shipped but
unused; wire them up.

 - db.FeeScheduleStoreDB is a new small adapter over the existing
   sqlc queries. InsertFeeSchedule appends a row inside
   WriteTxOption(); LatestFeeSchedule reads the most recent row
   inside ReadTxOption() and returns (nil, false, nil) when the
   table is empty so callers can fall through to config.
 - server_fees.setupFeesSubsystem constructs the schedule store
   before the calculator, reads the latest persisted schedule,
   and falls back to scheduleFromConfig only when no row exists.
 - adminrpcserver.UpdateFeeSchedule calls InsertFeeSchedule after
   the in-memory UpdateSchedule succeeds, so the DB write never
   leaves the calculator and history out of sync.
 - GetFeeSchedule / UpdateFeeSchedule now round-trip
   MinRefreshDeltaBlocks (the proto field landed in the previous
   commit).

Unit tests cover insert/list round-trip, latest-wins ordering,
nil-input rejection, and pre-persist Validate() enforcement.
Additional tests in server_fees_test.go cover the
scheduleFromConfig conversion edge cases and the zero-config
back-compat contract.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
Two complementary helper sets that the per-file itest audits in
Phase B.2 consume:

 - itest/fees_helpers_test.go: feeQuoteForBoarding,
   feeQuoteForRefresh, expectedNetAfterBoarding /
   expectedNetAfterRefresh all back onto a test-local
   fees.Calculator constructed from harness.DefaultItestFeeSchedule.
   operatorEstimateFee proxies the operator's direct-gRPC
   EstimateFee for assertions that need the server's exact quote
   at a specific moment (for example after utilization has moved).
 - harness/ledger_assert.go: LedgerSnapshot /
   TakeLedgerSnapshot / ExpectedDelta / AssertLedgerDelta walk
   the admin ListFeeEvents RPC to compute per-account signed
   balances and assert per-event-type counts between two
   snapshots. Computing client-side avoids adding a new
   GetAccountBalance admin surface; test ledgers stay small
   enough that the O(n) walk is cheap.

Both helpers use testing.TB rather than *testing.T so systest
can reuse them without build-tag divergence.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
Three focused itest files covering the corners the existing
refresh/boarding/sweep tests don't naturally host:

 - fees_hotreload_test.go: TestFeesHotReloadAppliesOnNextRound
   verifies UpdateFeeSchedule immediately reflects in a
   subsequent GetFeeSchedule and in a follow-up EstimateFee
   quote. TestFeesHotReloadPersistsAcrossRestart proves the new
   persistence plumbing survives a RestartArkd: every field of
   the target schedule (including MinRefreshDeltaBlocks) is
   reflected by the first GetFeeSchedule after the restart.

 - fees_admin_rpc_test.go: TestFeesAdminRPCGetScheduleRoundTrip
   locks in every proto field round-trips through the admin
   handler. TestFeesAdminRPCUpdateRejectsInvalidDustPolicy
   guards the policy parse path. TestFeesAdminRPCListFeeEventsPaginates
   asserts the page<=Total invariant and strictly-increasing
   entry_id invariants that downstream analytics rely on.

 - fees_disabled_regression_test.go: TestFeesDisabledGreenPath
   proves the harness opt-out (WithZeroFeeSchedule) still
   produces a zero schedule and zero EstimateFee totals, so
   operators who haven't enabled fees see identical pre-#263
   behavior.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
rapid-driven regression harness covering five invariants the fee
and ledger subsystems rely on:

 1. Boarding never charges a liquidity fee. Every random
    (schedule, amount, batch size, rate) triple must produce a
    FeeBreakdown whose LiquidityFeeSat is zero.
 2. EffectiveRate is non-decreasing in utilization. Drawing two
    utilization points u1 <= u2 must yield EffectiveRate(u2) >=
    EffectiveRate(u1) for any valid schedule.
 3. Forfeit applies the MinRefreshDeltaBlocks floor. Any delta
    below the floor must compute the same liquidity fee as the
    floor itself.
 4. Every Record* helper is double-entry with a positive amount,
    distinct debit/credit accounts, and the caller-injected time.
 5. Boarding + refresh legs (RecordBoardingDeposit +
    RecordBoardingFee + RecordCapitalCommitted) sum to zero
    across the chart of accounts for any random (deposit, fee)
    pair.

Shared generators drawSchedule / drawFeeRate stay in this file
so other property tests can pick them up without export.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
The three hardcoded `int64(99_000)` VTXO-amount assertions in
boarding_test.go were only correct under the pre-#263 flat-fee
world (100_000 boarding minus the legacy 1_000 MinOperatorFee).
Under the post-#263 fees-on default the boarding fee is computed
dynamically by the operator's fees.Calculator over the full
theoretical tree size, so the net VTXO value shifts.

Replace the literal 99_000 with expectedNetAfterBoarding(t,
100_000, defaultBatchSizeForBoarding) so the assertion tracks
harness.DefaultItestFeeSchedule automatically. A new constant
defaultBatchSizeForBoarding=128 in fees_helpers_test.go mirrors
config.DefaultRoundsConfig.MaxVTXOsPerTree so the test-local
calculator computes the same per-input on-chain share the server
uses.

Other balance assertions in boarding_test.go are self-consistent
(client view vs live VTXO amount) and do not need changes.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
Three focused tests pin the partial-unique-index + `ON CONFLICT
DO NOTHING` contract that keeps durable mailbox replay safe:

 - TestLedgerReplayIdempotentWithKey: inserting the same
   (idempotency_key, event_type, debit, credit) quadruple twice
   returns rowcount=1 on the first attempt and rowcount=0 on the
   second, with only the first row surviving. A regression that
   removed the partial unique index or changed the conflict
   target would fail this test immediately.

 - TestLedgerReplayAllowsDifferentEventTypesSameRoundID: the
   refresh path books two legs (forfeit + fee) under one
   round/idempotency_key; both must commit because the partial
   unique index discriminates on event_type. Without this, the
   fee leg would silently dedup behind the forfeit leg and
   refresh_fee_revenue would never accumulate.

 - TestLedgerReplayIdempotentWithoutKeyIsNotDeduped: nil-key
   entries are outside the partial unique index and always
   commit. Matches the schema contract and prevents an
   accidental collapse of the `WHERE idempotency_key IS NOT NULL`
   clause.

Replaces the mailbox-replay itest proposed for Phase E.6 in the
plan: the DB-layer unit test is strictly more precise because it
isolates the invariant to the storage layer without needing to
inject a duplicate-delivery side effect at the actor boundary.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
Pin the darepo-client submodule at the tip of the parallel
`issue-263-fees-client-coverage` branch so darepo reviewers see
the exact client code the server-side assertions in this PR
expect to work with.

Client-side changes summary:
 - darepod.Server.quoteOperatorFee quotes the dynamic operator
   fee via EstimateFee; Board and SendVTXO now pay the schedule-
   derived fee instead of the legacy flat terms.MinOperatorFee.
 - darepoclicommands.mapFeeError translates server-side fee
   rejections (ErrVTXOBelowMinViable, ErrOperatorFeeTooLow,
   boarding-too-small, EstimateFee-no-calc) into concise CLI
   messages.
 - round/fees_invariants_test.go: rapid-driven properties over
   computeClientOperatorFee (non-negative, conservation identity,
   monotonicity, empty-intents identity).

Bumping the submodule as a dedicated commit keeps the server
diff clean to review without the client's code noise; git log
--follow client/ in this PR surfaces a single change.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
…reasury tests

Broader itest coverage and the remaining assertion audits
that the Phase A default flip requires:

 - itest/helpers_test.go: documents the post-#263 convention
   that every balance assertion under the default schedule
   computes expected net amounts via the fee-aware helpers.

 - itest/oor_test.go: replaces hardcoded int64(99_000) VTXO-
   amount assertions with expectedNetAfterBoarding. Other OOR
   amounts stay unchanged (OOR fees currently gated off).

 - itest/send_test.go: replaces operatorInfo.MinOperatorFee
   (legacy flat 1000 sat) in expectedChange / expectedTotal
   with feeQuoteForRefresh so the test tracks the post-#263
   dynamic-fee quoting in darepod.Server.quoteOperatorFee.

 - itest/fees_hotreload_test.go: build fresh FeeScheduleParams
   rather than copying the proto by value (the embedded
   MessageState contains a sync.Mutex).

 - systest/boarding_e2e_test.go: insufficient-fee negative
   test reserves zero implicit fee (vtxoAmount=amount) so the
   rejection holds under both the dynamic schedule and the
   legacy flat fee.

New itest/systest files:

 - itest/fees_validation_test.go: drives ErrVTXOBelowMinViable,
   ErrOperatorFeeTooLow, and warn-policy readback via
   EstimateFee.
 - itest/fees_congestion_test.go: compares baseline vs
   spread-active EffectiveAnnualRate and asserts the total
   fee rises when the spread activates.
 - itest/fees_classifier_test.go: verifies external_deposit /
   external_withdrawal event types are reachable via
   ListFeeEvents and debit/credit external_funding as expected.
 - itest/fees_treasury_rehydration_test.go: RestartArkd and
   assert every TreasuryTracker field survives the restart.
 - systest/fees_e2e_test.go: smoke test confirming the systest
   harness wires a non-zero calculator with the configured
   schedule shape.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
Pin darepo-client at the tip of issue-263-fees-client-coverage
now that the remaining client-side round-trip tests have landed
on that branch:

 - round/refresh_psbt_size_test.go: PSBT shape stays constant
   across fee schedules.
 - darepod/congestion_roundtrip_test.go: bufconn-driven fake
   arkrpc server proves quoteOperatorFee returns the operator's
   TotalFeeSat verbatim across a baseline → congestion-bump
   sequence.
 - darepod/rpc_fees_e2e_test.go: every EstimateFee field proxies
   verbatim to the CLI; the CLI-visible total matches the
   wallet-actor-used total for the same inputs.

Part of issue #263.
ellemouton pushed a commit that referenced this pull request May 22, 2026
In this commit, we bump LatestMigrationVersion from 11 to 13 to
match the actual set of migrations on master.

Master added migrations 000012_utxo_attribution and
000013_round_attribution without updating this constant. First-boot
works (DB starts at version 0, applies every migration it finds via
Up()), but on daemon restart the downgrade-protection check at
db/migrations.go:183 trips because the DB is at 13 while the
constant says 11, refusing to open the DB with "database downgrade
detected: db_version=13, latest_migration_version=11". The new
RestartArkd harness helper added under issue #263 is the first
thing in the test suite to exercise a restart, so it surfaced this
latent master bug.
ellemouton pushed a commit that referenced this pull request May 22, 2026
In this commit, we resolve a conflict between the fees-on default
and the client's stale pre-flight fee check, and fix a latent
systest harness gap where the dynamic fee validator never ran.

The client's round FSM at client/round/transitions.go:383 has a
legacy pre-flight check: if the implicit operator fee on a boarding
is below env.OperatorTerms.MinOperatorFee, the client rejects its
own submission before the server sees it. Under fees-off both
values are zero and the check is dormant. Under the post-#263
fees-on default the dynamic EstimateFee quote sits at roughly 167
sats for a 100k boarding, well below the production default of
1000 sats, so every boarding itest gets stuck in
ClientFailedState with "operator fee below minimum".

The dynamic schedule is authoritative under fees-on, so we zero
the legacy flat MinOperatorFee in both harnesses. Tests that need
the legacy floor can still install it via an OperatorConfigMutator.

For the systest harness, two additional fixes are required:

1. The rounds actor was constructed without a FeeCalculator,
   which meant validateOperatorFee fell through to the legacy
   flat path and ignored the dynamic schedule entirely. Any
   zero-fee boarding would slip through under fees-on. We wire
   FeeCalculator, TreasuryTracker, and LedgerRef on the rounds
   actor config so the systests exercise the same validation
   surface the production daemon does.

2. initFeesSubsystem() was called after the rounds actor config
   was constructed, so h.feeCalculator was still nil at wire
   time. We move it ahead of the rounds config so the field is
   populated.

TestBoardingE2EInsufficientOperatorFee is updated to drop the
now-obsolete "MinOperatorFee > 0" assertion; the dynamic validator
in rounds.validateOperatorFee is the real gate and rejects the
zero-fee submission with ErrOperatorFeeTooLow as expected.
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