Skip to content

Add outpoint to ClaimVHTLC request #381

@altafan

Description

@altafan

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.goClaimVHTLC() Picks vtxos[0] without ordering
VTXO fetching pkg/swap/swap.gogetVHTLCFunds() Returns resp.Vtxos in indexer order
Proto API api-spec/protobuf/fulmine/v1/service.protoClaimVHTLCRequest Missing outpoint field
gRPC handler internal/interface/grpc/handlers/service_handler.goClaimVHTLC() Will need to pass outpoint through
App service internal/core/application/service.goClaimVHTLC() 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.

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions