Skip to content

multi: consolidated security fixes (May 15) - #459

Merged
Roasbeef merged 9 commits into
mainfrom
consolidated/security-fixes-2026-05-15
May 19, 2026
Merged

multi: consolidated security fixes (May 15)#459
Roasbeef merged 9 commits into
mainfrom
consolidated/security-fixes-2026-05-15

Conversation

@ellemouton

@ellemouton ellemouton commented May 15, 2026

Copy link
Copy Markdown
Member

Bundles all of the in-flight client-side security fixes into a single PR to reduce CI load. Each commit closes exactly one issue and is independently reviewable.

Commits

Commit subject Closes Original PR
oor: Validate incoming ancestor packages (#366) #366 #439
db: Reject untrusted OOR ancestor packages by txid in resolver #371 #441
oor: Reject incomplete ancestry index on incoming OOR receive #374 #442
unroll: Keep restore failures retryable, restore via Ensure #381 #443
round: Enforce fee cap against realised quote economics #379 #452
round: Tighten single-output quote echo amount validation #378 #453
arkrpc: Validate ancestry tree_depth at indexer trust boundary #370 #454
unroll: Drop zero-tree-depth gate that can block legitimate proofs #372 #455
serverconn: Sign TLS leaf SPKI with mailbox key (pair with darepo#448) #456

Closes

Closes #366. Closes #370. Closes #371. Closes #372. Closes #374. Closes #378. Closes #379. Closes #381.

Companion server-side PR

The serverconn commit (closes lightninglabs/darepo#448) is paired with the server-side enforcement in the consolidated darepo PR (https://github.com/lightninglabs/darepo/pull/453).

CI scope

This branch combines work that previously lived in 9 separate PRs. CI runs here cover the union of those PRs' diffs; per-commit equivalence to the source PR diffs was independently verified.

Notable cross-PR interactions

Note on superseded PRs

PRs #439, #441, #442, #443, #452, #453, #454, #455, #456 will be closed once this PR is up. Closing them now would lose review history; folks pulling from those branches should rebase onto this one.

@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 several security and robustness enhancements focused on protecting user funds and ensuring reliable recovery. Key changes include the implementation of strict ancestry path depth validation and input coverage checks to prevent malicious indexers from stranding funds. It also adds TLS binding signatures to secure client identity registration against replay attacks and introduces "realised-fee" enforcement to prevent operators from bypassing fee caps by manipulating change outputs. Additionally, the unroll registry and artifact storage were refactored to improve resilience against transient failures and ensure idempotency during persistence. As there were no review comments provided, I have no feedback to offer on the code review process.

@ellemouton
ellemouton force-pushed the consolidated/security-fixes-2026-05-15 branch from 7a3263c to f0c6de5 Compare May 15, 2026 16:42
@ellemouton

Copy link
Copy Markdown
Member Author

Addressed Lint failure (dupl between signMailboxTLSBind and signMailboxAuth, plus their two *ViaKeyRing twins). New SHA: f0c6de5c. Refactored to a single signTaggedSchnorr helper that takes (msg, tag, opName) and dispatches to whichever wallet backend is configured (LND / lwwallet / btcwallet); the keyring-mode branch likewise collapses into signTaggedSchnorrViaKeyRing. Net: -113 / +48 lines in darepod/server.go, no behavioural change, lint and go test ./darepod/... clean locally.

@ellemouton
ellemouton marked this pull request as ready for review May 15, 2026 17:07
@ellemouton
ellemouton requested a review from Roasbeef May 15, 2026 21:47
@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 4m 12s —— View job


PR Review: multi: consolidated security fixes (May 15)

  • Gather PR context
  • Review OOR ancestry validation (oor/package_validation.go, oor/incoming_vtxo.go)
  • Review DB resolver changes (db/oor_unroll_resolver.go)
  • Review unroll registry refactor (unroll/registry.go, unroll/proof_assembler.go)
  • Review round fee cap enforcement (round/transitions.go)
  • Review serverconn TLS binding (serverconn/mailbox_auth.go, serverconn/types.go)
  • Review arkrpc tree_depth validation (arkrpc/ancestry_path_convert.go)
  • Synthesize findings and post review

Overall Assessment

This is a well-structured, defense-in-depth security PR. Each commit is independently reviewable and tightly scoped. The layered trust-boundary enforcement pattern (RPC ingest → FSM validation → persistence → unroll) is well-executed. Comments are informative and consistently explain why a given gate exists (fund-safety, timing surfaces, etc.). A few observations follow.


oor/package_validation.go — Ancestor graph reachability

The validateIncomingPackageGraph / walkPackageAncestors design is correct. One structural observation:

walkPackageAncestors (package_validation.go:107) only traverses checkpoint.UnsignedTx.TxIn[0] — the first input of each checkpoint transaction. The guard len(checkpoint.UnsignedTx.TxIn) == 0 prevents a panic but does not validate single-input invariant. If a checkpoint could ever have more than one input, ancestry paths reachable via later inputs would be silently missed and fail the "unreachable ancestor" check at line 82–86, causing a legitimate receive to be rejected. This appears to be a protocol invariant (checkpoint = single 2-of-2 collab spend), but it might be worth either an assertion or a comment confirming that len(TxIn) == 1 is guaranteed by oortx.ValidateFinalizePackage upstream. Fix this →


oor/incoming_vtxo.govalidateIncomingAncestry

Solid. The covered-bitmap approach for detecting duplicate / missing input indices at the receive boundary is correct and the error messages are specific enough to diagnose a malicious indexer response. The defense-in-depth call to arkrpc.ValidateAncestryPathDepth inside validateIncomingAncestry (line 308) correctly catches any in-process materializer that bypasses the RPC ingest path.

One minor observation: normalizeIncomingAncestry (incoming_vtxo.go:423) silently returns the unsorted slice if no primary fragment is found. Because validateIncomingAncestry is always called first in BuildIncomingVTXODescriptor, this dead path is unreachable in production. Fine as-is, but a panic("unreachable") or //nolint:exhaustive comment would make the contract explicit.


db/oor_unroll_resolver.go — Session-keyed ancestry lookup hardening

resolveInputPackagepackageProducesAncestorOutput is the right defense: a session-keyed row can only claim ancestry for a checkpoint input if the stored Ark tx actually hashes to the requested txid AND the referenced output is a non-anchor in-bounds output. The three-condition rejection (nil, txid mismatch, anchor) matches validAncestorOutput in package_validation.go, so both layers reject the same malformed/poisoned packages. Good symmetry.


unroll/registry.go — Retryable restore

The refactor from "fail terminal on any restore error" to "leave non-terminal for retry" is the correct safety posture for a user racing a CSV expiry. The async persist design (requestPersist → persistRecordAsync → Tell result back) keeps the registry goroutine unblocked. The in-flight-write deduplication via r.persisting (handlePersistActiveRecord:997) is correct.

schedulePersistRetry timer leak: time.AfterFunc at line 1189 fires after the registry stops. The subsequent r.selfRef.Tell silently returns an error (discarded). This is benign for correctness but means a stopped registry leaves a live timer for up to maxPersistRetryDelay (5s). No action needed — just worth knowing it's there.

isCancellationRace: the three-way union (Canceled | DeadlineExceeded | ErrActorTerminated) is correct and well-commented. Good.


round/transitions.go — Realised-fee cap

The two-layer design is correct:

  1. validateQuoteEchoes checks per-output amount echoes against intent targets.
  2. realisedQuoteFee independently computes Σ(authoritative inputs) − Σ(quoted outputs) and requires it to equal quote.OperatorFeeSat AND not exceed the cap.

The negative-amount guards on individual items are important (an operator could attempt to inflate inputsSat by injecting a negative output amount on their side).

implicitChange branch in validateQuoteEchoes: The fix correctly narrows the old "skip all amount checks when single-output" to "allow exactly Amount − OperatorFeeSat". The realisedQuoteFee check then independently verifies the operator didn't lie about OperatorFeeSat. Combined, the only honest single-output deviation is exactly the quoted fee, bounded by MaxOperatorFee. This closes issue #378.

realisedQuoteFee and zero-input paths: If a quote has no boarding and no forfeits (e.g., a future protocol extension), inputsSat == 0 and outputsSat > 0 produces a negative realised fee, which is correctly rejected. No gap here.

computeTotalForfeitAmount fallback: When env.VTXOStore == nil and forfeits[i].Amount == 0, the function returns an error, which surfaces as a QuoteRejected from realisedQuoteFee. This means test environments without a store must populate forfeits[i].Amount, which the test helpers in quote_echo_test.go do. Fine.


arkrpc/ancestry_path_convert.go — Tree-depth validation

ValidateAncestryPathDepth correctly rejects zero, rejects > MaxAncestryTreeWalkDepth (32), and compares claimed depth against the locally-walked actual depth. The bounded nodeMaxDepth recursive walk (line 168 cap) prevents stack overflow from hostile tree structures. The reconstructed == nil early-exit (line 100) correctly handles callers that only have the claimed scalar. Clean.


unroll/proof_assembler.go — Removing TreeDepth == 0 gate

The removal is correct and well-justified. TreeDepth is expiry-timing metadata consumed by MaxTreeDepth / vtxo/expiry.go, not proof material. Leaving the gate intact would allow an adversarial indexer to strand otherwise-recoverable funds at unilateral-exit time by supplying tree_depth = 0. The comment in descriptor_resolver.go correctly documents where TreeDepth IS validated (receive boundary). No correctness gap.


serverconn/mailbox_auth.go + serverconn/types.go + darepod/server.go — TLS SPKI binding

The SPKI choice (DER-encoded RawSubjectPublicKeyInfo) is correct — it captures algorithm OID + key material, preventing a P-256 leaf from being confused with one using a different curve on the same raw coordinates.

The signTaggedSchnorr / signTaggedSchnorrViaKeyRing refactor cleanly eliminates the previously-duplicate signMailboxAuth / signMailboxTLSBind bodies. The lint fix (−113/+48) noted in the PR comments is reflected accurately.

InitAuthHeader early return: When AuthSignature == nil, TLSBindSignature is also silently skipped. In practice both are always set together (you cannot have TLS binding without mailbox auth). Worth a one-line comment confirming this invariant so a future reader doesn't add TLSBindSignature alone and wonder why it's not sent. Fix this →


oor/incoming_adapter.go — Double-call to validateIncomingPackageGraph

The graph validation runs at two points: IncomingTransferEventFromResponseWithLimits (RPC parse time) and validateMaterializeIncoming (persistence time, only when h.PackageStore != nil). This is intentional defense-in-depth — the second call catches any in-process path that constructs a MaterializeIncomingVTXOsRequest without going through the FSM event. The FSM ordering guarantees IncomingTransferEvent is processed before MaterializeIncomingVTXOsRequest is emitted, so in the normal flow the package is validated twice. Fine.


Test coverage

The test additions are comprehensive:

  • oor/incoming_vtxo_test.go: direct ancestry validation paths including coverage gaps, duplicate fragments, nil tree path, mismatched CommitmentTxID
  • oor/local_persistence_handler_test.go: package graph validation at persistence boundary
  • db/oor_unroll_resolver_test.go: session-keyed ancestry lookup with poisoned rows
  • unroll/registry_test.go: restore retry, async persistence, failure handoff
  • round/quote_echo_test.go: realised-fee cap, single-output implicit-change, negative amounts

The TestEvaluateQuoteRejectsSingleOutputUnderpayment widened assertion noted in the PR description (either error message) is appropriate given the layered checks.


Summary

Two minor items worth a follow-up comment/assertion (noted above with fix links):

  1. walkPackageAncestors single-input assumption — confirm or widen the walk.
  2. InitAuthHeader early return with TLSBindSignature set — add a clarifying comment.

Neither is a correctness issue in the current protocol. Everything else looks solid. The security properties (ancestry coverage, realised-fee cap, TLS-binding replay prevention, retryable unroll restore) are correctly implemented and well-tested. LGTM pending the above.

@ellemouton
ellemouton force-pushed the consolidated/security-fixes-2026-05-15 branch from f0c6de5 to 2880ed3 Compare May 19, 2026 18:04
Incoming receive materialization persists ancestor OOR artifacts for
later unroll recovery. Those artifacts come from operator/indexer
responses, so accepting only parseable PSBTs lets a bad response
overwrite recovery state.

Validate the finalized package shape, txid-derived session id,
and ancestor reachability before converting RPC responses or writing
package rows. Cover the positive case where a checkpoint actually
spends an ancestor Ark output, the duplicate-ancestor rejection path,
and the per-ancestor checkpoint count cap added on the RPC adapter.

Additionally, harden UpsertPackage: previously it checked only the
stored direction before falling through to delete and re-insert the
Ark PSBT and checkpoint rows. That made the artifact store a rewrite
surface: a malicious operator/indexer response that produced a
parseable package for a known session id could overwrite previously
stored recovery artifacts even when the FSM-layer validation passed
(e.g. when the attacker holds the original Ark transaction). Treat
retried upserts as idempotent only when the serialized Ark PSBT and
checkpoint payload match the existing row. Any divergence now fails
the upsert so the original recovery artifact survives.
The foreign-ancestor fallback in ResolveUnrollPackages loads a
session-keyed package when no local outpoint binding exists for a
checkpoint input. Until now it only bounds-checked the referenced
output index, which meant any stored row whose session_id column
matched the checkpoint input's previous hash would be accepted as
resolved ancestry, even when the stored Ark transaction did not
actually hash to that session id or when the index landed on the
non-spendable anchor output.

Ancestor packages are operator/indexer-supplied artifacts, so a
poisoned or mismatched row could otherwise be grafted onto an
unroll-chain at resolution time and corrupt unilateral-exit proof
assembly. Re-verify the txid binding and reject anchor outputs at
read time so the resolver does not depend on writers having
enforced those invariants. Mismatches are treated as unresolved
inputs rather than hard errors so the chain still degrades to the
existing recovery path.

Closes #371.
@ellemouton
ellemouton force-pushed the consolidated/security-fixes-2026-05-15 branch 2 times, most recently from 7ce3c3d to b714ae2 Compare May 19, 2026 18:20

ellemouton commented May 19, 2026

Copy link
Copy Markdown
Member Author

Updated head: 8f70cd2726ae9844996c4b01b850d461c7b05db0.

Addressed from Claude's review:

  • Documented the single-input finalized-checkpoint invariant before TxIn[0] is used in the OOR ancestor graph walk.
  • Added the InitAuthHeader comment explaining why TLS binding is only set when mailbox auth is present.

Additional CI follow-ups on this branch:

  • Fixed the static-check formatting failure in serverconn/types.go.
  • Adjusted single-output quote echo validation to rely on realised fee accounting for server-stamped implicit-change residuals.
  • Detached quote-time forfeit lookups from caller cancellation.
  • Fixed incoming OOR ancestry validation for chained multi-fragment inputs, where distinct ancestry fragments can legitimately serve the same OOR Ark input.
  • Fixed the client lint line-length issue in the new OOR ancestry test case.

Local checks run:

  • make fmt-changed-check base=6834d636d7067fffcffa8282fb5f97ee6cf5aa2d
  • make lint-changed-local base=6834d636d7067fffcffa8282fb5f97ee6cf5aa2d
  • make unit pkg=./serverconn timeout=5m
  • make unit pkg=./round timeout=5m
  • make unit pkg=./oor timeout=5m

@ellemouton
ellemouton force-pushed the consolidated/security-fixes-2026-05-15 branch 3 times, most recently from 878c626 to 239ecea Compare May 19, 2026 18:58
validateIncomingAncestry checked that each fragment's InputIndices was
non-empty and within range, but did not reject duplicate indices or
require the union of all fragments to cover every Ark tx input. An
indexer/operator could return ancestry for only a subset of a
multi-input OOR Ark tx — for example a two-input Ark tx with a single
fragment naming only input 0 — and the descriptor would be accepted
and persisted with incomplete recovery lineage. If the operator later
disappears, the uncovered input has no rooted-path material for
fraud-watch or unilateral-exit assembly, stranding the received VTXO.

Track which input indices each fragment claims, reject duplicates at
the malformed fragment, and require coverage of every Ark tx input
before returning. Add direct table-driven coverage for the partition
checks and a two-input materialization helper so the existing
primary-ancestry normalization test exercises a genuine cross-round
multi-input shape rather than a single-input PSBT with two fake
fragments.

Closes #374.
The registry used to mark each non-terminal job PhaseFailed when
RestoreNonTerminal could not spawn or resume its child actor, and
handleEnsure short-circuited on any existing record. Resume can
fail for transient external dependencies — chain backend
SubscribeBlocks / RegisterSpend through ChainSource, or a flaky
DB. Once the record was marked terminal, ListNonTerminalRecords
skipped it on every subsequent boot, turning a transient outage
during daemon start into a permanent shutdown of the recovery
path for that VTXO. For a VTXO that is in unilateral_exit and
near expiry, that translates into locked or lost funds.

This commit makes restore failures non-sticky on two paths:

  1. restoreNonTerminal no longer calls MarkTerminal on spawn /
     resume failure. The durable record stays non-terminal, is
     logged, and gets retried on the next daemon restart and on
     any in-boot EnsureUnrollRequest for the same outpoint.

  2. handleEnsure detects "non-terminal store record, no active
     child" — the signature of a previous restore that did not
     wire up — and attempts an inline restore via a shared
     tryRestoreOne helper. Success returns Created=false with
     the historical ActorID; failure surfaces the error so the
     caller can retry.

Three new tests lock the behavior in:

  - TestRegistryRestoreFailureLeavesRecordRetryable:
    first RestoreNonTerminal fails on resume, record stays
    non-terminal, a second RestoreNonTerminal with a healthy
    spawn succeeds.
  - TestRegistryEnsureRestoresFailedNonTerminalRecord:
    a fresh EnsureUnroll on an unrestored non-terminal record
    triggers inline restore.
  - TestRegistryEnsureRetriesAfterInlineRestoreFailure:
    a failed inline restore inside Ensure does not strand the
    job; the next Ensure retries.

Closes #381.
The quote.OperatorFeeSat field is operator-attested; a malicious
operator could quote a small fee within env.MaxOperatorFee while
shaving the IsChange=true output by a much larger delta. The echo
validator intentionally permits change-output amount deviation, so
without a second gate the client would accept and sign a round
whose actual economic fee exceeds the cap.

Recompute the realised fee at evaluateQuote time as Σ(authoritative
inputs) − Σ(quoted outputs), with inputs sourced from the client's
own intent composition (boarding ChainInfo amounts and VTXOStore
forfeit values) and outputs from the quote's positional slices.
Reject when the realised value exceeds the cap, is negative, or
disagrees with the declared OperatorFeeSat (operator dishonesty
that would otherwise drift downstream fee accounting).

Closes #379.
validateQuoteEchoes previously skipped the non-change amount-equality
check entirely whenever the combined VTXORequests + LeaveRequests
count was one. The shortcut was added to mirror the server's
implicit-change relaxation (the lone slot absorbs the residual) but
was too broad: it admitted any echoed amount on that slot, including
zero. A malicious or compromised operator endpoint could shave
arbitrary value from any single-output flow -- the worst case being
a single-recipient directed send with coin-selection-exact change=0,
where the lone slot is a third-party recipient marked IsChange=false
and downstream commitment validation treats the quote's AmountSat as
authoritative.

The server's residual on the implicit-change slot is exactly
(intent target - OperatorFeeSat). Enforce that equality on the lone
slot for both VTXO and leave channels. The OperatorFeeSat itself is
already bounded by env.MaxOperatorFee at line 840, so the only honest
deviation reduces to a capped fee deduction -- not unbounded shaving.

Closes #378.
The receive path copied AncestryPath.tree_depth directly from the
indexer into the persisted descriptor without validation. An untrusted
indexer could therefore return a matching VTXO with tree_depth = 0
(or a non-zero value that disagrees with the supplied tree_path) and
either silently strand the OOR VTXO at unroll time (the proof
assembler rejects zero-depth fragments as proof-unavailable) or
under-report MaxTreeDepth and delay refresh/unilateral-exit past the
safe CSV deadline. Both outcomes are fund-availability bugs.

Introduce arkrpc.ValidateAncestryPathDepth as the shared validator
at the indexer→client boundary. It rejects zero claims, claims above
MaxAncestryTreeWalkDepth (the same cap the receive-side tree walk
enforces, so any tree that survives decode also survives this gate),
and claims that disagree with the reconstructed tree path's actual
depth. Both ancestryFromRPC helpers (darepod + oor) call it before
materializing vtxo.Ancestry, and validateIncomingAncestry calls it
again as defense-in-depth so in-process descriptor construction is
also gated.

Closes #370.
The per-fragment validateProofDescriptorShape rejected any Ancestry
fragment whose TreeDepth scalar was zero. TreeDepth is expiry-timing
metadata (see vtxo.Descriptor.MaxTreeDepth and vtxo/expiry.go); the
proof assembler walks TreePath.Root directly and never reads the
scalar. Because incoming OOR ancestry is built from indexer RPC data
by copying p.GetTreeDepth() verbatim, a malicious or version-skewed
indexer that supplies a non-empty TreePath with TreeDepth omitted or
forged to zero can persist a VTXO that accepts and validates fine on
receive but is permanently rejected at unroll time. That turns an
indexer-controlled scalar into a fund-stranding lever on the
cooperative-operator-unavailable path, which is the exact threat
model unilateral exit exists to defend against.

Drop the gate so a zero TreeDepth no longer blocks proof assembly,
and document the receive-side ingest boundary as the proper place to
validate the scalar against TreePath.Depth() (issue #370).

Closes #372.
Add a secp256k1 → TLS-leaf binding signature carried in a new
x-mailbox-tls-bind-sig envelope header so the server can verify, on
first-contact Send, that the secp256k1 mailbox key holder chose the
TLS leaf the server observes on the connection.

Without this binding, the existing Schnorr auth signature (which
covers only the mailbox identity + envelope contents) does not
constrain which TLS session the envelope is replayed across. An
attacker who captured a victim's signed first Send could replay the
same bytes over a TLS connection backed by an attacker-controlled
leaf and have their fingerprint bound to the victim's mailbox ID,
defeating the post-registration fingerprint defense added in #443.

The new digest uses a dedicated BIP-340 tag (mailbox-tls-bind) so
neither signature can be reinterpreted as the other. The leaf is
identified by its SubjectPublicKeyInfo DER bytes, which commits to
the curve and algorithm identifier alongside the raw key — TLS
certs in this deployment use P-256, distinct from the secp256k1
mailbox key, so the binding has to come from a signature rather
than key identity.

darepod stamps the binding header on every outbound envelope from
the connector and on the inline response-send path so the server
can complete first-contact registration regardless of which Send is
the one that triggers it.

Closes #448.
@ellemouton
ellemouton force-pushed the consolidated/security-fixes-2026-05-15 branch from 239ecea to 8f70cd2 Compare May 19, 2026 19:10
@ellemouton

Copy link
Copy Markdown
Member Author

@claude re review this

@claude

claude Bot commented May 19, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 2m 48s —— View job


Re-review: multi: consolidated security fixes (May 15)

  • Gather updated diff and context
  • Verify previous review items were addressed (single-input invariant comment, InitAuthHeader comment)
  • Review new changes: chained multi-fragment OOR ancestry fix
  • Review new changes: single-output quote echo validation adjustment
  • Review new changes: detached forfeit lookups from caller cancellation
  • Synthesize and post final review

Previous Review Items — Both Addressed

walkPackageAncestors single-input invariant (oor/package_validation.go:107): the comment "Finalized OOR checkpoints are single-input collab spends. oortx.ValidateFinalizePackage enforces that invariant before this graph walk, so TxIn[0] is the only ancestry edge to follow." is in place. ✅

InitAuthHeader early return (serverconn/types.go): the comment "TLS binding is meaningful only alongside mailbox auth: the server verifies the binding against the same Schnorr-authenticated mailbox identity." is in place. ✅


New Changes — Chained Multi-Fragment OOR Ancestry

validateIncomingAncestry (oor/incoming_vtxo.go:266): the rework correctly separates two orthogonal invariants that were previously conflated into a single seen map:

  1. Within-fragment uniquenessseenInFragment (reset per fragment) rejects duplicate indices inside a single fragment.
  2. Full-input coveragecovered[idx] = true accumulates across fragments, with the closing loop rejecting any index that no fragment claimed.

This correctly admits the chained case: two fragments can legally cover the same Ark input index when that input is itself funded by an earlier multi-fragment OOR receive. The test suite adds the key case "chained input may have two fragments" (arkTxInputCount=1, two fragments both covering index 0) alongside "cross duplicate misses input" (arkTxInputCount=2, two fragments both covering index 0 but index 1 uncovered), which confirms the within-fragment/cross-fragment boundary is correctly drawn.

One minor observation: the covered slice has length arkTxInputCount, and entries are set only after the per-fragment range check passes. If arkTxInputCount == 0, the covered loop is a no-op (no error). In practice a zero-input Ark tx has no fragments, so the !hasPrimary guard fires first. No gap, but worth noting.

The defense-in-depth arkrpc.ValidateAncestryPathDepth call added per-fragment (line 303) correctly catches any in-process materializer that bypasses the RPC ingress path. ✅


New Changes — Single-Output Quote Echo Validation

The validateQuoteEchoes / realisedQuoteFee split is cleaner than before:

  • validateQuoteEchoes now skips amount equality entirely for implicitChange (single-output) cases. The previous "allow exactly Amount − OperatorFeeSat" constraint has been removed. This is correct: the intent Amount is a target/hint for boarding/leave flows, not a binding floor.
  • realisedQuoteFee is the sole authoritative cap check. It requires realised == quote.OperatorFeeSat (exact match) AND realised ≤ feeCap. Because both the quoted OperatorFeeSat and the output AmountSat come from the same server-issued quote, an honest server will always satisfy this identity. A dishonest server would need to simultaneously lie about OperatorFeeSat (caught by the equality check) or exceed the cap (caught by the cap check). The two checks together cover both attack surfaces. ✅

The TestEvaluateQuoteEchoAcceptsChangeDeviation test was updated to set quote.OperatorFeeSat = 8_000 to match the new realised-fee equality requirement — this is correct and the test comment explains the arithmetic clearly.

TestEvaluateQuoteRejectsSingleOutputUnderpayment hits exactly the attack path closed by #378: single-output implicitChange would have let the echo check pass, but realisedQuoteFee rejects it because realised (20_000) > feeCap (10_000) and realised != quote.OperatorFeeSat (500). ✅


New Changes — Forfeit Lookups Detached from Caller Cancellation

opCtx := context.WithoutCancel(ctx) in evaluateQuote (round/transitions.go:874) is correct. Quote evaluation can outlive the actor request that triggered it (the same reasoning used by IntentRequested at line 314 for registration-time accounting). Without this, a caller disconnect during a VTXOStore lookup would cancel computeTotalForfeitAmount, surface as forfeit amount lookup: context canceled, and produce a spurious QuoteRejected. ✅


Static-Check / Lint Fixes

mergeAuthHeaders map capacity: make(map[string]string, len(src)+len(c.authHeaderCache)) is now correct. With two auth headers (mailbox auth + TLS bind), +1 would cause a map grow on the second insert. ✅


Summary

All items from the previous review are addressed. The three substantive new fixes (chained multi-fragment ancestry, single-output quote echo relaxation, forfeit context detachment) are all correct and well-tested. The security properties are intact: ancestry coverage is enforced, the realised-fee cap applies uniformly regardless of implicit-change designation, and TLS binding cannot be silently stripped from the auth flow. LGTM.

@Roasbeef Roasbeef 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 🪻

Comment thread oor/incoming_vtxo.go
// Accepting a missing input would leave the uncovered input with no
// rooted-path material for unilateral exit, stranding the received
// VTXO if the operator later refuses cooperation.
for idx, ok := range covered {

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.

A simpler variant on this would just be to check a running counter. Tho this is nice as it gives you the exact index that isn't covered in the error.

Comment thread round/transitions.go
// Returns an error only when the VTXOStore lookup for a forfeited
// VTXO fails; an unset store falls back to the embedded forfeit
// Amount hint so harness paths without persistence keep working.
func realisedQuoteFee(ctx context.Context, env *ClientEnvironment,

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.

Good defense in depth here

Comment thread round/transitions.go
return fmt.Sprintf("leave[%d] non-change "+
"amount %d != intent target %d", i,
entry.AmountSat, leaveReq.Output.Value), false
if !implicitChange && !leaveReq.IsChange {

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.

AFAICT, this is the exact same logic as before.

Perhaps a rebase ended up modifying this for some lint/style issue?

So commit primarily adds extra test coverage.

@Roasbeef
Roasbeef merged commit 375775a into main May 19, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment