Skip to content

instantout: improve reservation and swap handling - #1194

Merged
hieblmi merged 12 commits into
lightninglabs:masterfrom
hieblmi:instantout-fixes
Aug 12, 2026
Merged

instantout: improve reservation and swap handling#1194
hieblmi merged 12 commits into
lightninglabs:masterfrom
hieblmi:instantout-fixes

Conversation

@hieblmi

@hieblmi hieblmi commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • make reservation notification handling resilient to malformed and duplicate entries while bounding active state
  • validate reservation outputs and cooperative transaction inputs before progressing
  • improve MuSig2 lifecycle handling, recovery behavior, fee limits, and RPC permission consistency
  • expose and persist a client-approved Instant Out swap fee cap

Motivation

Instant Out and reservation flows currently assume several inputs and lifecycle transitions are well formed. That can leave state machines stuck, produce inconsistent errors, or make recovery less predictable. These changes make the flows fail cleanly and keep persisted state aligned with chain and RPC data.

User impact

Instant Out requests now enforce the quoted service-fee ceiling, recovered swaps use the on-chain fallback when reservation timing is too tight, and reservation notifications no longer interrupt the manager. The CLI automatically supplies the accepted quote as the fee limit.

Testing

  • go test ./...
  • go test -race ./instantout/...
  • go test ./... from looprpc
  • go vet ./instantout/... ./loopd/...

@hieblmi
hieblmi force-pushed the instantout-fixes branch 2 times, most recently from 44c544a to 4f07354 Compare August 11, 2026 10:16
@hieblmi
hieblmi marked this pull request as ready for review August 11, 2026 12:46
@hieblmi
hieblmi requested review from sputn1ck and starius August 11, 2026 12:46

@sputn1ck sputn1ck left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM! (human review)

@hieblmi

hieblmi commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway review

2 similar comments
@djkazic

djkazic commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

/gateway review

@hieblmi

hieblmi commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway review

@lightninglabs-gateway lightninglabs-gateway 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.

Gateway review — 10 findings

🔴 1 Blocker · 🟠 7 Major · 🟡 2 Minor · 🔵 0 Nit

Summary

This PR hardens the instant-out and reservation paths against a misbehaving server: length/nil validation on server-controlled MuSig2 vectors, local script-engine verification of the combined signature, MuSig2 session cleanup on every exit path, exact-value matching of the reservation output, duplicate and resource bounds on reservation notifications, a client-side swap-fee cap, and consistent macaroon scoping. The direction is right and the new test coverage is genuinely targeted at the new logic.

Three things need attention before merge. First, registering finalStateObserver turns a pre-existing unlocked map write in RecoverReservations into a live data race that aborts the process at startup. Second, the new resource bound is enforced fail-closed during recovery, so exceeding it permanently prevents the reservation subsystem from starting — worse than the exhaustion it defends against, and inconsistent with the same change making the notification loop resilient. Third, the fee cap is weaker than it reads: its only value comes from the server's own quote, and its wire default of 0 rejects every fee-bearing swap for any client that isn't rebuilt.

The recovery gate in PushPreimageAction also deserves a second look. It is keyed on the dynamic type of the event context and compares against reservation expiry only, so on the paths and heights where revealing the preimage is most dangerous it either isn't reached or passes anyway.

Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

