Skip to content

client: Recover expired VTXOs through normal refresh - #1000

Merged
sputn1ck merged 7 commits into
mainfrom
kon/offline-vtxo-reissue
Jul 29, 2026
Merged

client: Recover expired VTXOs through normal refresh#1000
sputn1ck merged 7 commits into
mainfrom
kon/offline-vtxo-reissue

Conversation

@sputn1ck

@sputn1ck sputn1ck commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

  • persist expired VTXOs as recoverable but non-spendable local state
  • classify expiry from the synchronized chain height and persisted descriptor
  • automatically send an expired VTXO through the existing refresh and forfeit flow
  • recover ordinary refresh rounds across restart and roll failed setup back to Expired
  • preview exact expired refreshes as fee-free
  • preserve the existing settlement-participant requirements for custom policies

Design

There is no claim RPC, server sweep journal, or sweep-confirmation gate. The client decides that a VTXO is expired from BatchExpiry, then uses the normal refresh protocol. The ordinary forfeit protects the operator if the old lineage remains spendable.

This intentionally drops the one-participant override for multi-party policies. Expired custom policies use the same authorization and signing rules as any other refresh.

Validation

  • make fmt-changed-check
  • make lint-changed-local
  • go test ./vtxo ./waved ./fraud ./round
  • go test -tags=test_sqlite ./db
  • make tidy-module-check
  • companion integration tests:
    • TestSweepIntegrationOfflineClientReclaimsExpiredVTXO
    • TestSweepIntegrationReclaimWithoutObservingSweep

Companion server PR: https://github.com/lightninglabs/lumos/pull/691

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a selective expired-VTXO redemption and reissue mechanism, allowing clients to reconcile locally-expired VTXOs with the operator via a new CheckVTXORedeemability RPC. It adds a RedemptionCoordinator to manage the polling and finalization of redemptions, updates the database schema to track claims and redemptions, and enhances the fraud watcher to recognize operator sweeps. The review feedback highlights several critical improvement opportunities, including potential memory leaks in slice operations within the round actor, potential nil pointer dereferences across multiple files, database schema inconsistencies regarding outpoint index types, and a potential wedging of the redemption coordinator loop on persistent validation errors. Addressing these issues will significantly improve the robustness and reliability of the redemption flow.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread round/actor.go Outdated
slog.String(
"outpoint", req.VTXOOutpoint.String(),
),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Slicing a.deferredRefreshes without nil-ing out the first element causes a memory leak because the underlying array still holds a reference to the popped pointer, preventing it from being garbage collected. Nil out the pointer before slicing.

		a.deferredRefreshes[0] = nil
		a.deferredRefreshes = a.deferredRefreshes[1:]

Comment thread round/actor.go Outdated

return
}
a.deferredBoardings = a.deferredBoardings[1:]

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

Slicing a.deferredBoardings without nil-ing out the first element causes a memory leak because the underlying array still holds a reference to the popped pointer, preventing it from being garbage collected. Nil out the pointer before slicing.

		a.deferredBoardings[0] = nil
		a.deferredBoardings = a.deferredBoardings[1:]

Comment thread round/actor.go Outdated
continue
}

leaves := clientTree.Root.GetLeafNodes()

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

While clientTree is checked for nil, clientTree.Root is not. If clientTree.Root is nil, calling clientTree.Root.GetLeafNodes() will cause a nil pointer dereference panic. Add a check for clientTree.Root == nil.

Suggested change
leaves := clientTree.Root.GetLeafNodes()
if clientTree.Root == nil {
log.WarnS(
ctx,
"Claim replacement tree root is missing",
nil,
slog.Int("claim_index", i),
)
continue
}
leaves := clientTree.Root.GetLeafNodes()

Comment thread waved/server.go Outdated
Comment on lines +1596 to +1600
heightResp, ok := heightRespAny.(*chainsource.BestHeightResponse)
if !ok {
return fmt.Errorf("query startup best height: unexpected "+
"response %T", heightRespAny)
}

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 type assertion heightRespAny.(*chainsource.BestHeightResponse) can succeed even if the interface value contains a nil pointer of that type. Accessing heightResp.Height would then cause a nil pointer dereference panic. Add a check to ensure heightResp is not nil.

Suggested change
heightResp, ok := heightRespAny.(*chainsource.BestHeightResponse)
if !ok {
return fmt.Errorf("query startup best height: unexpected "+
"response %T", heightRespAny)
}
heightResp, ok := heightRespAny.(*chainsource.BestHeightResponse)
if !ok || heightResp == nil {
return fmt.Errorf("query startup best height: unexpected or nil "+
"response %T", heightRespAny)
}

