Skip to content

darepod: recover indexed vHTLC refunds from seed restore - #757

Open
sputn1ck wants to merge 4 commits into
mainfrom
kon/indexer-vhtlc-recovery
Open

darepod: recover indexed vHTLC refunds from seed restore#757
sputn1ck wants to merge 4 commits into
mainfrom
kon/indexer-vhtlc-recovery

Conversation

@sputn1ck

Copy link
Copy Markdown
Member

Summary

  • add compact indexer receive-script labels for pay-side vHTLC recovery manifests
  • register labelled vHTLC funding scripts before OOR submission
  • extend seed restore to discover live pay-side vHTLC manifests and arm daemon-owned refund recovery
  • expose recovered vHTLC counters through daemonrpc and walletdkrpc responses

This is intentionally stacked on #711 so seed-restore recovery can merge first.

Tests

  • make rpc
  • make unit pkg=./vhtlcrecovery timeout=5m
  • make unit pkg=./sdk/swaps timeout=5m
  • make unit pkg=./darepod timeout=5m
  • go test -tags="dev nolog" ./...
  • go test -tags="dev nolog walletdkrpc swapruntime" ./swapwallet ./sdk/walletdk
  • make lint-local

Copilot AI review requested due to automatic review settings June 18, 2026 11:14

@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 vHTLC recovery manifest registration and wallet recovery capabilities, allowing the daemon to persist compact recovery metadata on the indexer and restore pay-side sender refunds during seed recovery. The feedback focuses on making the recovery process more robust by continuing the recovery loop instead of aborting on malformed labels or validation failures. Additionally, the reviewer recommends adding defensive nil checks for s.cfg.VHTLCConfig, terms, script, and indexed to prevent potential nil pointer dereferences.

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 on lines +590 to +592
if err != nil {
return 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.

high

If decoding a manifest label fails, returning the error aborts the entire wallet recovery process. Since wallet recovery is a critical operation, a single corrupt or malformed label on the indexer should not prevent the recovery of other valid scripts and VTXOs. Consider logging the error and continuing the loop instead of returning the error.

Suggested change
if err != nil {
return err
}
if err != nil {
continue
}

Comment thread darepod/wallet_recovery.go Outdated
Comment on lines +598 to +609
if len(manifest.PkScript) > 0 &&
!bytes.Equal(manifest.PkScript, pkScript) {

return fmt.Errorf("vHTLC recovery manifest script " +
"does not match registered script")
}

if err := validateVHTLCManifestScript(
manifest, pkScript,
); err != nil {
return 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.

high

Similar to the decoding error, returning an error here when a manifest script mismatch occurs or validation fails will abort the entire wallet recovery. It is highly recommended to log these validation errors and continue processing other scripts to ensure the recovery process is robust against isolated data corruption.

		if len(manifest.PkScript) > 0 &&
			!bytes.Equal(manifest.PkScript, pkScript) {

			continue
		}

		if err := validateVHTLCManifestScript(
			manifest, pkScript,
		); err != nil {
			continue
		}

Comment thread sdk/swaps/in_swap.go
Comment on lines +884 to +887
func (s *paySession) recoveryManifestLabel() (string, error) {
if s.cfg == nil {
return "", fmt.Errorf("pay swap config is required")
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

Add a defensive check to ensure s.cfg.VHTLCConfig is not nil before accessing its fields (such as RefundLocktime, UnilateralClaimDelay, etc.). If VHTLCConfig is nil, this will cause a nil pointer dereference and panic the client/daemon during swap execution.

func (s *paySession) recoveryManifestLabel() (string, error) {
	if s.cfg == nil {
		return "", fmt.Errorf("pay swap config is required")
	}
	if s.cfg.VHTLCConfig == nil {
		return "", fmt.Errorf("VHTLC config is required")
	}

Comment on lines +572 to +575
func (r *RPCServer) recoverIndexedVHTLCs(ctx context.Context,
terms *libtypes.OperatorTerms, result *walletRecoveryResult) error {

registered, err := r.server.indexer.ListMyReceiveScripts(ctx)

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

Add a nil check for terms at the beginning of recoverIndexedVHTLCs to prevent potential nil pointer dereferences if the operator terms are not available or returned as nil.

Suggested change
func (r *RPCServer) recoverIndexedVHTLCs(ctx context.Context,
terms *libtypes.OperatorTerms, result *walletRecoveryResult) error {
registered, err := r.server.indexer.ListMyReceiveScripts(ctx)
func (r *RPCServer) recoverIndexedVHTLCs(ctx context.Context,
terms *libtypes.OperatorTerms, result *walletRecoveryResult) error {
if terms == nil {
return fmt.Errorf("operator terms are required")
}
registered, err := r.server.indexer.ListMyReceiveScripts(ctx)

Comment on lines +586 to +587
for _, script := range registered.GetScripts() {
manifest, ok, err := vhtlcrecovery.DecodeManifestLabel(

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

Add a nil check for script to prevent a nil pointer dereference when iterating over registered.GetScripts().

	for _, script := range registered.GetScripts() {
		if script == nil {
			continue
		}
		manifest, ok, err := vhtlcrecovery.DecodeManifestLabel(

Comment on lines +620 to +624
for _, indexed := range vtxos {
live := arkrpc.VTXOStatus_VTXO_STATUS_LIVE
if indexed.GetStatus() != live {
continue
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Add a nil check for indexed to prevent a nil pointer dereference when calling indexed.GetStatus().

		for _, indexed := range vtxos {
			if indexed == nil {
				continue
			}
			live := arkrpc.VTXOStatus_VTXO_STATUS_LIVE
			if indexed.GetStatus() != live {
				continue
			}

@sputn1ck

Copy link
Copy Markdown
Member Author

@claude review pls

@claude

claude Bot commented Jun 18, 2026

Copy link
Copy Markdown

Claude finished @sputn1ck's task in 3m 5s —— View job


Review: indexer-backed vHTLC refund recovery

  • Read vhtlcrecovery/manifest.go + tests
  • Read darepod/wallet_recovery.go recovery flow
  • Read sdk/swaps/in_swap.go manifest label generation + RPC plumbing
  • Assess the Gemini review feedback
  • Post review

Nice, focused stack. The manifest encode/decode is clean, the idempotency key matches the SDK's recoveryRequestID shape exactly (sdk-swaps:pay:<hash>:VHTLC_RECOVERY_ACTION_REFUND_WITHOUT_RECEIVER), and the orphan-registration case (label registered, OOR submit failed) is correctly handled by the len(vtxos) == 0 → continue guard so no phantom refund is ever armed. The pubkey role mapping (client→sender, swap-server→receiver, operator→server) is right for the pay-side vHTLC policy.

A few things worth addressing, and a verdict on the Gemini comments.

1. Robustness: aborting the whole recovery on one bad manifest (valid concern)

This is the one substantive point from the Gemini review I agree with. In recoverIndexedVHTLCs (darepod/wallet_recovery.go:590), a decode error, a manifest/script mismatch, or a validateVHTLCManifestScript failure each return err, which propagates all the way up through recoverWalletState and fails the entire InitWallet/recovery — even though boarding keys, VTXOs and OOR scripts were already recovered and persisted. A single corrupt or future-version label on the indexer shouldn't be able to block the rest of recovery.

I'd log + continue rather than silently continue, so the corruption stays observable (Gemini suggested a bare continue, but a silent skip can hide a real bug):

manifest, ok, err := vhtlcrecovery.DecodeManifestLabel(script.GetLabel())
if err != nil {
        r.server.subLogger(Subsystem).WarnS(ctx, "Skipping undecodable "+
                "vHTLC recovery manifest", slog.String("err", err.Error()))
        continue
}

Same treatment for the script-mismatch (:601) and validateVHTLCManifestScript (:607) branches. Fix this →

2. The other Gemini suggestions are not needed

  • s.cfg.VHTLCConfig == nil check in recoveryManifestLabel — incorrect. VHTLCConfig is a value type (VHTLCConfig VHTLCConfig, see sdk/swaps/client.go:267), not a pointer, so comparing it to nil wouldn't even compile. The existing s.cfg == nil guard is the only meaningful one.
  • nil checks on script / indexed — redundant. These are generated protobuf getters (script.GetLabel(), indexed.GetStatus()), which are nil-receiver-safe and return zero values; a nil script yields ""DecodeManifestLabel returns ok=false → skipped. The sibling recoverOORReceiveScripts loop already iterates the same way without nil guards, so adding them only here would be inconsistent.
  • terms == nil check — harmless but unnecessary; recoverWalletState only calls this after fetchOperatorTerms succeeds, and none of the sibling recover* helpers nil-check terms.

3. Privacy: payment hash + amount published to the indexer

The manifest label stores the PaymentHash, full vHTLC timelock params, and AmountSat in plaintext (base64 JSON) on the indexer, keyed to the client's proof-of-control identity. The operator/indexer already sees the pkScript and VTXO amount, but the payment hash is new information that links a specific vHTLC to a Lightning payment. Probably acceptable given the recovery benefit, but worth a deliberate note in the commit/PR description that this is an intentional disclosure. SignerKeyFamily/SignerKeyIndex are fixed (NodeKey / 0), so no derivation-path leak.

4. Minor

  • result.VHTLCs++ (:619) counts a manifest as a "recovered vHTLC" whenever it has any indexed VTXO, including ones already fully spent/forfeited/refunded (the status filter in listVHTLCManifestVTXOs is broad). The recovered_vhtlcs counter is therefore "manifests with on-chain history" rather than "live vHTLCs". Fine as a counter, just make sure the semantics match what the field doc implies ("matched during recovery" — OK).
  • vhtlcRecoveryRequestExists lists all recovery statuses per manifest and linear-scans (O(manifests × statuses)). Negligible at expected scale, just noting it.
  • registerSendOORRecipientScripts re-checks r.server.indexer == nil inside the per-recipient loop and rebuilds the idx signer each iteration; could hoist out of the loop, but it's cold-path.

Plumbing (proto fields, walletdk types, swapwallet admin) is consistent end-to-end, and the tests cover the round-trip, the pay-sender filter, script validation, and the idempotency-key shape. No correctness blockers — item #1 is the one I'd land before merge.
· kon/indexer-vhtlc-recovery

@sputn1ck
sputn1ck force-pushed the kon/seed-restore-recovery branch from 4f75b83 to c164d0e Compare June 18, 2026 11:17
@sputn1ck
sputn1ck force-pushed the kon/indexer-vhtlc-recovery branch from f98b3bb to 16aaed7 Compare June 18, 2026 11:17

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f98b3bb659

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +795 to +801
_, _, err = service.ArmRecovery(ctx, vhtlcrecovery.RecoveryJob{
RequestID: requestID,
SwapID: append([]byte(nil), manifest.PaymentHash...),
Direction: vhtlcrecovery.DirectionPay,
Action: vhtlcrecovery.ActionRefundWithoutReceiver,
VTXOOutpoint: outpoint,
VTXOAmountSat: int64(indexed.GetValueSat()),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Persist funding package for recovered vHTLC refunds

When restoring into a fresh data dir, this only creates the vHTLC recovery row from the indexed VTXO. Later EscalateVHTLCRecovery calls the materializer, which loads the funding OOR package from the local package store by target.Hash (darepod/vhtlc_recovery_target.go), but recoverIndexedVHTLCs never fetches or persists that package/checkpoints during seed restore. As a result the recovered refund is counted/armed but escalation fails with a missing recovery target package, leaving the restored pay-side vHTLC unrecoverable through the daemon.

Useful? React with 👍 / 👎.

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 extends seed-restore recovery to find and re-arm pay-side vHTLC refund recovery using compact metadata embedded in indexer receive-script labels, and surfaces new recovery counters through daemon RPC and walletdk RPC/SDK results.

Changes:

  • Add vHTLC recovery manifests encoded into indexer receive-script labels, and emit them when funding pay-side vHTLCs.
  • Register labeled OOR recipient scripts with the indexer before OOR submission, and extend seed restore to scan/index those manifests and arm refund recovery.
  • Expose recovered vHTLC/vHTLC-refund counters via daemonrpc.InitWalletResponse, walletdkrpc.CreateResponse, and walletdk SDK results.

Reviewed changes

Copilot reviewed 17 out of 19 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
vhtlcrecovery/manifest.go Adds manifest encoding/decoding + validation for indexer-stored vHTLC recovery labels.
vhtlcrecovery/manifest_test.go Adds round-trip + negative tests for manifest label parsing.
swapwallet/admin.go Plumbs new recovered vHTLC counters into walletdkrpc create handler response.
sdk/walletdk/types.go Extends CreateWalletResult with recovered vHTLC counters.
sdk/walletdk/client.go Maps new daemon recovery counters into walletdk SDK create result.
sdk/swaps/out_swap_test.go Updates test daemon conn to record the new OOR label parameter.
sdk/swaps/in_swap.go Generates and attaches vHTLC recovery manifest label when sending OOR funding for pay swaps.
sdk/swaps/in_swap_test.go Verifies manifest label content is attached during pay flow; adjusts a timeout to reduce flakes.
sdk/swaps/client.go Extends DaemonConn with SendOORWithPolicyDetailsAndLabel.
sdk/ark/client.go Implements labeled OOR send methods and sets receive_script_label on outputs.
rpc/walletdkrpc/wallet.proto Adds recovered_vhtlcs and recovered_vhtlc_refunds to create response.
rpc/walletdkrpc/wallet.pb.go Regenerated protobuf bindings for walletdkrpc create response fields.
darepod/wallet_recovery.go Implements seed-restore vHTLC manifest discovery and refund recovery arming; returns new counters.
darepod/wallet_recovery_test.go Adds unit tests for manifest filtering, script validation, and idempotency key shape.
darepod/rpc_server.go Registers labeled recipient scripts with the indexer prior to OOR submission; adds label TTL constant.
daemonrpc/daemon.proto Adds vHTLC recovery counters to init response; adds receive_script_label to OOR Output.
daemonrpc/daemon.pb.go Regenerated protobuf bindings for daemonrpc changes.
Files not reviewed (2)
  • daemonrpc/daemon.pb.go: Generated file
  • rpc/walletdkrpc/wallet.pb.go: Generated file

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

Comment thread vhtlcrecovery/manifest.go
Comment on lines +124 to +131
case m.UnilateralRefundDelay == 0:
return fmt.Errorf("vhtlc recovery manifest refund delay is " +
"required")

case m.UnilateralRefundWithoutReceiverDelay == 0:
return fmt.Errorf("vhtlc recovery manifest refund-without-" +
"receiver delay is required")
}
Comment thread darepod/rpc_server.go
Comment on lines +484 to +504
if r.server.indexer == nil {
return status.Errorf(codes.Internal, "indexer client "+
"not initialized")
}

idx := r.server.indexer.WithSigner(
r.server.proofKeyBackend.ProofSigner(
r.server.clientKeyDesc,
),
)
expiresAt := r.server.clk.Now().Add(
vhtlcRecoveryManifestRegistrationTTL,
)
_, err := idx.RegisterReceiveScriptTaproot(
ctx, oorRecipients[i].PkScript, expiresAt, label,
)
if err != nil && status.Code(err) != codes.AlreadyExists {
return status.Errorf(codes.Internal, "register "+
"OOR recipient script %d: %v", i, err)
}
}
Comment on lines +841 to +852
statuses, err := service.ListRecoveryStatuses(ctx)
if err != nil {
return false, err
}

for i := range statuses {
if statuses[i].Job.RequestID == requestID {
return true, nil
}
}

return false, nil
Comment thread darepod/rpc_server.go
Comment on lines +2248 to +2252
if err := r.registerSendOORRecipientScripts(
ctx, requestRecipients, oorRecipients,
); err != nil {
return nil, err
}
Comment thread darepod/rpc_server.go
Comment on lines +79 to +82
// vhtlcRecoveryManifestRegistrationTTL keeps swap recovery manifests
// available across realistic offline restore windows. The signed
// registration proof itself remains short lived in the indexer client.
vhtlcRecoveryManifestRegistrationTTL = 365 * 24 * time.Hour
sputn1ck added 4 commits June 18, 2026 16:58
Add a compact versioned manifest label that can be stored with indexer receive-script registrations and later decoded during seed restore.

The manifest records the vHTLC participant keys, hash, locktimes, script, amount, and signer locator needed to rebuild recovery candidates.
Expose the OOR output receive-script label used by indexer-backed recovery and add seed-restore counters for discovered vHTLCs and restored refunds.

Generated daemonrpc and walletdkrpc stubs are updated from make rpc.
Attach a vHTLC recovery manifest to pay-side OOR funding outputs and have SendOOR register labelled recipient scripts with the indexer before submitting the transfer.

This keeps unswept out-swaps out of the recovery path while making client-funded pay vHTLCs discoverable after local swap DB loss.
Extend seed restore to parse indexed vHTLC recovery manifests, verify each manifest against the registered script, and arm daemon-owned refund recovery for live pay-side candidates.

Propagate the recovered vHTLC counters through walletdk and add focused tests for manifest filtering, script validation, and request-id compatibility.
@sputn1ck
sputn1ck force-pushed the kon/indexer-vhtlc-recovery branch from 16aaed7 to 6442b22 Compare June 18, 2026 14:58
@sputn1ck
sputn1ck changed the base branch from kon/seed-restore-recovery to main June 18, 2026 14:58
@levmi levmi added the P1 Priority 1 — high label Jun 22, 2026
@levmi levmi added daemon safety Fund-safety: stuck, lost, or mis-counted funds Swaps labels Jul 6, 2026
@lightninglabs-deploy

Copy link
Copy Markdown
Collaborator

@sputn1ck, remember to re-request review from reviewers when ready

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

daemon P1 Priority 1 — high safety Fund-safety: stuck, lost, or mis-counted funds Swaps

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants