From fbc5dd184a6df1ffb13637bd19cfee8b7ed0f95b Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 16:38:57 -0500 Subject: [PATCH 1/9] db/sqlc: add VTXO selection-candidate projection query Profiling at ~9.5 payments/s showed ListVTXOsByStatus consuming 32% of all CPU samples, with 26% in the per-row descriptor decode. Coin selection runs that listing on every payment but only consumes the outpoint, amount, and pkScript columns, so this adds a projection query returning exactly those three fields. The Go-side switch to the projection lands separately. --- db/sqlc/querier.go | 6 +++++ db/sqlc/queries/vtxo.sql | 11 ++++++++++ db/sqlc/vtxo.sql.go | 47 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 5ba479e8e..d03dfefb2 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -205,6 +205,12 @@ type Querier interface { // ListVTXOAncestryPathsByStatus returns every ancestry row whose parent // VTXO matches the given status code. Companion to ListVTXOsByStatus. ListVTXOAncestryPathsByStatus(ctx context.Context, status int32) ([]VtxoAncestryPath, error) + // 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. + ListVTXOSelectionCandidatesByStatus(ctx context.Context, status int32) ([]ListVTXOSelectionCandidatesByStatusRow, error) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, error) // VTXO status and lifecycle queries. // These queries support the vtxo.VTXOStore interface for VTXO lifecycle diff --git a/db/sqlc/queries/vtxo.sql b/db/sqlc/queries/vtxo.sql index 7de969cd1..2727e3815 100644 --- a/db/sqlc/queries/vtxo.sql +++ b/db/sqlc/queries/vtxo.sql @@ -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), diff --git a/db/sqlc/vtxo.sql.go b/db/sqlc/vtxo.sql.go index fb1e0f29b..4e72e1a37 100644 --- a/db/sqlc/vtxo.sql.go +++ b/db/sqlc/vtxo.sql.go @@ -147,6 +147,53 @@ func (q *Queries) ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) { return items, nil } +const ListVTXOSelectionCandidatesByStatus = `-- name: ListVTXOSelectionCandidatesByStatus :many +SELECT outpoint_hash, outpoint_index, amount, pk_script +FROM vtxos +WHERE status = $1 +ORDER BY creation_time DESC +` + +type ListVTXOSelectionCandidatesByStatusRow struct { + OutpointHash []byte + OutpointIndex int32 + Amount int64 + PkScript []byte +} + +// 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. +func (q *Queries) ListVTXOSelectionCandidatesByStatus(ctx context.Context, status int32) ([]ListVTXOSelectionCandidatesByStatusRow, error) { + rows, err := q.db.QueryContext(ctx, ListVTXOSelectionCandidatesByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListVTXOSelectionCandidatesByStatusRow + for rows.Next() { + var i ListVTXOSelectionCandidatesByStatusRow + if err := rows.Scan( + &i.OutpointHash, + &i.OutpointIndex, + &i.Amount, + &i.PkScript, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListVTXOsByStatus = `-- name: ListVTXOsByStatus :many SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, policy_template, client_key_id, operator_pubkey, batch_expiry, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time, chain_depth FROM vtxos From 12c233159a35196278003f0292b51998ab22ab21 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 16:43:06 -0500 Subject: [PATCH 2/9] multi: run coin selection on the lightweight VTXO projection Profiling at ~9.5 payments/s showed coin selection responsible for a large slice of the hottest CPU path: selectAndReserveVTXOs listed all live VTXOs through the full descriptor decode on every payment, paying pubkey parsing, taproot script reconstruction, policy template decode, and the batched ancestry query for fields it never reads. Selection consumes exactly the outpoint, the amount, and the pkScript. VTXOStore gains ListSelectionCandidatesByStatus, returning SelectedVTXO projections straight from the new sqlc query. The manager wraps the rows as minimal descriptors at its single call site so the shared largest-first machinery (dust handling, liquidity classification) stays unchanged; the partial descriptors never escape the selection function, whose response already carries the same three fields. --- darepod/wallet_ops_test.go | 7 ++++ db/round_store.go | 9 ++++ db/vtxo_store.go | 45 ++++++++++++++++++++ db/vtxo_store_test.go | 59 +++++++++++++++++++++++++++ oor/local_persistence_handler_test.go | 19 +++++++++ unroll/actor_test.go | 7 ++++ vtxo/harness_test.go | 23 +++++++++++ vtxo/interfaces.go | 9 ++++ vtxo/manager.go | 24 ++++++++++- 9 files changed, 200 insertions(+), 2 deletions(-) diff --git a/darepod/wallet_ops_test.go b/darepod/wallet_ops_test.go index 570002368..beca6b544 100644 --- a/darepod/wallet_ops_test.go +++ b/darepod/wallet_ops_test.go @@ -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 { diff --git a/db/round_store.go b/db/round_store.go index 92045f5d7..a0bdf446d 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -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 diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 5cbc8ea39..fea4a8227 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -293,6 +293,51 @@ func (s *VTXOPersistenceStore) ListVTXOsByStatus(ctx context.Context, return result, err } +// ListSelectionCandidatesByStatus returns the lightweight projection coin +// selection runs on: outpoint, amount, and pkScript per VTXO in the given +// status. Selection happens on every payment and needs only these fields, so +// this path skips the full descriptor decode (pubkey parsing, taproot script +// reconstruction, policy template decode) and the batched ancestry query +// entirely. +func (s *VTXOPersistenceStore) ListSelectionCandidatesByStatus( + ctx context.Context, status vtxo.VTXOStatus) ([]vtxo.SelectedVTXO, + error) { + + readTxOpts := ReadTxOption() + + var result []vtxo.SelectedVTXO + + err := s.db.ExecTx(ctx, readTxOpts, func(q RoundStore) error { + rows, err := q.ListVTXOSelectionCandidatesByStatus( + ctx, int32(status), + ) + if err != nil { + return fmt.Errorf("list selection candidates: %w", err) + } + + candidates := make([]vtxo.SelectedVTXO, 0, len(rows)) + for _, row := range rows { + var outpointHash chainhash.Hash + copy(outpointHash[:], row.OutpointHash) + + candidates = append(candidates, vtxo.SelectedVTXO{ + Outpoint: wire.OutPoint{ + Hash: outpointHash, + Index: uint32(row.OutpointIndex), + }, + Amount: btcutil.Amount(row.Amount), + PkScript: row.PkScript, + }) + } + + result = candidates + + return nil + }) + + return result, err +} + // UpdateVTXOStatus atomically updates a VTXO's status. This is the primary // method for state transitions that don't require additional data. func (s *VTXOPersistenceStore) UpdateVTXOStatus( diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index b13d1373e..684e68fee 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -288,6 +288,65 @@ func TestVTXOPersistenceStoreSaveAndGet(t *testing.T) { ) } +// TestListSelectionCandidatesByStatus verifies the lightweight selection +// projection agrees with the full descriptor listing on outpoint, amount, +// and pkScript. +func TestListSelectionCandidatesByStatus(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + roundID := testRoundIDDB("test-round-sel-proj") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, state)) + + descA := createTestVTXODescriptor(t, roundID, 11) + require.NoError(t, vtxoStore.SaveVTXO(ctx, descA)) + + descB := createTestVTXODescriptor(t, roundID, 12) + require.NoError(t, vtxoStore.SaveVTXO(ctx, descB)) + + full, err := vtxoStore.ListVTXOsByStatus(ctx, vtxo.VTXOStatusLive) + require.NoError(t, err) + require.Len(t, full, 2) + + candidates, err := vtxoStore.ListSelectionCandidatesByStatus( + ctx, vtxo.VTXOStatusLive, + ) + require.NoError(t, err) + require.Len(t, candidates, len(full)) + + byOutpoint := make(map[wire.OutPoint]*vtxo.Descriptor) + for _, desc := range full { + byOutpoint[desc.Outpoint] = desc + } + + for _, candidate := range candidates { + desc, ok := byOutpoint[candidate.Outpoint] + require.True(t, ok) + require.Equal(t, desc.Amount, candidate.Amount) + require.Equal(t, desc.PkScript, candidate.PkScript) + } + + // A status the projection was not asked for stays invisible. + require.NoError( + t, vtxoStore.UpdateVTXOStatus( + ctx, descA.Outpoint, vtxo.VTXOStatusSpent, + ), + ) + + candidates, err = vtxoStore.ListSelectionCandidatesByStatus( + ctx, vtxo.VTXOStatusLive, + ) + require.NoError(t, err) + require.Len(t, candidates, 1) + require.Equal(t, descB.Outpoint, candidates[0].Outpoint) +} // addAncestryFragment appends a synthetic ancestry fragment to a // Descriptor under construction so multi-tree round-trip tests can // build N>1 ancestry layouts without re-implementing the per-fragment diff --git a/oor/local_persistence_handler_test.go b/oor/local_persistence_handler_test.go index c4a22a83b..1d82b51bf 100644 --- a/oor/local_persistence_handler_test.go +++ b/oor/local_persistence_handler_test.go @@ -137,6 +137,25 @@ func (s *testVTXOStore) ListVTXOsByStatus(_ context.Context, return out, nil } +// ListSelectionCandidatesByStatus projects stored descriptors matching the +// given status down to the selection fields. +func (s *testVTXOStore) ListSelectionCandidatesByStatus(_ context.Context, + status vtxo.VTXOStatus) ([]vtxo.SelectedVTXO, error) { + + var out []vtxo.SelectedVTXO + for _, desc := range s.records { + if desc.Status == status { + out = append(out, vtxo.SelectedVTXO{ + Outpoint: desc.Outpoint, + Amount: desc.Amount, + PkScript: desc.PkScript, + }) + } + } + + return out, nil +} + // UpdateVTXOStatus updates status for the given outpoint. func (s *testVTXOStore) UpdateVTXOStatus(_ context.Context, outpoint wire.OutPoint, status vtxo.VTXOStatus) error { diff --git a/unroll/actor_test.go b/unroll/actor_test.go index 082e8964e..207bc8c74 100644 --- a/unroll/actor_test.go +++ b/unroll/actor_test.go @@ -80,6 +80,13 @@ func (m *mockVTXOStore) ListVTXOsByStatus(context.Context, vtxo.VTXOStatus) ( return nil, nil } +// ListSelectionCandidatesByStatus is unused in these tests. +func (m *mockVTXOStore) ListSelectionCandidatesByStatus(context.Context, + vtxo.VTXOStatus) ([]vtxo.SelectedVTXO, error) { + + return nil, nil +} + // UpdateVTXOStatus is unused in these tests. func (m *mockVTXOStore) UpdateVTXOStatus(context.Context, wire.OutPoint, vtxo.VTXOStatus) error { diff --git a/vtxo/harness_test.go b/vtxo/harness_test.go index cb30f3967..a4e467187 100644 --- a/vtxo/harness_test.go +++ b/vtxo/harness_test.go @@ -74,6 +74,29 @@ func (m *MockVTXOStore) ListVTXOsByStatus(ctx context.Context, return vtxos, args.Error(1) } +// ListSelectionCandidatesByStatus derives the selection projection from the +// mocked ListVTXOsByStatus expectation, so existing tests keep stubbing a +// single listing surface for both the full and the projected reads. +func (m *MockVTXOStore) ListSelectionCandidatesByStatus(ctx context.Context, + status VTXOStatus) ([]SelectedVTXO, error) { + + descs, err := m.ListVTXOsByStatus(ctx, status) + if err != nil { + return nil, err + } + + candidates := make([]SelectedVTXO, 0, len(descs)) + for _, desc := range descs { + candidates = append(candidates, SelectedVTXO{ + Outpoint: desc.Outpoint, + Amount: desc.Amount, + PkScript: desc.PkScript, + }) + } + + return candidates, nil +} + func (m *MockVTXOStore) UpdateVTXOStatus(ctx context.Context, outpoint wire.OutPoint, status VTXOStatus) error { diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index 6f842de7b..bbb167449 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -430,6 +430,8 @@ func (d *Descriptor) PrimaryAncestry() *Ancestry { // VTXOStore defines the persistence interface for VTXO lifecycle management. // The store provides per-VTXO operations since each VTXO has its own actor. // The VTXO manager (parent actor) tracks active VTXOs and routes block epochs. +// +//nolint:interfacebloat type VTXOStore interface { // SaveVTXO persists a new VTXO to storage. Called when a VTXO actor is // created. Returns error if a VTXO with the same outpoint already @@ -451,6 +453,13 @@ type VTXOStore interface { ListVTXOsByStatus(ctx context.Context, status VTXOStatus) ([]*Descriptor, error) + // ListSelectionCandidatesByStatus returns the lightweight + // (outpoint, amount, pkScript) projection coin selection runs on. + // Selection happens on every payment and needs only these fields, + // so this avoids decoding full descriptors on the hot path. + ListSelectionCandidatesByStatus(ctx context.Context, + status VTXOStatus) ([]SelectedVTXO, error) + // UpdateVTXOStatus atomically updates a VTXO's status. This is the // primary method for state transitions. UpdateVTXOStatus(ctx context.Context, outpoint wire.OutPoint, diff --git a/vtxo/manager.go b/vtxo/manager.go index fc01eea8b..02516007a 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -883,14 +883,34 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( return nil, 0, fmt.Errorf("target amount must be positive") } - // List live candidates from the store. - candidates, err := m.cfg.Store.ListVTXOsByStatus( + // List live candidates from the store via the lightweight selection + // projection: selection only consumes outpoint, amount, and pkScript, + // so there is no reason to decode full descriptors (taproot script + // reconstruction, policy decode, ancestry load) on this per-payment + // path. The projection rows are wrapped as minimal descriptors so the + // shared largest-first machinery below stays unchanged; these partial + // descriptors never escape this function (the response carries + // SelectedVTXO projections built from the same three fields). + rows, err := m.cfg.Store.ListSelectionCandidatesByStatus( ctx, VTXOStatusLive, ) if err != nil { return nil, 0, fmt.Errorf("list live vtxos: %w", err) } + // Wrap the lightweight projection rows as minimal descriptors so the + // shared largest-first selector can consume them without decoding full + // descriptors. These partial descriptors never escape this function. + candidates := make([]*Descriptor, 0, len(rows)) + for _, row := range rows { + candidates = append(candidates, &Descriptor{ + Outpoint: row.Outpoint, + Amount: row.Amount, + PkScript: row.PkScript, + Status: VTXOStatusLive, + }) + } + // Run largest-first selection through the shared selector. Map its // typed outcomes back onto the manager's liquidity diagnostics: a // dust-change rejection is reported verbatim, while any shortfall From ac85910789aca4c32d931281796cc9ad8e554dae Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 16:43:06 -0500 Subject: [PATCH 3/9] db: memoize the immutable VTXO descriptor decode by outpoint Profiling at ~9.5 payments/s put ListVTXOsByStatus at 32% of all CPU samples, 26% of it in rowToDescriptor: every listing re-parsed both public keys, re-decoded the policy template (twice), and re-derived the taproot output script with live secp256k1 point math for every row, on every call. All of that material is immutable for a given outpoint, since a VTXO's script is bound to its on-chain output. rowToDescriptor now resolves the derived parts through a process-local outpoint-keyed LRU, following the same read-only sharing contract as the existing ancestry tree cache. Mutable row state (status and friends) is read fresh on every call and stamped onto a new Descriptor per row, so listings observe status transitions immediately while the expensive derivation runs once per outpoint. The cache needs no invalidation for the same reason it is correct: an outpoint can never map to different script material. This also makes the ListVTXOs RPC cheap on repeat calls, since the handler decodes through the same store. --- db/vtxo_descriptor_cache.go | 112 ++++++++++++++++++++++++++++++ db/vtxo_store.go | 133 +++++++++++++++++++++++++----------- db/vtxo_store_test.go | 55 +++++++++++++++ 3 files changed, 261 insertions(+), 39 deletions(-) create mode 100644 db/vtxo_descriptor_cache.go diff --git a/db/vtxo_descriptor_cache.go b/db/vtxo_descriptor_cache.go new file mode 100644 index 000000000..103ffdde1 --- /dev/null +++ b/db/vtxo_descriptor_cache.go @@ -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" +) + +// 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] +} + +// 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 +} + +// 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 +} diff --git a/db/vtxo_store.go b/db/vtxo_store.go index fea4a8227..82877a7ab 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -36,6 +36,12 @@ type VTXOPersistenceStore struct { // across repeated list and selection queries. ancestryCache *ancestryTreeCache + // descriptorCache memoizes the immutable derived parts of VTXO rows + // (parsed keys, reconstructed taproot script, policy-resolved expiry) + // by outpoint, so repeated listings skip the per-row secp256k1 and + // policy-decode work. + descriptorCache *vtxoDescriptorCache + // Log is an optional logger for this persistence store. If None, // the store falls back to extracting a logger from context via // build.LoggerFromContext, or uses btclog.Disabled if no logger @@ -54,9 +60,10 @@ func NewVTXOPersistenceStore( ) *VTXOPersistenceStore { return &VTXOPersistenceStore{ - db: db, - clock: c, - ancestryCache: newAncestryTreeCache(), + db: db, + clock: c, + ancestryCache: newAncestryTreeCache(), + descriptorCache: newVTXODescriptorCache(), } } @@ -66,10 +73,11 @@ func NewVTXOPersistenceStoreWithLogger(db BatchedRoundStore, c clock.Clock, log fn.Option[btclog.Logger]) *VTXOPersistenceStore { return &VTXOPersistenceStore{ - db: db, - clock: c, - ancestryCache: newAncestryTreeCache(), - Log: log, + db: db, + clock: c, + ancestryCache: newAncestryTreeCache(), + descriptorCache: newVTXODescriptorCache(), + Log: log, } } @@ -603,17 +611,28 @@ func (s *VTXOPersistenceStore) rowToDescriptor(ctx context.Context, clientKey = desc } - policyTemplate := bytes.Clone(row.PolicyTemplate) - - // Parse operator public key. - var operatorPubkey *btcec.PublicKey - if len(row.OperatorPubkey) > 0 { - key, err := btcec.ParsePubKey(row.OperatorPubkey) + // Resolve the expensive derived parts (parsed keys, taproot script, + // policy-resolved expiry) through the per-outpoint cache. The script + // material is bound to the on-chain output, so an outpoint can never + // map to different derived values; only the mutable row state below + // is read fresh on every call. + derived, ok := s.descriptorCache.get(outpoint) + if !ok { + var err error + derived, err = s.deriveDescriptorParts(ctx, row, outpoint) if err != nil { - return nil, fmt.Errorf("parse operator pubkey: %w", err) + return nil, err } - operatorPubkey = key + if err := s.descriptorCache.put(outpoint, derived); err != nil { + // A put failure only costs a future re-derivation. + s.logger(ctx).WarnS( + ctx, + "Failed to cache VTXO descriptor parts", + err, + slog.String("outpoint", outpoint.String()), + ) + } } // Load ancestry tree fragments from the side table. Round-direct @@ -639,6 +658,58 @@ func (s *VTXOPersistenceStore) rowToDescriptor(ctx context.Context, } } + // Parse commitment txid. + var commitmentTxID chainhash.Hash + if len(row.CommitmentTxid) == chainhash.HashSize { + copy(commitmentTxID[:], row.CommitmentTxid) + } + + if clientKey.PubKey == nil { + clientKey.PubKey = derived.clientPubkey + } + + return &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: btcutil.Amount(row.Amount), + PolicyTemplate: derived.policyTemplate, + PkScript: row.PkScript, + ClientKey: clientKey, + OperatorKey: derived.operatorPubkey, + TapScript: derived.tapscript, + Ancestry: ancestry, + RoundID: row.RoundID, + CommitmentTxID: commitmentTxID, + BatchExpiry: row.BatchExpiry, + RelativeExpiry: derived.relativeExpiry, + ChainDepth: int(row.ChainDepth), + CreatedHeight: row.CreatedHeight, + Status: vtxo.VTXOStatus(row.Status), + }, nil +} + +// deriveDescriptorParts computes the immutable derived parts of one VTXO row: +// the parsed public keys, the reconstructed taproot script, and the +// policy-resolved relative expiry. This is the expensive slice of descriptor +// decoding (secp256k1 point math, policy template decode), pulled out so +// rowToDescriptor can memoize it per outpoint. +func (s *VTXOPersistenceStore) deriveDescriptorParts(ctx context.Context, + row VTXORow, outpoint wire.OutPoint) (*vtxoDescriptorCacheValue, + error) { + + var clientPubkey *btcec.PublicKey + policyTemplate := bytes.Clone(row.PolicyTemplate) + + // Parse operator public key. + var operatorPubkey *btcec.PublicKey + if len(row.OperatorPubkey) > 0 { + key, err := btcec.ParsePubKey(row.OperatorPubkey) + if err != nil { + return nil, fmt.Errorf("parse operator pubkey: %w", err) + } + + operatorPubkey = key + } + // Reconstruct the TapScript from the semantic policy when // the descriptor uses the standard Ark VTXO shape. Custom // policies keep TapScript nil and rely on explicit spend @@ -671,8 +742,8 @@ func (s *VTXOPersistenceStore) rowToDescriptor(ctx context.Context, operatorPubkey = params.OperatorKey } - if clientKey.PubKey == nil { - clientKey.PubKey = params.OwnerKey + if clientPubkey == nil { + clientPubkey = params.OwnerKey } // Warn on expiry drift between the stored column @@ -708,28 +779,12 @@ func (s *VTXOPersistenceStore) rowToDescriptor(ctx context.Context, } } - // Parse commitment txid. - var commitmentTxID chainhash.Hash - if len(row.CommitmentTxid) == chainhash.HashSize { - copy(commitmentTxID[:], row.CommitmentTxid) - } - - return &vtxo.Descriptor{ - Outpoint: outpoint, - Amount: btcutil.Amount(row.Amount), - PolicyTemplate: policyTemplate, - PkScript: row.PkScript, - ClientKey: clientKey, - OperatorKey: operatorPubkey, - TapScript: tapscript, - Ancestry: ancestry, - RoundID: row.RoundID, - CommitmentTxID: commitmentTxID, - BatchExpiry: row.BatchExpiry, - RelativeExpiry: relativeExpiry, - ChainDepth: int(row.ChainDepth), - CreatedHeight: row.CreatedHeight, - Status: vtxo.VTXOStatus(row.Status), + return &vtxoDescriptorCacheValue{ + clientPubkey: clientPubkey, + operatorPubkey: operatorPubkey, + policyTemplate: policyTemplate, + tapscript: tapscript, + relativeExpiry: relativeExpiry, }, nil } diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 684e68fee..13b0c872a 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -288,6 +288,60 @@ func TestVTXOPersistenceStoreSaveAndGet(t *testing.T) { ) } +// TestVTXODescriptorDecodeMemoization verifies the per-outpoint descriptor +// decode cache: repeated listings return equal descriptors whose immutable +// derived parts (taproot script and parsed operator key) are the SAME shared +// objects, while mutable row state (status) is read fresh on every call. +func TestVTXODescriptorDecodeMemoization(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + roundID := testRoundIDDB("test-round-decode-memo") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, state)) + + desc := createTestVTXODescriptor(t, roundID, 7) + require.NoError(t, vtxoStore.SaveVTXO(ctx, desc)) + + first, err := vtxoStore.ListVTXOsByStatus(ctx, vtxo.VTXOStatusLive) + require.NoError(t, err) + require.Len(t, first, 1) + + second, err := vtxoStore.ListVTXOsByStatus(ctx, vtxo.VTXOStatusLive) + require.NoError(t, err) + require.Len(t, second, 1) + + // Equal contents across calls. + require.Equal(t, first[0], second[0]) + + // The derived parts must be memoized: pointer identity proves the + // second listing skipped the re-derivation. Client keys are hydrated + // through the internal key registry, so equality above covers them. + require.Same(t, first[0].TapScript, second[0].TapScript) + require.Same(t, first[0].OperatorKey, second[0].OperatorKey) + + // Mutable row state is read fresh: a status flip shows up on the next + // listing while the derived parts stay the same shared objects. + require.NoError( + t, vtxoStore.UpdateVTXOStatus( + ctx, desc.Outpoint, vtxo.VTXOStatusSpent, + ), + ) + + spent, err := vtxoStore.ListVTXOsByStatus(ctx, vtxo.VTXOStatusSpent) + require.NoError(t, err) + require.Len(t, spent, 1) + require.Equal(t, vtxo.VTXOStatusSpent, spent[0].Status) + require.Same(t, first[0].TapScript, spent[0].TapScript) + require.Same(t, first[0].OperatorKey, spent[0].OperatorKey) +} + // TestListSelectionCandidatesByStatus verifies the lightweight selection // projection agrees with the full descriptor listing on outpoint, amount, // and pkScript. @@ -347,6 +401,7 @@ func TestListSelectionCandidatesByStatus(t *testing.T) { require.Len(t, candidates, 1) require.Equal(t, descB.Outpoint, candidates[0].Outpoint) } + // addAncestryFragment appends a synthetic ancestry fragment to a // Descriptor under construction so multi-tree round-trip tests can // build N>1 ancestry layouts without re-implementing the per-fragment From 885780020a6b1304eec38c7809a0bdc4a6f43948 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 16:53:17 -0500 Subject: [PATCH 4/9] vtxo: self-heal actorless live VTXOs during coin selection The materialized-VTXO notification that spawns a new VTXO's actor is delivered asynchronously after the producing OOR session's commit, so a coin selection racing that window observes the committed Live row before the actor exists and fails the payment with "no actor for outpoint". A 1000-payment stress run at ~9.3 payments/s hit the window twice; a notification lost outright (e.g. to a crash) used to strand the liquidity until restart. The store row is the source of truth, so a missing resident actor must never make committed liquidity unusable. The reserve loop now respawns the actor from the persisted descriptor on a map miss, on the manager goroutine where the actors map is owned. The respawn fails closed: a store miss or a no-longer-live row surfaces as a normal reservation error, and no actor is registered for liquidity that is not spendable. --- vtxo/manager.go | 61 ++++++++++++++++++++++++++++++++-- vtxo/manager_admission_test.go | 46 +++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/vtxo/manager.go b/vtxo/manager.go index 02516007a..1f8198ccc 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -817,6 +817,46 @@ func (m *Manager) handleRelayToRound(ctx context.Context, } // spawnVTXOActor creates a new VTXO FSM actor. +// respawnActorFromStore self-heals a live-in-DB but actorless VTXO by +// spawning its actor from the persisted descriptor. The materialized-VTXO +// notification that normally spawns the actor is delivered asynchronously +// after the producing session's commit, so a coin selection racing that +// window (or a notification lost to a crash before the next restart) can +// observe the committed row before the actor exists. The row is the source +// of truth; a missing actor must never make the liquidity unusable. Runs on +// the manager goroutine, so the actors map mutation is safe. +func (m *Manager) respawnActorFromStore(ctx context.Context, + outpoint wire.OutPoint) (VTXOActorRef, error) { + + desc, err := m.cfg.Store.GetVTXO(ctx, outpoint) + if err != nil { + return nil, fmt.Errorf("load descriptor: %w", err) + } + + // Only a Live row is a valid selection candidate; anything else means + // the candidate list went stale between the listing and this reserve, + // which the caller handles as a normal reservation failure. + if desc.Status != VTXOStatusLive { + return nil, fmt.Errorf("descriptor status %v is not live", + desc.Status) + } + + ref, err := m.spawnVTXOActor(ctx, desc) + if err != nil { + return nil, fmt.Errorf("respawn actor: %w", err) + } + + m.actors[outpoint] = ref + + m.logger(ctx).InfoS(ctx, "Respawned VTXO actor from store during "+ + "selection", + slog.String("outpoint", outpoint.String()), + slog.Int64("amount", int64(desc.Amount)), + ) + + return ref, nil +} + func (m *Manager) spawnVTXOActor(ctx context.Context, vtxo *Descriptor) ( VTXOActorRef, error) { @@ -946,10 +986,25 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( for _, vtxo := range selected { ref, ok := m.actors[vtxo.Outpoint] if !ok { - p.rollback(ctx, reserved) + // The row is committed Live but the actor is not + // resident yet: the materialized-VTXO notification is + // delivered asynchronously after the OOR session's + // commit, so a selection racing that window (or a + // notification lost to a crash) sees the row before + // the actor. The store row is the source of truth, so + // self-heal by spawning the actor from it instead of + // failing the payment. + lazyRef, err := m.respawnActorFromStore( + ctx, vtxo.Outpoint, + ) + if err != nil { + p.rollback(ctx, reserved) + + return nil, 0, fmt.Errorf("no actor for "+ + "outpoint %s: %w", vtxo.Outpoint, err) + } - return nil, 0, fmt.Errorf("no actor for outpoint %s", - vtxo.Outpoint) + ref = lazyRef } result := p.ask(ctx, ref, p.reserveEvent) diff --git a/vtxo/manager_admission_test.go b/vtxo/manager_admission_test.go index 478dd0155..9c7dd71bf 100644 --- a/vtxo/manager_admission_test.go +++ b/vtxo/manager_admission_test.go @@ -212,6 +212,52 @@ func TestSelectAndReserveSpendSuccess(t *testing.T) { require.True(t, ok, "expected SpendingState, got %T", ref.state) } +// TestSelectAndReserveRespawnGuards verifies the self-heal path for a +// live-in-DB but actorless selection candidate fails closed: a store miss +// and a non-live row both surface as reservation errors instead of spawning +// an actor for liquidity that is not actually spendable. +func TestSelectAndReserveRespawnGuards(t *testing.T) { + t.Parallel() + + vtxo1 := makeDescriptor(t, 50000, 0) + + // The manager has NO resident actor for the candidate. + mgr, store := newTestManager(t, nil) + + store.On( + "ListVTXOsByStatus", t.Context(), VTXOStatusLive, + ).Return([]*Descriptor{vtxo1}, nil) + + // First attempt: the row vanished between listing and reserve. + store.On("GetVTXO", t.Context(), vtxo1.Outpoint).Return( + nil, fmt.Errorf("not found"), + ).Once() + + result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 40000, + }) + _, err := result.Unpack() + require.ErrorContains(t, err, "no actor for outpoint") + require.ErrorContains(t, err, "not found") + + // Second attempt: the row exists but is no longer live, so the + // candidate list was stale; the respawn must refuse to spawn. + spent := *vtxo1 + spent.Status = VTXOStatusSpent + store.On("GetVTXO", t.Context(), vtxo1.Outpoint).Return( + &spent, nil, + ).Once() + + result = mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ + TargetAmount: 40000, + }) + _, err = result.Unpack() + require.ErrorContains(t, err, "is not live") + + // No actor may have been registered by either failed attempt. + require.Empty(t, mgr.actors) +} + // TestSelectAndReserveSpendMultipleVTXOs verifies that coin selection picks // multiple VTXOs when no single VTXO covers the target. func TestSelectAndReserveSpendMultipleVTXOs(t *testing.T) { From d8769dee3b027b492113e2ef2a2786670a50fb13 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 17:34:23 -0500 Subject: [PATCH 5/9] vtxo: deliver spend reservations tell-style off the manager turn Profiling after the listing levers showed the system wait-bound, not CPU-bound: 9.5 payments/s with 16 workers works out to an effective parallelism of ~1.4 by Little's law, with zero pacing and zero liquidity waits. The serializer is the manager goroutine, which held every payment's selection turn across a synchronous Ask per selected input, each one a mailbox hop plus an FSM turn plus a write transaction committing VTXOStatusSpending. Spend reservations are now detached. The manager marks the outpoint in a goroutine-owned in-memory reservation map, issues the Ask, and observes the child's future via OnComplete on a detached goroutine instead of awaiting it, so the selection turn ends in microseconds. A failed reservation (the candidate raced out of LiveState) hops back as a manager-internal message that drops the mark; the owning session then fails at signing or submit and retries through the existing machinery. The map gates both spend and forfeit admission ahead of the durable status, since a detached Spending write may still be in flight when the next selection lists candidates. Crash safety rests where it already rested: the spending_reservations index is written by the OOR session's checkpoint, and the startup sweep releases Spending rows with no reservation row. The sweep gains the reverse reconcile for the new write ordering: a reservation row whose VTXO row is still Live means the owner checkpointed before the detached status write landed, so the boot re-marks the reservation and re-drives the reserve event. The forfeit path keeps its synchronous ask: round participation wants the durable state settled before proceeding, and it is not on the payment hot path. Admission tests are adapted to the detached contract, including the asynchronous failure hop-back and the post-restart actor-level rejection arm. --- vtxo/manager.go | 313 ++++++++++++++++++++++++++++++- vtxo/manager_admission_test.go | 158 ++++++++++++++-- vtxo/manager_reservation_test.go | 55 ++++++ vtxo/messages.go | 27 +++ 4 files changed, 533 insertions(+), 20 deletions(-) diff --git a/vtxo/manager.go b/vtxo/manager.go index 1f8198ccc..843f40182 100644 --- a/vtxo/manager.go +++ b/vtxo/manager.go @@ -146,6 +146,28 @@ type Manager struct { // actors tracks active VTXO actors by outpoint. actors map[wire.OutPoint]VTXOActorRef + // reserved is the manager-goroutine-owned admission gate for spend + // reservations. An entry means the outpoint was handed to a spend + // session this process lifetime and must not be selected again, even + // though the VTXO actor's durable Spending status write may still be + // in flight: the spend reserve is delivered tell-style (an Ask whose + // future is observed via OnComplete rather than awaited), so the + // manager turn no longer blocks on the per-input FSM write + // transaction. Entries are dropped on release, completion, terminal + // notification, or an asynchronous reservation failure. Only the + // manager goroutine touches the map. + // + // The value is a monotonic reservation epoch stamped by markReserved. + // The asynchronous failure hop-back carries the epoch it observed and + // only drops the mark when it still matches, so a stale failure from a + // released-then-re-reserved outpoint (ABA) cannot un-gate a mark a + // newer reservation owns. + reserved map[wire.OutPoint]uint64 + + // reserveEpoch is the monotonic counter stamped into the reserved map + // on each markReserved. Manager-goroutine-owned, like the map. + reserveEpoch uint64 + // liveDescriptors snapshots the live VTXO descriptors recovered // from the store during Start. The list is the source of truth for // daemon-local subsystems that need to re-arm per-VTXO state on @@ -162,9 +184,56 @@ func NewManager(cfg *ManagerConfig) *Manager { } return &Manager{ - cfg: cfg, - actors: make(map[wire.OutPoint]VTXOActorRef), + cfg: cfg, + actors: make(map[wire.OutPoint]VTXOActorRef), + reserved: make(map[wire.OutPoint]uint64), + } +} + +// markReserved records an in-memory spend reservation and returns the +// monotonic epoch stamped for it. The epoch lets an asynchronous failure +// hop-back distinguish the reservation it observed from a later one on the +// same outpoint (see dropReservedEpoch). Nil-safe so test fixtures that build +// a Manager literal without NewManager keep working. +func (m *Manager) markReserved(op wire.OutPoint) uint64 { + if m.reserved == nil { + m.reserved = make(map[wire.OutPoint]uint64) } + + m.reserveEpoch++ + m.reserved[op] = m.reserveEpoch + + return m.reserveEpoch +} + +// dropReserved clears an in-memory spend reservation, if present. Used by the +// synchronous, in-turn paths (rollback, release, completion, terminal +// notification) where no concurrent re-reservation can have intervened. +func (m *Manager) dropReserved(op wire.OutPoint) { + delete(m.reserved, op) +} + +// dropReservedEpoch clears an in-memory spend reservation only if its current +// epoch still matches the one the caller observed. The asynchronous reserve +// failure hop-back uses this so a stale failure (the outpoint was released and +// re-reserved by a newer session before the failure landed) cannot drop the +// newer reservation's mark. +func (m *Manager) dropReservedEpoch(op wire.OutPoint, epoch uint64) bool { + if cur, ok := m.reserved[op]; !ok || cur != epoch { + return false + } + + delete(m.reserved, op) + + return true +} + +// isReserved reports whether the outpoint holds an in-memory spend +// reservation. +func (m *Manager) isReserved(op wire.OutPoint) bool { + _, ok := m.reserved[op] + + return ok } // logger returns the configured logger or falls back to extracting from @@ -197,6 +266,118 @@ func (m *Manager) askVTXOActor(ctx context.Context, ref VTXOActorRef, return ref.Ask(ctx, msg).Await(ctx) } +// detachedReserveTimeout bounds the asynchronous observation of a detached +// spend reservation's outcome. Generous on purpose: it only has to outlive a +// loaded child actor's FSM turn plus its write transaction. +const detachedReserveTimeout = 30 * time.Second + +// detachedReserve hands a spend reservation to a child VTXO actor without +// blocking the manager turn on the child's FSM write transaction. The Ask +// enqueues and returns a future immediately; the outcome is observed on a +// detached goroutine via OnComplete. A failure (the candidate raced out of +// LiveState, or the child died) hops back to the manager goroutine as a +// spendReservationFailedMsg so the in-memory reservation mark is dropped +// where the map is owned. The observation context is daemon-owned: the turn +// context must not cancel the outcome watch. The epoch is the reservation +// generation observed at mark time; it rides the failure hop-back so the +// manager only drops a mark this reservation still owns. +func (m *Manager) detachedReserve(ctx context.Context, ref VTXOActorRef, + op wire.OutPoint, epoch uint64, event actormsg.VTXOActorMsg, + label string) { + + log := m.logger(ctx) + managerRef := m.managerRef + + // The observation context is daemon-owned: the turn context must not + // cancel the outcome watch. askCtx bounds how long the OnComplete + // goroutine waits on the child's outcome so a wedged child cannot leak + // the watcher. The failure report, however, runs on a fresh bounded + // context derived from the same detached root rather than askCtx: + // reporting on askCtx would drop the spendReservationFailedMsg the + // instant the wait exhausted askCtx's budget, stranding the in-memory + // reservation mark until restart. + detachedCtx := context.WithoutCancel(ctx) + askCtx, cancel := context.WithTimeout( + detachedCtx, detachedReserveTimeout, + ) + + future := ref.Ask(askCtx, event) + future.OnComplete( + askCtx, func(res fn.Result[actormsg.VTXOActorResp]) { + defer cancel() + + _, err := res.Unpack() + if err == nil { + return + } + + // A watch timeout is ambiguous: the child's FSM write + // may still be in flight (and about to commit Spending) + // rather than having failed. Reporting a failure here + // would drop a mark whose durable Spending write then + // lands, briefly re-opening the in-flight window the + // mark exists to cover. Re-confirm the durable status + // before treating a timeout as a failure: only a + // still-Live row means the reserve never took effect. A + // read error or a still-Live row falls through to + // report the failure so the mark cannot leak. + if errors.Is(err, context.DeadlineExceeded) || + errors.Is(err, context.Canceled) { + + desc, gErr := m.cfg.Store.GetVTXO( + detachedCtx, op, + ) + if gErr == nil && + desc.Status != VTXOStatusLive { + + log.DebugS(detachedCtx, "Detached reserve "+ + "watch timed out but VTXO advanced "+ + "past Live; keeping reservation", + slog.String( + "outpoint", op.String(), + ), + slog.String( + "status", + desc.Status.String(), + ), + ) + + return + } + } + + log.WarnS( + detachedCtx, + label+" detached reserve failed", + err, + slog.String("outpoint", op.String()), + ) + + if managerRef == nil { + return + } + + reportCtx, reportCancel := context.WithTimeout( + detachedCtx, detachedReserveTimeout, + ) + defer reportCancel() + + tellErr := managerRef.Tell( + reportCtx, &spendReservationFailedMsg{ + Outpoint: op, + Epoch: epoch, + }, + ) + if tellErr != nil { + log.WarnS(detachedCtx, "Failed to report "+ + "detached reserve failure", tellErr, + slog.String("outpoint", op.String()), + ) + } + }, + ) +} + // askForfeitVTXOActor asks a child VTXO actor with the manager's bounded // forfeit timeout. The parent context is still honored, but a blocked // refresh/forfeit child actor can only hold the shared manager admission point @@ -360,6 +541,22 @@ func (m *Manager) Receive(ctx context.Context, case *round.VTXOTerminatedMsg: return m.handleVTXOTerminated(ctx, req) + case *spendReservationFailedMsg: + // The detached reserve's outcome watcher reports a failed + // reservation; drop the in-memory mark on the goroutine that + // owns the map so the liquidity becomes selectable again. The + // drop is epoch-guarded: a stale failure whose outpoint was + // released and re-reserved by a newer session before the report + // landed must not un-gate the newer reservation's mark. + dropped := m.dropReservedEpoch(req.Outpoint, req.Epoch) + + m.logger(ctx).InfoS(ctx, "Released failed spend reservation", + slog.String("outpoint", req.Outpoint.String()), + slog.Bool("dropped", dropped), + ) + + return fn.Ok[ManagerResp](&ReleaseSpendResponse{}) + case *RelayToRoundMsg: return m.handleRelayToRound(ctx, req) @@ -756,6 +953,7 @@ func (m *Manager) handleVTXOTerminated(ctx context.Context, msg *round.VTXOTerminatedMsg) fn.Result[ManagerResp] { delete(m.actors, msg.Outpoint) + m.dropReserved(msg.Outpoint) m.logger(ctx).InfoS(ctx, "VTXO actor terminated", slog.String("outpoint", msg.Outpoint.String()), @@ -909,6 +1107,15 @@ type reserveParams struct { ask func(context.Context, VTXOActorRef, actormsg.VTXOActorMsg) fn.Result[actormsg.VTXOActorResp] label string + + // detached delivers the reservation tell-style: the manager marks + // the outpoint in its in-memory reservation map, issues the Ask, and + // observes the child's future via OnComplete instead of awaiting it, + // so the manager turn never blocks on the per-input FSM write + // transaction. The spend path enables this; the forfeit path keeps + // the synchronous ask because round participation wants the durable + // state settled before it proceeds. + detached bool } // selectAndReserveVTXOs performs largest-first coin selection and @@ -941,8 +1148,18 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( // Wrap the lightweight projection rows as minimal descriptors so the // shared largest-first selector can consume them without decoding full // descriptors. These partial descriptors never escape this function. + // + // The in-memory reservation map gates admission ahead of the durable + // status: a detached spend reservation's Spending write may still be + // in flight, so a row can read Live here while the outpoint is + // already owned by a spend session. Both the spend and the forfeit + // selection paths funnel through this filter. candidates := make([]*Descriptor, 0, len(rows)) for _, row := range rows { + if m.isReserved(row.Outpoint) { + continue + } + candidates = append(candidates, &Descriptor{ Outpoint: row.Outpoint, Amount: row.Amount, @@ -1007,6 +1224,24 @@ func (m *Manager) selectAndReserveVTXOs(ctx context.Context, p reserveParams) ( ref = lazyRef } + // Detached (spend) path: mark the in-memory reservation and + // hand the FSM event to the child without awaiting its write + // transaction. The outcome is observed asynchronously; a + // failed reservation hops back as a manager message that + // drops the mark, and the owning session's spend then fails + // at signing/submit and retries through the normal machinery. + if p.detached { + epoch := m.markReserved(vtxo.Outpoint) + m.detachedReserve( + ctx, ref, vtxo.Outpoint, epoch, p.reserveEvent, + p.label, + ) + + reserved = append(reserved, vtxo.Outpoint) + + continue + } + result := p.ask(ctx, ref, p.reserveEvent) if _, err := result.Unpack(); err != nil { m.logger(ctx).WarnS( @@ -1104,6 +1339,7 @@ func (m *Manager) handleSelectAndReserveSpend(ctx context.Context, rollback: m.rollbackSpend, ask: m.askVTXOActor, label: "spend", + detached: true, }) if err != nil { return fn.Err[ManagerResp](err) @@ -1124,6 +1360,12 @@ func (m *Manager) rollbackSpend(ctx context.Context, defer cancel() for _, op := range outpoints { + // Per-actor mailbox FIFO guarantees the release lands after + // any detached reserve already queued for the same actor, so + // dropping the in-memory mark here cannot resurrect a + // half-reserved outpoint. + m.dropReserved(op) + ref, ok := m.actors[op] if !ok { continue @@ -1168,10 +1410,13 @@ func (m *Manager) sweepOrphanedReservations(ctx context.Context) { return } - if len(spending) == 0 { - return - } - + // Note: do not early-return when there are no Spending VTXOs. The + // reverse-direction recovery below re-drives reservation rows whose + // VTXO is still Live (the owning session checkpointed its reservation + // but the detached SpendingState write never landed before the + // shutdown). In exactly that case the spending set is empty, so a + // len(spending) == 0 short-circuit here would skip recovery and leave + // the live input selectable by another spend. reserved, err := m.cfg.ReservationStore.ListReservedOutpoints(ctx) if err != nil { // Never release on incomplete info: an unreadable reservation @@ -1220,10 +1465,50 @@ func (m *Manager) sweepOrphanedReservations(ctx context.Context) { released++ } + // Reverse direction: a reservation row whose VTXO row is NOT in + // SpendingState means the owning session checkpointed but the + // detached Spending status write never landed before the shutdown. + // The session resumes on this boot and still owns the input, so + // re-mark the in-memory reservation and re-drive the reserve event + // to converge the durable status. Without this, a restarted daemon + // could select an input an in-flight session owns. + spendingSet := fn.NewSet[wire.OutPoint]() + for _, desc := range spending { + spendingSet.Add(desc.Outpoint) + } + + var redriven int + for _, op := range reserved { + if spendingSet.Contains(op) { + continue + } + + ref, ok := m.actors[op] + if !ok { + // Terminal row (the spend completed) or unknown; the + // owning session's completion path reconciles it. + continue + } + + m.markReserved(op) + + if err := ref.Tell(ctx, &SpendReserveEvent{}); err != nil { + m.logger(ctx).WarnS( + ctx, + "Reservation sweep: re-drive reserve failed", + err, + slog.String("outpoint", op.String()), + ) + } + + redriven++ + } + m.logger(ctx).InfoS(ctx, "Reservation sweep complete", slog.Int("spending", len(spending)), slog.Int("reserved", len(reserved)), slog.Int("released", released), + slog.Int("redriven", redriven), ) } @@ -1269,6 +1554,7 @@ func (m *Manager) handleReleaseSpend(ctx context.Context, // the same transaction as the status change (see the VTXO // actor's processStatusUpdate), so no separate delete is needed // here. + m.dropReserved(op) released++ } @@ -1301,6 +1587,7 @@ func (m *Manager) handleCompleteSpend(ctx context.Context, return fn.Err[ManagerResp](err) } if spent { + m.dropReserved(op) completed++ continue @@ -1323,6 +1610,7 @@ func (m *Manager) handleCompleteSpend(ctx context.Context, // the same transaction as the status change (see the VTXO // actor's processStatusUpdate), so no separate delete is needed // here. + m.dropReserved(op) completed++ } @@ -1378,13 +1666,24 @@ func (m *Manager) handleReserveForfeit(ctx context.Context, outpoints := dedupOutpoints(req.Outpoints) - // Validate all outpoints are known before attempting reservation. + // Validate all outpoints are known before attempting reservation, + // and refuse outpoints holding an in-memory spend reservation whose + // durable Spending write may still be in flight: the child's FSM + // would otherwise still read LiveState and accept a conflicting + // forfeit reservation. for _, op := range outpoints { if _, ok := m.actors[op]; !ok { return fn.Err[ManagerResp]( fmt.Errorf("no actor for outpoint %s", op), ) } + + if m.isReserved(op) { + return fn.Err[ManagerResp]( + fmt.Errorf("%w: outpoint %s is spend-reserved", + ErrVTXOLiquidityLocked, op), + ) + } } // Reserve each VTXO. Track successes for rollback on failure. diff --git a/vtxo/manager_admission_test.go b/vtxo/manager_admission_test.go index 9c7dd71bf..07be79d97 100644 --- a/vtxo/manager_admission_test.go +++ b/vtxo/manager_admission_test.go @@ -5,6 +5,7 @@ import ( "database/sql" "errors" "fmt" + "sync" "testing" "time" @@ -91,6 +92,37 @@ func (m *mockVTXOActorRef) Ask(ctx context.Context, // Compile-time check that mockVTXOActorRef implements VTXOActorRef. var _ VTXOActorRef = (*mockVTXOActorRef)(nil) +// capturingManagerRef records manager-bound Tells so admission tests can +// observe the detached reserve failure hop-back and replay it into Receive +// deterministically (the real manager would consume it on its own goroutine). +type capturingManagerRef struct { + mu sync.Mutex + msgs []ManagerMsg +} + +// ID returns the capture stub's identifier. +func (c *capturingManagerRef) ID() string { return "manager-capture" } + +// Tell records the message. +func (c *capturingManagerRef) Tell(_ context.Context, msg ManagerMsg) error { + c.mu.Lock() + defer c.mu.Unlock() + c.msgs = append(c.msgs, msg) + + return nil +} + +// captured returns a snapshot of the recorded messages. +func (c *capturingManagerRef) captured() []ManagerMsg { + c.mu.Lock() + defer c.mu.Unlock() + + return append([]ManagerMsg(nil), c.msgs...) +} + +// Compile-time check that capturingManagerRef is a manager TellOnlyRef. +var _ actor.TellOnlyRef[ManagerMsg] = (*capturingManagerRef)(nil) + // blockingVTXOActorRef returns asks that never complete on their own. Awaiters // are unblocked only by their context, which lets admission tests verify that // the manager bounds child actor asks. @@ -709,8 +741,10 @@ func TestReserveForfeitRejectedWhenSpending(t *testing.T) { _, err := result.Unpack() require.NoError(t, err) - // Now try to reserve both for forfeit — vtxo1 succeeds, vtxo2 - // fails because it's Spending. vtxo1 should be rolled back. + // While the in-memory spend reservation is held, a forfeit reserve + // is rejected up front, before any child actor is touched: the + // detached Spending write may still be in flight, so the durable + // state cannot be trusted for this admission decision. result = mgr.Receive(t.Context(), &ReserveForfeitRequest{ Outpoints: []wire.OutPoint{ vtxo1.Outpoint, vtxo2.Outpoint, @@ -718,15 +752,33 @@ func TestReserveForfeitRejectedWhenSpending(t *testing.T) { }) _, err = result.Unpack() require.Error(t, err) - require.Contains(t, err.Error(), "cannot accept pending forfeit") + require.ErrorIs(t, err, ErrVTXOLiquidityLocked) + require.Contains(t, err.Error(), "spend-reserved") - // vtxo1 should be rolled back to LiveState. + // vtxo1 was never reserved, so its actor is untouched LiveState. refAny, ok := mgr.actors[vtxo1.Outpoint] require.True(t, ok, "actor not found for vtxo1") ref1, ok := refAny.(*mockVTXOActorRef) require.True(t, ok, "expected *mockVTXOActorRef, got %T", refAny) + _, ok = ref1.state.(*LiveState) + require.True(t, ok, "expected LiveState, got %T", ref1.state) + + // Post-restart shape: the in-memory mark is gone but the child's + // durable Spending state survived. The actor-level rejection then + // guards the same invariant, and vtxo1 is rolled back to Live. + mgr.dropReserved(vtxo2.Outpoint) + + result = mgr.Receive(t.Context(), &ReserveForfeitRequest{ + Outpoints: []wire.OutPoint{ + vtxo1.Outpoint, vtxo2.Outpoint, + }, + }) + _, err = result.Unpack() + require.Error(t, err) + require.Contains(t, err.Error(), "cannot accept pending forfeit") + _, ok = ref1.state.(*LiveState) require.True( t, ok, "expected LiveState after rollback, got %T", ref1.state, @@ -814,6 +866,9 @@ func TestSpendReserveRejectedWhenPendingForfeit(t *testing.T) { vtxo1 := makeDescriptor(t, 50000, 0) mgr, store := newTestManager(t, []*Descriptor{vtxo1}) + capture := &capturingManagerRef{} + mgr.managerRef = capture + // Reserve for forfeit first. result := mgr.Receive(t.Context(), &ReserveForfeitRequest{ Outpoints: []wire.OutPoint{vtxo1.Outpoint}, @@ -822,7 +877,9 @@ func TestSpendReserveRejectedWhenPendingForfeit(t *testing.T) { require.NoError(t, err) // Now try to select for spend — store still lists it as Live - // (store is a mock), but the actor will reject. + // (store is a mock). The detached reserve admits optimistically, + // the FSM rejects the reservation, and the failure hops back to + // the manager asynchronously instead of failing the selection. store.On( "ListVTXOsByStatus", t.Context(), VTXOStatusLive, ).Return([]*Descriptor{vtxo1}, nil) @@ -831,9 +888,29 @@ func TestSpendReserveRejectedWhenPendingForfeit(t *testing.T) { TargetAmount: 40000, }) _, err = result.Unpack() - require.Error(t, err) - require.ErrorIs(t, err, ErrVTXOLiquidityLocked) - require.Contains(t, err.Error(), "cannot reserve for spend") + require.NoError(t, err) + + // The FSM rejected the reserve, so the actor stays pending-forfeit. + refAny := mgr.actors[vtxo1.Outpoint] + ref, ok := refAny.(*mockVTXOActorRef) + require.True(t, ok) + _, ok = ref.state.(*PendingForfeitState) + require.True(t, ok, "expected PendingForfeitState, got %T", ref.state) + + // The failure report arrives on the capture; replaying it into the + // manager drops the optimistic in-memory mark. + require.True(t, mgr.isReserved(vtxo1.Outpoint)) + require.Eventually(t, func() bool { + return len(capture.captured()) == 1 + }, 5*time.Second, 10*time.Millisecond) + + failed, ok := capture.captured()[0].(*spendReservationFailedMsg) + require.True(t, ok) + require.Equal(t, vtxo1.Outpoint, failed.Outpoint) + + result = mgr.Receive(t.Context(), failed) + require.True(t, result.IsOk()) + require.False(t, mgr.isReserved(vtxo1.Outpoint)) } // TestReleaseForfeit verifies that releasing a forfeit reservation returns @@ -1154,29 +1231,61 @@ func TestRollbackOnPartialSpendFailure(t *testing.T) { RequestedAtHeight: 0, } + capture := &capturingManagerRef{} + mgr.managerRef = capture + // Selection returns both (store doesn't know about FSM state). store.On( "ListVTXOsByStatus", t.Context(), VTXOStatusLive, ).Return([]*Descriptor{vtxo1, vtxo2}, nil) + // The detached reserve admits both optimistically: vtxo1's FSM + // accepts and moves to Spending; vtxo2's FSM rejects, and the + // failure hops back asynchronously rather than aborting selection. result := mgr.Receive(t.Context(), &SelectAndReserveSpendRequest{ TargetAmount: 50000, }) _, err := result.Unpack() - require.Error(t, err) - require.ErrorIs(t, err, ErrVTXOLiquidityLocked) + require.NoError(t, err) - // vtxo1 should be rolled back to LiveState. refAny1, ok := mgr.actors[vtxo1.Outpoint] require.True(t, ok, "actor not found for vtxo1") ref1, ok := refAny1.(*mockVTXOActorRef) require.True(t, ok, "expected *mockVTXOActorRef, got %T", refAny1) + _, ok = ref1.state.(*SpendingState) + require.True(t, ok, "expected SpendingState, got %T", ref1.state) + + // vtxo2's failure report arrives and releases only its mark. + require.Eventually(t, func() bool { + return len(capture.captured()) == 1 + }, 5*time.Second, 10*time.Millisecond) + + failed, ok := capture.captured()[0].(*spendReservationFailedMsg) + require.True(t, ok) + require.Equal(t, vtxo2.Outpoint, failed.Outpoint) + + result = mgr.Receive(t.Context(), failed) + require.True(t, result.IsOk()) + require.False(t, mgr.isReserved(vtxo2.Outpoint)) + require.True(t, mgr.isReserved(vtxo1.Outpoint)) + + // The owning session's failure path performs the rollback: releasing + // the batch returns vtxo1 to LiveState (vtxo2's release errors + // benignly since its FSM never left PendingForfeit) and drops the + // remaining mark. + result = mgr.Receive(t.Context(), &ReleaseSpendRequest{ + Outpoints: []wire.OutPoint{vtxo1.Outpoint, vtxo2.Outpoint}, + }) + _, err = result.Unpack() + require.Error(t, err) + _, ok = ref1.state.(*LiveState) require.True( t, ok, "expected LiveState after rollback, got %T", ref1.state, ) + require.False(t, mgr.isReserved(vtxo1.Outpoint)) } // ============================================================================= @@ -1349,20 +1458,43 @@ func TestRecoveredPendingForfeitRejectsSpend(t *testing.T) { }, ) + capture := &capturingManagerRef{} + mgr.managerRef = capture + // Store returns the VTXO as a live candidate (the store // doesn't filter by FSM state, only by persisted status). store.On( "ListVTXOsByStatus", t.Context(), VTXOStatusLive, ).Return([]*Descriptor{vtxo}, nil) - // Spend selection should fail because the actor rejects it. + // The detached reserve admits optimistically; the recovered + // pending-forfeit FSM rejects it and the failure hops back + // asynchronously, releasing the optimistic mark. result := mgr.Receive( t.Context(), &SelectAndReserveSpendRequest{ TargetAmount: 40000, }, ) _, err := result.Unpack() - require.Error(t, err) + require.NoError(t, err) + + refAny := mgr.actors[vtxo.Outpoint] + ref, ok := refAny.(*mockVTXOActorRef) + require.True(t, ok) + _, ok = ref.state.(*PendingForfeitState) + require.True(t, ok, "expected PendingForfeitState, got %T", ref.state) + + require.Eventually(t, func() bool { + return len(capture.captured()) == 1 + }, 5*time.Second, 10*time.Millisecond) + + failed, ok := capture.captured()[0].(*spendReservationFailedMsg) + require.True(t, ok) + require.Equal(t, vtxo.Outpoint, failed.Outpoint) + + result = mgr.Receive(t.Context(), failed) + require.True(t, result.IsOk()) + require.False(t, mgr.isReserved(vtxo.Outpoint)) } // TestRecoveredPendingForfeitAllowsRelease verifies that a VTXO diff --git a/vtxo/manager_reservation_test.go b/vtxo/manager_reservation_test.go index 98a99f501..476fe8b22 100644 --- a/vtxo/manager_reservation_test.go +++ b/vtxo/manager_reservation_test.go @@ -230,3 +230,58 @@ func TestCompleteSpendTransitionsToSpent(t *testing.T) { _, ok = actorState(t, mgr, vtxo1.Outpoint).(*SpentState) require.True(t, ok, "expected SpentState after complete") } + +// TestSpendReservationFailedEpochGuard verifies that a stale reservation +// failure does not drop a newer reservation's in-memory mark. An outpoint can +// be reserved, released, and re-reserved by a different session before the +// first session's detached failure watcher reports back; without the epoch +// guard that stale failure would un-gate the input the new session owns (ABA). +func TestSpendReservationFailedEpochGuard(t *testing.T) { + t.Parallel() + + vtxo1 := makeDescriptor(t, 50000, 0) + op := vtxo1.Outpoint + + mgr := &Manager{ + cfg: &ManagerConfig{ + Store: &MockVTXOStore{}, + }, + actors: make(map[wire.OutPoint]VTXOActorRef), + reserved: make(map[wire.OutPoint]uint64), + } + + // Session A reserves the outpoint and then releases it. + epochA := mgr.markReserved(op) + require.True(t, mgr.isReserved(op)) + mgr.dropReserved(op) + require.False(t, mgr.isReserved(op)) + + // Session B re-reserves the same outpoint; it gets a strictly newer + // epoch. + epochB := mgr.markReserved(op) + require.Greater(t, epochB, epochA) + require.True(t, mgr.isReserved(op)) + + // Session A's detached failure finally lands, carrying the stale epoch. + // It must NOT drop B's mark. + res := mgr.Receive(context.Background(), &spendReservationFailedMsg{ + Outpoint: op, + Epoch: epochA, + }) + require.True(t, res.IsOk()) + require.True( + t, mgr.isReserved(op), + "stale failure must not un-gate the newer reservation", + ) + + // Session B's own failure, carrying the current epoch, drops the mark. + res = mgr.Receive(context.Background(), &spendReservationFailedMsg{ + Outpoint: op, + Epoch: epochB, + }) + require.True(t, res.IsOk()) + require.False( + t, mgr.isReserved(op), + "matching-epoch failure must drop the reservation", + ) +} diff --git a/vtxo/messages.go b/vtxo/messages.go index 92a209fb0..fd0fabb61 100644 --- a/vtxo/messages.go +++ b/vtxo/messages.go @@ -63,6 +63,33 @@ func (m *VTXOsMaterializedNotification) MessageType() string { // VTXOManagerMsg implements actormsg.VTXOManagerMsg marker interface. func (m *VTXOsMaterializedNotification) VTXOManagerMsg() {} +// spendReservationFailedMsg is the manager-internal hop-back for an +// asynchronously delivered spend reservation whose FSM turn failed. The +// detached reserve path observes each child's future via OnComplete on a +// separate goroutine; mutating the manager's in-memory reservation map from +// there would race the manager turn, so the failure is delivered as a +// message and the map entry is dropped on the manager goroutine. +type spendReservationFailedMsg struct { + actor.BaseMessage + + // Outpoint is the VTXO whose reservation failed. + Outpoint wire.OutPoint + + // Epoch is the reservation epoch the detached watcher observed when it + // issued the reserve. The manager drops the in-memory mark only if this + // still matches the outpoint's current epoch, so a stale failure cannot + // un-gate a reservation a newer session owns. + Epoch uint64 +} + +// MessageType returns the message type identifier. +func (m *spendReservationFailedMsg) MessageType() string { + return "SpendReservationFailedMsg" +} + +// VTXOManagerMsg implements actormsg.VTXOManagerMsg marker interface. +func (m *spendReservationFailedMsg) VTXOManagerMsg() {} + // GetActiveVTXOCountRequest requests the number of active VTXO actors managed // by the VTXO Manager. This goes through the actor message path to avoid // requiring synchronization. From 79a02cf15e1df986426fc7d7d80eae611da14812 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 18:09:48 -0500 Subject: [PATCH 6/9] daemonrpc: let ListVTXOs callers exclude checkpoint PSBTs Profiling the OOR stress workload at its post-optimization rate shows ListVTXOs at 31% of daemon CPU, and 21% of that is attaching the finalized OOR checkpoint PSBTs to every returned VTXO: each one costs a package lookup in the artifact store, which re-reads the PSBT blobs from disk on every call. Listing-only consumers such as balance views and coin selection never inspect those PSBTs, yet they pay the full price on every poll. In this commit, we add an exclude_checkpoint_psbts field to ListVTXOsRequest so those callers can opt out of the package loads. The default keeps the full response, so existing consumers (the SDK VTXO model and the swap lookup path) are unaffected. --- daemonrpc/daemon.pb.go | 24 +++++++++++++++++++----- daemonrpc/daemon.proto | 7 +++++++ 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/daemonrpc/daemon.pb.go b/daemonrpc/daemon.pb.go index 9c3a31bef..8316c1450 100644 --- a/daemonrpc/daemon.pb.go +++ b/daemonrpc/daemon.pb.go @@ -1635,9 +1635,15 @@ type ListVTXOsRequest struct { // If VTXO_STATUS_UNSPECIFIED (default), all statuses are returned. StatusFilter VTXOStatus `protobuf:"varint,1,opt,name=status_filter,json=statusFilter,proto3,enum=daemonrpc.VTXOStatus" json:"status_filter,omitempty"` // min_amount_sat excludes VTXOs below this value. - MinAmountSat int64 `protobuf:"varint,2,opt,name=min_amount_sat,json=minAmountSat,proto3" json:"min_amount_sat,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + MinAmountSat int64 `protobuf:"varint,2,opt,name=min_amount_sat,json=minAmountSat,proto3" json:"min_amount_sat,omitempty"` + // 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. + ExcludeCheckpointPsbts bool `protobuf:"varint,3,opt,name=exclude_checkpoint_psbts,json=excludeCheckpointPsbts,proto3" json:"exclude_checkpoint_psbts,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ListVTXOsRequest) Reset() { @@ -1684,6 +1690,13 @@ func (x *ListVTXOsRequest) GetMinAmountSat() int64 { return 0 } +func (x *ListVTXOsRequest) GetExcludeCheckpointPsbts() bool { + if x != nil { + return x.ExcludeCheckpointPsbts + } + return false +} + type ListVTXOsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // vtxos is the list of VTXOs matching the request filters. @@ -7871,10 +7884,11 @@ const file_daemon_proto_rawDesc = "" + " \x01(\rR\n" + "chainDepth\x12;\n" + "\x1aoor_final_checkpoint_psbts\x18\v \x03(\fR\x17oorFinalCheckpointPsbts\x12\"\n" + - "\rspent_by_txid\x18\f \x01(\tR\vspentByTxid\"t\n" + + "\rspent_by_txid\x18\f \x01(\tR\vspentByTxid\"\xae\x01\n" + "\x10ListVTXOsRequest\x12:\n" + "\rstatus_filter\x18\x01 \x01(\x0e2\x15.daemonrpc.VTXOStatusR\fstatusFilter\x12$\n" + - "\x0emin_amount_sat\x18\x02 \x01(\x03R\fminAmountSat\":\n" + + "\x0emin_amount_sat\x18\x02 \x01(\x03R\fminAmountSat\x128\n" + + "\x18exclude_checkpoint_psbts\x18\x03 \x01(\bR\x16excludeCheckpointPsbts\":\n" + "\x11ListVTXOsResponse\x12%\n" + "\x05vtxos\x18\x01 \x03(\v2\x0f.daemonrpc.VTXOR\x05vtxos\"\x13\n" + "\x11NewAddressRequest\".\n" + diff --git a/daemonrpc/daemon.proto b/daemonrpc/daemon.proto index dd5f24b71..1009b8a5f 100644 --- a/daemonrpc/daemon.proto +++ b/daemonrpc/daemon.proto @@ -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 { From a5027d21fea6f02986f4e468d6be9ab1c62e6d8e Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 18:09:54 -0500 Subject: [PATCH 7/9] darepod: skip checkpoint PSBT population when excluded In this commit, we honor the new exclude_checkpoint_psbts request field in the ListVTXOs handler: when set, the handler never opens the OOR artifact store, so no per-VTXO package reads happen at all. This removes the dominant CPU cost of high-frequency live-VTXO polling during the stress workload while leaving the default response shape untouched. --- darepod/rpc_server.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index ca1ec772e..e7107142f 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -980,14 +980,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 + } } } From 9f19824dda06faf564a7625e7ceabb5dbdc03edd Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Tue, 9 Jun 2026 18:22:43 -0500 Subject: [PATCH 8/9] multi: skip ancestry load on RPC VTXO listings Profiling the stress workload at ~11.6 payments/s shows the ListVTXOs RPC still at ~40% of daemon CPU after the checkpoint-PSBT opt-out, now inside ListVTXOAncestryPathsByStatus: the batched ancestry join drags every VTXO's TLV tree fragments through SQLite's external merge sorter, and those blobs grow with OOR chain depth, so the sorter spills PMA files to disk on every listing call (~61% of CPU samples were the resulting pwrite syscalls). The listing response never carries ancestry: only the unroller walks lineage, which is exactly the split migration 000009 introduced the side table for. In this commit, we add ListVTXOsByStatusLight and ListLiveVTXOsLight to the VTXO persistence store, identical to their full counterparts except the ancestry side table is never touched, and switch the ListVTXOs RPC handler to them unconditionally. The VTXO manager's recovery and exit paths keep the full listings since their descriptors do feed unroll. --- darepod/rpc_server.go | 7 ++-- db/vtxo_store.go | 83 +++++++++++++++++++++++++++++++++++++++++++ db/vtxo_store_test.go | 68 +++++++++++++++++++++++++++++++++++ 3 files changed, 156 insertions(+), 2 deletions(-) diff --git a/darepod/rpc_server.go b/darepod/rpc_server.go index e7107142f..8f8feb3f1 100644 --- a/darepod/rpc_server.go +++ b/darepod/rpc_server.go @@ -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 { diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 82877a7ab..f0532df05 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -301,6 +301,89 @@ func (s *VTXOPersistenceStore) ListVTXOsByStatus(ctx context.Context, return result, err } +// ListLiveVTXOsLight returns the same descriptors as ListLiveVTXOs with a +// nil Ancestry on every entry. The ancestry side table's TLV tree fragments +// grow with OOR chain depth, and the batched join sorts those blobs through +// SQLite's external sorter on every call, so consumers that never walk the +// lineage (the ListVTXOs RPC response carries no ancestry) skip the side +// table entirely. +func (s *VTXOPersistenceStore) ListLiveVTXOsLight(ctx context.Context) ( + []*vtxo.Descriptor, error) { + + readTxOpts := ReadTxOption() + + var result []*vtxo.Descriptor + + err := s.db.ExecTx(ctx, readTxOpts, func(q RoundStore) error { + rows, err := q.ListLiveVTXOs(ctx) + if err != nil { + return fmt.Errorf("list live VTXOs: %w", err) + } + + descs, err := s.rowsToDescriptorsNoAncestry(ctx, q, rows) + if err != nil { + return err + } + + result = descs + + return nil + }) + + return result, err +} + +// ListVTXOsByStatusLight returns the same descriptors as ListVTXOsByStatus +// with a nil Ancestry on every entry. See ListLiveVTXOsLight for why +// listing-only consumers skip the ancestry side table. +func (s *VTXOPersistenceStore) ListVTXOsByStatusLight(ctx context.Context, + status vtxo.VTXOStatus) ([]*vtxo.Descriptor, error) { + + readTxOpts := ReadTxOption() + + var result []*vtxo.Descriptor + + err := s.db.ExecTx(ctx, readTxOpts, func(q RoundStore) error { + rows, err := q.ListVTXOsByStatus(ctx, int32(status)) + if err != nil { + return fmt.Errorf("list VTXOs by status: %w", err) + } + + descs, err := s.rowsToDescriptorsNoAncestry(ctx, q, rows) + if err != nil { + return err + } + + result = descs + + return nil + }) + + return result, err +} + +// rowsToDescriptorsNoAncestry converts VTXO rows to descriptors without +// touching the ancestry side table. The non-nil empty index keeps +// rowToDescriptor on the preloaded path (zero ancestry) instead of falling +// back to the per-row singleton ancestry query. +func (s *VTXOPersistenceStore) rowsToDescriptorsNoAncestry(ctx context.Context, + q RoundStore, rows []VTXORow) ([]*vtxo.Descriptor, error) { + + noAncestry := map[wire.OutPoint][]vtxo.Ancestry{} + + descs := make([]*vtxo.Descriptor, 0, len(rows)) + for _, row := range rows { + desc, err := s.rowToDescriptor(ctx, q, row, noAncestry) + if err != nil { + return nil, fmt.Errorf("convert VTXO: %w", err) + } + + descs = append(descs, desc) + } + + return descs, nil +} + // ListSelectionCandidatesByStatus returns the lightweight projection coin // selection runs on: outpoint, amount, and pkScript per VTXO in the given // status. Selection happens on every payment and needs only these fields, so diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 13b0c872a..625442349 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -402,6 +402,74 @@ func TestListSelectionCandidatesByStatus(t *testing.T) { require.Equal(t, descB.Outpoint, candidates[0].Outpoint) } +// TestListVTXOsLightSkipsAncestry exercises the light listing variants the +// ListVTXOs RPC runs on: the descriptors must match the full listing in +// every field except Ancestry, which the light path never loads. +func TestListVTXOsLightSkipsAncestry(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + roundID := testRoundIDDB("test-round-light-list") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + require.NoError(t, roundStore.CommitState(ctx, testRound, state)) + + descA := createTestVTXODescriptor(t, roundID, 21) + require.NoError(t, vtxoStore.SaveVTXO(ctx, descA)) + + descB := createTestVTXODescriptor(t, roundID, 22) + require.NoError(t, vtxoStore.SaveVTXO(ctx, descB)) + + full, err := vtxoStore.ListVTXOsByStatus(ctx, vtxo.VTXOStatusLive) + require.NoError(t, err) + require.Len(t, full, 2) + + byOutpoint := make(map[wire.OutPoint]*vtxo.Descriptor) + for _, desc := range full { + // The full listing carries the persisted ancestry; the light + // assertions below lean on this contrast. + require.NotEmpty(t, desc.Ancestry) + byOutpoint[desc.Outpoint] = desc + } + + assertLight := func(light []*vtxo.Descriptor) { + t.Helper() + + require.Len(t, light, len(full)) + for _, desc := range light { + fullDesc, ok := byOutpoint[desc.Outpoint] + require.True(t, ok) + + require.Empty(t, desc.Ancestry) + require.Equal(t, fullDesc.Amount, desc.Amount) + require.Equal(t, fullDesc.PkScript, desc.PkScript) + require.Equal(t, fullDesc.Status, desc.Status) + require.Equal(t, fullDesc.RoundID, desc.RoundID) + require.Equal( + t, fullDesc.ChainDepth, desc.ChainDepth, + ) + require.Equal( + t, fullDesc.RelativeExpiry, desc.RelativeExpiry, + ) + } + } + + light, err := vtxoStore.ListVTXOsByStatusLight( + ctx, vtxo.VTXOStatusLive, + ) + require.NoError(t, err) + assertLight(light) + + liveLight, err := vtxoStore.ListLiveVTXOsLight(ctx) + require.NoError(t, err) + assertLight(liveLight) +} + // addAncestryFragment appends a synthetic ancestry fragment to a // Descriptor under construction so multi-tree round-trip tests can // build N>1 ancestry layouts without re-implementing the per-fragment From 54e8a77f34af99a06b369772afe6d2e44f5fd117 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Wed, 17 Jun 2026 16:26:23 -0700 Subject: [PATCH 9/9] vtxo: release reservation on unroll during spending A manual unroll of a VTXO mid-OOR-spend escalates SpendingState to UnilateralExitState, but the emitted VTXOStatusUpdate omitted the ReleaseSpendReservation flag the sibling critical-expiry branch sets, so the durable spending-reservation row was left behind even though its comment claimed the same outbox shape. A stale row was previously benign, overwritten by the next legitimate spend. With the startup reservation sweep's reverse direction, a restart that rolls a recoverable exit back to LiveState now sees the orphan row, re-marks the recovered VTXO reserved, and re-drives SpendReserveEvent on an input no session owns, stranding it in SpendingState. Set the flag so the row is deleted atomically with the status change, and assert it in the SpendingState ForceUnroll transition test. --- vtxo/transitions.go | 10 +++++++++- vtxo/transitions_test.go | 13 ++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/vtxo/transitions.go b/vtxo/transitions.go index b78165756..67e7279b3 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -884,7 +884,13 @@ func (s *SpendingState) ProcessEvent(_ context.Context, event VTXOEvent, } // Non-terminal exit: no VTXOTerminatedNotification, so a failed - // unroll can recover the VTXO (darepo-client#602). + // unroll can recover the VTXO (darepo-client#602). Leaving + // SpendingState drops the durable reservation row in the same + // transaction as the status change (ReleaseSpendReservation), + // matching the critical-expiry branch above; otherwise the + // stale row outlives the spend and the startup reservation + // sweep would re-reserve a recovered-to-Live VTXO that no + // session owns. outbox := []VTXOOutMsg{ &ExpiringNotification{ VTXO: s.VTXO, @@ -894,6 +900,8 @@ func (s *SpendingState) ProcessEvent(_ context.Context, event VTXOEvent, &VTXOStatusUpdate{ Outpoint: s.VTXO.Outpoint, NewStatus: VTXOStatusUnilateralExit, + + ReleaseSpendReservation: true, }, } diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index cca7d95e2..a4f0daf37 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -1371,8 +1371,19 @@ func TestSpendingStateForceUnroll(t *testing.T) { exit := assertState[*UnilateralExitState](h) require.Equal(t, int32(200), exit.LastCheckedHeight) assertOutboxContains[*ExpiringNotification](h) - assertOutboxContains[*VTXOStatusUpdate](h) assertOutboxLacks[*VTXOTerminatedNotification](h) + + // Escalating out of SpendingState must flag the status update for + // atomic reservation-row deletion, exactly like SpendReleased and + // SpendCompleted; otherwise the row outlives the spend and the startup + // reservation sweep re-reserves a recovered-to-Live VTXO no session + // owns. + su := assertOutboxContains[*VTXOStatusUpdate](h) + require.Equal(t, VTXOStatusUnilateralExit, su.NewStatus) + require.True( + t, su.ReleaseSpendReservation, + "unroll while spending must drop the reservation row", + ) } // TestForfeitingStateForceUnroll verifies that ForfeitingState escalates to