Comment thread waved/server.go Outdated
Comment on lines +1614 to +1618
}
reconcileResp, ok := reconcileRespAny.(*vtxo.ReconcileExpiryResponse)
if !ok {
return fmt.Errorf("reconcile VTXOs: unexpected response %T",
reconcileRespAny)

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 type assertion reconcileRespAny.(*vtxo.ReconcileExpiryResponse) can succeed even if the interface value contains a nil pointer of that type. Accessing reconcileResp.Checked would then cause a nil pointer dereference panic. Add a check to ensure reconcileResp is not nil.

Suggested change
}
reconcileResp, ok := reconcileRespAny.(*vtxo.ReconcileExpiryResponse)
if !ok {
return fmt.Errorf("reconcile VTXOs: unexpected response %T",
reconcileRespAny)
reconcileResp, ok := reconcileRespAny.(*vtxo.ReconcileExpiryResponse)
if !ok || reconcileResp == nil {
return fmt.Errorf("reconcile VTXOs: unexpected or nil response %T",
reconcileRespAny)
}

Comment thread db/vtxo_store.go Outdated
Comment on lines +1244 to +1245
func pendingRedemptionFromDB(sourceHash []byte, sourceIndex int32,
replacementHash []byte, replacementIndex int32,

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 database schema defines source_index and replacement_index as INTEGER (mapped to int32 in Go) in the vtxo_redemption_outbox table, whereas round_vtxo_claims defines source_index as BIGINT (mapped to int64 in Go). Since wire.OutPoint.Index is a uint32, any index greater than 2,147,483,647 will overflow to a negative value when cast to int32, triggering the negative index validation error. It is highly recommended to use BIGINT (int64) consistently for all outpoint indices across the database schema.

Comment thread ledger/handlers.go Outdated

const errMsg = "Failed to handle VTXO claim reissue"

if msg.AmountSat <= 0 {

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 pointer msg is dereferenced directly without a nil check. If msg is nil, this will cause a panic. Add a nil check at the beginning of the handler.

	if msg == nil {
		return a.fail(
			ctx, errMsg, fmt.Errorf("%w: VTXOClaimReissuedMsg is nil",
				ErrInvalidMessage),
		)
	}
	if msg.AmountSat <= 0 {

Comment thread vtxo/redemption.go Outdated
Comment on lines +276 to +280
if err := c.replayPendingRedemption(
ctx, redemption,
); err != nil {
return outcome, err
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

If c.replayPendingRedemption encounters a persistent validation error (such as a status mismatch or a conflicting replacement), returning the error here will cause the entire coordinator loop to back off and retry indefinitely. This wedges the coordinator and blocks all other pending redemptions from being processed. Consider logging persistent validation errors and skipping or acknowledging them rather than returning an error that halts the entire loop.

@sputn1ck
sputn1ck force-pushed the kon/offline-vtxo-reissue branch from 865b856 to 2fb14e9 Compare July 20, 2026 21:08
@sputn1ck
sputn1ck marked this pull request as ready for review July 24, 2026 06:57
Copilot AI review requested due to automatic review settings July 24, 2026 06:57
@sputn1ck

Copy link
Copy Markdown
Member Author

@claude please review

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @sputn1ck's task in 4m 32s —— View job


PR Review: client — Reissue swept expired VTXOs

I reviewed the full PR across vtxo, waved, round, db, ledger, fraud, and lib/types, and re-verified each Gemini comment. Overall this is a well-structured, carefully-documented feature with strong test coverage. Two items are worth addressing before merge; most of the Gemini flags don't survive verification.

⚠️ Diff note: this branch is based on an older main (its parent 0445c93 vs current origin/main b463776), and the checkout is shallow. git diff origin/main...HEAD has no merge base and git diff origin/main HEAD is polluted with unrelated main-ahead changes. The real PR diff is git diff HEAD~1 HEAD (the single commit). A rebase onto latest main is advisable before merge.


🔴 Should fix before merge

1. CI is red — exhaustive lint failure (vtxo/filter.go:81).
The PR adds VTXOStatusExpired, VTXOStatusRedeeming, VTXOStatusRedeemed, but the pre-existing SumPendingBalance switch isn't updated, so the exhaustive linter fails the Lint code CI job:

vtxo/filter.go:81:3: missing cases in switch of type vtxo.VTXOStatus:
  vtxo.VTXOStatusExpired, vtxo.VTXOStatusRedeeming, vtxo.VTXOStatusRedeemed (exhaustive)

Since GetBalance already reports these via the dedicated VtxoPendingReissueSat field, they belong in the no-op "separately accounted" branch. Fix this →

2. Redemption coordinator can wedge on a poisoned pending record (vtxo/redemption.go:279). (Gemini flagged this; confirmed, and the blast radius is larger than described.)
In checkNow, the pending-redemption loop returns on the first replayPendingRedemption error. That propagates to run, sets stalled, and the loop backs off and retries the same record forever. A pending row is only cleared by AcknowledgeVTXORedemption, which runs only after a fully successful replay — so a persistently-invalid record (e.g. FinalizedObserver failing, validateRedemptionReplacement mismatch, source no longer Redeemed) is never acked and never skipped. Because the early return sits before the expired/redeeming scan, one poisoned record blocks all other expired-VTXO reissue on the client, not just its siblings. Recommend: log + continue past a persistent record (ideally distinguishing transient ctx/DB errors from persistent validation errors). Fix this →


🟡 Minor / nice-to-have

3. Unsynchronized access to Server.redemptionCoordinator. It's written in startWalletDependentActors (waved/server.go:2498) after monitorOperatorConnection is already running, and that loop reads it at server.go:928. No happens-before edge → race-detector-flaggable. Practical impact is small (first tick is 15s out, read is nil-guarded, periodic poll covers a missed trigger), but it's a genuine data race. Fix with atomic.Pointer or assign the field before starting the monitor. Fix this →

4. In-memory submitting reservation can leak (vtxo/redemption.go:414). If the round contract drops a submitted claim without ever calling MarkRedeeming/RevertRedeeming, the outpoint stays reserved and that expired VTXO is never retried until daemon restart. In-memory only; depends on the external round contract. A defensive timeout/expiry on reservations would harden it.

5. Deferred-slice pops don't nil the popped element (round/actor.go:3740, :3769). (Gemini.) Real but negligible: both drain loops run only on the actor goroutine, drain to empty, and the next append reallocates and frees the old backing array. Tidy-up (a.deferredRefreshes[0] = nil) only — not a correctness issue.

6. Missing nil guard in replayPendingRedemption (vtxo/redemption.go:613/647). Dereferences source.Status / replacement.RoundID without the nil check recoverReplacement uses. Not exploitable with the production store (GetVTXO errors on missing rows), but relies on an undocumented store contract.


✅ Gemini flags that don't hold up (verified)

  • waved/server.go:1617 & :1635 typed-nil interface derefs — not reachable. Both response producers return fn.Ok(&T{...}) non-nil literals, and Future.Await/Unpack can never yield (nil, nil). The , ok checks already handle the wrong-type/error branches.
  • ledger/handlers.go:387 nil msg deref — false positive. Matches every other handler's convention; the message is built by a non-nil codec factory and dispatched via a type switch, so nil is unreachable.
  • round/actor.go:1629 clientTree.Root nil deref — unreachable by construction. Every *tree.Tree in ClientTrees comes from tree.ValidatePath, which itself dereferences Root; a Root-nil tree can't reach this path.
  • db/vtxo_store.go INTEGER vs BIGINT outpoint indices — cosmetic only. Output indices are bounded far below 2³¹ so the overflow is unreachable, int32 is actually the established convention across the vtxos table (BIGINT round_vtxo_claims is the outlier), and the read path rejects negative indices loudly. Consider normalizing for consistency, but no functional bug.

❓ Worth a maintainer confirm (not bugs)

  • joinRoundAuthMessageVersion bumped 3→4 with the claim record now unconditionally required in decode — a hard wire-compat break between v3/v4 peers. Presumably intentional for a coordinated client+server release; just confirm no rolling-upgrade path relies on v3 acceptance.
  • JoinRoundAuthMessage binds the round target only at the per-claim signature level, not in the outer identity signature. Verify the operator cross-checks each claim's signed round ID against the request's RoundID (server-side, companion PR).

Things that looked good

  • Coordinator lifecycle correctly captures the daemon-lifetime runCtx (not a request ctx); startOnce, the size-1 coalescing trigger, and mutex-guarded submitting are sound.
  • Ledger claim-reissue is genuinely net-zero on both vtxo_balance and transfers_out; idempotency key + composite unique index avoid leg collisions and make replays no-ops.
  • Fraud operator-sweep recognition correctly proves the committed timeout leaf, only suppresses at/after BatchExpiry, and nacks on reconcile failure.
  • Claim-only round isolation (fresh FSM, findAssemblingRound filtering, conservative pre-seal conflict handling) and zero-operator-fee quote accounting are well-reasoned.
  • chainbackends/lnd.go sync.Once cancel guard + local channel copies are a clean reorg/reconfirmation fix.

Nice work overall — the two 🔴 items are the main blockers (CI + the coordinator wedge).
· branch kon/offline-vtxo-reissue

Copilot AI 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.

Pull request overview

This PR adds a client-side “expired VTXO reissue” flow so wallets that were offline during an operator sweep can deterministically (and durably) detect locally-expired VTXOs, selectively reconcile them with the operator, and reissue the swept value at full amount with the same policy/pkScript—while tracking the lifecycle in RPC, persistence, and accounting.

Changes:

  • Extend VTXO lifecycle with EXPIRED/REDEEMING/REDEEMED, plus replacement linkage, and expose pending-reissue balance via GetBalance.
  • Add claim-only round participation: claim inputs/quotes, join-auth binding, and durable recovery/ledger semantics for net-zero reissues.
  • Add selective operator reconciliation RPC (CheckVTXORedeemability) and durable DB schema/migrations to persist claim/redemption state.

Reviewed changes

Copilot reviewed 69 out of 76 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
waverpc/daemon.proto Adds pending-reissue balance + new VTXO statuses/fields
waverpc/daemon.pb.go Regenerated RPC bindings for new fields/statuses
waved/vtxo_startup_reconcile_test.go Tests startup reconcile gating of wallet readiness
waved/rpc_server.go Computes vtxo_pending_reissue_sat; maps new statuses; sets replaced_by
waved/config.go Adds redemption coordinator integration hooks to daemon config
vtxo/transitions.go Transitions active states to durable Expired terminal state
vtxo/transitions_test.go Tests local expiry transitions across active states
vtxo/states.go Adds terminal Expired/Redeeming/Redeemed states
vtxo/messages.go Adds manager startup reconcile request/response messages
vtxo/manager.go Adds block-trigger observer + startup expiry reconciliation + expired observer hook
vtxo/manager_observer_test.go Tests redemption block observer and legacy-expired recovery path
vtxo/interfaces.go Adds new VTXO statuses + replacement/redemption metadata + legacy store interface
vtxo/harness_test.go Extends mock store with legacy-expired recovery method
vtxo/actor.go Recovers new redemption lifecycle statuses into terminal states
vtxo/actor_test.go Tests recovery of redemption lifecycle statuses
rpc/roundpb/round.proto Adds claim inputs and claim quotes to round RPCs
round/transitions_test.go Ensures claim reissue preserves custom policy metadata in outputs
round/states.go Extends client quote state with claim quote entries
round/outbox_messages.go Serializes claim inputs into JoinRoundRequest protobuf
round/outbox_messages_test.go Tests claim input protobuf serialization
round/ledger_emit_test.go Ensures claim reissue doesn’t double-emit generic received ledger events
round/join_auth.go Includes claim inputs in join auth, supports claim-only joins
round/join_auth_test.go Adds claim-only join-auth test and updates existing tests
round/interfaces.go Adds claim intents and claim-only intent helpers
round/from_proto.go Decodes claim quotes and claim inputs from protobuf
round/from_proto_test.go Tests claim input decode round-trip
round/events.go Adds claim logging/emptiness checks in intent packages
round/events_test.go Updates log-attribute expectations to include claims
round/claim_quote_test.go Adds quote validation tests for claim-only flows
round/claim_lifecycle_test.go Tests claim replacement mapping + finalization hooks
round/claim_isolation_test.go Tests that claim rounds remain isolated from ordinary registrations
round/actor_test.go Tests recovery + concurrency gating updates related to registration sealing
lib/types/vtxo_claim.go Adds tagged Schnorr claim authorization message/digest/verification
lib/types/vtxo_claim_test.go Tests claim digest binding and signature exclusion properties
lib/types/codec.go Bumps join-auth TLV version; adds claim input encoding/decoding
lib/types/codec_test.go Tests join-auth claim binding + updated versioned TLV expectations
lib/types/boarding.go Adds JoinRoundRequest claim inputs + round ID; adds claim-reissue origin
ledger/messages.go Adds durable ledger message type for atomic claim reissue pair
ledger/handlers.go Implements claim reissue handler + idempotency key derivation
ledger/handlers_test.go Tests claim reissue atomic-pair and malformed rejection
ledger/actor.go Routes new claim-reissue ledger message; adds source label
indexer/client.go Adds selective redeemability request builder + RPC method
indexer/client_test.go Tests selective redeemability request building + limits
fraud/watch_model.go Tracks batch expiry and sweep tapscript root in watch model
fraud/messages.go Extends spend-observed message with tx and input index
fraud/actor.go Detects operator sweep leaf spends and suppresses fraud escalation after local expiry reconcile
fraud/actor_test.go Tests operator sweep classification triggers expiry reconcile, not unroll
db/vtxo_store_test.go Tests legacy-expired recovery and full redemption lifecycle persistence/outbox
db/sqlc/schemas/generated_schema.sql Updates generated schema for claims, sweep_delay, redemption outbox, new fields
db/sqlc/queries/vtxo.sql Adds queries for legacy recovery + redemption lifecycle + redemption outbox
db/sqlc/queries/round.sql Persists sweep_delay, request origins, and claim rows
db/sqlc/querier.go Adds generated querier methods for new queries/tables
db/sqlc/models.go Adds models for claim rows, sweep_delay, redemption outbox, origins
db/sqlc/migrations/000016_vtxo_redemption_round.up.sql Adds redemption round id, claim tables, sweep_delay, redemption outbox
db/sqlc/migrations/000016_vtxo_redemption_round.down.sql Down migration for new redemption/claim schema
db/sqlc/migrations/000004_vtxos.up.sql Documents new VTXO status enum values in base schema
db/round_store_test.go Tests checkpoint recovery of claim authorization + origin/sweep_delay
db/migrations.go Bumps latest migration version to 16
chainbackends/lnd.go Improves spend registration forwarder to handle reorg+replacement spends safely
chainbackends/lnd_test.go Adds test ensuring replacement spend is forwarded after reorg
arkrpc/indexer.proto Adds CheckVTXORedeemability RPC + response messages
arkrpc/indexer_mailboxrpc.pb.go Regenerated mailbox RPC bindings for new indexer method
arkrpc/indexer_grpc.pb.go Regenerated gRPC bindings for new indexer method
Files not reviewed (7)
  • arkrpc/indexer.pb.go: Generated file
  • arkrpc/indexer_grpc.pb.go: Generated file
  • arkrpc/indexer_mailboxrpc.pb.go: Generated file
  • db/sqlc/models.go: Generated file
  • db/sqlc/querier.go: Generated file
  • db/sqlc/round.sql.go: Generated file
  • db/sqlc/vtxo.sql.go: Generated file

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread round/outbox_messages.go Outdated
Comment on lines +403 to +420
ci := &roundpb.VTXOClaimInput{
SourceOutpoint: roundpb.OutpointToProto(
claim.SourceOutpoint,
),
Nonce: bytes.Clone(claim.Nonce[:]),
ValidFrom: claim.ValidFrom,
ValidUntil: claim.ValidUntil,
Signature: bytes.Clone(claim.Signature),
}
if claim.ParticipantPubKey != nil {
ci.ParticipantPubkey = claim.ParticipantPubKey.
SerializeCompressed()
}
if claim.ReplacementSigningKey.PubKey != nil {
ci.ReplacementSigningPubkey =
claim.ReplacementSigningKey.PubKey.
SerializeCompressed()
}
Comment thread round/claim_quote_test.go Outdated
@sputn1ck
sputn1ck force-pushed the kon/offline-vtxo-reissue branch 3 times, most recently from d63a35b to a556d84 Compare July 24, 2026 08:47
@sputn1ck

Copy link
Copy Markdown
Member Author

@claude rereview

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @sputn1ck's task in 1m 50s —— View job


Re-review: client — Reissue swept expired VTXOs

Re-reviewed the three follow-up commits (d8c8baa, a556d84, 2938df1) against my prior review. The branch is now rebased onto current main (b463776), CI is green (CI + Agent Docs Sync both ✅), and every blocker and minor item I raised has been addressed. Details below.


🔴 Blockers — both fixed ✅

1. exhaustive lint (vtxo/filter.go:81). SumPendingBalance's switch now lists VTXOStatusExpired, VTXOStatusRedeeming, VTXOStatusRedeemed in the no-op "separately accounted" branch. CI's Lint code job passes. ✅

2. Coordinator wedge (vtxo/redemption.go). checkNow now logs (WarnS "Failed to replay finalized VTXO redemption") and continues past a failing replayPendingRedemption instead of returning, so one poisoned pending row can no longer block sibling acks or the expired-claim scan. ✅


🟡 Minor items — all addressed ✅

3. Data race on redemptionCoordinator. Field is now atomic.Pointer[vtxo.RedemptionCoordinator]; every access (monitorOperatorConnection, startWalletReadyServices, initRoundActor, the two observer hooks) goes through Load()/Store(). initRoundActor additionally fails fast if the pointer is nil. ✅

4. submitting reservation leak. submitting is now map[wire.OutPoint]time.Time with a SubmittingTimeout (default 5m); markSubmitting re-admits once the reservation ages out, so a dropped round callback no longer strands an expired VTXO until restart. The comment correctly notes the operator remains authoritative for the durable claim lock, so a later duplicate is safe. ✅

5. Deferred-slice pops (round/actor.go:3757, :3787). Both loops now nil the popped element before reslicing. ✅

6. Startup typed-nil derefs (waved/server.go). reconcileVTXOExpiryAtBestHeight now guards !ok || heightResp == nil and !ok || reconcileResp == nil (2938df1). ✅


✅ Reviewer-comment items resolved

  • Copilot round/outbox_messages.goToProto claim validation: now rejects nil ParticipantPubKey, nil ReplacementSigningKey.PubKey, and wrong-length Signature before building the proto, so an invalid claim can't be queued for delivery. ✅
  • Copilot round/claim_quote_test.go — origin tag: expected replacement now tagged VTXOOriginClaimReissue, matching the actual lifecycle. ✅
  • Gemini ledger/handlers.go — nil msg: handleVTXOClaimReissued now fails with ErrInvalidMessage on nil before dereferencing (2938df1), with a regression test. ✅

🟢 Remaining non-blocking notes (unchanged from last review)

  • replayPendingRedemption (vtxo/redemption.go:~630) still dereferences source.Status / replacement.RoundID without an explicit nil check after GetVTXO. Not exploitable with the production store (errors on missing rows), and a bad replay now merely logs-and-continues rather than wedging — so this is purely defensive tidy-up against an undocumented store contract. Not a blocker.
  • Maintainer confirms (not bugs): joinRoundAuthMessageVersion 3→4 is a hard v3/v4 wire break — fine for a coordinated client+server release; just confirm no rolling-upgrade path expects v3. And verify the operator (companion PR) cross-checks each per-claim signed round ID against the request RoundID, since the outer identity signature doesn't bind the round target.

New in this round: a556d84 (whitelist startup)

waved: Preserve startup across whitelist rejection is new since my last pass. Board now calls refreshAuthenticatedOperatorTerms and preserves the gRPC status code (mapping UnknownInternal) instead of blindly returning Internal. This lets a client awaiting whitelist admission still reach ready and re-enforce admission on the next Board. Looks sound and is covered by operator_negotiation_test.go.


Verdict: All prior 🔴/🟡 items and outstanding bot comments are resolved, CI is green. No new blockers. Nice, thorough follow-up. 👍
· branch kon/offline-vtxo-reissue

@sputn1ck
sputn1ck force-pushed the kon/offline-vtxo-reissue branch 2 times, most recently from 7197b61 to 1aad4fc Compare July 24, 2026 21:04
@sputn1ck

Copy link
Copy Markdown
Member Author

@claude please rereview the new approach where we reuse the refresh/forfeit mechanics

@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown

Claude finished @sputn1ck's task in 8m 22s —— View job


Re-review: client — Reissue swept expired VTXOs (refresh/forfeit reuse approach)

Re-reviewed against the reworked design. This is a much simpler and cleaner approach than the prior claim-round protocol: the whole round/ claim-only FSM, ledger/ claim-reissue handler, lib/types/vtxo_claim.go, and the join-auth v3→4 wire break are gone. Reissue now rides the existing custom-refresh path, so an N-party policy needs exactly the signatures any refresh needs. All prior blockers are moot by deletion. Verified vtxo, wallet, waved, db, fraud, indexer, and the shared refresh wiring.

CI: last run green; current CI run is queued (no code changed since).


🟢 The reuse design is sound

  • The source stays durably Expired/Redeeming while a temporary PreserveStatus signer actor (vtxo/actor.go:355-374, processStatusUpdate no-op at :663) produces the ordinary connector-bound forfeit — so the coordinator-owned lifecycle is never clobbered by the FSM, and an expired source can't be resurrected as live on a crash.
  • Lifecycle CAS in the DB is correct and atomic: FinalizeVTXORedemption writes the replacement + source→replacement link + outbox row in one tx; MarkVTXORedeeming/RevertVTXORedeeming/MarkVTXORedeemed/AcknowledgeVTXORedemption all carry exact-state WHERE guards and assert rows==1, so no wrong-state transition succeeds silently. Outbox is ON CONFLICT DO NOTHING + read-back tuple check → replays are idempotent.
  • Coordinator recovery is robust: Redeeming is polled alongside Expired so a crash-after-checkpoint or a failed operator round unlocks back to Expired via revertOrphanedClaim (vtxo/redemption.go:383-388); one poisoned pending row logs-and-continues (:285-291) rather than wedging siblings; markSubmitting has a timeout re-admit and the operator stays authoritative for the durable lock.
  • Zero-fee / exact-script reissue is enforced twice — locally (validateRedemptionReplacement, redemption.go:587) and against the indexer-resolved replacement (redemptionDescriptorFromIndexer, vtxo_redemption.go:389) — including amount, policy, pkScript, participant key, operator key, and batchExpiry strictly increasing.
  • Balance accounting is correct: spendable uses status-scoped ListLiveVTXOs (excludes Expired/Redeeming/Redeemed), and pending-reissue is surfaced separately via VtxoPendingReissueSat (rpc_server.go:742-796). No double-count of a Redeemed source and its live replacement.

🔵 Core semantic change — please confirm intent

Expiry now retires to a terminal ExpiredState instead of attempting unilateral exit (vtxo/transitions.go, Live/PendingForfeit/Forfeiting/Spending at ExpiryStatusExpired).

  • For LiveState this is a strict improvement — it previously went to FailedState{Recoverable:false}; now it has a reissue path.
  • The ExpiryStatusCritical escalation is preserved and fires before expiry, so the "you can always unilaterally exit before batch expiry" guarantee still holds for an online client.
  • After expiry, ExpiredState/RedeemingState/RedeemedState self-loop on every event, including ForceUnrollEvent — so a manual Unroll on an expired VTXO returns "already terminal". Post-expiry, recovery depends entirely on the operator acknowledging redeemability.

This is defensible within Ark's trust model (post-expiry the operator's sweep path is spendable anyway, so racing it is unreliable). Worth an explicit confirm: is there any intended fallback if the operator never reports the source as redeemable? Such value sits in ExpiredVtxoPendingReissueSat indefinitely with no client-driven exit. If that's acceptable-by-design, a one-line note on ExpiredState would save a future reader the same question.


🟡 Worth fixing

1. fraud/actor.go:369 — reconcile failure drops fraud escalation for non-suppressed targets.
On an operator-sweep spend, handleSpendObserved returns early if reconcileExpiry fails:

if len(suppressed) > 0 {
        if err := w.reconcileExpiry(ctx, msg.Height); err != nil {
                return nil, err          // ← also skips the escalation loop below
        }
}
for target := range targets { if suppressed ... else ensureUnroll(...) }

SpendObservedMsg arrives fire-and-forget (MapSpendEventTellOnlyRef) on a non-durable actor (NewWatcherActoractor.NewActor, :131), so the returned error is dropped — no in-session redelivery. Two consequences: (a) a transient VTXOManagerRef.Ask failure skips the whole event, and (b) any non-suppressed target in the same event (targets sharing this ancestor can have a later BatchExpiry than the sweep height, so a mixed set is reachable) never gets its legitimate fraud unroll, and nothing retries it until a daemon restart re-observes the spend. Fail-safe fix: on reconcile failure, still run the escalation loop for non-suppressed targets (escalating a swept target is harmless — the unroll fails against an already-spent output). Fix this →


🟢 Minor / nice-to-have

2. Shared custom-refresh path now unconditionally populates output OperatorKey/Expiry/ClientKey/OwnerKey (wallet/wallet.go:1895-1898). For custom (non-standard) policies these are not overwritten by the round (round/transitions.go:4333 only overwrites when isStandard). The pre-existing vHTLC RPC caller (rpc_server.go:2199, CustomRefreshOutput without OwnerKey) therefore now yields a replacement ClientVTXO carrying OperatorKey=input.OperatorKey and Expiry=input.RelativeExpiry where it was previously nil/zero. Likely more correct, but it's a behavior change to a shared path — confirm the vHTLC custom-refresh output is unchanged (or covered by a test).

3. fraud/actor.go:452leafHash == SweepTapscriptRoot is correct only because the sweep tree is single-leaf today (lib/tree/batch.go: root == leaf hash). The output-key commitment is independently proven by VerifyTaprootLeafCommitment, so this is a redundant gate — but if the sweep path ever becomes multi-leaf, every legit sweep would be escalated as fraud. A one-line invariant comment tying it to the single-leaf assumption would prevent that regression. Also confirm the track/admission path always populates BatchExpiry (a 0 value neuters the BatchExpiry > Height height gate; not a fraud false-negative, since the committed CSV-timeout leaf is still required).

4. Doc nit: fraud/CLAUDE.md calls WatcherActor a "Durable actor", but it's constructed via actor.NewActor (non-durable). Relevant to item 1's retry semantics — worth reconciling.

5. Cosmetic: vtxos.replaced_by_index stays INTEGER while the new vtxo_redemption_outbox indices are BIGINT. The roundtrip is bitwise-lossless (indices are far below 2³¹), so no functional bug — just an inconsistency.


Verdict: The refresh/forfeit reuse is a solid simplification and the lifecycle/persistence is carefully guarded. One worth-fixing correctness gap (fraud escalation drop, item 1) and one design point to confirm (post-expiry terminal state, above). Everything else is minor. Nice rework. 👍
· branch kon/offline-vtxo-reissue

@sputn1ck
sputn1ck force-pushed the kon/offline-vtxo-reissue branch 2 times, most recently from ab3e596 to 7826d5f Compare July 27, 2026 10:02
@levmi levmi added safety Fund-safety: stuck, lost, or mis-counted funds vtxo labels Jul 27, 2026
@levmi
levmi requested review from bhandras and ellemouton July 27, 2026 18:39
@sputn1ck
sputn1ck force-pushed the kon/offline-vtxo-reissue branch from 7826d5f to be5a6d3 Compare July 29, 2026 11:16
@sputn1ck sputn1ck changed the title client: Reissue swept expired VTXOs client: Recover expired VTXOs through normal refresh Jul 29, 2026
Every expiry decision is derived from

	blocksRemaining = BatchExpiry - currentHeight

and BatchExpiry was trusted unconditionally. It is copied verbatim
from the wire by the incoming-VTXO handler, so a zero arriving from
the operator reads back as "expired by the entire height of the
chain" and classifies a brand-new VTXO as expired. Nothing
validated it at ingress and nothing guarded the arithmetic.

Add HasUsableBatchExpiry and reject three shapes: a non-positive
expiry, and an expiry earlier than the height the VTXO was created
at, since a VTXO cannot expire before it existed. CheckExpiry now
returns the new ExpiryStatusUnknown for those rather than
ExpiryStatusExpired.

Unknown is deliberately its own status rather than folding into
either extreme. Reporting "expired" surrenders live funds on the
strength of a corrupt field; reporting "safe" silently skips the
refresh a real deadline needs. LiveState holds the VTXO live and
warns, so a data fault neither retires the coin nor wedges the
actor on every block. The status is appended last so the existing
numeric values are unchanged, and the remaining call sites already
treat anything that is not Critical/Expired/NeedsRefresh as
inaction.

The incoming handler now drops an event carrying an unusable
expiry instead of materializing it. Dropping is the safer failure:
the wallet still re-derives the VTXO from ListVTXOsByScripts,
which reads the authoritative expiry off the server's round row,
whereas a poisoned expiry persists locally and is never rewritten.
CalculateCriticalThreshold sized the unilateral-exit window from
the commitment-tree depth and the CSV delay alone. It ignored
ChainDepth, the number of OOR checkpoint hops between the
commitment and the VTXO.

Those hops are not free. Each is a recovery transaction that must
confirm before the exit's final CSV even starts, and they are
strictly sequential because each checkpoint spends the previous
one. unroll already budgets fees this way, one recovery tx per
hop, so the time budget disagreed with the fee budget.

The threshold exists precisely so a client never has to race the
operator's sweep, and it was under-sized for exactly the deep OOR
chains that need the most room. Factor the sequential transaction
count into exitTxDepth: the deepest tree path, since parallel
ancestry fragments confirm concurrently and the worst branch sets
the pace, plus one transaction per OOR hop. A negative hop count
is treated as zero rather than being allowed to shorten the
budget.
Identify the witness path that actually spent each watched VTXO instead
of inferring intent from chain height. Operator batch sweeps can then
retire expired watches without being escalated as client fraud.
A round is checkpointed at input_sig_sent, the point of no return,
and can confirm long afterwards. The confirmation handler derives
each new VTXO's absolute batch expiry as

	confirmation_height + sweep_delay

but the delay lived only in the in-memory FSM state. A daemon
restart between checkpoint and confirmation rebuilt
InputSigSentState without it, so the resumed round computed an
expiry of confirmation_height + 0 and stamped every VTXO it
created with BatchExpiry == CreatedHeight. The wallet reads that
back as already expired, which retires a VTXO that was created
seconds earlier.

Add a sweep_delay column to the rounds table, carry the value on
round.Round, and restore it onto both the round record and the
FSM state. The upsert only adopts an incoming delay when it is
non-zero, since the value is fixed for the life of a round and a
later checkpoint must not clear what an earlier one recorded.

Rounds checkpointed before this migration have no recorded delay.
For those the confirmation path now leaves the expiry unstamped
rather than stamping a wrong one, and logs at error level. An
unstamped expiry classifies as ExpiryStatusUnknown, so the VTXO
stays live and spendable with only expiry monitoring disabled,
and the authoritative expiry is still recoverable from the
operator's indexer.
@sputn1ck
sputn1ck force-pushed the kon/offline-vtxo-reissue branch 2 times, most recently from 5c0874f to b31e43b Compare July 29, 2026 13:25
ellemouton and others added 3 commits July 29, 2026 17:27
Keep expired value recoverable but outside the spendable set. After
chain catch-up, replay the synchronized tip through the ordinary refresh
and forfeit flow while preserving in-flight locks and restart recovery.

Co-authored-by: sputn1ck <kon@kon.ninja>
vtxoStatusToProto had no case for the new expired status, so an
expired VTXO was reported to clients as VTXO_STATUS_UNSPECIFIED.

Expiry is not terminal — the value is recovered by forfeiting the
VTXO in an ordinary round — so a wallet UI has to be able to tell
"expired, recoverable, not counted in spendable balance" apart
from "the daemon does not know what this is". Add
VTXO_STATUS_EXPIRED and map it in both directions.
Allow explicit and automatic expired refresh intents through the daemon.
Count quarantined value against the boarding limit and preview the exact
one-for-one recovery as fee-free.

Co-authored-by: sputn1ck <kon@kon.ninja>
@sputn1ck
sputn1ck force-pushed the kon/offline-vtxo-reissue branch from b31e43b to 4171deb Compare July 29, 2026 15:27

@ellemouton ellemouton 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.

thanks for the updates! ✅

@sputn1ck
sputn1ck merged commit d03b706 into main Jul 29, 2026
21 checks passed
@sputn1ck
sputn1ck deleted the kon/offline-vtxo-reissue branch July 30, 2026 12:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

safety Fund-safety: stuck, lost, or mis-counted funds vtxo

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants