Problem
A VHTLC script address is derived from fixed parameters (preimage hash, pubkeys, locktimes) — it does not change between payments. This means a single VHTLC can be funded multiple times, resulting in multiple VTXOs sitting at the same script address.
The current ClaimVHTLC implementation fetches all VTXOs for the VHTLC script via getVHTLCFunds, then unconditionally picks vtxos[0] — whichever the indexer returns first. There is no guarantee about ordering, and no way for the caller to control which specific VTXO gets claimed.
Affected Components
| Component |
File |
Notes |
| Core claim logic |
pkg/swap/swap.go → ClaimVHTLC() |
Picks vtxos[0] without ordering |
| VTXO fetching |
pkg/swap/swap.go → getVHTLCFunds() |
Returns resp.Vtxos in indexer order |
| Proto API |
api-spec/protobuf/fulmine/v1/service.proto → ClaimVHTLCRequest |
Missing outpoint field |
| gRPC handler |
internal/interface/grpc/handlers/service_handler.go → ClaimVHTLC() |
Will need to pass outpoint through |
| App service |
internal/core/application/service.go → ClaimVHTLC() |
Passes through to swap handler |
| E2E tests |
internal/test/e2e/vhtlc_test.go |
Will need a multi-fund test case |
Current Behavior (Problematic)
// pkg/swap/swap.go
vtxos, err := h.getVHTLCFunds(ctx, []*vhtlc.VHTLCScript{vHTLC})
// ...
vtxo := &vtxos[0] // arbitrary — indexer order, no guarantee
The Input type (txid + vout) already exists in types.proto and is used as the Vtxo.outpoint field — it can be reused or a dedicated Outpoint message can be added to ClaimVHTLCRequest.
Suggested Approach
1. Add optional outpoint to ClaimVHTLCRequest in proto
message ClaimVHTLCRequest {
string vhtlc_id = 1;
string preimage = 2;
optional Input outpoint = 3; // If set, claim this specific VTXO
}
Input is already defined in types.proto with txid (string) and vout (uint32).
2. Filter by outpoint in ClaimVHTLC (swap.go)
When an outpoint is provided, filter vtxos to the matching one and return an error if not found. When absent, fall back to oldest-first ordering.
3. Sort by creation date when no outpoint specified
// Sort vtxos oldest-first as a safe default
sort.Slice(vtxos, func(i, j int) bool {
return vtxos[i].CreatedAt < vtxos[j].CreatedAt
})
vtxo := &vtxos[0]
This ensures deterministic behavior — the oldest (presumably first-funded) VTXO is always claimed first, matching expected payment semantics.
4. Thread outpoint through the call chain
service_handler.go: parse req.GetOutpoint(), convert to wire.OutPoint, pass to service
service.go: add optional outpoint param to ClaimVHTLC signature
swap.go: use outpoint for filtering or default to sorted selection
Related Code
getVHTLCFunds() — pkg/swap/swap.go — the indexer call that returns unordered VTXOs
vtxo.CreatedAt — available on clientTypes.Vtxo, can be used for sorting
Input message in types.proto — reusable as the outpoint type
- Chain swap handler calls
ClaimVHTLC without outpoint: pkg/swap/chainswap_btc_ark_handler.go
Context
This was triggered by a scenario where Boltz funds the same VHTLC address more than once (e.g., retry after a partial failure). Without explicit outpoint selection or stable ordering, claiming becomes non-deterministic and could claim the wrong VTXO or fail silently on the expected one.
Problem
A VHTLC script address is derived from fixed parameters (preimage hash, pubkeys, locktimes) — it does not change between payments. This means a single VHTLC can be funded multiple times, resulting in multiple VTXOs sitting at the same script address.
The current
ClaimVHTLCimplementation fetches all VTXOs for the VHTLC script viagetVHTLCFunds, then unconditionally picksvtxos[0]— whichever the indexer returns first. There is no guarantee about ordering, and no way for the caller to control which specific VTXO gets claimed.Affected Components
pkg/swap/swap.go→ClaimVHTLC()vtxos[0]without orderingpkg/swap/swap.go→getVHTLCFunds()resp.Vtxosin indexer orderapi-spec/protobuf/fulmine/v1/service.proto→ClaimVHTLCRequestoutpointfieldinternal/interface/grpc/handlers/service_handler.go→ClaimVHTLC()internal/core/application/service.go→ClaimVHTLC()internal/test/e2e/vhtlc_test.goCurrent Behavior (Problematic)
The
Inputtype (txid + vout) already exists intypes.protoand is used as theVtxo.outpointfield — it can be reused or a dedicatedOutpointmessage can be added toClaimVHTLCRequest.Suggested Approach
1. Add optional
outpointtoClaimVHTLCRequestin protoInputis already defined intypes.protowithtxid(string) andvout(uint32).2. Filter by outpoint in
ClaimVHTLC(swap.go)When an outpoint is provided, filter
vtxosto the matching one and return an error if not found. When absent, fall back to oldest-first ordering.3. Sort by creation date when no outpoint specified
This ensures deterministic behavior — the oldest (presumably first-funded) VTXO is always claimed first, matching expected payment semantics.
4. Thread outpoint through the call chain
service_handler.go: parsereq.GetOutpoint(), convert towire.OutPoint, pass to serviceservice.go: add optional outpoint param toClaimVHTLCsignatureswap.go: use outpoint for filtering or default to sorted selectionRelated Code
getVHTLCFunds()—pkg/swap/swap.go— the indexer call that returns unordered VTXOsvtxo.CreatedAt— available onclientTypes.Vtxo, can be used for sortingInputmessage intypes.proto— reusable as the outpoint typeClaimVHTLCwithout outpoint:pkg/swap/chainswap_btc_ark_handler.goContext
This was triggered by a scenario where Boltz funds the same VHTLC address more than once (e.g., retry after a partial failure). Without explicit outpoint selection or stable ordering, claiming becomes non-deterministic and could claim the wrong VTXO or fail silently on the expected one.