darepod: support custom vHTLC refresh signatures - #745
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces support for custom-policy VTXO refreshes (such as vHTLCs) and multi-participant forfeit signing in the Ark client and daemon. It adds new RPC endpoints (SignVTXOForfeit, RefreshCustomVTXOs, ListPendingForfeitParticipantSignatureRequests, and SubmitForfeitParticipantSignatures) along with a forfeitSignatureBroker to coordinate external participant signatures. Feedback on these changes highlights a memory leak in the forfeit signature broker's sign function due to uncleaned waiter channels on cancellation and unremoved contexts on success, as well as a potential nil-pointer panic in the swap client's forfeit-refresh handler.
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.
| func (b *forfeitSignatureBroker) sign(ctx context.Context, | ||
| req *vtxo.ForfeitParticipantSignRequest) ( | ||
| []*types.ForfeitParticipantSig, error) { | ||
|
|
||
| if b == nil || req == nil || req.VTXO == nil { | ||
| return nil, nil | ||
| } | ||
|
|
||
| outpoint := req.VTXO.Outpoint.String() | ||
|
|
||
| b.mu.Lock() | ||
| correlation, ok := b.contexts[outpoint] | ||
| b.mu.Unlock() | ||
| if !ok { | ||
| return nil, nil | ||
| } | ||
|
|
||
| if correlation.direction == daemonrpc. | ||
| ForfeitSigningDirection_FORFEIT_SIGNING_DIRECTION_IN_SWAP { | ||
|
|
||
| b.mu.Lock() | ||
| signer := b.inSwapSigner | ||
| b.mu.Unlock() | ||
| if signer != nil { | ||
| return signer(ctx, req) | ||
| } | ||
| } | ||
|
|
||
| pending, err := pendingForfeitSignatureRequest(correlation, req) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| requestID := string(pending.GetRequestId()) | ||
| waiter := make(chan []*types.ForfeitParticipantSig, 1) | ||
|
|
||
| b.mu.Lock() | ||
| stored, ok := b.requests[requestID] | ||
| if ok { | ||
| if !samePendingForfeitSignatureRequest(stored.proto, pending) { | ||
| b.mu.Unlock() | ||
| return nil, fmt.Errorf( | ||
| "forfeit signature request id conflict", | ||
| ) | ||
| } | ||
| } else { | ||
| b.nextSequence++ | ||
| pending.Sequence = b.nextSequence | ||
| stored = &forfeitSignatureRequest{proto: pending} | ||
| b.requests[requestID] = stored | ||
| b.order = append(b.order, requestID) | ||
| } | ||
|
|
||
| if len(stored.signatures) != 0 { | ||
| sigs := cloneParticipantSigs(stored.signatures) | ||
| b.mu.Unlock() | ||
| return sigs, nil | ||
| } | ||
|
|
||
| stored.waiters = append(stored.waiters, waiter) | ||
| b.mu.Unlock() | ||
|
|
||
| select { | ||
| case sigs := <-waiter: | ||
| return sigs, nil | ||
|
|
||
| case <-ctx.Done(): | ||
| return nil, ctx.Err() | ||
| } | ||
| } |
There was a problem hiding this comment.
Memory Leak and Resource Cleanup Issues in sign
There are two distinct resource/memory leaks in the sign function:
- Waiter Channel Leak on Cancellation: If
ctx.Done()is selected, thewaiterchannel is left insidestored.waiters. Sincestoredis persisted in theb.requestsmap, these cancelled waiter channels will accumulate over time, causing a memory leak. Additionally, whensubmitis called later, it will iterate over and try to notify/close these dead channels. - Context Map Leak on Success: Once a forfeit signature is successfully obtained (either immediately or via the waiter), the outpoint's context is no longer needed. However, the returned
unregisterfunction fromregisterContextis discarded on success inRefreshCustomVTXOs, meaning the context is never deleted fromb.contextsand will leak indefinitely.
We can resolve both issues by:
- Cleaning up the waiter from
stored.waitersunder lock whenctx.Done()is triggered. - Deleting the outpoint from
b.contextsunder lock once the signatures are successfully returned.
func (b *forfeitSignatureBroker) sign(ctx context.Context,
req *vtxo.ForfeitParticipantSignRequest) (
[]*types.ForfeitParticipantSig, error) {
if b == nil || req == nil || req.VTXO == nil {
return nil, nil
}
outpoint := req.VTXO.Outpoint.String()
b.mu.Lock()
correlation, ok := b.contexts[outpoint]
b.mu.Unlock()
if !ok {
return nil, nil
}
if correlation.direction == daemonrpc.
ForfeitSigningDirection_FORFEIT_SIGNING_DIRECTION_IN_SWAP {
b.mu.Lock()
signer := b.inSwapSigner
b.mu.Unlock()
if signer != nil {
sigs, err := signer(ctx, req)
if err == nil {
b.mu.Lock()
delete(b.contexts, outpoint)
b.mu.Unlock()
}
return sigs, err
}
}
pending, err := pendingForfeitSignatureRequest(correlation, req)
if err != nil {
return nil, err
}
requestID := string(pending.GetRequestId())
waiter := make(chan []*types.ForfeitParticipantSig, 1)
b.mu.Lock()
stored, ok := b.requests[requestID]
if ok {
if !samePendingForfeitSignatureRequest(stored.proto, pending) {
b.mu.Unlock()
return nil, fmt.Errorf(
"forfeit signature request id conflict",
)
}
} else {
b.nextSequence++
pending.Sequence = b.nextSequence
stored = &forfeitSignatureRequest{proto: pending}
b.requests[requestID] = stored
b.order = append(b.order, requestID)
}
if len(stored.signatures) != 0 {
sigs := cloneParticipantSigs(stored.signatures)
delete(b.contexts, outpoint)
b.mu.Unlock()
return sigs, nil
}
stored.waiters = append(stored.waiters, waiter)
b.mu.Unlock()
select {
case sigs := <-waiter:
b.mu.Lock()
delete(b.contexts, outpoint)
b.mu.Unlock()
return sigs, nil
case <-ctx.Done():
b.mu.Lock()
for i, w := range stored.waiters {
if w == waiter {
stored.waiters = append(stored.waiters[:i], stored.waiters[i+1:]...)
break
}
}
b.mu.Unlock()
return nil, ctx.Err()
}
}| resp, err := s.client.daemon.SignVTXOForfeit(ctx, req) | ||
| if err != nil { | ||
| return fmt.Errorf("sign out-swap forfeit payload: %w", err) | ||
| } |
There was a problem hiding this comment.
Defensive Programming: Nil Check for Daemon Response
If SignVTXOForfeit returns a nil response with a nil error (which can happen in mock or custom implementations), accessing resp.GetPubkey() will panic. Adding a nil check for resp prevents potential runtime panics.
resp, err := s.client.daemon.SignVTXOForfeit(ctx, req)
if err != nil {
return fmt.Errorf("sign out-swap forfeit payload: %w", err)
}
if resp == nil {
return fmt.Errorf("sign out-swap forfeit payload: empty response")
}7c18d19 to
e20c0df
Compare
|
@claude pls review this refreshed custom vHTLC refresh support. Context: the latest force-push folds the lint fixes and the PR body now calls out the multi-participant refresh integration coverage in the companion darepo and swapdk-server PRs. Please focus on durable signing/ack ordering, participant forfeit signature plumbing, and whether the daemon RPC surface is sufficient for swapdk-server active refresh. |
|
Claude finished @bhandras's task in 5m 20s —— View job Review: custom vHTLC refresh supportI focused on the three areas you called out: durable signing/ack ordering, participant forfeit-signature plumbing, and whether the daemon RPC surface is sufficient for swapdk-server active refresh. Overall the design is sound — the request transcript is bound to the exact connector + unsigned forfeit tx, the out-swap ack is correctly gated on server acceptance, and the daemon-side validation in 🔴 1.
|
e20c0df to
6d00788
Compare
|
@claude pls re-review with context: since the last request I folded the local review finding that successful forfeit signing left daemon-local signing contexts behind. The broker now clears the context after external signatures are accepted and after synchronous in-swap signing succeeds, with tests for both paths. I also updated the PR body/validation to reflect the folded tests. Please re-check durable signing/ack ordering and context lifecycle. |
|
Claude finished @bhandras's task in 2m 33s —— View job Re-review: custom vHTLC refresh — context lifecycle & durable orderingI re-read the broker against the folded fix and re-checked the three areas you called out. The context-clearing fold is correct on the two paths it targets, and both tests genuinely assert it. But the fold only closes the success paths — the cancellation/error paths (the ones that matter most for restart + timeout durability) still leak, and the prior ✅ The folded context-clearing fix is correct (verified)
🔴 1. Context still leaks on every non-success path (fold is success-only)
For a refresh that is cancelled or times out (precisely the restart/timeout durability case you asked about), the daemon-local context is never reclaimed. So the fold improved the steady state but the unhappy-path lifecycle — the one that actually exercises durability — is still open. 🔴 2. Waiter leak on
|
6d00788 to
a35102a
Compare
|
@claude small extra context: I force-pushed once more after the prior re-review request to fold the static-check fix. The new head adds a tiny generated-schema comment refresh and gofmt/import formatting; local |
|
Claude finished @bhandras's task in 1m 59s —— View job Re-review (current head
|
a09b9fd to
5d5a26d
Compare
|
@claude pls re-review with context: I folded the custom refresh signing API away from swap-direction terminology and into route terminology. The daemon proto now documents the full flow: RefreshCustomVTXOs queues the custom vTXO before a connector exists, LOCAL_SIGNER signs inside darepod when the connector-bound ForfeitRequest arrives, and PENDING_REQUEST publishes the exact transcript through ListPendingForfeitParticipantSignatureRequests until SubmitForfeitParticipantSignatures answers it. Client head is 5d5a26d. Local validation: make fmt-changed-check base=origin/main; make commitmsg-lint range=origin/main..HEAD; go test ./darepod ./wallet ./daemonrpc ./sdk/swaps -count=1. |
|
Claude finished @bhandras's task in 2m 46s —— View job Re-review (head
|
5d5a26d to
7cb5bb6
Compare
|
@claude pls re-review. Context since the last review: I expanded the daemon proto/RPC comments and PR body to describe the route-based signing flow in detail; fixed the broker cursor bug so answered later requests do not advance next_sequence past an earlier pending request; added TestForfeitSignatureBrokerListDoesNotAdvancePastEarlierPendingRequest; and documented that custom policy refresh outputs may fail standard policy decode while still persisting policy_template and pk_script as authoritative script data. Current head is 7cb5bb6. |
|
Claude finished @bhandras's task in 2m 25s —— View job Re-review (head
|
7cb5bb6 to
6b0ebfd
Compare
|
@claude pls re-review with context. Folded the last local/CI cleanup on top of the expanded proto/RPC documentation:
Latest pushed client head: Local validation after the fold:
|
|
Claude finished @bhandras's task in 1m 28s —— View job Re-review (head
|
|
@claude pls re-review with context. I folded the remaining review concerns on the client side:
Focused local checks passed:
|
|
Claude finished @bhandras's task in 2m 7s —— View job Re-review (head
|
7ce9cd7 to
df4dec7
Compare
|
Folded the remaining client-side review items and force-pushed the branch. What changed:
Focused validation rerun:
@claude please re-review when you get a chance. The two substantive client blockers from the last review round should now be addressed. |
|
Claude finished @bhandras's task in 2m 50s —— View job Re-review (head
|
82d678d to
6fc0a90
Compare
5def4c5 to
74834f1
Compare
|
@bhandras, remember to re-request review from reviewers when ready |
In this commit we add the arkscript helpers needed by custom vHTLC refresh flows. Callers can recover the CHECKSIG public keys from the policy-template leaf selected by an encoded spend path. The helper keeps the semantic key order from the policy AST, which lets multi-signer refund paths build and verify witnesses consistently. The vHTLC policy also exposes named unilateral claim and sender-only refund spend paths. These accessors let higher layers choose auth and forfeit paths explicitly instead of rebuilding them from private knowledge of the template shape. The tests cover both the signing-key order and the new named accessors.
In this commit we extend the round wire and durable message types with the metadata required by custom refresh inputs. Join requests can now carry the auth spend path, the connector-bound forfeit spend path, keyed participant signatures, and fixed-amount output markers. The local boarding and codec paths preserve the same fields so a queued custom refresh can survive actor persistence and retry. Proto conversion rejects malformed spend paths early and keeps legacy single-signer requests compatible. The roundpb generated output is included with its source proto so reviewers can audit one schema change. The tests cover codec round trips and proto conversion for the new fields.
In this commit the round actor starts consuming the custom forfeit metadata that was added to the request envelope. The transition logic carries fixed outputs through the registration path without treating them as wallet change. Outbox messages can now request participant signatures for connector-bound forfeits instead of assuming the daemon identity key is the only non-operator signer. Rollback and timeout paths also drop temporary custom admissions when a quote is rejected or the round cannot complete. This keeps the state-machine behavior separate from the test-only coverage that exercises the new cases.
In this commit the round tests pin the custom forfeit signing behavior added by the runtime change. The actor coverage checks that custom refresh requests keep their fixed marker and signing context through admission. Outbox serialization tests assert that the participant signature request carries the custom forfeit transcript instead of dropping it at the boundary. Quote, change-marker, and timeout tests cover the rollback side of rejected or stalled custom admissions. Keeping the assertions in their own commit makes the protocol behavior easy to inspect without mixing it with the state-machine rewrite.
In this commit the wallet layer learns how to accept caller-supplied custom refresh inputs and outputs. The wallet messages carry the old VTXO proof material, the replacement output policy, and the signing route that should be used after the round assigns a connector. Admission checks keep ordinary wallet-managed refreshes on their legacy path while allowing custom-policy VTXOs to be queued intentionally. The replacement output can be marked fixed amount so the round cannot pay fees by shrinking a contract output. Tests cover the wallet-facing admission rules and make sure invalid custom refresh requests fail before they reach the VTXO manager. This commit deliberately stops at the wallet boundary; actor activation follows next.
In this commit the VTXO manager can materialize temporary actors for custom refresh inputs that are not ordinary wallet-managed live rows. The manager validates amount, pkScript, policy template, auth path, and forfeit path before queueing the old VTXO for a refresh round. It preserves pre-existing custom rows and rolls back partial activations when quote rejection or admission failure prevents the round from completing. The VTXO messages carry the custom signing context through to the actor that later receives the exact forfeit request. The implementation stays focused on manager state so the admission coverage can land separately.
In this commit the manager admission tests cover the custom refresh actor path. The tests assert that duplicate rows are preserved, custom rows can be activated, and failed admissions roll their temporary state back. They also check that fixed replacement outputs survive the manager path without being mistaken for ordinary change. The coverage exercises the message payloads that carry custom signing context toward the VTXO actor. Splitting these assertions out keeps the production manager change smaller while documenting the safety cases around activation and rollback.
In this commit VTXO actors use the custom signing context once a round has assigned the connector-bound forfeit transaction. Standard refreshes still use the local wallet signer and legacy single-signature path. Custom-policy actors ask the daemon environment for participant signatures selected by the stored forfeit spend path. The environment boundary carries enough transcript material for either a local signer or an external pending request to answer safely. Transition tests cover the pending participant-signature handoff and ensure the actor waits for the requested signatures before completing the forfeit. This is the runtime counterpart to the manager activation commit.
In this commit OOR receive flows preserve the policy template attached to a custom recipient output. The indexer RPC exposes the template recorded by the server, and the receive adapter copies it into the incoming VTXO material. Durable receive messages and snapshots carry the template so a restart does not downgrade a custom vHTLC into an opaque standard output. Materialization validates the template against the pkScript and falls back to standard VTXO construction only when older servers omit the field. Later refresh and forfeit signing code needs this semantic policy to choose the right spend path. Existing non-custom OOR flows continue to use the same receive state machine with an empty policy template.
In this commit vHTLC recovery jobs become durable across refreshed output generations. A refreshed swap output can keep the same policy semantics while moving to a new VTXO outpoint, so the recovery store keys jobs by the concrete target outpoint as well as the swap/action pair. This lets the daemon retain one row per active recovery target instead of treating the first row as the only row the old schema could represent. The migration updates recovery-job uniqueness and makes the downgrade path collapse multiple generations back to the newest representable job. The recovery target builder reconstructs the vHTLC policy from stored participants, delays, hash, and policy template. It then verifies that the rebuilt policy still matches the VTXO pkScript before handing the target to the sweeper. The tests cover round-store persistence, recovery-store generation changes, lossy downgrade behavior, and idempotent cancellation for missing or advanced jobs.
In this commit the daemon RPC surface grows the primitives needed by active custom refresh callers. The schema adds VTXO expiry lookup, exact VTXO forfeit signing, custom refresh admission, pending participant-signature listing, and participant-signature submission. Generated grpc, gateway, mailbox, REST, and CLI registry output is kept with the proto so the API movement is explicit. The SDK Ark client wraps the same methods for higher-level callers that should not know about raw protobuf types. The generated size is large, but the source schema is the actual review target. Proto and client tests cover the new enum and request surfaces.
In this commit darepod gains the in-memory broker used to publish and resolve connector-bound participant signature requests. The broker stores pending requests by request id and exposes sequence-based listing so external coordinators can poll without missing entries. Submitters can answer a request with the participant signatures required by the selected spend path. A blocked VTXO actor is woken only for the exact request id it emitted, which prevents one connector transcript from satisfying another. The broker remains independent from the RPC handlers so the service primitive can be reviewed on its own.
In this commit the forfeit signature broker receives focused unit coverage. The tests cover request delivery, duplicate handling, sequence-based listing, response submission, and cancellation behavior. They assert that a waiter only wakes for the matching request id and that unrelated responses do not leak across pending requests. The coverage also pins the broker's copy behavior around signature payloads so later RPC handlers can rely on stable transcripts. Keeping the tests separate makes the broker contract visible before the daemon RPC surface starts using it.
In this commit darepod can classify the expiry posture of a VTXO by outpoint or indexed pkScript. The handler asks the wallet and VTXO layers for the same threshold data used internally by refresh monitoring. Callers receive the current height, batch expiry, remaining blocks, refresh threshold, critical threshold, relative expiry, and tree-depth context. This lets swapdk-server make direction-specific refresh decisions without guessing the daemon's wallet policy. Missing or unregistered VTXOs are reported as not found instead of being treated as safe. Tests cover local and indexed lookup paths along with status conversion.
In this commit darepod gains the metadata helpers and server wiring needed by the custom refresh RPC surface. The daemon environment now owns the forfeit signature broker and carries the payment-hash routing metadata used by swapdk-server. Helper code converts caller-supplied swap metadata into the internal lookup shape without forcing the handlers to know every storage detail. The VTXO expiry and refresh handlers added later can share this plumbing instead of open-coding route extraction. This keeps the structural daemon wiring separate from the RPC methods that execute refresh work.
In this commit darepod wires the refresh RPC methods into the wallet and VTXO runtime. RefreshCustomVTXOs validates caller-supplied custom inputs, fixed replacement outputs, auth spend paths, forfeit spend paths, and signing route metadata before queueing the refresh. SignVTXOForfeit exposes the local daemon identity signer for exact connector-bound forfeit transactions after swap-level authorization has happened elsewhere. Pending participant-signature requests can be listed and submitted through the broker added earlier in the stack. The handlers use the metadata plumbing from the previous commit so the RPC boundary stays focused on validation and orchestration.
In this commit the daemon RPC tests cover the custom refresh handlers and signing boundary. The custom refresh coverage checks validation of custom inputs, fixed replacement outputs, auth paths, forfeit paths, and swap routing metadata. The VTXO forfeit tests assert that the daemon signs only the exact connector-bound transcript it can verify from local VTXO context. Broker-facing tests cover pending request listing and participant-signature submission through the RPC surface. Splitting the tests from the handlers makes the service behavior easier to audit while preserving the same end-state coverage.
In this commit the swap RPC surface gains the messages used to exchange custom vHTLC refresh signatures. The schema carries the connector-bound forfeit transcript, payment-hash routing key, participant signature, and the RPCs used by in-swap and out-swap coordinators. Generated grpc, gateway, and mailbox bindings are included with the proto so downstream callers can use the new methods immediately. The swap client server glue converts the swap-level messages into daemon-level participant signature operations. This commit only moves the wire surface and service bridge; the higher-level SDK orchestration lands next. Keeping the proto separate makes the API review much easier.
In this commit the swap SDK exposes the refresh-signing methods through its gRPC transport. The connection wrapper maps the new daemon RPCs onto the same client path used by the rest of the SDK. Callers can queue custom refreshes, list pending forfeit-signature requests, submit participant signatures, and ask for exact local forfeit signatures without handling raw protobuf plumbing. The transport layer keeps protobuf conversion at the edge so orchestration code can work with swap-domain request and response shapes. The tests cover request and response conversion across that boundary. Keeping this layer first gives the later orchestration code a stable SDK surface to call.
In this commit the swap SDK coordinates the refresh-signing flow exposed by swaprpc and darepod. Helper code builds custom refresh inputs from vHTLC material and selects the correct auth and forfeit paths for each swap side. The SDK registers a mailbox-driven responder that reacts to receiver-signature requests and feeds the synchronous daemon callback when the local process owns the participant key. Out-swap and in-swap callers keep the payment hash as the routing key so each connector-bound request maps to the right swap state machine. Mailbox helpers then publish and consume receiver-side signatures without leaking daemon internals. The orchestration code lands separately from its tests so the flow can be reviewed as a compact runtime change.
In this commit the swap SDK tests cover the refresh-signing orchestration path. The out-swap coverage checks custom refresh input construction, pending request polling, participant-signature submission, and refreshed receive tracking. Mailbox tests assert that receiver-side signature messages are published and consumed without exposing daemon internals to the swap state machine. In-swap coverage pins the small routing hook that shares the payment hash with the refresh flow. Keeping these assertions separate from the runtime helpers makes the SDK behavior easier to inspect at the end of the stack.
Add unit coverage for the OOR recipient policy template invariant that previously had no client-side tests: - BuildIncomingVTXODescriptor preserves a server-supplied template that binds to the recipient pkScript, and rejects one that decodes cleanly but does not bind (no silent downgrade to the standard template). - The recipient template survives an IncomingSnapshot encode/decode cycle, so a custom policy is not lost across a restart between notify and materialization.
PR745 added migration 000020_vhtlc_recovery_job_generations but did not document it. Add a migration note describing the widened recovery-job uniqueness key and the de-duplicating down migration. Also bring db/AGENTS.md back in sync with db/CLAUDE.md: the mirror had drifted (it was missing the pending-intents entries and the schema version), so this makes the pair byte-identical again.
4ce67e7 to
0442333
Compare
What this PR does
This PR adds the darepod and SDK support needed for cooperative custom vHTLC refresh.
A vHTLC refresh is the path where swapdk-server keeps a live swap vTXO alive by moving it into a fresh Ark round before the old batch expires. That keeps the swap on the normal Lightning path instead of immediately escalating to recovery.
The important detail is that a vHTLC is not a normal single-party wallet vTXO. Its future forfeit transaction can require signatures from more than one participant. The daemon that queues the refresh must therefore be able to collect a participant signature after the round builder has assigned the connector and built the exact forfeit transaction.
This branch teaches darepod to do that.
What changed
The PR adds four pieces of plumbing.
There is also a small cleanup path for failed custom refresh admission. If darepod queued temporary custom refresh actors but the later trigger step fails, the RPC asks the wallet to drop those temporary actors so stale signer contexts do not linger.
The RPC contract
The protocol is intentionally route-based, not swap-direction-based. A custom vHTLC may be used by swaps today, but the daemon RPC does not need to know whether the caller thinks of the vHTLC as a swap in or a swap out.
RefreshCustomVTXOstakes the old custom vTXO, the replacement output, and aForfeitSigningContext:payment_hashis an opaque 32-byte correlation id. Swap users set it to the swap hash, but darepod only copies it into later pending requests so the external coordinator can find its own state.signing_routetells darepod how to answer the future connector-bound participant-signature request.There are two routes:
LOCAL_SIGNER: when the round later supplies the exact forfeit transaction, darepod asks its configured local signer to sign the vTXO input immediately.PENDING_REQUEST: darepod stores the exact transcript, returns it fromListPendingForfeitParticipantSignatureRequests, blocks the temporary VTXO actor, and resumes only afterSubmitForfeitParticipantSignaturessupplies the participant signature for thatrequest_id.That split is necessary because
RefreshCustomVTXOsruns before a round has assigned a connector. At that time the final forfeit transaction does not exist, so the participant signature cannot be collected yet. The later pending request includes the connector outpoint, connector amount, connector pkScript, selected forfeit spend path, unsigned forfeit transaction, and server forfeit output script. The external participant signs that exact VTXO input transcript.Function-level flow
RefreshCustomVTXOsvalidates the caller-supplied custom input and queues a temporary refresh actor. That actor stores the old vTXO, the replacement output, and the requested signing route, but it cannot ask anyone to sign yet because the connector has not been assigned.When the round actor later builds the exact connector-bound forfeit transaction, it calls into the temporary actor with the full transcript. The actor then chooses the route:
LOCAL_SIGNER, it calls the local forfeit signer and returns the participant signature directly to the round path;PENDING_REQUEST, it registers the transcript in the in-process broker and waits onSubmitForfeitParticipantSignatures.ListPendingForfeitParticipantSignatureRequestsis the polling API for external coordinators. It returns only unanswered requests and usesnext_sequenceas a cursor. Answered later requests no longer advance that cursor past an earlier pending request, so an external signer cannot accidentally skip a still-unanswered transcript.SubmitForfeitParticipantSignaturesmatches therequest_id, wakes the blocked actor, and removes the answered request from the broker. Repeated submits for an already-answered request are treated as idempotent success only when the submitted signatures match the stored answer. A different answer for the same request id is rejected asAlreadyExists.Why this is safe
The participant signs an exact forfeit transaction transcript. That keeps the signature scoped to the connector-bound transaction that the operator will use for the refreshed round.
The pending-request path is durable at the coordinator layer: the request contains the opaque payment hash and a stable request id, so swapdk-server can recover its own state and resubmit the participant signature if it restarts in the middle.
Daemon-local signing contexts are removed after a successful external submit or synchronous local signer response, so successful refresh signing does not leave stale contexts behind.
Ordinary wallet-managed VTXOs keep using the existing behavior. The new signing path is only used for custom refresh inputs that carry a forfeit signing context.
Stack
This is the darepo-client side of the active vHTLC refresh stack. The matching darepo PR carries the round-wire fields, and the swapdk-server PR uses these APIs when a funded vHTLC is approaching batch expiry.
Validation
make fmt-changed-check base=origin/mainmake commitmsg-lint range=origin/main..HEADgo test ./darepod ./db -count=1go test ./darepod ./wallet ./daemonrpc ./sdk/swaps -count=1The matching darepo and swapdk-server PRs add full integration coverage for the multi-participant refresh path. Those tests prove that the extra participant signature is requested, submitted, carried into the round input artifacts, accepted by the operator, and confirmed in the replacement round.