Skip to content

Add swapserver-backed vHTLC recovery - #759

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

Add swapserver-backed vHTLC recovery#759
sputn1ck wants to merge 4 commits into
mainfrom
kon/swapserver-vhtlc-recovery

Conversation

@sputn1ck

@sputn1ck sputn1ck commented Jun 18, 2026

Copy link
Copy Markdown
Member

Summary

  • add recoverable-swap RPCs, owner-proof fields, daemon identity Schnorr signing, and wallet recovery counters
  • register in-swap owner proofs and sealed out-swap recovery blobs at swap setup time
  • call ListRecoverableSwaps during wallet recovery to arm safe in-swap refunds and decrypt/escalate out-swap claims
  • companion swapserver PR: lightninglabs/swapdk-server#131

Tests

  • make rpc
  • make commitmsg-lint range=origin/main..HEAD
  • go test ./sdk/swaps ./darepod ./sdk/ark ./sdk/walletdk
  • go test -tags=swapruntime ./swapclientserver
  • go test -tags='swapruntime walletdkrpc' ./swapwallet

sputn1ck added 3 commits June 18, 2026 20:43
Add owner-proof fields, recoverable swap listing RPCs,
identity Schnorr signing, and wallet recovery counters to the public
API surface. Regenerate the protobuf, gateway, mailbox, and devrpc
stubs.
Register owner proofs when creating swap rows and seal out-swap
preimages with daemon-derived recovery key material before requesting
route hints. Restored clients can ask swapd for owned rows without
storing plaintext preimages server-side.
Call the swapserver recovery listing during wallet restore, validate
live vHTLC scripts with the daemon and indexer, and arm daemon-owned
refund or claim recovery rows. Surface vHTLC recovery counters through
walletdk and swapwallet responses.
Copilot AI review requested due to automatic review settings June 18, 2026 18:48

