Skip to content
24 changes: 19 additions & 5 deletions daemonrpc/daemon.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions daemonrpc/daemon.proto
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,13 @@ message ListVTXOsRequest {

// min_amount_sat excludes VTXOs below this value.
int64 min_amount_sat = 2;

// exclude_checkpoint_psbts skips attaching the finalized OOR checkpoint
// PSBTs to each returned VTXO. Loading those packages costs one artifact
// store read per VTXO, which dominates the call for listing-only
// consumers (balance views, coin selection) that never inspect the
// PSBTs. The default keeps the full response for compatibility.
bool exclude_checkpoint_psbts = 3;
}

message ListVTXOsResponse {
Expand Down
22 changes: 15 additions & 7 deletions darepod/rpc_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -959,11 +959,14 @@ func (r *RPCServer) ListVTXOs(ctx context.Context,
"invalid status filter: %v", sErr)
}

dbVTXOs, err = r.server.vtxoStore.ListVTXOsByStatus(
// The listing response never carries ancestry, so the light
// variants skip the ancestry side-table join (whose TLV tree
// fragments grow with OOR chain depth) entirely.
dbVTXOs, err = r.server.vtxoStore.ListVTXOsByStatusLight(
ctx, domainStatus,
)
} else {
dbVTXOs, err = r.server.vtxoStore.ListLiveVTXOs(ctx)
dbVTXOs, err = r.server.vtxoStore.ListLiveVTXOsLight(ctx)
}