@@ -174,6 +241,11 @@ func (m *Manager) RecoverReservations(ctx context.Context) error {
reservationFSM := NewFSMFromReservation(m.cfg, reservation)

m.activeReservations[reservation.ID] = reservationFSM

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 F1 (Blocker) — Reservation recovery races the new observer into a fatal map write · instantout/reservation/manager.go:243

RecoverReservations writes m.activeReservations[reservation.ID] = reservationFSM without holding m.Mutex, and this PR now registers a finalStateObserver on each recovered FSM whose Notify deletes from that same map under the lock, from the FSM's own goroutine. With two or more non-final persisted reservations where the first reaches a terminal state while the loop is still inserting later entries, the Go runtime kills the process with an unrecoverable fatal error: concurrent map writes. RecoverReservations is called from Manager.Run, so this is a crash on the loopd startup path.

Why this matters

The unlocked write predates this PR and was benign, because nothing removed entries from activeReservations during recovery; the observer is what introduces the concurrent mutator. A node that has been offline past a reservation expiry hits exactly this shape — the first recovered reservation finalizes almost immediately on OnRecover while the loop continues — which makes it a restart crash loop rather than a narrow timing window.

Take m.Lock() around the insert in the recovery loop, mirroring the guarded insert newReservation already performs; registering the observer and spawning the goroutine after the unlock is still safe, since the goroutine cannot run before it is created. Note that the identity check o.manager.activeReservations[o.id] == o.fsm in Notify does not help here — the race is on the map itself, not on the value. Blame on the surrounding lines is 4430627 (PR 839) and 61a5f9d (PR 632); the map's locking discipline has been inconsistent since it was introduced, and this is the change that makes it load-bearing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 42748896. RecoverReservations now holds the manager mutex while inserting each recovered FSM, matching the locking used by observer-driven removal and all other activeReservations access. TestManagerRecoversAllPersistedReservations exercises concurrent terminal observer removals, and the reservation suite passes under -race.

Comment thread cmd/loop/instantout.go Outdated
ReservationIds: selectedReservations,
OutgoingChanSet: outgoingChanSet,
DestAddr: cmd.String("addr"),
MaxSwapFeeSat: quote.ServiceFeeSat,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 F2 (Major) — The client fee cap is chosen by the server it is meant to bound · cmd/loop/instantout.go:193

MaxSwapFee is documented as "the maximum off-chain swap fee accepted by the client", but the only caller that sets it is the CLI, which echoes back quote.ServiceFeeSat from the server's own InstantOutQuote response with no flag to override it. A server that wants to overcharge simply quotes a large ServiceFeeSat; the CLI adopts it as the ceiling and validateInstantOutInvoiceAmount accepts the matching invoice. The check therefore detects only a server that contradicts its own quote, not one that quotes an extortionate fee — the fund-loss case the cap exists for is not bounded.

Why this matters

Compare loop out, where the client supplies max_swap_fee / max_miner_fee limits independently of what the server quotes. The user's only protection here remains the printed Service fee: line and the CONTINUE SWAP? (y/n) prompt, which is what already existed before this PR.

Second, the cap has zero tolerance: validateInstantOutInvoiceAmount rounds the observed fee up to the next satoshi and rejects at one millisatoshi over, so any drift between the quote call and the swap call fails the swap with instant out swap fee N exceeds maximum M and no operator remedy. A --max_swap_fee flag that defaults to the quoted fee (optionally with a small documented tolerance, or a percentage-of-amount ceiling the quote must fit inside) would make the cap a real client-side limit and give the user a way out of a repriced quote.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No code change for this finding. The in-tree CLI displays the quote, requires explicit confirmation, and then supplies that exact accepted service fee as the cap. Keeping the cap exact avoids silently accepting a different fee; if the quote changes, the intended flow is to quote and confirm again. A separate tolerance flag would be a product/API enhancement rather than a correctness change for this PR.

Comment thread instantout/actions.go
// A recovered swap may have been offline long enough that the server's
// reservation timeout is now close. Fall back to the already finalized
// HTLC instead of revealing the preimage without enough time to publish
// that safety transaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 F3 (Major) — Preimage-reveal gate is keyed on the event context's dynamic type · instantout/actions.go:427

The new safety gate in PushPreimageAction runs only when eventCtx type-asserts to *RecoverInstantOutCtx, and that type is constructed in exactly one place — recoverInstantOuts — for a single SendEvent(ctx, OnRecover, recoverCtx). A swap recovered in an earlier state that walks forward through BuildHTLCAction (which returns OnHtlcSigReceived with no event context of its own) therefore reaches PushPreimageAction without that type and reveals the preimage with no expiry check at all — precisely the long-offline case the gate was added for.

Why this matters

I cannot confirm from the diff how fsm propagates event contexts across transitions triggered by an action's returned event type, and the FSM's state/transition table is not in the reviewed context; if internal transitions forward the originating event context, the gate holds for those paths. TestPushPreimageRejectsExpiringReservation calls PushPreimageAction directly with a *RecoverInstantOutCtx, so it cannot distinguish the two cases either.

Deriving the safety condition from durable state — the manager's current height plus fields already on InstantOut — and checking it unconditionally in PushPreimageAction would remove the dependence on how the state was entered. Revealing the preimage is irreversible in one direction only, so this check should fail closed regardless of the event path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

No code change for this finding. SendEvent retains the same event context through action-driven transitions, so a recovered PushPreimageAction receives RecoverInstantOutCtx both when recovery starts in PushPreimage and when BuildHtlc advances into it. Recovery from Init or SendPaymentAndPollAccepted transitions directly to Failed, so those paths do not reach this action.

Comment thread instantout/actions.go Outdated
// reservation timeout is now close. Fall back to the already finalized
// HTLC instead of revealing the preimage without enough time to publish
// that safety transaction.
if recoverCtx, ok := eventCtx.(*RecoverInstantOutCtx); ok {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 F4 (Major) — Recovery gate ignores the swap's own CLTV expiry · instantout/actions.go:428

The gate requires res.Expiry >= currentHeight + htlcExpiryDelta but says nothing about InstantOut.CltvExpiry, so it permits revealing the preimage at heights well past the HTLC's own timeout — where the OnErrorPublishHtlc fallback is worthless. Once the tip is at or beyond cltvExpiry, publishing the finalized HTLC gives the client no blocks to sweep it with the preimage while the server's timeout branch is already (or imminently) spendable, and the server has the preimage from PushPreimage.

Why this matters

Concretely: InitInstantOutAction enforces res.Expiry >= cltvExpiry + htlcExpiryDelta, so with cltvExpiry = 200 and res.Expiry = 400 the gate keeps passing until height 360 — 160 blocks after the HTLC expired. The two deltas are not interchangeable: htlcExpiryDelta is documented as the margin "between the htlc expiry and reservation expiry", not between the current tip and reservation expiry.

The check should also require the tip to sit some margin below f.InstantOut.CltvExpiry before revealing, and fall back to the HTLC (or a terminal error) otherwise. I have not traced the HTLC-sweep deadline in PublishHtlcSweepAction / generateHtlcSweepTx in detail, so the exact margin needed is for the author to set; the missing condition itself is visible in the diff.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6077a269. Recovery now fetches a fresh chain height inside PushPreimageAction, verifies that CltvExpiry leaves two urgent confirmation targets (one for the HTLC and one for its preimage sweep), and selects the HTLC path before revealing the preimage when that window is too short. A focused test covers the HTLC deadline.

Comment thread instantout/reservation/manager.go Outdated
fsm.WithWaitForStateOption(time.Second),
)
if err != nil {
m.Lock()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 F5 (Major) — Timed-out reservation is dropped from the map while its FSM keeps running · instantout/reservation/manager.go:197

When WaitForState(ctx, 5*time.Second, WaitForConfirmation, ...) fails, newReservation now deletes the entry from activeReservations, but the goroutine started just above keeps driving OnServerRequest and may still persist and advance that reservation. The reservation becomes invisible to LockReservation / UnlockReservation / GetActiveInstantOut until the next restart even though it can confirm and hold funds, and it no longer counts toward the new len(m.activeReservations) >= maxActiveReservations bound.

Why this matters

That second effect defeats the bound this PR is adding: a server that keeps each notification just slow enough to miss the 5s deadline drives an unbounded number of live FSMs and persisted non-final rows past the cap, and those same rows are what RecoverReservations counts to decide whether to refuse startup (see the recovery-abort finding).

Either cancel/abort the FSM when the wait fails, or keep the entry until the FSM actually reaches a final state and let the observer evict it — the mechanism this PR already added for exactly that purpose.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 42748896. A WaitForState timeout no longer deletes the active map entry while initialization can continue on the manager context. Terminal-state observation is now the authoritative eviction path. A delayed-initialization test verifies that the FSM remains tracked after the caller's wait times out and can still advance.

Comment thread instantout/reservation/manager.go Outdated
activeCount++
}
}
if activeCount > maxActiveReservations {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 F6 (Major) — Exceeding the reservation cap permanently prevents startup · instantout/reservation/manager.go:228

RecoverReservations returns ErrTooManyActiveReservations when more than 1000 stored reservations are non-final, and that error propagates out of Manager.Run — the function that starts the reservation subsystem. Once a node's store crosses that line, the manager never starts again: no reservation is recovered, no notification is served, and funds committed to in-flight reservations go unmanaged until they expire. Since reservations only reach a final state by the manager running, there is no path back short of editing the database.

Why this matters

This is the one place the new bound fails closed. The in-memory bound is already enforced where it matters, in newReservation, and the same change makes the Run loop log-and-continue instead of returning — the right shape. Recovering up to the cap (nearest-expiry first) and logging the remainder with the observed count would degrade service instead of bricking the subsystem. Making the limit an operator-visible config value with a documented default of 1000, rather than an unexported constant, would also give an affected node a way out without a rebuild.

Two smaller points in the same block: the boundary disagrees with newReservation, which rejects at >= maxActiveReservations while this path aborts at > maxActiveReservations; and this branch has no test — TestManagerLimitsActiveReservations covers the newReservation path only.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 42748896. All persisted non-terminal reservations are now resumed, even when their count exceeds the live-notification cap; the cap applies only to newly accepted notifications. The >/>= comparison already allowed exactly 1000 in both paths, so the substantive fix was removing the startup rejection for persisted obligations. A test recovers maxActiveReservations + 1 entries.

Comment thread looprpc/client.proto Outdated
/*
The maximum off-chain swap fee that may be charged for the swap.
*/
int64 max_swap_fee_sat = 4;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 F7 (Major) — Unset max_swap_fee_sat is enforced as a zero cap · looprpc/client.proto:1692

max_swap_fee_sat is a plain proto3 int64 with no presence tracking, so every client that does not set it sends 0, loopd forwards btcutil.Amount(0) into NewInstantOut, and validateInstantOutInvoiceAmount then fails the swap as soon as the invoice charges a single millisatoshi of fee. Instant out breaks outright for any caller not rebuilt against this PR — lightning-terminal, RTL, scripts against the gRPC/REST surface — and the failure arrives as the opaque instant out swap fee N exceeds maximum 0 from inside the FSM rather than as an argument error naming the field.

Why this matters

Nothing distinguishes "the client approved a zero fee" from "the client expressed no opinion": NewInstantOut rejects only negative values, and the proto comment says nothing about the zero value — that comment is also the entire documentation REST callers see via client.swagger.json. The in-tree CLI is updated, so this break is invisible from inside the repo, and InstantOut (*swapClientServer) is the highest-reach symbol touched here.

Either give the field presence (optional int64) and treat unset as "no client-side cap", or reject unset/zero at the RPC boundary with status.Error(codes.InvalidArgument, ...) before calling NewInstantOut, and document the chosen semantics in the proto comment. Two related items worth handling in the same pass: NewInstantOut's signature change is breaking for in-process consumers and isn't called out in the release notes, and now that MaxSwapFee is read back from the store, rows written before this PR report 0 indistinguishably from a genuine zero cap.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 6f3aa345. The RPC field is now a presence-aware oneof that retains the original scalar wire representation: omitted means legacy behavior with no cap, while an explicitly encoded zero remains a strict zero-fee cap. The RPC layer adds WithMaxSwapFee only when present, and NewInstantOut uses a variadic option so existing three-argument Go callers still compile. A wire-level test covers omitted versus explicit zero.

Comment thread looprpc/perms.go
Entity: "swap",
Action: "read",
}, {
Entity: "loop",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 F8 (Major) — New macaroon op breaks existing scoped macaroons · looprpc/perms.go:181

Appending {Entity: "loop", Action: "out"} makes it a second required op on ListReservations, InstantOut, InstantOutQuote and ListInstantOuts, since all ops listed for a method must be satisfied together. Any already-issued custom-baked macaroon scoped to only swap:read / swap:execute stops working against these four RPCs the moment the operator upgrades, and the failure surfaces as a generic permission-denied with nothing pointing at the newly added entity.

Why this matters

The end state is defensible — it matches the LoopOut / LoopOutQuote / SweepHtlc siblings and closes a real under-scoping gap — so this is about how the change lands, not whether it should. But loopd documents custom baked macaroons as a supported workflow, and the release-notes entry files this under #### Bug Fixes while #### Breaking Changes is left empty, so an operator has no way to learn they must re-bake.

Add an entry under #### Breaking Changes naming the four methods and the required loop:out op, so the remediation is mechanical rather than a bisect against permission-denied errors. That entry is also missing the #1194 link that the neighbouring PR #1189 entry carries.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 920e2384. The Breaking Changes section now lists the four affected Instant Out/reservation RPCs, the added loop:out permission, and the need for operators using custom scoped macaroons to rebake them.

Comment thread instantout/manager.go Outdated
// As SendEvent can block, we'll start a goroutine to process
// the event.
recoverCtx := &RecoverInstantOutCtx{
currentHeight: m.currentHeight,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F9 (Minor) — Recovery height is a stale, unsynchronized snapshot · instantout/manager.go:123

recoverInstantOuts snapshots m.currentHeight without holding m.Lock() (the block-epoch loop writes it under the lock) and this value now feeds the preimage-reveal gate; because the loop only starts after recovery, the height is whatever was passed to NewInstantOutManager and is never refreshed for the recovered swap. A height lower than the real tip yields a lower minReservationExpiry and makes the gate less conservative, which is the wrong direction for a safety check — read the height under the mutex, and prefer evaluating it when the action runs rather than when the event is queued.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Addressed the valid portion in 6077a269. Recovery runs before the manager enters its block-update loop, so the former read was not concurrent with that loop's writes; however, the startup snapshot could still be stale. The snapshot has been removed, and the action now fetches the chain height immediately before both recovery deadline checks.

defer o.manager.Unlock()

if o.manager.activeReservations[o.id] == o.fsm {
delete(o.manager.activeReservations, o.id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 F10 (Minor) — Eviction makes activeReservations lookups newly fallible · instantout/reservation/manager.go:47

Entries now disappear once a reservation reaches a terminal state, so LockReservation / UnlockReservation, which return fmt.Errorf("reservation not found") on a map miss, can now fail where they previously always found the entry — notably in handleErrorAndUnlockReservations, which turns that miss into a further FSM error for a swap whose reservations have already gone final. Worth confirming those call sites treat a missing entry as benign rather than as a failure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 42748896. If an active entry has already been evicted, UnlockReservation checks persisted state and treats a terminal reservation as an idempotent success while still returning an error for unknown or non-terminal missing entries. Cleanup now attempts every unlock, preserves the original action error, and always dispatches cancellation with its own bounded uncancelled context. Focused tests cover terminal eviction and cleanup-error behavior.

@lightninglabs-gateway

lightninglabs-gateway Bot commented Aug 11, 2026

Copy link
Copy Markdown

🤖 gateway audit metadata for this PR — auto-generated, please don't edit.

@hieblmi

hieblmi commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

/gateway re-review

Handover for the next pass:

  • Reservation lifecycle feedback is folded into 42748896: synchronized recovery insertion, timeout-safe FSM ownership, recovery of all persisted obligations, idempotent terminal unlocks, and cleanup that preserves the original failure while still cancelling.
  • Recovery timing feedback is folded into 6077a269: fresh chain height plus reservation and HTLC deadline checks before preimage release.
  • Fee-cap compatibility feedback is folded into 6f3aa345: presence-aware wire-compatible field semantics, omitted-cap compatibility, explicit-zero enforcement, and compatibility for existing Go callers.
  • The custom-macaroon migration note is folded into 920e2384.
  • The exact-quote and recovery-context comments received rationale replies and no code changes. The chain-height comment received the valid stale-snapshot fix; its concurrent-race premise did not apply because startup recovery completes before block updates begin.
  • All changes remain folded into the original 12 commits, and affected issue-to-commit links were refreshed.

Validation completed: go test ./..., go test -race ./instantout/..., looprpc go test ./..., go vet ./instantout/... ./cmd/loop ./loopd, and clean protobuf regeneration with make rpc.

@hieblmi hieblmi self-assigned this Aug 11, 2026

@lightninglabs-gateway lightninglabs-gateway 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.

Gateway review — 3 findings

🔴 0 Blocker · 🟠 0 Major · 🟡 3 Minor · 🔵 0 Nit

Summary

Eight of the ten prior findings are fixed, each with a focused test. The reservation-lifecycle rework is the substantive one: the recovery insert now holds the manager mutex (removing the concurrent map writes startup crash), terminal-state observation is the sole eviction path so a caller-side wait timeout no longer orphans a live FSM, all persisted non-terminal reservations are resumed regardless of the new cap, and terminal unlocks are idempotent while cleanup preserves the original action error. The recovery gate now reads a fresh chain height and checks both the HTLC and reservation deadlines before releasing the preimage, and the fee cap is presence-aware at the wire level with the macaroon change documented under Breaking Changes.

Two priors stay open at reduced severity rather than as blockers. The fee cap is still exactly the server's own quote — I accept the rationale that the CLI's printed fee plus y/n confirmation makes it a user-approved ceiling, leaving only the non-interactive path as a follow-up. The recovery gate still keys off the event-context type; that one depends on fsm transition semantics I cannot see in the reviewed context, so it is recorded as a defense-in-depth note, not a defect.

One genuinely-new minor item falls out of the fix I asked for: retaining the FSM after a wait timeout means nothing evicts an entry whose server call never returns. Separately, I checked the new per-input script verification in finalizeMusig2Transaction — verifying input idx before later inputs' witnesses are set is safe, since taproot sighashes commit to prevouts, amounts, scripts, sequences and outputs but not to other inputs' witness data.


Status of prior findings

  • F1 addressed: Fixed in instantout/reservation/manager.go:232 — the recovery insert is now wrapped in m.Lock()/m.Unlock(), matching the guarded insert in newReservation, and TestManagerRecoversAllPersistedReservations exercises concurrent observer-driven removal.
  • F4 addressed: Fixed in instantout/actions.go:452 — recovery now requires CltvExpiry >= currentHeight + htlcRecoverySafetyDelta (one urgent conf target for the HTLC, one for its preimage sweep) before revealing, covered by TestPushPreimageRejectsExpiringHtlc.
  • F5 addressed: Fixed in instantout/reservation/manager.go:200 — the wait-timeout path no longer deletes the map entry, and TestManagerKeepsReservationAfterWaitTimeout confirms the FSM stays tracked and can still reach WaitForConfirmation.
  • F6 addressed: Fixed in instantout/reservation/manager.go:220RecoverReservations no longer consults maxActiveReservations, so the cap applies only to new notifications and the subsystem can always start.
  • F7 addressed: Fixed in looprpc/client.proto:1688max_swap_fee_sat is now a oneof member, so omitted means no cap while an explicit zero is preserved; NewInstantOut took a variadic option to keep existing Go callers compiling, and looprpc/client_test.go asserts the wire-level distinction.
  • F8 addressed: Fixed in docs/release-notes/release-notes-next.md:7 — the Breaking Changes section names the four RPCs, the added loop:out op, and the need to rebake custom scoped macaroons, with the #1194 link.
  • F9 addressed: Fixed in instantout/actions.go:437 — the stale m.currentHeight snapshot is gone; RecoverInstantOutCtx is now an empty marker and the action fetches the height via GetInfo immediately before both deadline checks.
  • F10 addressed: Fixed in instantout/reservation/manager.go:297UnlockReservation consults persisted state on a map miss and treats a terminal reservation as success, while cleanup attempts every unlock and returns the action error in preference to the unlock error.
Bot commands
  • /gateway re-review — re-run after pushing changes (maintainers)
  • /gateway dismiss <id> — silence a finding (maintainers)
  • /gateway explain <id> — elaborate on a finding (anyone)

Comment thread instantout/reservation/manager.go
Comment thread cmd/loop/instantout.go
Comment thread instantout/actions.go
Apply the loop:out permission to Instant Out and reservation RPCs so
their authorization requirements match the rest of the Loop Out API.
Log individual reservation initialization failures and continue
consuming later notifications instead of stopping the manager.
Use a goroutine-local result for event dispatch so observer errors
remain independent and initialization outcomes stay deterministic.
Check active and persisted reservations before creating a new state
machine, preserving the existing reservation when a duplicate arrives.
Limit newly accepted reservation state machines, resume all persisted
reservations, remove terminal entries from memory, and make cleanup
resilient to observer-driven eviction.
Compare each confirmed transaction output with the expected reservation
amount before advancing the state machine.
Check nonce, signature, session, and transaction input counts before
indexing signing vectors, returning clear errors for incomplete data.
Run script validation for every combined signature before accepting a
finalized transaction, surfacing invalid witnesses immediately.
Clean up abandoned signing sessions on error paths while leaving
completed sessions to lnd.
Refresh the chain height when a swap resumes, verify both the
reservation and HTLC windows, and select the HTLC path when either
remaining window is too short.
Carry the accepted quote into each request, persist it, and reject
invoices above that limit. Preserve compatibility for requests that
omit the cap while distinguishing an explicit zero.
Record the Instant Out and reservation validation, recovery,
fee-limit, lifecycle, and custom macaroon updates in the next release
notes.
@hieblmi
hieblmi merged commit 6d1dbcb into lightninglabs:master Aug 12, 2026
12 checks passed
@hieblmi
hieblmi deleted the instantout-fixes branch August 12, 2026 08:55
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.

3 participants