@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 functionality during seed restore, allowing the discovery of swapserver-owned recoverable swaps and the arming of daemon-owned recovery jobs. It adds the SignIdentitySchnorr RPC to the daemon service and the ListRecoverableSwaps RPC to the swap service, along with client SDK updates, encryption/decryption of recovery blobs, and corresponding tests. The review feedback highlights a critical compilation error in recovery_auth.go due to incorrect arguments passed to chainhash.TaggedHash. Additionally, potential nil pointer dereferences were flagged when allocating receive scripts, and an improvement was suggested to make the recovery loop more robust by skipping individual malformed rows instead of aborting the entire process.

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 +187 to +192
key := chainhash.TaggedHash(
[]byte(swapRecoveryBlobTag),
sharedSecret[:],
paymentHash[:],
clientKey.SerializeCompressed(),
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

chainhash.TaggedHash from github.com/btcsuite/btcd/chaincfg/chainhash expects exactly two arguments: tag []byte and msg []byte. Passing four arguments here will cause a compilation error.

To fix this, concatenate the message fields into a single byte slice before passing it to chainhash.TaggedHash.

	var msg []byte
	msg = append(msg, sharedSecret[:]...)
	msg = append(msg, paymentHash[:]...)
	msg = append(msg, clientKey.SerializeCompressed()...)

	key := chainhash.TaggedHash(
		[]byte(swapRecoveryBlobTag),
		msg,
	)

Comment on lines +141 to +143
if err != nil {
return fmt.Errorf("allocate refund destination: %w", 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 AllocateReceiveScript returns a nil destination without an error, accessing destination.PkScript on line 172 will cause a nil pointer dereference panic. It is safer to add a nil check for destination.

	if err != nil {
		return fmt.Errorf("allocate refund destination: %w", err)
	}
	if destination == nil {
		return fmt.Errorf("allocated refund destination is nil")
	}

Comment on lines +201 to +203
if err != nil {
return fmt.Errorf("allocate claim destination: %w", 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 AllocateReceiveScript returns a nil destination without an error, accessing destination.PkScript on line 238 will cause a nil pointer dereference panic. It is safer to add a nil check for destination.

	if err != nil {
		return fmt.Errorf("allocate claim destination: %w", err)
	}
	if destination == nil {
		return fmt.Errorf("allocated claim destination is nil")
	}

Comment on lines +59 to +65
for _, row := range rows {
recovered, err := c.recoverSwapserverVHTLC(
ctx, clientKey, row,
)
if err != nil {
return nil, 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

During wallet recovery, if a single row returned by the swapserver is malformed or fails validation (e.g., recoverSwapserverVHTLC returns an error), aborting the entire loop will prevent the recovery of all other valid swaps.

Consider logging the error and continuing with the remaining rows to make the recovery process more robust against isolated server-side data corruption or compatibility issues.

Suggested change
for _, row := range rows {
recovered, err := c.recoverSwapserverVHTLC(
ctx, clientKey, row,
)
if err != nil {
return nil, err
}
for _, row := range rows {
recovered, err := c.recoverSwapserverVHTLC(
ctx, clientKey, row,
)
if err != nil {
continue
}

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

ℹ️ 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 +138 to +140
destination, err := c.daemon.AllocateReceiveScript(
ctx, "vhtlc-recovery-refund",
)

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 Make recovered arm requests idempotent

When seed recovery is retried after this swap was already armed, this fresh AllocateReceiveScript returns a different destination, but the RequestId below is deterministic for the same payment hash/action. ArmRecovery treats the same request id with different durable parameters (including DestinationScript) as an idempotency conflict, so a second recovery pass or a retry after a later step fails instead of resuming the existing job. Reuse a deterministic/stored destination or load the existing recovery before allocating; the claim path below has the same pattern.

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

Adds swapserver-backed recovery for vHTLC-based swaps by extending swap RPCs with owner-proof authentication and encrypted recovery metadata, wiring daemon identity Schnorr signing, and invoking the new recovery flow during wallet seed restore.

Changes:

  • Introduces ListRecoverableSwaps plus SwapOwnerProof/recovery metadata fields in swap RPCs and REST/gateway/mailbox stubs.
  • Adds daemon RPC SignIdentitySchnorr and SDK plumbing to produce tagged BIP-340 Schnorr signatures for recovery ownership proofs.
  • Implements SDK swap recovery discovery/arming (RecoverSwapserverVHTLCs) and integrates recovery counters into wallet creation/recovery responses.

Reviewed changes

Copilot reviewed 28 out of 36 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
swapwallet/register_test.go Updates fakes to satisfy the expanded swap backend interface (RecoverVHTLCs).
swapwallet/admin.go Surfaces new vHTLC recovery counters in wallet create response.
swaprpc/swap.yaml Exposes ListRecoverableSwaps via grpc-gateway HTTP mapping.
swaprpc/swap.proto Adds ListRecoverableSwaps, SwapOwnerProof, recovery blob fields, and recoverable swap metadata.
swaprpc/swap.pb.gw.go Regenerates gateway handlers for the new swap RPC.
swaprpc/swap.pb.go Regenerates protobuf Go types for new swap recovery messages/enums/RPCs.
swaprpc/swap_mailboxrpc.pb.go Regenerates mailbox RPC adapter to include ListRecoverableSwaps.
swaprpc/swap_grpc.pb.go Regenerates gRPC client/server stubs to include ListRecoverableSwaps.
swapclientserver/service.go Implements RecoverVHTLCs to bridge daemon recovery with sdk/swaps recovery.
swapclientserver/service_test.go Updates call sites for new RequestChannelID signature and fake runtime recovery method.
sdk/walletdk/types.go Adds recovered vHTLC counters to SDK wallet result type.
sdk/walletdk/client.go Maps new daemon recovery counters into SDK wallet create response.
sdk/swaps/server_recovery.go Implements swapserver-driven vHTLC discovery + daemon recovery arming logic.
sdk/swaps/server_recovery_test.go Adds tests for recovery blob round-trip and recovery arming behavior.
sdk/swaps/rest_conn_test.go Updates REST swap server conn tests for new request parameters.
sdk/swaps/recovery_auth.go Implements owner-proof signing and out-swap recovery blob sealing/opening.
sdk/swaps/out_swap.go Registers owner proof + sealed recovery blob during out-swap setup.
sdk/swaps/out_swap_test.go Extends test server/daemon fakes for owner proofs, recoverable listing, and identity Schnorr signing.
sdk/swaps/in_swap.go Registers owner proof during in-swap creation.
sdk/swaps/in_swap_test.go Updates in-swap test server fake interface for new parameters/methods.
sdk/swaps/grpc_conn.go Extends swap server conn with owner proof fields and ListRecoverableSwaps.
sdk/swaps/client.go Extends SwapServerConn and DaemonConn interfaces for recovery/signing.
sdk/ark/client.go Implements SignIdentitySchnorr via daemon RPC for SDK consumers.
rpc/walletdkrpc/wallet.proto Adds recovered vHTLC counters to wallet create response schema.
rpc/walletdkrpc/wallet.pb.go Regenerates walletdk protobuf Go types/accessors with new counters.
rpc/restclient/clients.go Adds REST client methods for ListRecoverableSwaps and SignIdentitySchnorr.
darepod/wallet_recovery.go Invokes swap vHTLC recovery during wallet seed restore and records counters.
darepod/rpc_wallet.go Adds daemon RPC handler SignIdentitySchnorr.
darepod/config.go Extends swap backend interface with RecoverVHTLCs and defines SwapRecoveryResult.
daemonrpc/daemon.yaml Exposes SignIdentitySchnorr via grpc-gateway HTTP mapping.
daemonrpc/daemon.proto Adds daemon RPC SignIdentitySchnorr and new recovery counters in InitWalletResponse.
daemonrpc/daemon.pb.gw.go Regenerates gateway handlers for SignIdentitySchnorr.
daemonrpc/daemon_mailboxrpc.pb.go Regenerates mailbox RPC adapter to include SignIdentitySchnorr.
daemonrpc/daemon_grpc.pb.go Regenerates gRPC client/server stubs to include SignIdentitySchnorr.
cmd/darepocli/darepoclicommands/devrpc/registry_generated.go Regenerates CLI dev RPC registry to include SignIdentitySchnorr.
Files not reviewed (7)
  • cmd/darepocli/darepoclicommands/devrpc/registry_generated.go: Generated file
  • daemonrpc/daemon.pb.gw.go: Generated file
  • daemonrpc/daemon_grpc.pb.go: Generated file
  • daemonrpc/daemon_mailboxrpc.pb.go: Generated file
  • rpc/walletdkrpc/wallet.pb.go: Generated file
  • swaprpc/swap.pb.go: Generated file
  • swaprpc/swap.pb.gw.go: Generated file

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

Comment on lines +35 to +40
if daemon == nil {
return nil, fmt.Errorf("daemon connection is required")
}
if clientKey == nil {
return nil, fmt.Errorf("client identity key is required")
}
Comment thread sdk/swaps/recovery_auth.go
Comment thread sdk/swaps/recovery_auth.go Outdated
Comment on lines +137 to +156
if len(blob) < 1+24 {
return nil, fmt.Errorf("recovery blob is too short")
}
if blob[0] != swapRecoveryBlobVersion {
return nil, fmt.Errorf("unsupported recovery blob version %d",
blob[0])
}

key, err := outSwapRecoveryBlobKey(ctx, daemon, clientKey, paymentHash)
if err != nil {
return nil, err
}

var nonce [24]byte
copy(nonce[:], blob[1:25])

plaintext, ok := secretbox.Open(nil, blob[25:], &nonce, &key)
if !ok {
return nil, fmt.Errorf("open recovery blob")
}
Comment thread sdk/swaps/server_recovery.go Outdated
Comment on lines +145 to +169
_, err = c.daemon.ArmVHTLCRecovery(
ctx, &daemonrpc.ArmVHTLCRecoveryRequest{
RequestId: recoveryRequestID(
string(SwapDirectionPay), paymentHash,
recoveryActionRefundWithoutReceiver,
),
SwapId: append([]byte(nil), paymentHash[:]...),
Direction: recoveryDirectionPay,
Action: recoveryActionRefundWithoutReceiver,
VtxoOutpoint: live.Outpoint,
VtxoAmountSat: live.AmountSat,
SenderPubkey: append([]byte(nil), row.GetSenderPubkey()...),
ReceiverPubkey: append([]byte(nil), row.GetReceiverPubkey()...),
ServerPubkey: append([]byte(nil), row.GetOperatorPubkey()...),
RefundLocktime: int32(row.GetRefundLocktime()),
UnilateralClaimDelay: int32(
row.GetUnilateralClaimDelay(),
),
UnilateralRefundDelay: int32(
row.GetUnilateralRefundDelay(),
),
UnilateralRefundWithoutReceiverDelay: int32(
row.GetUnilateralRefundWithoutReceiverDelay(),
),
PreimageHash: append([]byte(nil), paymentHash[:]...),
Comment thread sdk/swaps/server_recovery.go Outdated
Comment on lines +225 to +234
RefundLocktime: int32(row.GetRefundLocktime()),
UnilateralClaimDelay: int32(
row.GetUnilateralClaimDelay(),
),
UnilateralRefundDelay: int32(
row.GetUnilateralRefundDelay(),
),
UnilateralRefundWithoutReceiverDelay: int32(
row.GetUnilateralRefundWithoutReceiverDelay(),
),
@levmi levmi added the P0 Priority 0 — blocker label Jun 22, 2026
@sputn1ck

Copy link
Copy Markdown
Member Author

@claude please review, focus on overcomplication of the process

@claude

claude Bot commented Jun 22, 2026

Copy link
Copy Markdown

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


Review: swapserver-backed vHTLC recovery — focus on overcomplication

  • Read the new recovery files (recovery_auth.go, server_recovery.go)
  • Trace the recovery flow end-to-end (daemon → SDK → swapserver)
  • Assess for overcomplication / redundancy / simpler alternatives
  • Post review feedback

I focused on process complexity as requested. The feature is sound, but there are a few places where the recovery path carries more machinery than it needs. Ranked by how much they'd simplify things.


1. The recovery arming path is a near-verbatim copy of the live arming path 🔴

armRecoveredInSwapRefund / armRecoveredOutSwapClaim (sdk/swaps/server_recovery.go:134-258) rebuild an ArmVHTLCRecoveryRequest that is structurally identical to the one already built by ensurePayRefundRecoveryArmed / ensureReceiveClaimRecoveryArmed (sdk/swaps/recovery.go:283-468) — same recoveryRequestID(direction, paymentHash, action) key, same field set, same signer family/index, same fee policy. The only real difference is the source of the pubkeys/delays (server row vs. session state).

This is the biggest simplification opportunity: extract one builder, e.g.

func buildArmRequest(p armParams) *daemonrpc.ArmVHTLCRecoveryRequest

and have both the live sessions and the recovery path populate armParams. Today a change to the arm request shape has to be made in four places that must stay byte-for-byte in sync.

Fix this →


2. Is the sealed recovery blob necessary at all? 🟡 (worth a design discussion)

The entire recovery_auth.go blob subsystem — secretbox seal/open, the versioned blob format, the ECDH-derived key, the encrypted_recovery_blob proto field, and the blobHash commitment in the owner proof — exists for exactly one reason: the out-swap preimage is random (NewPreimage()rand.Read, invoice.go:43) and therefore can't be reproduced after a seed restore, so it has to be stashed on the server.

But the blob key is already derived deterministically from daemon seed material (outSwapRecoveryBlobKeyReceiveAuthECDH(paymentHash, clientKey)). If the daemon can deterministically reproduce per-swap secret key material at recovery time, it could equally derive the preimage itself deterministically (the lightning-loop approach), and the recovery path would just re-derive it locally — deleting the seal/open code, the proto field, and the claim-side blob decryption entirely.

The one wrinkle: the current key is bound to paymentHash, which is circular for the preimage (preimage → hash). You'd need a non-paymentHash per-swap index (the same window-scan pattern you already use to recover OOR receive scripts). That's a real change, not a one-liner — but given the request to look at overcomplication, this is the subsystem most worth questioning, since it's roughly half of recovery_auth.go plus an RPC field.

If random preimages are a hard requirement, the blob is fine — just flagging the tradeoff.


3. Recovery arming isn't idempotent across retries 🟡

recoveryRequestID(...) is deterministic per (direction, paymentHash, action), and ArmVHTLCRecovery is idempotent on request_id. But both recovery arm functions call AllocateReceiveScript unconditionally (server_recovery.go:138, :198) and pass the fresh DestinationScript into the request. A second recovery pass (or a retry after a later step fails) produces the same request id with a different destination → idempotency conflict. The live path avoids this by persisting the destination in the session (refundReceiveScript / claimReceiveScript). The Codex reviewer raised the same thing. This folds naturally into #1: the shared builder should load the existing recovery row (or a stored destination) before allocating.


4. Smaller redundancies

  • Duplicated client key in the owner proof. newSwapOwnerProof already commits clientKeyBytes as a framed field (recovery_auth.go:73). Both call sites then pass clientKey.SerializeCompressed() again as the first variadic field (out_swap.go:657, in_swap.go:549). Drop the duplicate — it's signed twice for no benefit.
  • Double pkScript check. recoverableSwapPolicy already errors on a pkScript mismatch (server_recovery.go:348-354), then recoverSwapserverVHTLC re-checks the same equality (:98-101). The second check can't fire. Pick one home for the invariant.
  • Redundant binding in the blob key. outSwapRecoveryBlobKey mixes sharedSecret + paymentHash + clientKey into the tagged hash, but the shared secret is already derived from paymentHash and clientKey. Harmless, but it reads as defensive ceremony. (Moot if multI: add initial repo scaffolding #2 is adopted.)

5. Note on the other bots' findings

  • Gemini's "critical compile error" on chainhash.TaggedHash (recovery_auth.go:192) is a false positive. TaggedHash(tag []byte, msgs ...[]byte) is variadic — the three message args are valid and get concatenated. Your own go test ./sdk/swaps in the PR description compiles this file, so no fix is needed there. (The "concatenate manually" rewrite would change nothing semantically.)
  • The AllocateReceiveScript nil-check suggestions (Gemini) and uint32→int32 bounds (Codex) are reasonable defensive hardening on swapserver-supplied data, but lower priority than the structural items above.

Overall: the feature works, but the recovery path would shrink meaningfully by collapsing the duplicated arm-request construction (#1) and reconsidering whether the preimage blob is needed (#2). #3 is a real retry bug.
· kon/swapserver-vhtlc-recovery

@sputn1ck

sputn1ck commented Jun 22, 2026

Copy link
Copy Markdown
Member Author

Addressed the review follow-ups in 3cc192e:

  • Ran changed-file formatting and fixed lint issues.
  • Hardened owner-proof signing and recovery blob parsing; the local x/crypto secretbox package does not expose a NonceSize constant, so the blob nonce size is centralized in one named constant instead of repeating 24/25 offsets.
  • Kept vHTLC participant key binding in owner proofs and documented why.
  • Refactored vHTLC arming through a shared request builder and added int32 bounds checks for server-provided script timing fields.
  • Made restore-time arming retry-safe via ListVHTLCRecoveries, reusing existing non-terminal rows and escalating existing out-swap claim recoveries with the decrypted preimage.
  • Malformed recoverable rows now warn and continue; daemon/indexer failures still abort.
  • Added unit coverage for auth hardening, retry reuse, malformed rows, and overflow config.

Deterministic preimages are intentionally deferred as a larger protocol redesign. The Gemini TaggedHash compile comment is a false positive: chainhash.TaggedHash is variadic, and the existing call shape compiles.

@levmi levmi added 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

P0 Priority 0 — blocker 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