if err != nil {
Expand All @@ -980,14 +983,19 @@ func (r *RPCServer) ListVTXOs(ctx context.Context,

filtered := vtxo.FilterDescriptors(dbVTXOs, filterOpts)

// Resolving the OOR package for an outpoint costs one artifact-store
// read per VTXO, so listing-only callers can opt out of checkpoint
// PSBT population entirely.
var packageStore *db.OORArtifactPersistenceStore
for i := range filtered {
if filtered[i].Status == vtxo.VTXOStatusSpent ||
filtered[i].ChainDepth > 0 {
if !req.ExcludeCheckpointPsbts {
for i := range filtered {
if filtered[i].Status == vtxo.VTXOStatusSpent ||
filtered[i].ChainDepth > 0 {

packageStore = r.newLocalOORArtifactStore()
packageStore = r.newLocalOORArtifactStore()

break
break
}
}
}

Expand Down
7 changes: 7 additions & 0 deletions darepod/wallet_ops_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ func (s *testCustomInputStore) ListVTXOsByStatus(context.Context,
return nil, fmt.Errorf("unexpected ListVTXOsByStatus call")
}

func (s *testCustomInputStore) ListSelectionCandidatesByStatus(context.Context,
vtxo.VTXOStatus) ([]vtxo.SelectedVTXO, error) {

return nil, fmt.Errorf("unexpected ListSelectionCandidatesByStatus " +
"call")
}

func (s *testCustomInputStore) UpdateVTXOStatus(context.Context, wire.OutPoint,
vtxo.VTXOStatus) error {

Expand Down
9 changes: 9 additions & 0 deletions db/round_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ type RoundStore interface {

ListVTXOsByStatus(ctx context.Context, status int32) ([]VTXORow, error)

// ListVTXOSelectionCandidatesByStatus returns the lightweight
// (outpoint, amount, pkScript) projection coin selection runs on,
// avoiding the full descriptor decode on the per-payment hot path.
ListVTXOSelectionCandidatesByStatus(ctx context.Context,
status int32) (
[]sqlc.ListVTXOSelectionCandidatesByStatusRow,
error,
)

UpdateVTXOStatus(
ctx context.Context, arg sqlc.UpdateVTXOStatusParams,
) error
Expand Down
6 changes: 6 additions & 0 deletions db/sqlc/querier.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions db/sqlc/queries/vtxo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ SELECT * FROM vtxos
WHERE status = $1
ORDER BY creation_time DESC;

-- name: ListVTXOSelectionCandidatesByStatus :many
-- ListVTXOSelectionCandidatesByStatus returns the lightweight projection coin
-- selection runs on: outpoint, amount, and pkScript. Selection happens on
-- every payment and only needs these three fields, so this avoids decoding
-- full descriptors (pubkey parsing, taproot script reconstruction, policy
-- template decode) and the batched ancestry-path query on the hot path.
SELECT outpoint_hash, outpoint_index, amount, pk_script
FROM vtxos
WHERE status = $1
ORDER BY creation_time DESC;

-- name: ListLiveVTXOs :many
-- ListLiveVTXOs returns all VTXOs that are not in a terminal state.
-- Terminal states are: Forfeited (3), Spent (4), UnilateralExit (5),
Expand Down
47 changes: 47 additions & 0 deletions db/sqlc/vtxo.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

112 changes: 112 additions & 0 deletions db/vtxo_descriptor_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package db

import (
"errors"
"fmt"

"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightninglabs/neutrino/cache"
"github.com/lightninglabs/neutrino/cache/lru"
)
Comment on lines +3 to +12

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

The vtxoDescriptorCache is accessed concurrently by multiple goroutines (e.g., during concurrent RPC requests). Since lru.Cache is not thread-safe, concurrent access to get and put will cause data races and potential memory corruption. We should add a sync.Mutex to protect the cache operations.

Suggested change
import (
"errors"
"fmt"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightninglabs/neutrino/cache"
"github.com/lightninglabs/neutrino/cache/lru"
)
import (
"errors"
"fmt"
"sync"
"github.com/btcsuite/btcd/btcec/v2"
"github.com/btcsuite/btcd/wire"
"github.com/btcsuite/btcwallet/waddrmgr"
"github.com/lightninglabs/neutrino/cache"
"github.com/lightninglabs/neutrino/cache/lru"
)


// maxVTXODescriptorCacheEntries bounds the process-local decoded descriptor
// cache by entry count. The derived script material is immutable per
// outpoint, so eviction is opportunistic: a miss simply re-derives from the
// row.
const maxVTXODescriptorCacheEntries = 8192

// vtxoDescriptorCacheValue holds the expensive derived parts of one VTXO row
// that never change for a given outpoint: the parsed public keys, the
// reconstructed taproot script, and the policy-resolved relative expiry.
// Profiling showed this derivation (secp256k1 point math in
// StandardTapScript, policy template decode, pubkey parsing) dominating the
// hot listing paths, recomputed from scratch for every row on every call.
//
// All reference fields are shared across cache hits and MUST be treated as
// read-only by callers, exactly like the decoded trees in ancestryTreeCache.
type vtxoDescriptorCacheValue struct {
clientPubkey *btcec.PublicKey
operatorPubkey *btcec.PublicKey
policyTemplate []byte
tapscript *waddrmgr.Tapscript
relativeExpiry uint32
}

// Size implements the lru.Value interface. Every entry counts as one unit so
// the cache is bounded by entry count.
func (v *vtxoDescriptorCacheValue) Size() (uint64, error) {
return 1, nil
}

// vtxoDescriptorCacheKey is the fixed-size outpoint key: the 32-byte txid
// followed by the 4-byte little-endian output index.
type vtxoDescriptorCacheKey [36]byte

// vtxoDescriptorCache memoizes the immutable derived parts of VTXO rows by
// outpoint. A VTXO's script material is bound to its on-chain output, so an
// outpoint can never map to different derived values; no invalidation is
// needed and mutable row state (status, last update time) stays out of the
// cache.
type vtxoDescriptorCache struct {
entries *lru.Cache[vtxoDescriptorCacheKey, *vtxoDescriptorCacheValue]
}
Comment on lines +52 to +54

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 sync.Mutex to the vtxoDescriptorCache struct to ensure thread-safe access to the underlying lru.Cache.

Suggested change
type vtxoDescriptorCache struct {
entries *lru.Cache[vtxoDescriptorCacheKey, *vtxoDescriptorCacheValue]
}
type vtxoDescriptorCache struct {
mu sync.Mutex
entries *lru.Cache[vtxoDescriptorCacheKey, *vtxoDescriptorCacheValue]
}


// newVTXODescriptorCache creates a process-local cache for the derived
// descriptor parts.
func newVTXODescriptorCache() *vtxoDescriptorCache {
return &vtxoDescriptorCache{
entries: lru.NewCache[
vtxoDescriptorCacheKey, *vtxoDescriptorCacheValue,
](
uint64(maxVTXODescriptorCacheEntries),
),
}
}

// keyForOutpoint packs an outpoint into the fixed-size cache key.
func keyForOutpoint(op wire.OutPoint) vtxoDescriptorCacheKey {
var key vtxoDescriptorCacheKey
copy(key[:], op.Hash[:])
key[32] = byte(op.Index)
key[33] = byte(op.Index >> 8)
key[34] = byte(op.Index >> 16)
key[35] = byte(op.Index >> 24)

return key
}

// get returns the cached derived parts for the outpoint, if present.
func (c *vtxoDescriptorCache) get(op wire.OutPoint) (*vtxoDescriptorCacheValue,
bool) {

if c == nil || c.entries == nil {
return nil, false
}

cached, err := c.entries.Get(keyForOutpoint(op))
if err != nil {
return nil, false
}

return cached, true
}
Comment on lines +81 to +94

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

Lock the mutex during cache retrieval to prevent concurrent read/write races on the LRU list.

Suggested change
func (c *vtxoDescriptorCache) get(op wire.OutPoint) (*vtxoDescriptorCacheValue,
bool) {
if c == nil || c.entries == nil {
return nil, false
}
cached, err := c.entries.Get(keyForOutpoint(op))
if err != nil {
return nil, false
}
return cached, true
}
func (c *vtxoDescriptorCache) get(op wire.OutPoint) (*vtxoDescriptorCacheValue,
bool) {
if c == nil || c.entries == nil {
return nil, false
}
c.mu.Lock()
defer c.mu.Unlock()
cached, err := c.entries.Get(keyForOutpoint(op))
if err != nil {
return nil, false
}
return cached, true
}


// put stores the derived parts for the outpoint. Failures are surfaced so
// callers can decide whether to ignore them; a put failure only costs a
// future re-derivation.
func (c *vtxoDescriptorCache) put(op wire.OutPoint,
value *vtxoDescriptorCacheValue) error {

if c == nil || c.entries == nil {
return nil
}

if _, err := c.entries.Put(keyForOutpoint(op), value); err != nil &&
!errors.Is(err, cache.ErrElementNotFound) {
return fmt.Errorf("put descriptor cache: %w", err)
}

return nil
}
Comment on lines +99 to +112

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

Lock the mutex during cache insertion to prevent concurrent write races on the LRU list.

Suggested change
func (c *vtxoDescriptorCache) put(op wire.OutPoint,
value *vtxoDescriptorCacheValue) error {
if c == nil || c.entries == nil {
return nil
}
if _, err := c.entries.Put(keyForOutpoint(op), value); err != nil &&
!errors.Is(err, cache.ErrElementNotFound) {
return fmt.Errorf("put descriptor cache: %w", err)
}
return nil
}
func (c *vtxoDescriptorCache) put(op wire.OutPoint,
value *vtxoDescriptorCacheValue) error {
if c == nil || c.entries == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if _, err := c.entries.Put(keyForOutpoint(op), value); err != nil &&
!errors.Is(err, cache.ErrElementNotFound) {
return fmt.Errorf("put descriptor cache: %w", err)
}
return nil
}

Loading
Loading