From cd7a0d23c7cbd3f5abd16424e4df84331bf2772f Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 23 Jan 2026 22:10:50 -0500 Subject: [PATCH 01/11] round+vtxo: use *schnorr.Signature for forfeit signatures In this commit, we change the forfeit signature type from raw []byte to *schnorr.Signature across the forfeit flow types. This provides type safety and consistency with the server's types.ForfeitTxSig which uses *schnorr.Signature for ClientVTXOSig. The following types are updated: - ForfeitSignatureResponse.Signature (round/events.go) - ForfeitSignatureSubmission.Signature (vtxo/outbox_messages.go) - SubmitVTXOForfeitSigsToServer.ForfeitSigs map values (round/outbox_messages.go) The signForfeitVTXOInput function now parses the serialized signature into a typed *schnorr.Signature before returning. Test files add a testSchnorrSignature helper that creates deterministic signatures from a seed for consistent test behavior. --- internal/testutils/keys.go | 23 +++++++++++++++++++++++ round/actor_test.go | 3 ++- round/events.go | 4 ++-- round/outbox_messages.go | 7 ++++--- round/transitions.go | 2 +- round/transitions_test.go | 21 ++++++++++++++------- vtxo/actor_test.go | 6 ++++-- vtxo/outbox_messages.go | 5 +++-- vtxo/transitions.go | 12 ++++++++++-- vtxo/transitions_test.go | 15 ++++++++------- 10 files changed, 71 insertions(+), 27 deletions(-) diff --git a/internal/testutils/keys.go b/internal/testutils/keys.go index dc9dd3fa4..79758317b 100644 --- a/internal/testutils/keys.go +++ b/internal/testutils/keys.go @@ -1,8 +1,13 @@ package testutils import ( + "testing" + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" + "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/lightningnetwork/lnd/input" + "github.com/stretchr/testify/require" ) // CreateKey returns a deterministically generated key pair. It returns the @@ -18,3 +23,21 @@ func CreateKey(index int32) (*btcec.PublicKey, input.Signer) { return pubKey, mockSigner } + +// TestSchnorrSignature creates a deterministic schnorr signature for tests. +// The seed string is hashed to create a private key, which signs a fixed test +// message. +func TestSchnorrSignature(t *testing.T, seed string) *schnorr.Signature { + t.Helper() + + // Create a deterministic private key from the seed. + h := chainhash.HashH([]byte(seed)) + privKey, _ := btcec.PrivKeyFromBytes(h[:]) + + // Sign a test message. + msg := chainhash.HashH([]byte("test message")) + sig, err := schnorr.Sign(privKey, msg[:]) + require.NoError(t, err) + + return sig +} diff --git a/round/actor_test.go b/round/actor_test.go index 06180dbd1..875abbb3e 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -7,6 +7,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/internal/testutils" "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" "github.com/lightninglabs/darepo-client/wallet" @@ -1199,7 +1200,7 @@ func TestHandleForfeitSignatureResponse(t *testing.T) { response := &ForfeitSignatureResponse{ RoundID: "non-existent-round", VTXOOutpoint: vtxoOutpoint, - Signature: []byte{0x30, 0x44}, // Dummy signature + Signature: testutils.TestSchnorrSignature(t, "forfeit"), ForfeitTx: wire.NewMsgTx(2), } diff --git a/round/events.go b/round/events.go index aeb7901b0..2ff005bfb 100644 --- a/round/events.go +++ b/round/events.go @@ -396,8 +396,8 @@ type ForfeitSignatureResponse struct { // ForfeitTx is the built forfeit transaction. ForfeitTx *wire.MsgTx - // Signature is the client's signature for the forfeit tx. - Signature []byte + // Signature is the client's schnorr signature for the forfeit tx. + Signature *schnorr.Signature } func (e *ForfeitSignatureResponse) clientEventSealed() {} diff --git a/round/outbox_messages.go b/round/outbox_messages.go index 3cafad1da..d445e5589 100644 --- a/round/outbox_messages.go +++ b/round/outbox_messages.go @@ -2,6 +2,7 @@ package round import ( "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/btcec/v2/schnorr/musig2" "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/chaincfg/chainhash" @@ -215,9 +216,9 @@ type SubmitVTXOForfeitSigsToServer struct { RoundID string // ForfeitSigs maps VTXO outpoints to their forfeit transaction - // signatures. Each signature is the client's portion of the 2-of-2 - // collaborative spend from the VTXO. - ForfeitSigs map[wire.OutPoint][]byte + // signatures. Each signature is the client's schnorr signature for the + // collaborative 2-of-2 spend from the VTXO. + ForfeitSigs map[wire.OutPoint]*schnorr.Signature // ForfeitTxs maps VTXO outpoints to the built forfeit transactions. // The server uses these to broadcast after adding its signature. diff --git a/round/transitions.go b/round/transitions.go index 193e359c9..199120914 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -994,7 +994,7 @@ func (s *ForfeitSignaturesCollectingState) ProcessEvent( } // All forfeit signatures collected! Build the submission. - forfeitSigs := make(map[wire.OutPoint][]byte) + forfeitSigs := make(map[wire.OutPoint]*schnorr.Signature) forfeitTxs := make(map[wire.OutPoint]*wire.MsgTx) forfeitedVTXOs := make([]wire.OutPoint, 0, len(updatedForfeits)) for outpoint, resp := range updatedForfeits { diff --git a/round/transitions_test.go b/round/transitions_test.go index c9f55d860..d055b7cb5 100644 --- a/round/transitions_test.go +++ b/round/transitions_test.go @@ -8,6 +8,7 @@ import ( "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/wire" "github.com/google/uuid" + "github.com/lightninglabs/darepo-client/internal/testutils" "github.com/lightninglabs/darepo-client/lib/scripts" "github.com/lightninglabs/darepo-client/lib/tree" "github.com/lightninglabs/darepo-client/lib/types" @@ -1766,11 +1767,12 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { h.withState(state) // Send forfeit signature response. + sig := testutils.TestSchnorrSignature(t, "forfeit") event := &ForfeitSignatureResponse{ VTXOOutpoint: vtxoOutpoint, RoundID: roundID.String(), ForfeitTx: forfeitTx, - Signature: make([]byte, 64), + Signature: sig, } transition, err := h.sendEvent(event) @@ -1834,11 +1836,12 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { forfeitTx1 := h.newTestForfeitTx( vtxoOutpoint1, connectorOutpoint1, serverForfeitScript, ) + sig1 := testutils.TestSchnorrSignature(t, "forfeit") event1 := &ForfeitSignatureResponse{ VTXOOutpoint: vtxoOutpoint1, RoundID: roundID.String(), ForfeitTx: forfeitTx1, - Signature: make([]byte, 64), + Signature: sig1, } _, err := h.sendEvent(event1) @@ -1853,11 +1856,12 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { forfeitTx2 := h.newTestForfeitTx( vtxoOutpoint2, connectorOutpoint2, serverForfeitScript, ) + sig2 := testutils.TestSchnorrSignature(t, "forfeit") event2 := &ForfeitSignatureResponse{ VTXOOutpoint: vtxoOutpoint2, RoundID: roundID.String(), ForfeitTx: forfeitTx2, - Signature: make([]byte, 64), + Signature: sig2, } transition, err := h.sendEvent(event2) @@ -1910,11 +1914,12 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { forfeitTx := h.newTestForfeitTx( vtxoOutpoint, connectorOutpoint, serverForfeitScript, ) + sigDup := testutils.TestSchnorrSignature(t, "forfeit") event := &ForfeitSignatureResponse{ VTXOOutpoint: vtxoOutpoint, RoundID: roundID.String(), ForfeitTx: forfeitTx, - Signature: make([]byte, 64), + Signature: sigDup, } // First response. @@ -1963,10 +1968,11 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { // Send forfeit for unknown VTXO. unknownOutpoint := h.newTestOutpoint() + sigUnknown := testutils.TestSchnorrSignature(t, "forfeit") event := &ForfeitSignatureResponse{ VTXOOutpoint: unknownOutpoint, RoundID: roundID.String(), - Signature: make([]byte, 64), + Signature: sigUnknown, } _, err := h.sendEvent(event) @@ -2059,11 +2065,12 @@ func TestForfeitSignaturesCollectingState(t *testing.T) { ) h.withState(state) + sigPenalty := testutils.TestSchnorrSignature(t, "forfeit") event := &ForfeitSignatureResponse{ VTXOOutpoint: vtxoOutpoint, RoundID: roundID.String(), ForfeitTx: forfeitTx, - Signature: make([]byte, 64), + Signature: sigPenalty, } _, err := h.sendEvent(event) @@ -2233,7 +2240,7 @@ func TestForfeitCollectionStateImmutability(t *testing.T) { VTXOOutpoint: vtxoOutpoint1, RoundID: "round-immut-001", ForfeitTx: forfeitTx1, - Signature: make([]byte, 64), + Signature: testutils.TestSchnorrSignature(t, "forfeit"), } _, err := h.sendEvent(event1) diff --git a/vtxo/actor_test.go b/vtxo/actor_test.go index 348605baa..e124aec65 100644 --- a/vtxo/actor_test.go +++ b/vtxo/actor_test.go @@ -6,6 +6,7 @@ import ( "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/internal/testutils" "github.com/lightninglabs/darepo-client/round" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" @@ -42,12 +43,13 @@ func TestProcessOutboxForfeitSignature(t *testing.T) { PkScript: []byte{0x51, 0x20}, }) + testSig := testutils.TestSchnorrSignature(t, "forfeit") outbox := []VTXOOutMsg{ &ForfeitSignatureSubmission{ VTXOOutpoint: vtxo.Outpoint, RoundID: "round-123", ForfeitTx: forfeitTx, - Signature: []byte{0x30, 0x44}, + Signature: testSig, }, } @@ -63,7 +65,7 @@ func TestProcessOutboxForfeitSignature(t *testing.T) { require.Equal(t, vtxo.Outpoint, resp.VTXOOutpoint) require.Equal(t, "round-123", resp.RoundID) require.NotNil(t, resp.ForfeitTx) - require.Equal(t, []byte{0x30, 0x44}, resp.Signature) + require.Equal(t, testSig, resp.Signature) } // TestProcessOutboxMarkForfeiting verifies that VTXOStatusUpdate with diff --git a/vtxo/outbox_messages.go b/vtxo/outbox_messages.go index 727197f0f..53f09d014 100644 --- a/vtxo/outbox_messages.go +++ b/vtxo/outbox_messages.go @@ -1,6 +1,7 @@ package vtxo import ( + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/baselib/actor" ) @@ -96,8 +97,8 @@ type ForfeitSignatureSubmission struct { // ForfeitTx is the signed forfeit transaction. ForfeitTx *wire.MsgTx - // Signature is the client's signature for the forfeit tx. - Signature []byte + // Signature is the client's schnorr signature for the forfeit tx. + Signature *schnorr.Signature } func (m *ForfeitSignatureSubmission) vtxoOutMsgSealed() {} diff --git a/vtxo/transitions.go b/vtxo/transitions.go index c7fb3e78a..62c0c7801 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/lib/tx" @@ -193,7 +194,8 @@ func (s *LiveState) handleForfeitRequest( // collaborative spend path, so both client and operator signatures are needed. // This function only produces the client's half; the operator adds theirs. func signForfeitVTXOInput(vtxo *Descriptor, evt *ForfeitRequestEvent, - forfeitTx *wire.MsgTx, env *VTXOEnvironment) ([]byte, error) { + forfeitTx *wire.MsgTx, + env *VTXOEnvironment) (*schnorr.Signature, error) { if vtxo.TapScript == nil { return nil, fmt.Errorf("VTXO tapscript is required for signing") @@ -244,7 +246,13 @@ func signForfeitVTXOInput(vtxo *Descriptor, evt *ForfeitRequestEvent, return nil, fmt.Errorf("failed to sign: %w", err) } - return sig.Serialize(), nil + // Parse the serialized signature to get a typed schnorr.Signature. + schnorrSig, err := schnorr.ParseSignature(sig.Serialize()) + if err != nil { + return nil, fmt.Errorf("parse schnorr signature: %w", err) + } + + return schnorrSig, nil } // ProcessEvent handles events in RefreshRequestedState. The VTXO is waiting diff --git a/vtxo/transitions_test.go b/vtxo/transitions_test.go index 81f81dff9..ccabf710b 100644 --- a/vtxo/transitions_test.go +++ b/vtxo/transitions_test.go @@ -3,7 +3,6 @@ package vtxo import ( "testing" - "github.com/btcsuite/btcd/btcec/v2/schnorr" "github.com/btcsuite/btcd/chaincfg/chainhash" "github.com/btcsuite/btcd/txscript" "github.com/btcsuite/btcd/wire" @@ -491,9 +490,12 @@ func TestForfeitRequestRealSigning(t *testing.T) { }) require.NoError(t, err) - // Verify the signature is non-empty and has valid length (64 bytes for - // Schnorr). - require.Len(t, submission.Signature, 64, "signature should be 64 bytes") + // Verify the signature is non-nil and serializes to 64 bytes (Schnorr). + require.NotNil(t, submission.Signature) + require.Len( + t, submission.Signature.Serialize(), 64, + "signature should be 64 bytes", + ) } // TestForfeitingStateCriticalExpiry verifies that ForfeitingState transitions @@ -818,9 +820,8 @@ func TestForfeitSignatureValidity(t *testing.T) { ) require.NoError(t, err) - // Parse client signature from raw bytes. - clientSig, err := schnorr.ParseSignature(submission.Signature) - require.NoError(t, err) + // The client signature is already parsed as *schnorr.Signature. + clientSig := submission.Signature // Build complete witness for collaborative spend. witness, err := scripts.VTXOCollabSpendWitness( From 949c6a0791d26c3945bebb0d0473982417df1d38 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 00:46:56 -0500 Subject: [PATCH 02/11] db: add VTXO lifecycle status schema and queries Add migration 000004_vtxo_status to extend the vtxos table with status tracking fields for the VTXO refresh flow: - status: lifecycle state (Live, RefreshRequested, Forfeiting, etc.) - forfeit_round_id: tracks the new round during refresh - forfeit_tx: stores signed forfeit tx for crash recovery - forfeit_txid: records confirmed forfeit transaction ID - replaced_by_hash/index: links old VTXO to its replacement Also adds vtxo.sql queries for status updates, forfeit tracking, and VTXO retrieval by status. --- .../migrations/000003_round_tables.down.sql | 1 + db/sqlc/migrations/000003_round_tables.up.sql | 51 +++ db/sqlc/models.go | 10 + db/sqlc/querier.go | 38 +++ db/sqlc/queries/round.sql | 22 +- db/sqlc/queries/vtxo.sql | 71 +++++ db/sqlc/round.sql.go | 78 ++++- db/sqlc/schemas/generated_schema.sql | 50 +++ db/sqlc/vtxo.sql.go | 296 ++++++++++++++++++ 9 files changed, 605 insertions(+), 12 deletions(-) create mode 100644 db/sqlc/queries/vtxo.sql create mode 100644 db/sqlc/vtxo.sql.go diff --git a/db/sqlc/migrations/000003_round_tables.down.sql b/db/sqlc/migrations/000003_round_tables.down.sql index f824b7258..08e5cd8ee 100644 --- a/db/sqlc/migrations/000003_round_tables.down.sql +++ b/db/sqlc/migrations/000003_round_tables.down.sql @@ -1,6 +1,7 @@ -- Round tables down migration. -- Drops all tables created in the up migration in reverse order. +DROP INDEX IF EXISTS idx_vtxos_status; DROP INDEX IF EXISTS idx_vtxos_creation_time; DROP INDEX IF EXISTS idx_vtxos_spent; DROP INDEX IF EXISTS idx_vtxos_round_id; diff --git a/db/sqlc/migrations/000003_round_tables.up.sql b/db/sqlc/migrations/000003_round_tables.up.sql index 04581af6e..023e0008c 100644 --- a/db/sqlc/migrations/000003_round_tables.up.sql +++ b/db/sqlc/migrations/000003_round_tables.up.sql @@ -240,9 +240,56 @@ CREATE TABLE IF NOT EXISTS vtxos ( -- tree_path is the TLV-encoded extracted tree.Tree path. tree_path BLOB NOT NULL, + -- batch_expiry is the absolute block height at which the batch expires + -- (when the operator can sweep via the batch-level timelock). Zero value + -- is used for VTXOs created via the round store before the VTXO manager + -- fills in the full metadata via ON CONFLICT DO UPDATE. + batch_expiry INTEGER NOT NULL, + + -- tree_depth is the depth of this VTXO in the VTXT (used for expiry + -- calculation based on TreeDepthMultiplier). Zero for same reason. + tree_depth INTEGER NOT NULL, + + -- created_height is the block height when this VTXO was created. + -- Zero for same reason. + created_height INTEGER NOT NULL, + + -- commitment_txid is the 32-byte txid of the commitment transaction that + -- anchors this VTXO's tree on-chain. Empty blob until the VTXO manager + -- fills in the full metadata via ON CONFLICT DO UPDATE. + commitment_txid BLOB NOT NULL, + -- spent indicates if this VTXO has been used. spent BOOLEAN NOT NULL DEFAULT FALSE, + -- status tracks VTXO lifecycle (vtxo.VTXOStatus enum): + -- 0 = Live (default) + -- 1 = RefreshRequested + -- 2 = Forfeiting + -- 3 = Forfeited + -- 4 = Spent + -- 5 = Expiring + -- 6 = Failed + status INTEGER NOT NULL DEFAULT 0, + + -- forfeit_round_id is the round in which this VTXO is being forfeited. + -- NULL unless VTXO is in Forfeiting or Forfeited status. + forfeit_round_id TEXT, + + -- forfeit_tx is the serialized wire.MsgTx (binary) of the forfeit tx. + -- Persisted when entering Forfeiting state for crash recovery. + forfeit_tx BLOB, + + -- forfeit_txid is the 32-byte hash of the forfeit transaction. + -- Set when the forfeit is confirmed (transition to Forfeited state). + forfeit_txid BLOB, + + -- replaced_by_hash is the outpoint hash of the replacement VTXO. + replaced_by_hash BLOB, + + -- replaced_by_index is the outpoint index of the replacement VTXO. + replaced_by_index INTEGER, + -- creation_time is the unix epoch timestamp when this VTXO was created. creation_time BIGINT NOT NULL, @@ -265,3 +312,7 @@ CREATE INDEX IF NOT EXISTS idx_vtxos_spent -- Index on creation_time for chronological queries. CREATE INDEX IF NOT EXISTS idx_vtxos_creation_time ON vtxos(creation_time DESC); + +-- Index on status for efficient status-based queries (ListLiveVTXOs, etc.). +CREATE INDEX IF NOT EXISTS idx_vtxos_status + ON vtxos(status); diff --git a/db/sqlc/models.go b/db/sqlc/models.go index e0a0f0927..69cc782da 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -113,7 +113,17 @@ type Vtxo struct { ClientPubkey []byte OperatorPubkey []byte TreePath []byte + BatchExpiry int32 + TreeDepth int32 + CreatedHeight int32 + CommitmentTxid []byte Spent bool + Status int32 + ForfeitRoundID sql.NullString + ForfeitTx []byte + ForfeitTxid []byte + ReplacedByHash []byte + ReplacedByIndex sql.NullInt32 CreationTime int64 LastUpdateTime int64 } diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 93f0c7b0f..775c2cefa 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -11,7 +11,12 @@ import ( type Querier interface { CountBoardingIntentsByStatus(ctx context.Context, status string) (int64, error) CountUnspentVTXOs(ctx context.Context) (int64, error) + // CountVTXOsByStatus returns the count of VTXOs with the specified status. + CountVTXOsByStatus(ctx context.Context, status int32) (int64, error) DeleteClientTreeTxids(ctx context.Context, arg DeleteClientTreeTxidsParams) error + // DeleteVTXO removes a VTXO from storage. Used for cleanup after terminal + // states are reached and the VTXO is no longer needed. + DeleteVTXO(ctx context.Context, arg DeleteVTXOParams) error FinalizeRound(ctx context.Context, arg FinalizeRoundParams) error GetBoardingAddress(ctx context.Context, pkScript []byte) (BoardingAddress, error) GetBoardingIntent(ctx context.Context, arg GetBoardingIntentParams) (BoardingIntent, error) @@ -26,6 +31,12 @@ type Querier interface { GetRoundClientTrees(ctx context.Context, roundID string) ([]RoundClientTree, error) GetRoundVtxoRequests(ctx context.Context, roundID string) ([]RoundVtxoRequest, error) GetVTXO(ctx context.Context, arg GetVTXOParams) (Vtxo, error) + // GetVTXOForfeitTx retrieves the persisted forfeit transaction for a VTXO. + // Used during recovery to restore the ForfeitingState with its tx. + GetVTXOForfeitTx(ctx context.Context, arg GetVTXOForfeitTxParams) (GetVTXOForfeitTxRow, error) + // GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited + // VTXO. Returns NULL if not forfeited or no replacement recorded. + GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacementParams) (GetVTXOReplacementRow, error) // Boarding address queries. InsertBoardingAddress(ctx context.Context, arg InsertBoardingAddressParams) error // Boarding intent queries. @@ -41,6 +52,10 @@ type Querier interface { // Round VTXO request queries. InsertRoundVtxoRequest(ctx context.Context, arg InsertRoundVtxoRequestParams) error // VTXO queries. + // InsertVTXO creates or updates a VTXO. On conflict, metadata fields are + // updated if the new values are non-zero/non-null (allowing the VTXO manager + // to fill in BatchExpiry, TreeDepth, CreatedHeight, CommitmentTxid after the + // round store creates the initial record). InsertVTXO(ctx context.Context, arg InsertVTXOParams) error ListActiveRounds(ctx context.Context) ([]Round, error) ListAllBoardingAddresses(ctx context.Context) ([]BoardingAddress, error) @@ -52,15 +67,38 @@ type Querier interface { ListBoardingIntentsByStatus(ctx context.Context, status string) ([]BoardingIntent, error) ListBoardingIntentsByStatusAndMinHeight(ctx context.Context, arg ListBoardingIntentsByStatusAndMinHeightParams) ([]BoardingIntent, error) ListChainInfo(ctx context.Context) ([]ChainInfo, error) + // ListLiveVTXOs returns all VTXOs that are not in a terminal state. + // Terminal states are: Forfeited (3), Spent (4), Expiring (5), Failed (6). + // This is used during startup to recover active VTXO actors. + // Also filter on spent = FALSE to handle VTXOs marked spent via the legacy + // flag before the status field was introduced. + ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) ListRoundsByStatus(ctx context.Context, status string) ([]Round, error) ListUnspentVTXOs(ctx context.Context) ([]Vtxo, error) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, error) + // VTXO status and lifecycle queries. + // These queries support the vtxo.VTXOStore interface for VTXO lifecycle + // management, including status transitions and forfeit transaction tracking. + // ListVTXOsByStatus returns all VTXOs with the specified status. + ListVTXOsByStatus(ctx context.Context, status int32) ([]Vtxo, error) + // MarkVTXOForfeited marks a VTXO as forfeited and records the forfeit + // transaction ID and replacement VTXO outpoint. Called when the new round's + // commitment transaction confirms. + MarkVTXOForfeited(ctx context.Context, arg MarkVTXOForfeitedParams) error + // MarkVTXOForfeiting transitions a VTXO to Forfeiting status and persists + // the forfeit round ID and transaction for crash recovery. Called when + // entering the forfeit flow. + MarkVTXOForfeiting(ctx context.Context, arg MarkVTXOForfeitingParams) error + // Also sets status = 4 (Spent) to keep status in sync with spent flag. MarkVTXOSpent(ctx context.Context, arg MarkVTXOSpentParams) error SumBoardingIntentAmountsByStatus(ctx context.Context, status string) (interface{}, error) SumUnspentVTXOAmounts(ctx context.Context) (interface{}, error) UpdateBoardingIntentStatus(ctx context.Context, arg UpdateBoardingIntentStatusParams) error UpdateRoundBoardingIntentSignature(ctx context.Context, arg UpdateRoundBoardingIntentSignatureParams) error UpdateRoundStatus(ctx context.Context, arg UpdateRoundStatusParams) error + // UpdateVTXOStatus atomically updates a VTXO's status. This is the primary + // method for state transitions that don't require additional data. + UpdateVTXOStatus(ctx context.Context, arg UpdateVTXOStatusParams) error UpsertChainInfo(ctx context.Context, arg UpsertChainInfoParams) error } diff --git a/db/sqlc/queries/round.sql b/db/sqlc/queries/round.sql index 60c99aa06..81ebcd55e 100644 --- a/db/sqlc/queries/round.sql +++ b/db/sqlc/queries/round.sql @@ -115,12 +115,25 @@ DELETE FROM client_tree_txids WHERE round_id = $1 AND client_key = $2; -- VTXO queries. -- name: InsertVTXO :exec +-- InsertVTXO creates or updates a VTXO. On conflict, metadata fields are +-- updated if the new values are non-zero/non-null (allowing the VTXO manager +-- to fill in BatchExpiry, TreeDepth, CreatedHeight, CommitmentTxid after the +-- round store creates the initial record). INSERT INTO vtxos ( outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, - tree_path, spent, creation_time, last_update_time -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) -ON CONFLICT (outpoint_hash, outpoint_index) DO NOTHING; + tree_path, batch_expiry, tree_depth, created_height, commitment_txid, + spent, creation_time, last_update_time +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + $16, $17, $18 +) +ON CONFLICT (outpoint_hash, outpoint_index) DO UPDATE SET + batch_expiry = CASE WHEN excluded.batch_expiry != 0 THEN excluded.batch_expiry ELSE vtxos.batch_expiry END, + tree_depth = CASE WHEN excluded.tree_depth != 0 THEN excluded.tree_depth ELSE vtxos.tree_depth END, + created_height = CASE WHEN excluded.created_height != 0 THEN excluded.created_height ELSE vtxos.created_height END, + commitment_txid = CASE WHEN excluded.commitment_txid IS NOT NULL AND length(excluded.commitment_txid) > 0 THEN excluded.commitment_txid ELSE vtxos.commitment_txid END, + last_update_time = excluded.last_update_time; -- name: GetVTXO :one SELECT * FROM vtxos @@ -136,7 +149,8 @@ SELECT * FROM vtxos WHERE spent = FALSE ORDER BY creation_time DESC; SELECT * FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC; -- name: MarkVTXOSpent :exec -UPDATE vtxos SET spent = TRUE, last_update_time = $3 +-- Also sets status = 4 (Spent) to keep status in sync with spent flag. +UPDATE vtxos SET spent = TRUE, status = 4, last_update_time = $3 WHERE outpoint_hash = $1 AND outpoint_index = $2; -- name: CountUnspentVTXOs :one diff --git a/db/sqlc/queries/vtxo.sql b/db/sqlc/queries/vtxo.sql new file mode 100644 index 000000000..37e9b0a18 --- /dev/null +++ b/db/sqlc/queries/vtxo.sql @@ -0,0 +1,71 @@ +-- VTXO status and lifecycle queries. +-- These queries support the vtxo.VTXOStore interface for VTXO lifecycle +-- management, including status transitions and forfeit transaction tracking. + +-- name: ListVTXOsByStatus :many +-- ListVTXOsByStatus returns all VTXOs with the specified status. +SELECT * 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), Expiring (5), Failed (6). +-- This is used during startup to recover active VTXO actors. +-- Also filter on spent = FALSE to handle VTXOs marked spent via the legacy +-- flag before the status field was introduced. +SELECT * FROM vtxos +WHERE status < 3 AND spent = FALSE +ORDER BY creation_time DESC; + +-- name: UpdateVTXOStatus :exec +-- UpdateVTXOStatus atomically updates a VTXO's status. This is the primary +-- method for state transitions that don't require additional data. +UPDATE vtxos +SET status = $3, last_update_time = $4 +WHERE outpoint_hash = $1 AND outpoint_index = $2; + +-- name: MarkVTXOForfeiting :exec +-- MarkVTXOForfeiting transitions a VTXO to Forfeiting status and persists +-- the forfeit round ID and transaction for crash recovery. Called when +-- entering the forfeit flow. +UPDATE vtxos +SET status = 2, -- Forfeiting + forfeit_round_id = $3, + forfeit_tx = $4, + last_update_time = $5 +WHERE outpoint_hash = $1 AND outpoint_index = $2; + +-- name: GetVTXOForfeitTx :one +-- GetVTXOForfeitTx retrieves the persisted forfeit transaction for a VTXO. +-- Used during recovery to restore the ForfeitingState with its tx. +SELECT forfeit_tx, forfeit_round_id FROM vtxos +WHERE outpoint_hash = $1 AND outpoint_index = $2; + +-- name: MarkVTXOForfeited :exec +-- MarkVTXOForfeited marks a VTXO as forfeited and records the forfeit +-- transaction ID and replacement VTXO outpoint. Called when the new round's +-- commitment transaction confirms. +UPDATE vtxos +SET status = 3, -- Forfeited + forfeit_txid = $3, + replaced_by_hash = $4, + replaced_by_index = $5, + last_update_time = $6 +WHERE outpoint_hash = $1 AND outpoint_index = $2; + +-- name: DeleteVTXO :exec +-- DeleteVTXO removes a VTXO from storage. Used for cleanup after terminal +-- states are reached and the VTXO is no longer needed. +DELETE FROM vtxos +WHERE outpoint_hash = $1 AND outpoint_index = $2; + +-- name: GetVTXOReplacement :one +-- GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited +-- VTXO. Returns NULL if not forfeited or no replacement recorded. +SELECT replaced_by_hash, replaced_by_index FROM vtxos +WHERE outpoint_hash = $1 AND outpoint_index = $2; + +-- name: CountVTXOsByStatus :one +-- CountVTXOsByStatus returns the count of VTXOs with the specified status. +SELECT COUNT(*) FROM vtxos WHERE status = $1; diff --git a/db/sqlc/round.sql.go b/db/sqlc/round.sql.go index 81355b515..a42154aca 100644 --- a/db/sqlc/round.sql.go +++ b/db/sqlc/round.sql.go @@ -300,7 +300,7 @@ func (q *Queries) GetRoundVtxoRequests(ctx context.Context, roundID string) ([]R } const GetVTXO = `-- name: GetVTXO :one -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, spent, creation_time, last_update_time FROM vtxos +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos WHERE outpoint_hash = $1 AND outpoint_index = $2 ` @@ -324,7 +324,17 @@ func (q *Queries) GetVTXO(ctx context.Context, arg GetVTXOParams) (Vtxo, error) &i.ClientPubkey, &i.OperatorPubkey, &i.TreePath, + &i.BatchExpiry, + &i.TreeDepth, + &i.CreatedHeight, + &i.CommitmentTxid, &i.Spent, + &i.Status, + &i.ForfeitRoundID, + &i.ForfeitTx, + &i.ForfeitTxid, + &i.ReplacedByHash, + &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, ) @@ -508,9 +518,18 @@ const InsertVTXO = `-- name: InsertVTXO :exec INSERT INTO vtxos ( outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, - tree_path, spent, creation_time, last_update_time -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) -ON CONFLICT (outpoint_hash, outpoint_index) DO NOTHING + tree_path, batch_expiry, tree_depth, created_height, commitment_txid, + spent, creation_time, last_update_time +) VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, + $16, $17, $18 +) +ON CONFLICT (outpoint_hash, outpoint_index) DO UPDATE SET + batch_expiry = CASE WHEN excluded.batch_expiry != 0 THEN excluded.batch_expiry ELSE vtxos.batch_expiry END, + tree_depth = CASE WHEN excluded.tree_depth != 0 THEN excluded.tree_depth ELSE vtxos.tree_depth END, + created_height = CASE WHEN excluded.created_height != 0 THEN excluded.created_height ELSE vtxos.created_height END, + commitment_txid = CASE WHEN excluded.commitment_txid IS NOT NULL AND length(excluded.commitment_txid) > 0 THEN excluded.commitment_txid ELSE vtxos.commitment_txid END, + last_update_time = excluded.last_update_time ` type InsertVTXOParams struct { @@ -525,12 +544,20 @@ type InsertVTXOParams struct { ClientPubkey []byte OperatorPubkey []byte TreePath []byte + BatchExpiry int32 + TreeDepth int32 + CreatedHeight int32 + CommitmentTxid []byte Spent bool CreationTime int64 LastUpdateTime int64 } // VTXO queries. +// InsertVTXO creates or updates a VTXO. On conflict, metadata fields are +// updated if the new values are non-zero/non-null (allowing the VTXO manager +// to fill in BatchExpiry, TreeDepth, CreatedHeight, CommitmentTxid after the +// round store creates the initial record). func (q *Queries) InsertVTXO(ctx context.Context, arg InsertVTXOParams) error { _, err := q.db.ExecContext(ctx, InsertVTXO, arg.OutpointHash, @@ -544,6 +571,10 @@ func (q *Queries) InsertVTXO(ctx context.Context, arg InsertVTXOParams) error { arg.ClientPubkey, arg.OperatorPubkey, arg.TreePath, + arg.BatchExpiry, + arg.TreeDepth, + arg.CreatedHeight, + arg.CommitmentTxid, arg.Spent, arg.CreationTime, arg.LastUpdateTime, @@ -590,7 +621,7 @@ func (q *Queries) ListActiveRounds(ctx context.Context) ([]Round, error) { } const ListAllVTXOs = `-- name: ListAllVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, spent, creation_time, last_update_time FROM vtxos ORDER BY creation_time DESC +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos ORDER BY creation_time DESC ` func (q *Queries) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) { @@ -614,7 +645,17 @@ func (q *Queries) ListAllVTXOs(ctx context.Context) ([]Vtxo, error) { &i.ClientPubkey, &i.OperatorPubkey, &i.TreePath, + &i.BatchExpiry, + &i.TreeDepth, + &i.CreatedHeight, + &i.CommitmentTxid, &i.Spent, + &i.Status, + &i.ForfeitRoundID, + &i.ForfeitTx, + &i.ForfeitTxid, + &i.ReplacedByHash, + &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, ); err != nil { @@ -670,7 +711,7 @@ func (q *Queries) ListRoundsByStatus(ctx context.Context, status string) ([]Roun } const ListUnspentVTXOs = `-- name: ListUnspentVTXOs :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, spent, creation_time, last_update_time FROM vtxos WHERE spent = FALSE ORDER BY creation_time DESC +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos WHERE spent = FALSE ORDER BY creation_time DESC ` func (q *Queries) ListUnspentVTXOs(ctx context.Context) ([]Vtxo, error) { @@ -694,7 +735,17 @@ func (q *Queries) ListUnspentVTXOs(ctx context.Context) ([]Vtxo, error) { &i.ClientPubkey, &i.OperatorPubkey, &i.TreePath, + &i.BatchExpiry, + &i.TreeDepth, + &i.CreatedHeight, + &i.CommitmentTxid, &i.Spent, + &i.Status, + &i.ForfeitRoundID, + &i.ForfeitTx, + &i.ForfeitTxid, + &i.ReplacedByHash, + &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, ); err != nil { @@ -712,7 +763,7 @@ func (q *Queries) ListUnspentVTXOs(ctx context.Context) ([]Vtxo, error) { } const ListVTXOsByRound = `-- name: ListVTXOsByRound :many -SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, spent, creation_time, last_update_time FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos WHERE round_id = $1 ORDER BY creation_time DESC ` func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, error) { @@ -736,7 +787,17 @@ func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, &i.ClientPubkey, &i.OperatorPubkey, &i.TreePath, + &i.BatchExpiry, + &i.TreeDepth, + &i.CreatedHeight, + &i.CommitmentTxid, &i.Spent, + &i.Status, + &i.ForfeitRoundID, + &i.ForfeitTx, + &i.ForfeitTxid, + &i.ReplacedByHash, + &i.ReplacedByIndex, &i.CreationTime, &i.LastUpdateTime, ); err != nil { @@ -754,7 +815,7 @@ func (q *Queries) ListVTXOsByRound(ctx context.Context, roundID string) ([]Vtxo, } const MarkVTXOSpent = `-- name: MarkVTXOSpent :exec -UPDATE vtxos SET spent = TRUE, last_update_time = $3 +UPDATE vtxos SET spent = TRUE, status = 4, last_update_time = $3 WHERE outpoint_hash = $1 AND outpoint_index = $2 ` @@ -764,6 +825,7 @@ type MarkVTXOSpentParams struct { LastUpdateTime int64 } +// Also sets status = 4 (Spent) to keep status in sync with spent flag. func (q *Queries) MarkVTXOSpent(ctx context.Context, arg MarkVTXOSpentParams) error { _, err := q.db.ExecContext(ctx, MarkVTXOSpent, arg.OutpointHash, arg.OutpointIndex, arg.LastUpdateTime) return err diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index 6deb4cce9..1f456d392 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -149,6 +149,9 @@ CREATE INDEX idx_vtxos_round_id CREATE INDEX idx_vtxos_spent ON vtxos(spent); +CREATE INDEX idx_vtxos_status + ON vtxos(status); + CREATE TABLE round_boarding_intents ( -- round_id links to the parent round. round_id TEXT NOT NULL, @@ -313,9 +316,56 @@ CREATE TABLE vtxos ( -- tree_path is the TLV-encoded extracted tree.Tree path. tree_path BLOB NOT NULL, + -- batch_expiry is the absolute block height at which the batch expires + -- (when the operator can sweep via the batch-level timelock). Zero value + -- is used for VTXOs created via the round store before the VTXO manager + -- fills in the full metadata via ON CONFLICT DO UPDATE. + batch_expiry INTEGER NOT NULL, + + -- tree_depth is the depth of this VTXO in the VTXT (used for expiry + -- calculation based on TreeDepthMultiplier). Zero for same reason. + tree_depth INTEGER NOT NULL, + + -- created_height is the block height when this VTXO was created. + -- Zero for same reason. + created_height INTEGER NOT NULL, + + -- commitment_txid is the 32-byte txid of the commitment transaction that + -- anchors this VTXO's tree on-chain. Empty blob until the VTXO manager + -- fills in the full metadata via ON CONFLICT DO UPDATE. + commitment_txid BLOB NOT NULL, + -- spent indicates if this VTXO has been used. spent BOOLEAN NOT NULL DEFAULT FALSE, + -- status tracks VTXO lifecycle (vtxo.VTXOStatus enum): + -- 0 = Live (default) + -- 1 = RefreshRequested + -- 2 = Forfeiting + -- 3 = Forfeited + -- 4 = Spent + -- 5 = Expiring + -- 6 = Failed + status INTEGER NOT NULL DEFAULT 0, + + -- forfeit_round_id is the round in which this VTXO is being forfeited. + -- NULL unless VTXO is in Forfeiting or Forfeited status. + forfeit_round_id TEXT, + + -- forfeit_tx is the serialized wire.MsgTx (binary) of the forfeit tx. + -- Persisted when entering Forfeiting state for crash recovery. + forfeit_tx BLOB, + + -- forfeit_txid is the 32-byte hash of the forfeit transaction. + -- Set when the forfeit is confirmed (transition to Forfeited state). + forfeit_txid BLOB, + + -- replaced_by_hash is the outpoint hash of the replacement VTXO. + replaced_by_hash BLOB, + + -- replaced_by_index is the outpoint index of the replacement VTXO. + replaced_by_index INTEGER, + -- creation_time is the unix epoch timestamp when this VTXO was created. creation_time BIGINT NOT NULL, diff --git a/db/sqlc/vtxo.sql.go b/db/sqlc/vtxo.sql.go new file mode 100644 index 000000000..54523fb76 --- /dev/null +++ b/db/sqlc/vtxo.sql.go @@ -0,0 +1,296 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: vtxo.sql + +package sqlc + +import ( + "context" + "database/sql" +) + +const CountVTXOsByStatus = `-- name: CountVTXOsByStatus :one +SELECT COUNT(*) FROM vtxos WHERE status = $1 +` + +// CountVTXOsByStatus returns the count of VTXOs with the specified status. +func (q *Queries) CountVTXOsByStatus(ctx context.Context, status int32) (int64, error) { + row := q.db.QueryRowContext(ctx, CountVTXOsByStatus, status) + var count int64 + err := row.Scan(&count) + return count, err +} + +const DeleteVTXO = `-- name: DeleteVTXO :exec +DELETE FROM vtxos +WHERE outpoint_hash = $1 AND outpoint_index = $2 +` + +type DeleteVTXOParams struct { + OutpointHash []byte + OutpointIndex int32 +} + +// DeleteVTXO removes a VTXO from storage. Used for cleanup after terminal +// states are reached and the VTXO is no longer needed. +func (q *Queries) DeleteVTXO(ctx context.Context, arg DeleteVTXOParams) error { + _, err := q.db.ExecContext(ctx, DeleteVTXO, arg.OutpointHash, arg.OutpointIndex) + return err +} + +const GetVTXOForfeitTx = `-- name: GetVTXOForfeitTx :one +SELECT forfeit_tx, forfeit_round_id FROM vtxos +WHERE outpoint_hash = $1 AND outpoint_index = $2 +` + +type GetVTXOForfeitTxParams struct { + OutpointHash []byte + OutpointIndex int32 +} + +type GetVTXOForfeitTxRow struct { + ForfeitTx []byte + ForfeitRoundID sql.NullString +} + +// GetVTXOForfeitTx retrieves the persisted forfeit transaction for a VTXO. +// Used during recovery to restore the ForfeitingState with its tx. +func (q *Queries) GetVTXOForfeitTx(ctx context.Context, arg GetVTXOForfeitTxParams) (GetVTXOForfeitTxRow, error) { + row := q.db.QueryRowContext(ctx, GetVTXOForfeitTx, arg.OutpointHash, arg.OutpointIndex) + var i GetVTXOForfeitTxRow + err := row.Scan(&i.ForfeitTx, &i.ForfeitRoundID) + return i, err +} + +const GetVTXOReplacement = `-- name: GetVTXOReplacement :one +SELECT replaced_by_hash, replaced_by_index FROM vtxos +WHERE outpoint_hash = $1 AND outpoint_index = $2 +` + +type GetVTXOReplacementParams struct { + OutpointHash []byte + OutpointIndex int32 +} + +type GetVTXOReplacementRow struct { + ReplacedByHash []byte + ReplacedByIndex sql.NullInt32 +} + +// GetVTXOReplacement retrieves the replacement VTXO outpoint for a forfeited +// VTXO. Returns NULL if not forfeited or no replacement recorded. +func (q *Queries) GetVTXOReplacement(ctx context.Context, arg GetVTXOReplacementParams) (GetVTXOReplacementRow, error) { + row := q.db.QueryRowContext(ctx, GetVTXOReplacement, arg.OutpointHash, arg.OutpointIndex) + var i GetVTXOReplacementRow + err := row.Scan(&i.ReplacedByHash, &i.ReplacedByIndex) + return i, err +} + +const ListLiveVTXOs = `-- name: ListLiveVTXOs :many +SELECT outpoint_hash, outpoint_index, round_id, amount, pk_script, expiry, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos +WHERE status < 3 AND spent = FALSE +ORDER BY creation_time DESC +` + +// ListLiveVTXOs returns all VTXOs that are not in a terminal state. +// Terminal states are: Forfeited (3), Spent (4), Expiring (5), Failed (6). +// This is used during startup to recover active VTXO actors. +// Also filter on spent = FALSE to handle VTXOs marked spent via the legacy +// flag before the status field was introduced. +func (q *Queries) ListLiveVTXOs(ctx context.Context) ([]Vtxo, error) { + rows, err := q.db.QueryContext(ctx, ListLiveVTXOs) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Vtxo + for rows.Next() { + var i Vtxo + if err := rows.Scan( + &i.OutpointHash, + &i.OutpointIndex, + &i.RoundID, + &i.Amount, + &i.PkScript, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.ClientPubkey, + &i.OperatorPubkey, + &i.TreePath, + &i.BatchExpiry, + &i.TreeDepth, + &i.CreatedHeight, + &i.CommitmentTxid, + &i.Spent, + &i.Status, + &i.ForfeitRoundID, + &i.ForfeitTx, + &i.ForfeitTxid, + &i.ReplacedByHash, + &i.ReplacedByIndex, + &i.CreationTime, + &i.LastUpdateTime, + ); 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, client_key_family, client_key_index, client_pubkey, operator_pubkey, tree_path, batch_expiry, tree_depth, created_height, commitment_txid, spent, status, forfeit_round_id, forfeit_tx, forfeit_txid, replaced_by_hash, replaced_by_index, creation_time, last_update_time FROM vtxos +WHERE status = $1 +ORDER BY creation_time DESC +` + +// VTXO status and lifecycle queries. +// These queries support the vtxo.VTXOStore interface for VTXO lifecycle +// management, including status transitions and forfeit transaction tracking. +// ListVTXOsByStatus returns all VTXOs with the specified status. +func (q *Queries) ListVTXOsByStatus(ctx context.Context, status int32) ([]Vtxo, error) { + rows, err := q.db.QueryContext(ctx, ListVTXOsByStatus, status) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Vtxo + for rows.Next() { + var i Vtxo + if err := rows.Scan( + &i.OutpointHash, + &i.OutpointIndex, + &i.RoundID, + &i.Amount, + &i.PkScript, + &i.Expiry, + &i.ClientKeyFamily, + &i.ClientKeyIndex, + &i.ClientPubkey, + &i.OperatorPubkey, + &i.TreePath, + &i.BatchExpiry, + &i.TreeDepth, + &i.CreatedHeight, + &i.CommitmentTxid, + &i.Spent, + &i.Status, + &i.ForfeitRoundID, + &i.ForfeitTx, + &i.ForfeitTxid, + &i.ReplacedByHash, + &i.ReplacedByIndex, + &i.CreationTime, + &i.LastUpdateTime, + ); 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 MarkVTXOForfeited = `-- name: MarkVTXOForfeited :exec +UPDATE vtxos +SET status = 3, -- Forfeited + forfeit_txid = $3, + replaced_by_hash = $4, + replaced_by_index = $5, + last_update_time = $6 +WHERE outpoint_hash = $1 AND outpoint_index = $2 +` + +type MarkVTXOForfeitedParams struct { + OutpointHash []byte + OutpointIndex int32 + ForfeitTxid []byte + ReplacedByHash []byte + ReplacedByIndex sql.NullInt32 + LastUpdateTime int64 +} + +// MarkVTXOForfeited marks a VTXO as forfeited and records the forfeit +// transaction ID and replacement VTXO outpoint. Called when the new round's +// commitment transaction confirms. +func (q *Queries) MarkVTXOForfeited(ctx context.Context, arg MarkVTXOForfeitedParams) error { + _, err := q.db.ExecContext(ctx, MarkVTXOForfeited, + arg.OutpointHash, + arg.OutpointIndex, + arg.ForfeitTxid, + arg.ReplacedByHash, + arg.ReplacedByIndex, + arg.LastUpdateTime, + ) + return err +} + +const MarkVTXOForfeiting = `-- name: MarkVTXOForfeiting :exec +UPDATE vtxos +SET status = 2, -- Forfeiting + forfeit_round_id = $3, + forfeit_tx = $4, + last_update_time = $5 +WHERE outpoint_hash = $1 AND outpoint_index = $2 +` + +type MarkVTXOForfeitingParams struct { + OutpointHash []byte + OutpointIndex int32 + ForfeitRoundID sql.NullString + ForfeitTx []byte + LastUpdateTime int64 +} + +// MarkVTXOForfeiting transitions a VTXO to Forfeiting status and persists +// the forfeit round ID and transaction for crash recovery. Called when +// entering the forfeit flow. +func (q *Queries) MarkVTXOForfeiting(ctx context.Context, arg MarkVTXOForfeitingParams) error { + _, err := q.db.ExecContext(ctx, MarkVTXOForfeiting, + arg.OutpointHash, + arg.OutpointIndex, + arg.ForfeitRoundID, + arg.ForfeitTx, + arg.LastUpdateTime, + ) + return err +} + +const UpdateVTXOStatus = `-- name: UpdateVTXOStatus :exec +UPDATE vtxos +SET status = $3, last_update_time = $4 +WHERE outpoint_hash = $1 AND outpoint_index = $2 +` + +type UpdateVTXOStatusParams struct { + OutpointHash []byte + OutpointIndex int32 + Status int32 + LastUpdateTime int64 +} + +// UpdateVTXOStatus atomically updates a VTXO's status. This is the primary +// method for state transitions that don't require additional data. +func (q *Queries) UpdateVTXOStatus(ctx context.Context, arg UpdateVTXOStatusParams) error { + _, err := q.db.ExecContext(ctx, UpdateVTXOStatus, + arg.OutpointHash, + arg.OutpointIndex, + arg.Status, + arg.LastUpdateTime, + ) + return err +} From b4572a1d70765102b748c95d80b33cc928e87f08 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 00:47:13 -0500 Subject: [PATCH 03/11] db: implement VTXOPersistenceStore for lifecycle tracking Add VTXOPersistenceStore implementing vtxo.VTXOStore interface for VTXO lifecycle persistence. The store uses the BatchedTx pattern for transaction-safe operations and handles: - SaveVTXO: persist new VTXOs with serialized tree paths - GetVTXO/ListLiveVTXOs: retrieve VTXOs for actor recovery - UpdateVTXOStatus: atomic status transitions - MarkForfeiting: store forfeit tx for crash recovery - MarkForfeited: record forfeit confirmation Also updates migrations.go with the new migration and makes minor adjustments to round_store.go for consistency. --- db/round_store.go | 39 ++- db/vtxo_store.go | 421 +++++++++++++++++++++++++ db/vtxo_store_test.go | 694 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 1151 insertions(+), 3 deletions(-) create mode 100644 db/vtxo_store.go create mode 100644 db/vtxo_store_test.go diff --git a/db/round_store.go b/db/round_store.go index 743d319ca..b576c1529 100644 --- a/db/round_store.go +++ b/db/round_store.go @@ -98,6 +98,29 @@ type RoundStore interface { MarkVTXOSpent(ctx context.Context, arg sqlc.MarkVTXOSpentParams) error + // VTXO lifecycle status queries. + ListLiveVTXOs(ctx context.Context) ([]VTXORow, error) + + ListVTXOsByStatus(ctx context.Context, status int32) ([]VTXORow, error) + + UpdateVTXOStatus( + ctx context.Context, arg sqlc.UpdateVTXOStatusParams, + ) error + + MarkVTXOForfeiting( + ctx context.Context, arg sqlc.MarkVTXOForfeitingParams, + ) error + + GetVTXOForfeitTx( + ctx context.Context, arg sqlc.GetVTXOForfeitTxParams, + ) (sqlc.GetVTXOForfeitTxRow, error) + + MarkVTXOForfeited( + ctx context.Context, arg sqlc.MarkVTXOForfeitedParams, + ) error + + DeleteVTXO(ctx context.Context, arg sqlc.DeleteVTXOParams) error + // Include BoardingStore methods for fetching boarding intent details. GetBoardingIntent( ctx context.Context, arg BoardingIntentKey, @@ -1166,9 +1189,19 @@ func (s *RoundPersistenceStore) domainVTXOToInsertParams( ClientPubkey: clientPubkey, OperatorPubkey: operatorPubkey, TreePath: treePathBytes, - Spent: false, - CreationTime: nowUnix, - LastUpdateTime: nowUnix, + // BatchExpiry, TreeDepth, CreatedHeight, and CommitmentTxid + // are not available in ClientVTXO. These are populated + // later when the VTXO manager creates full Descriptors from + // VTXOCreatedNotification. The InsertVTXO query uses ON + // CONFLICT DO UPDATE, so subsequent inserts with full + // metadata will update these fields. + BatchExpiry: 0, + TreeDepth: 0, + CreatedHeight: 0, + CommitmentTxid: []byte{}, + Spent: false, + CreationTime: nowUnix, + LastUpdateTime: nowUnix, }, nil } diff --git a/db/vtxo_store.go b/db/vtxo_store.go new file mode 100644 index 000000000..5c1997c6b --- /dev/null +++ b/db/vtxo_store.go @@ -0,0 +1,421 @@ +package db + +import ( + "bytes" + "context" + "database/sql" + "fmt" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btcwallet/waddrmgr" + "github.com/lightninglabs/darepo-client/db/sqlc" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/clock" + "github.com/lightningnetwork/lnd/keychain" +) + +// VTXOPersistenceStore implements the vtxo.VTXOStore interface using the +// BatchedTx pattern for transaction-safe VTXO lifecycle operations. +type VTXOPersistenceStore struct { + // db provides the underlying batched transaction executor. + db BatchedRoundStore + + // clock provides time for timestamps. + clock clock.Clock +} + +// NewVTXOPersistenceStore creates a new VTXO persistence store using the +// transaction executor pattern. +func NewVTXOPersistenceStore( + db BatchedRoundStore, c clock.Clock, +) *VTXOPersistenceStore { + + return &VTXOPersistenceStore{ + db: db, + clock: c, + } +} + +// SaveVTXO persists a new VTXO to storage. Called when a VTXO actor is created. +// Returns error if a VTXO with the same outpoint already exists. +func (s *VTXOPersistenceStore) SaveVTXO( + ctx context.Context, desc *vtxo.Descriptor, +) error { + + writeTxOpts := WriteTxOption() + + return s.db.ExecTx(ctx, writeTxOpts, func(q RoundStore) error { + params, err := s.descriptorToInsertParams(desc) + if err != nil { + return fmt.Errorf("convert descriptor: %w", err) + } + + return q.InsertVTXO(ctx, params) + }) +} + +// GetVTXO retrieves a VTXO by its outpoint. Used for actor recovery on startup. +// Returns error if not found. +func (s *VTXOPersistenceStore) GetVTXO( + ctx context.Context, outpoint wire.OutPoint, +) (*vtxo.Descriptor, error) { + + readTxOpts := ReadTxOption() + + var result *vtxo.Descriptor + + err := s.db.ExecTx(ctx, readTxOpts, func(q RoundStore) error { + params := sqlc.GetVTXOParams{ + OutpointHash: outpoint.Hash[:], + OutpointIndex: int32(outpoint.Index), + } + + row, err := q.GetVTXO(ctx, params) + if err != nil { + return fmt.Errorf("get VTXO: %w", err) + } + + desc, err := s.rowToDescriptor(row) + if err != nil { + return fmt.Errorf("convert VTXO: %w", err) + } + + result = desc + + return nil + }) + + return result, err +} + +// ListLiveVTXOs returns all VTXOs not in a terminal state. Used during startup +// to recover active VTXO actors after restart. +func (s *VTXOPersistenceStore) ListLiveVTXOs( + 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 := make([]*vtxo.Descriptor, 0, len(rows)) + for _, row := range rows { + desc, err := s.rowToDescriptor(row) + if err != nil { + return fmt.Errorf("convert VTXO: %w", err) + } + + descs = append(descs, desc) + } + + result = descs + + 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( + ctx context.Context, outpoint wire.OutPoint, status vtxo.VTXOStatus, +) error { + + writeTxOpts := WriteTxOption() + + return s.db.ExecTx(ctx, writeTxOpts, func(q RoundStore) error { + params := sqlc.UpdateVTXOStatusParams{ + OutpointHash: outpoint.Hash[:], + OutpointIndex: int32(outpoint.Index), + Status: int32(status), + LastUpdateTime: s.clock.Now().Unix(), + } + + return q.UpdateVTXOStatus(ctx, params) + }) +} + +// MarkForfeiting transitions a VTXO to forfeiting state and persists the signed +// forfeit transaction for crash recovery. Called when entering the forfeit flow +// before the new round's commitment confirms. +func (s *VTXOPersistenceStore) MarkForfeiting( + ctx context.Context, outpoint wire.OutPoint, roundID string, + forfeitTx *wire.MsgTx, +) error { + + writeTxOpts := WriteTxOption() + + return s.db.ExecTx(ctx, writeTxOpts, func(q RoundStore) error { + // Serialize the forfeit transaction. + var forfeitTxBytes []byte + if forfeitTx != nil { + var buf bytes.Buffer + if err := forfeitTx.Serialize(&buf); err != nil { + return fmt.Errorf( + "serialize forfeit tx: %w", err, + ) + } + + forfeitTxBytes = buf.Bytes() + } + + params := sqlc.MarkVTXOForfeitingParams{ + OutpointHash: outpoint.Hash[:], + OutpointIndex: int32(outpoint.Index), + ForfeitRoundID: sql.NullString{ + String: roundID, + Valid: roundID != "", + }, + ForfeitTx: forfeitTxBytes, + LastUpdateTime: s.clock.Now().Unix(), + } + + return q.MarkVTXOForfeiting(ctx, params) + }) +} + +// GetForfeitTx retrieves the persisted forfeit transaction for a VTXO. Used +// during recovery to restore the ForfeitingState with its tx. Returns nil if +// no forfeit tx is stored for this outpoint. +func (s *VTXOPersistenceStore) GetForfeitTx( + ctx context.Context, outpoint wire.OutPoint, +) (*wire.MsgTx, error) { + + readTxOpts := ReadTxOption() + + var result *wire.MsgTx + + err := s.db.ExecTx(ctx, readTxOpts, func(q RoundStore) error { + params := sqlc.GetVTXOForfeitTxParams{ + OutpointHash: outpoint.Hash[:], + OutpointIndex: int32(outpoint.Index), + } + + row, err := q.GetVTXOForfeitTx(ctx, params) + if err != nil { + return fmt.Errorf("get forfeit tx: %w", err) + } + + if len(row.ForfeitTx) == 0 { + // No forfeit tx stored. + return nil + } + + // Deserialize the forfeit transaction. + tx := &wire.MsgTx{} + reader := bytes.NewReader(row.ForfeitTx) + if err := tx.Deserialize(reader); err != nil { + return fmt.Errorf("deserialize forfeit tx: %w", err) + } + + result = tx + + return nil + }) + + return result, err +} + +// MarkForfeited marks a VTXO as forfeited and records the forfeit transaction +// ID. This is called when the new round's commitment transaction confirms. +func (s *VTXOPersistenceStore) MarkForfeited( + ctx context.Context, outpoint wire.OutPoint, forfeitTxID chainhash.Hash, +) error { + + writeTxOpts := WriteTxOption() + + return s.db.ExecTx(ctx, writeTxOpts, func(q RoundStore) error { + params := sqlc.MarkVTXOForfeitedParams{ + OutpointHash: outpoint.Hash[:], + OutpointIndex: int32(outpoint.Index), + ForfeitTxid: forfeitTxID[:], + ReplacedByHash: nil, // Set separately if needed. + ReplacedByIndex: sql.NullInt32{ + Valid: false, + }, + LastUpdateTime: s.clock.Now().Unix(), + } + + return q.MarkVTXOForfeited(ctx, params) + }) +} + +// DeleteVTXO removes a VTXO from storage. Used for cleanup after terminal +// states are reached and the VTXO is no longer needed. +func (s *VTXOPersistenceStore) DeleteVTXO( + ctx context.Context, outpoint wire.OutPoint, +) error { + + writeTxOpts := WriteTxOption() + + return s.db.ExecTx(ctx, writeTxOpts, func(q RoundStore) error { + params := sqlc.DeleteVTXOParams{ + OutpointHash: outpoint.Hash[:], + OutpointIndex: int32(outpoint.Index), + } + + return q.DeleteVTXO(ctx, params) + }) +} + +// descriptorToInsertParams converts a vtxo.Descriptor to sqlc insert +// parameters. +func (s *VTXOPersistenceStore) descriptorToInsertParams( + desc *vtxo.Descriptor, +) (InsertVTXOParams, error) { + + // Serialize tree path. + var treePathBytes []byte + if desc.TreePath != nil { + data, err := SerializeTree(desc.TreePath) + if err != nil { + return InsertVTXOParams{}, fmt.Errorf( + "serialize tree path: %w", err, + ) + } + + treePathBytes = data + } + + var operatorPubkey []byte + if desc.OperatorKey != nil { + operatorPubkey = desc.OperatorKey.SerializeCompressed() + } + + var clientPubkey []byte + if desc.ClientKey.PubKey != nil { + clientPubkey = desc.ClientKey.PubKey.SerializeCompressed() + } + + nowUnix := s.clock.Now().Unix() + + return InsertVTXOParams{ + OutpointHash: desc.Outpoint.Hash[:], + OutpointIndex: int32(desc.Outpoint.Index), + RoundID: desc.RoundID, + Amount: int64(desc.Amount), + PkScript: desc.PkScript, + Expiry: int32(desc.RelativeExpiry), + ClientKeyFamily: int32(desc.ClientKey.Family), + ClientKeyIndex: int32(desc.ClientKey.Index), + ClientPubkey: clientPubkey, + OperatorPubkey: operatorPubkey, + TreePath: treePathBytes, + BatchExpiry: desc.BatchExpiry, + TreeDepth: int32(desc.TreeDepth), + CreatedHeight: desc.CreatedHeight, + CommitmentTxid: desc.CommitmentTxID[:], + Spent: false, + CreationTime: nowUnix, + LastUpdateTime: nowUnix, + }, nil +} + +// rowToDescriptor converts a database VTXO row to a vtxo.Descriptor. +func (s *VTXOPersistenceStore) rowToDescriptor( + row VTXORow, +) (*vtxo.Descriptor, error) { + + var outpointHash chainhash.Hash + copy(outpointHash[:], row.OutpointHash) + + outpoint := wire.OutPoint{ + Hash: outpointHash, + Index: uint32(row.OutpointIndex), + } + + // Parse client public key. + var clientPubkey *btcec.PublicKey + if len(row.ClientPubkey) > 0 { + key, err := btcec.ParsePubKey(row.ClientPubkey) + if err != nil { + return nil, fmt.Errorf("parse client pubkey: %w", err) + } + + clientPubkey = key + } + + // 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 + } + + // Deserialize tree path. + var treePath *tree.Tree + if len(row.TreePath) > 0 { + t, err := DeserializeTree(row.TreePath) + if err != nil { + return nil, fmt.Errorf("deserialize tree path: %w", err) + } + + treePath = t + } + + keyFamily := keychain.KeyFamily(row.ClientKeyFamily) + + // Reconstruct the TapScript from the client and operator keys. This is + // the standard VTXO tapscript with collaborative and timeout paths. The + // TapScript is not persisted directly but derived from the stored keys + // and exit delay (expiry). + var tapscript *waddrmgr.Tapscript + if clientPubkey != nil && operatorPubkey != nil { + ts, err := scripts.VTXOTapScript( + clientPubkey, operatorPubkey, uint32(row.Expiry), + ) + if err != nil { + return nil, fmt.Errorf("reconstruct tapscript: %w", err) + } + + tapscript = ts + } + + // 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), + PkScript: row.PkScript, + ClientKey: keychain.KeyDescriptor{ + PubKey: clientPubkey, + KeyLocator: keychain.KeyLocator{ + Family: keyFamily, + Index: uint32(row.ClientKeyIndex), + }, + }, + OperatorKey: operatorPubkey, + TapScript: tapscript, + TreePath: treePath, + RoundID: row.RoundID, + CommitmentTxID: commitmentTxID, + BatchExpiry: row.BatchExpiry, + RelativeExpiry: uint32(row.Expiry), + TreeDepth: int(row.TreeDepth), + CreatedHeight: row.CreatedHeight, + Status: vtxo.VTXOStatus(row.Status), + }, nil +} + +// Compile-time check that VTXOPersistenceStore implements vtxo.VTXOStore. +var _ vtxo.VTXOStore = (*VTXOPersistenceStore)(nil) diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go new file mode 100644 index 000000000..0d7833dc5 --- /dev/null +++ b/db/vtxo_store_test.go @@ -0,0 +1,694 @@ +package db + +import ( + "database/sql" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" + "github.com/btcsuite/btcd/chaincfg/chainhash" + "github.com/btcsuite/btcd/wire" + "github.com/btcsuite/btclog/v2" + "github.com/lightninglabs/darepo-client/db/sqlc" + "github.com/lightninglabs/darepo-client/lib/scripts" + "github.com/lightninglabs/darepo-client/lib/tree" + "github.com/lightninglabs/darepo-client/round" + "github.com/lightninglabs/darepo-client/vtxo" + "github.com/lightningnetwork/lnd/clock" + fn "github.com/lightningnetwork/lnd/fn/v2" + "github.com/lightningnetwork/lnd/keychain" + "github.com/stretchr/testify/require" +) + +// newVTXOStoreForTest creates a new VTXOPersistenceStore and the underlying +// round store for test setup. Returns both to allow tests to set up rounds +// first (for FK constraints). +func newVTXOStoreForTest(t *testing.T) ( + *VTXOPersistenceStore, *RoundPersistenceStore, *BaseDB, +) { + + db := NewTestDB(t) + + roundDB := NewTransactionExecutor( + db.BaseDB, + func(tx *sql.Tx) RoundStore { + return db.WithTx(tx) + }, + btclog.Disabled, + ) + + roundStore := NewRoundPersistenceStore( + roundDB, &chaincfg.RegressionNetParams, + clock.NewDefaultClock(), + ) + + vtxoStore := NewVTXOPersistenceStore(roundDB, clock.NewDefaultClock()) + + return vtxoStore, roundStore, db.BaseDB +} + +// createTestVTXODescriptor creates a vtxo.Descriptor for testing. The index +// parameter generates unique outpoints and keys. +func createTestVTXODescriptor( + t *testing.T, roundID round.RoundID, idx int, +) *vtxo.Descriptor { + + privKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + operatorKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + var hash chainhash.Hash + hash[0] = byte(idx) + hash[1] = 0xde + hash[2] = 0xad + + outpoint := wire.OutPoint{ + Hash: hash, + Index: uint32(idx), + } + + // Create a minimal tree path for testing. + treePath := &tree.Tree{ + BatchOutpoint: wire.OutPoint{Hash: hash, Index: 0}, + Root: &tree.Node{ + Input: wire.OutPoint{Hash: hash, Index: 0}, + Outputs: []*wire.TxOut{}, + CoSigners: []*btcec.PublicKey{}, + Children: make(map[uint32]*tree.Node), + }, + } + + // Create the commitment txid. + var commitmentTxID chainhash.Hash + commitmentTxID[0] = byte(idx) + commitmentTxID[1] = 0xc0 + commitmentTxID[2] = 0xff + commitmentTxID[3] = 0xee + + // Build the tapscript from client and operator keys. + const exitDelay uint32 = 144 + tapscript, err := scripts.VTXOTapScript( + privKey.PubKey(), operatorKey.PubKey(), exitDelay, + ) + require.NoError(t, err) + + return &vtxo.Descriptor{ + Outpoint: outpoint, + Amount: btcutil.Amount(100000 * (idx + 1)), + PkScript: []byte{0x51, 0x20, byte(idx)}, + ClientKey: keychain.KeyDescriptor{ + PubKey: privKey.PubKey(), + KeyLocator: keychain.KeyLocator{ + Family: keychain.KeyFamily(0), + Index: uint32(idx), + }, + }, + OperatorKey: operatorKey.PubKey(), + TapScript: tapscript, + TreePath: treePath, + RoundID: roundID.String(), + CommitmentTxID: commitmentTxID, + BatchExpiry: 1000 + int32(idx*100), + RelativeExpiry: exitDelay, + TreeDepth: 2 + idx, + CreatedHeight: 500 + int32(idx*10), + Status: vtxo.VTXOStatusLive, + } +} + +// TestVTXOPersistenceStoreSaveAndGet tests the basic save and get operations. +func TestVTXOPersistenceStoreSaveAndGet(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-save-get") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create and save a VTXO. + desc := createTestVTXODescriptor(t, roundID, 42) + err = vtxoStore.SaveVTXO(ctx, desc) + require.NoError(t, err) + + // Retrieve it. + fetched, err := vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.NotNil(t, fetched) + + // Verify fields. + require.Equal(t, desc.Outpoint, fetched.Outpoint) + require.Equal(t, desc.Amount, fetched.Amount) + require.Equal(t, desc.PkScript, fetched.PkScript) + require.Equal(t, desc.RelativeExpiry, fetched.RelativeExpiry) + require.Equal(t, desc.RoundID, fetched.RoundID) + require.Equal(t, desc.Status, fetched.Status) + + // Verify keys. + require.NotNil(t, fetched.ClientKey.PubKey) + require.NotNil(t, fetched.OperatorKey) + require.Equal(t, desc.ClientKey.Family, fetched.ClientKey.Family) + require.Equal(t, desc.ClientKey.Index, fetched.ClientKey.Index) + + // Verify tree path was persisted. + require.NotNil(t, fetched.TreePath) + require.Equal( + t, desc.TreePath.BatchOutpoint, fetched.TreePath.BatchOutpoint, + ) +} + +// TestVTXOPersistenceStoreListLiveVTXOs tests that ListLiveVTXOs returns only +// VTXOs in non-terminal states. +func TestVTXOPersistenceStoreListLiveVTXOs(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy foreign key constraint. + roundID := testRoundIDDB("test-round-live-vtxos") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create and save three test VTXOs. + vtxo1 := createTestVTXODescriptor(t, roundID, 1) + vtxo2 := createTestVTXODescriptor(t, roundID, 2) + vtxo3 := createTestVTXODescriptor(t, roundID, 3) + + err = vtxoStore.SaveVTXO(ctx, vtxo1) + require.NoError(t, err) + err = vtxoStore.SaveVTXO(ctx, vtxo2) + require.NoError(t, err) + err = vtxoStore.SaveVTXO(ctx, vtxo3) + require.NoError(t, err) + + // Verify all three VTXOs are returned as live. + liveVTXOs, err := vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 3) + + // Mark vtxo2 as Forfeited (terminal state). + err = vtxoStore.UpdateVTXOStatus( + ctx, vtxo2.Outpoint, vtxo.VTXOStatusForfeited, + ) + require.NoError(t, err) + + // Verify vtxo2 is no longer in the live list. + liveVTXOs, err = vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 2, "forfeited VTXO should be excluded") + + // Verify the correct VTXOs are returned. + outpoints := make(map[wire.OutPoint]bool) + for _, v := range liveVTXOs { + outpoints[v.Outpoint] = true + } + require.True(t, outpoints[vtxo1.Outpoint], "vtxo1 should be live") + require.False(t, outpoints[vtxo2.Outpoint], "vtxo2 should NOT be live") + require.True(t, outpoints[vtxo3.Outpoint], "vtxo3 should be live") + + // Mark vtxo3 as RefreshRequested (non-terminal, should still be live). + err = vtxoStore.UpdateVTXOStatus( + ctx, vtxo3.Outpoint, vtxo.VTXOStatusRefreshRequested, + ) + require.NoError(t, err) + + liveVTXOs, err = vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 2, "RefreshRequested is non-terminal") +} + +// TestVTXOPersistenceStoreStatusTransitions tests the status update methods. +func TestVTXOPersistenceStoreStatusTransitions(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-status") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create and save a VTXO. + desc := createTestVTXODescriptor(t, roundID, 1) + err = vtxoStore.SaveVTXO(ctx, desc) + require.NoError(t, err) + + // Verify initial status is Live. + fetched, err := vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusLive, fetched.Status) + + // Transition to RefreshRequested. + err = vtxoStore.UpdateVTXOStatus( + ctx, desc.Outpoint, vtxo.VTXOStatusRefreshRequested, + ) + require.NoError(t, err) + + fetched, err = vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusRefreshRequested, fetched.Status) + + // Transition to Forfeiting via MarkForfeiting. + forfeitRoundID := testRoundIDDB("forfeit-round") + err = vtxoStore.MarkForfeiting( + ctx, desc.Outpoint, forfeitRoundID.String(), nil, + ) + require.NoError(t, err) + + fetched, err = vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeiting, fetched.Status) + + // Transition to Forfeited via MarkForfeited. + forfeitTxID := chainhash.Hash{0xab, 0xcd} + err = vtxoStore.MarkForfeited(ctx, desc.Outpoint, forfeitTxID) + require.NoError(t, err) + + fetched, err = vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeited, fetched.Status) + + // Verify VTXO is no longer in live list (terminal state). + liveVTXOs, err := vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 0) +} + +// TestVTXOPersistenceStoreForfeitTxPersistence tests that MarkForfeiting +// correctly persists the forfeit transaction and GetForfeitTx retrieves it. +func TestVTXOPersistenceStoreForfeitTxPersistence(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-forfeit-tx") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create and save a VTXO. + desc := createTestVTXODescriptor(t, roundID, 1) + err = vtxoStore.SaveVTXO(ctx, desc) + require.NoError(t, err) + + // Initially, no forfeit tx should be stored. + forfeitTx, err := vtxoStore.GetForfeitTx(ctx, desc.Outpoint) + require.NoError(t, err) + require.Nil(t, forfeitTx, "no forfeit tx should exist initially") + + // Create a test forfeit transaction. + testForfeitTx := wire.NewMsgTx(2) + testForfeitTx.AddTxIn(&wire.TxIn{ + PreviousOutPoint: desc.Outpoint, + }) + testForfeitTx.AddTxOut(&wire.TxOut{ + Value: int64(desc.Amount) - 1000, + PkScript: []byte{0x00, 0x14, 0xab, 0xcd}, + }) + + // Mark forfeiting with the forfeit transaction. + forfeitRoundID := testRoundIDDB("forfeit-round") + err = vtxoStore.MarkForfeiting( + ctx, desc.Outpoint, forfeitRoundID.String(), testForfeitTx, + ) + require.NoError(t, err) + + // Verify status changed to Forfeiting. + fetched, err := vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeiting, fetched.Status) + + // Retrieve the forfeit transaction. + retrievedForfeitTx, err := vtxoStore.GetForfeitTx(ctx, desc.Outpoint) + require.NoError(t, err) + require.NotNil(t, retrievedForfeitTx) + + // Verify the transaction matches. + require.Equal(t, testForfeitTx.TxHash(), retrievedForfeitTx.TxHash()) + require.Len(t, retrievedForfeitTx.TxIn, 1) + require.Equal( + t, desc.Outpoint, retrievedForfeitTx.TxIn[0].PreviousOutPoint, + ) +} + +// TestVTXOPersistenceStoreDeleteVTXO tests the DeleteVTXO method. +func TestVTXOPersistenceStoreDeleteVTXO(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-delete") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create and save two VTXOs. + vtxo1 := createTestVTXODescriptor(t, roundID, 1) + vtxo2 := createTestVTXODescriptor(t, roundID, 2) + + err = vtxoStore.SaveVTXO(ctx, vtxo1) + require.NoError(t, err) + err = vtxoStore.SaveVTXO(ctx, vtxo2) + require.NoError(t, err) + + // Verify both exist. + liveVTXOs, err := vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 2) + + // Delete vtxo1. + err = vtxoStore.DeleteVTXO(ctx, vtxo1.Outpoint) + require.NoError(t, err) + + // Verify vtxo1 is gone. + liveVTXOs, err = vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 1) + require.Equal(t, vtxo2.Outpoint, liveVTXOs[0].Outpoint) + + // Attempting to get the deleted VTXO should fail. + _, err = vtxoStore.GetVTXO(ctx, vtxo1.Outpoint) + require.Error(t, err, "getting deleted VTXO should fail") +} + +// TestVTXOPersistenceStoreMarkForfeitedRecordsTxID tests that MarkForfeited +// correctly records the forfeit transaction ID. +func TestVTXOPersistenceStoreMarkForfeitedRecordsTxID(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, db := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-forfeited") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create and save a VTXO. + desc := createTestVTXODescriptor(t, roundID, 1) + err = vtxoStore.SaveVTXO(ctx, desc) + require.NoError(t, err) + + // Go through the forfeit flow: first mark forfeiting. + forfeitRoundID := testRoundIDDB("forfeit-round") + err = vtxoStore.MarkForfeiting( + ctx, desc.Outpoint, forfeitRoundID.String(), nil, + ) + require.NoError(t, err) + + // Now mark as forfeited with a txid. + forfeitTxID := chainhash.Hash{0xde, 0xad, 0xbe, 0xef} + err = vtxoStore.MarkForfeited(ctx, desc.Outpoint, forfeitTxID) + require.NoError(t, err) + + // Verify via raw db query that the forfeit_txid was stored. + row, err := db.GetVTXO(ctx, sqlc.GetVTXOParams{ + OutpointHash: desc.Outpoint.Hash[:], + OutpointIndex: int32(desc.Outpoint.Index), + }) + require.NoError(t, err) + require.Equal(t, int32(vtxo.VTXOStatusForfeited), row.Status) + require.Equal(t, forfeitTxID[:], row.ForfeitTxid) +} + +// TestVTXOPersistenceStoreMultipleVTXOsLifecycle tests a realistic scenario +// with multiple VTXOs going through different lifecycle paths. +func TestVTXOPersistenceStoreMultipleVTXOsLifecycle(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-lifecycle") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create 5 VTXOs simulating different scenarios. + vtxos := make([]*vtxo.Descriptor, 5) + for i := 0; i < 5; i++ { + vtxos[i] = createTestVTXODescriptor(t, roundID, i) + err = vtxoStore.SaveVTXO(ctx, vtxos[i]) + require.NoError(t, err) + } + + // All 5 should be live initially. + liveVTXOs, err := vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 5) + + // VTXO 0: stays live (no changes). + // VTXO 1: goes to RefreshRequested (still live). + err = vtxoStore.UpdateVTXOStatus( + ctx, vtxos[1].Outpoint, vtxo.VTXOStatusRefreshRequested, + ) + require.NoError(t, err) + + // VTXO 2: goes through full forfeit flow. + forfeitRoundID := testRoundIDDB("forfeit-round-2") + err = vtxoStore.MarkForfeiting( + ctx, vtxos[2].Outpoint, forfeitRoundID.String(), nil, + ) + require.NoError(t, err) + err = vtxoStore.MarkForfeited( + ctx, vtxos[2].Outpoint, chainhash.Hash{0x02}, + ) + require.NoError(t, err) + + // VTXO 3: goes to Forfeiting (still counted as live for recovery). + err = vtxoStore.MarkForfeiting( + ctx, vtxos[3].Outpoint, forfeitRoundID.String(), nil, + ) + require.NoError(t, err) + + // VTXO 4: gets deleted. + err = vtxoStore.DeleteVTXO(ctx, vtxos[4].Outpoint) + require.NoError(t, err) + + // Check live VTXOs: should be 0, 1, 3 (not 2 which is Forfeited, not 4 + // which is deleted). + liveVTXOs, err = vtxoStore.ListLiveVTXOs(ctx) + require.NoError(t, err) + require.Len(t, liveVTXOs, 3) + + // Verify the expected outpoints. + outpoints := make(map[wire.OutPoint]bool) + for _, v := range liveVTXOs { + outpoints[v.Outpoint] = true + } + require.True(t, outpoints[vtxos[0].Outpoint], "vtxo 0 live") + require.True(t, outpoints[vtxos[1].Outpoint], "vtxo 1 live") + require.False(t, outpoints[vtxos[2].Outpoint], "vtxo 2 NOT live") + require.True(t, outpoints[vtxos[3].Outpoint], "vtxo 3 live") + require.False(t, outpoints[vtxos[4].Outpoint], "vtxo 4 NOT live") + + // Verify statuses. + fetched0, err := vtxoStore.GetVTXO(ctx, vtxos[0].Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusLive, fetched0.Status) + + fetched1, err := vtxoStore.GetVTXO(ctx, vtxos[1].Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusRefreshRequested, fetched1.Status) + + fetched2, err := vtxoStore.GetVTXO(ctx, vtxos[2].Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeited, fetched2.Status) + + fetched3, err := vtxoStore.GetVTXO(ctx, vtxos[3].Outpoint) + require.NoError(t, err) + require.Equal(t, vtxo.VTXOStatusForfeiting, fetched3.Status) +} + +// TestVTXOPersistenceStoreMetadataPersistence tests that the new metadata +// fields (BatchExpiry, TreeDepth, CreatedHeight, CommitmentTxID) are correctly +// persisted and retrieved, and that TapScript is correctly reconstructed. +func TestVTXOPersistenceStoreMetadataPersistence(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-metadata") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create and save a VTXO with full metadata. + desc := createTestVTXODescriptor(t, roundID, 42) + err = vtxoStore.SaveVTXO(ctx, desc) + require.NoError(t, err) + + // Retrieve it. + fetched, err := vtxoStore.GetVTXO(ctx, desc.Outpoint) + require.NoError(t, err) + require.NotNil(t, fetched) + + // Verify the new metadata fields are persisted correctly. + require.Equal( + t, desc.BatchExpiry, fetched.BatchExpiry, + "BatchExpiry should be persisted", + ) + require.Equal( + t, desc.TreeDepth, fetched.TreeDepth, + "TreeDepth should be persisted", + ) + require.Equal( + t, desc.CreatedHeight, fetched.CreatedHeight, + "CreatedHeight should be persisted", + ) + require.Equal( + t, desc.CommitmentTxID, fetched.CommitmentTxID, + "CommitmentTxID should be persisted", + ) + + // Verify TapScript is reconstructed correctly. The tapscript should be + // derived from the client/operator keys and exit delay on retrieval. + require.NotNil( + t, fetched.TapScript, "TapScript should be reconstructed", + ) + require.NotNil(t, desc.TapScript, "original TapScript should exist") + + // Verify that the reconstructed TapScript has the same structure by + // checking it has leaves. + require.NotEmpty( + t, fetched.TapScript.Leaves, + "reconstructed TapScript should have leaves", + ) + require.Equal( + t, len(desc.TapScript.Leaves), len(fetched.TapScript.Leaves), + "reconstructed TapScript should have same number of leaves", + ) +} + +// TestVTXOPersistenceStoreMetadataUpdate tests that metadata fields can be +// updated via the ON CONFLICT DO UPDATE clause when a VTXO is inserted twice +// (first with defaults from round store, then with full metadata). +func TestVTXOPersistenceStoreMetadataUpdate(t *testing.T) { + t.Parallel() + + vtxoStore, roundStore, db := newVTXOStoreForTest(t) + ctx := t.Context() + + // Create a round to satisfy FK constraint. + roundID := testRoundIDDB("test-round-metadata-update") + testRound := createTestRound(t, roundID) + state := &round.InputSigSentState{ + RoundID: testRound.RoundID, + ClientTrees: make(map[round.SignerKey]*tree.Tree), + } + err := roundStore.CommitState(ctx, testRound, state) + require.NoError(t, err) + + // Create a VTXO with full metadata. + desc := createTestVTXODescriptor(t, roundID, 99) + + // Simulate the round store inserting first with default/zero metadata. + // This mimics what happens when SaveVTXOs is called from round + // transitions before the VTXO manager creates full Descriptors. + clientVTXO := &round.ClientVTXO{ + Outpoint: desc.Outpoint, + Amount: desc.Amount, + PkScript: desc.PkScript, + Expiry: desc.RelativeExpiry, + ClientKey: desc.ClientKey, + OperatorKey: desc.OperatorKey, + TreePath: desc.TreePath, + RoundID: fn.Some(roundID), + } + err = roundStore.SaveVTXOs(ctx, []*round.ClientVTXO{clientVTXO}) + require.NoError(t, err) + + // Verify the VTXO was inserted with zero metadata. + row, err := db.GetVTXO(ctx, sqlc.GetVTXOParams{ + OutpointHash: desc.Outpoint.Hash[:], + OutpointIndex: int32(desc.Outpoint.Index), + }) + require.NoError(t, err) + require.Equal( + t, int32(0), row.BatchExpiry, "initial BatchExpiry should be 0", + ) + require.Equal( + t, int32(0), row.TreeDepth, "initial TreeDepth should be 0", + ) + require.Equal( + t, int32(0), row.CreatedHeight, + "initial CreatedHeight should be 0", + ) + + // Now insert again with full metadata (simulates VTXO manager saving). + err = vtxoStore.SaveVTXO(ctx, desc) + require.NoError(t, err) + + // Verify the metadata was updated. + row, err = db.GetVTXO(ctx, sqlc.GetVTXOParams{ + OutpointHash: desc.Outpoint.Hash[:], + OutpointIndex: int32(desc.Outpoint.Index), + }) + require.NoError(t, err) + require.Equal( + t, desc.BatchExpiry, row.BatchExpiry, + "BatchExpiry should be updated", + ) + require.Equal( + t, int32(desc.TreeDepth), row.TreeDepth, + "TreeDepth should be updated", + ) + require.Equal( + t, desc.CreatedHeight, row.CreatedHeight, + "CreatedHeight should be updated", + ) + require.Equal( + t, desc.CommitmentTxID[:], row.CommitmentTxid, + "CommitmentTxid should be updated", + ) +} From b9cd58c0170845938d0a5af1352e53fa299fe956 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 00:47:25 -0500 Subject: [PATCH 04/11] wallet: add RefreshVTXOsRequest/Response messages Add wallet-level API messages for triggering VTXO refresh: - RefreshVTXOsRequest: specifies target VTXOs to refresh with optional ForceRefresh flag to bypass expiry threshold checking - RefreshVTXOsResponse: reports count of VTXOs queued for refresh and any errors encountered These messages form the high-level interface for the refresh flow, routing through the wallet actor to the round actor. --- wallet/messages.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/wallet/messages.go b/wallet/messages.go index 865024aac..fd091c971 100644 --- a/wallet/messages.go +++ b/wallet/messages.go @@ -3,6 +3,7 @@ package wallet import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/chainsource" fn "github.com/lightningnetwork/lnd/fn/v2" @@ -259,3 +260,44 @@ func (m BlockEpochNotification) MessageType() string { // walletMsgSealed implements the sealed WalletMsg interface. func (m BlockEpochNotification) walletMsgSealed() {} + +// RefreshVTXOsRequest triggers refresh of specified VTXOs or all VTXOs +// approaching expiry. This is the primary wallet-level API for refresh. +type RefreshVTXOsRequest struct { + actor.BaseMessage + + // TargetOutpoints specifies which VTXOs to refresh. If empty, refreshes + // all VTXOs within the expiry threshold. + TargetOutpoints []wire.OutPoint + + // ForceRefresh ignores the expiry threshold and refreshes immediately. + // Used by tests or when user explicitly requests refresh. + ForceRefresh bool +} + +// MessageType returns the message type identifier for logging and debugging. +func (m *RefreshVTXOsRequest) MessageType() string { + return "RefreshVTXOsRequest" +} + +// walletMsgSealed implements the sealed WalletMsg interface. +func (m *RefreshVTXOsRequest) walletMsgSealed() {} + +// RefreshVTXOsResponse indicates the result of the refresh request. +type RefreshVTXOsResponse struct { + actor.BaseMessage + + // RefreshingCount is the number of VTXOs that were queued for refresh. + RefreshingCount int + + // Errors contains any VTXOs that couldn't be refreshed and why. + Errors map[wire.OutPoint]error +} + +// MessageType returns the message type identifier for logging and debugging. +func (m *RefreshVTXOsResponse) MessageType() string { + return "RefreshVTXOsResponse" +} + +// walletRespSealed implements the sealed WalletResp interface. +func (m *RefreshVTXOsResponse) walletRespSealed() {} From e2ac35c46f845f1285387da63867468e08bea33a Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 00:47:34 -0500 Subject: [PATCH 05/11] wallet: add refresh request handler Add handler for RefreshVTXOsRequest that: 1. Retrieves target VTXOs from store (specific outpoints or all live) 2. Filters by expiry threshold unless ForceRefresh is set 3. Sends RefreshVTXORequest to round actor for each eligible VTXO Also adds vtxoStore and roundActor dependencies to the wallet actor config for refresh flow support. --- systest/helpers.go | 2 ++ wallet/wallet.go | 44 +++++++++++++++++++++++++++++++++++++++++++ wallet/wallet_test.go | 41 ++++++++++++++++++++++++++++++---------- 3 files changed, 77 insertions(+), 10 deletions(-) diff --git a/systest/helpers.go b/systest/helpers.go index e5a0b59dd..385fac670 100644 --- a/systest/helpers.go +++ b/systest/helpers.go @@ -12,6 +12,7 @@ import ( "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/wallet" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/stretchr/testify/require" @@ -113,6 +114,7 @@ func NewBoardingWalletFixture(t *testing.T) *BoardingWalletFixture { backend := h.NewBoardingBackend() walletActor := wallet.NewArk( backend, h.BoardingStore(), chainSourceRef, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), h.SubLogger(wallet.Subsystem), ) diff --git a/wallet/wallet.go b/wallet/wallet.go index ffde5e3a3..cabbf1776 100644 --- a/wallet/wallet.go +++ b/wallet/wallet.go @@ -8,10 +8,12 @@ import ( "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/wire" "github.com/btcsuite/btclog/v2" "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/actormsg" "github.com/lightninglabs/darepo-client/lib/scripts" fn "github.com/lightningnetwork/lnd/fn/v2" ) @@ -56,6 +58,10 @@ type Ark struct { // store persists boarding addresses and intents to the database. store BoardingStore + // roundActor is a reference to the round actor for forwarding refresh + // requests. The round actor handles VTXO actor coordination. + roundActor fn.Option[actor.TellOnlyRef[actormsg.RoundReceivable]] + // chainSource provides block epoch notifications for polling. chainSource actor.ActorRef[ chainsource.ChainSourceMsg, chainsource.ChainSourceResp, @@ -87,14 +93,19 @@ type Ark struct { // NewArk creates a new Ark wallet actor. The logger should already have the // subsystem set (e.g., created via handler.SubSystem(wallet.Subsystem)). +// +// The roundActor parameter is optional - if provided, refresh requests will be +// forwarded to the round actor for VTXO actor coordination. func NewArk(backend BoardingBackend, store BoardingStore, chainSource actor.ActorRef[chainsource.ChainSourceMsg, chainsource.ChainSourceResp], + roundActor fn.Option[actor.TellOnlyRef[actormsg.RoundReceivable]], log btclog.Logger) *Ark { return &Ark{ backend: backend, store: store, chainSource: chainSource, + roundActor: roundActor, notifiers: make(map[string]notifierInfo), seenUtxos: fn.NewSet[UtxoKey](), log: log, @@ -205,6 +216,9 @@ func (a *Ark) Receive(ctx context.Context, case BlockEpochNotification: return a.handleBlockEpoch(ctx, m.BlockEpoch) + case *RefreshVTXOsRequest: + return a.handleRefreshVTXOs(ctx, m) + default: return fn.Err[WalletResp]( fmt.Errorf("unknown message type: %T", msg)) @@ -513,6 +527,36 @@ func (a *Ark) sendBacklog(ctx context.Context, slog.Int("events_sent", len(intents))) } +// handleRefreshVTXOs processes a request to refresh VTXOs. This forwards the +// request to the round actor which coordinates with VTXO actors to initiate +// the refresh flow. +func (a *Ark) handleRefreshVTXOs(ctx context.Context, + req *RefreshVTXOsRequest) fn.Result[WalletResp] { + + a.log.InfoS(ctx, "Received VTXO refresh request", + slog.Int("target_count", len(req.TargetOutpoints)), + slog.Bool("force_refresh", req.ForceRefresh), + ) + + // Forward to round actor if configured. The round actor looks up VTXO + // actors by service key and sends TriggerRefreshEvent to each one. + a.roundActor.WhenSome( + func(ref actor.TellOnlyRef[actormsg.RoundReceivable]) { + ref.Tell(ctx, &actormsg.TriggerVTXORefreshMsg{ + TargetOutpoints: req.TargetOutpoints, + ForceRefresh: req.ForceRefresh, + }) + }, + ) + + resp := &RefreshVTXOsResponse{ + RefreshingCount: len(req.TargetOutpoints), + Errors: make(map[wire.OutPoint]error), + } + + return fn.Ok[WalletResp](resp) +} + // buildBoardingTapscript constructs a 2-of-2 tapscript with CSV timeout for // boarding. The tapscript has two spending paths: // - Collaborative: Requires both client and operator signatures (spendable diff --git a/wallet/wallet_test.go b/wallet/wallet_test.go index df7ea59f4..e125bd672 100644 --- a/wallet/wallet_test.go +++ b/wallet/wallet_test.go @@ -15,6 +15,7 @@ import ( "github.com/btcsuite/btcwallet/waddrmgr" "github.com/lightninglabs/darepo-client/baselib/actor" "github.com/lightninglabs/darepo-client/chainsource" + "github.com/lightninglabs/darepo-client/lib/actormsg" fn "github.com/lightningnetwork/lnd/fn/v2" "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/mock" @@ -288,7 +289,9 @@ func TestCreateBoardingAddress(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Create a boarding address. @@ -326,7 +329,9 @@ func TestRegisterNotifier(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Create a test notifier using the actor package helper. @@ -471,7 +476,9 @@ func TestProcessNewUtxo(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Initialize the actor's state. @@ -627,7 +634,9 @@ func TestProcessUtxoMinConfFiltering(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) walletActor.seenUtxos = fn.NewSet[UtxoKey]() @@ -762,7 +771,9 @@ func TestGetActiveBoardingAddresses(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Query addresses. @@ -803,7 +814,9 @@ func TestGetBoardingBalance(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Query balance. @@ -911,7 +924,9 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Create a notifier using the actor package helper. @@ -953,7 +968,9 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Create a notifier using the actor package helper. @@ -1001,7 +1018,9 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Create a notifier using the actor package helper. @@ -1069,7 +1088,9 @@ func TestSendBacklog(t *testing.T) { chainSource := newMockChainSourceActor(epochChan) walletActor := NewArk( - backend, store, chainSource, btclog.Disabled, + backend, store, chainSource, + fn.None[actor.TellOnlyRef[actormsg.RoundReceivable]](), + btclog.Disabled, ) // Create a notifier using the actor package helper. From 3dcc75fd7f16c7459572e3f9c7a0e136c7f80ebb Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 00:47:54 -0500 Subject: [PATCH 06/11] round: use RoundID type in SubmitVTXOForfeitSigsToServer Fix type consistency by using RoundID instead of string in the SubmitVTXOForfeitSigsToServer message. This aligns with other round messages that use the typed RoundID for better type safety. --- round/outbox_messages.go | 2 +- round/transitions.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/round/outbox_messages.go b/round/outbox_messages.go index d445e5589..1bde6c585 100644 --- a/round/outbox_messages.go +++ b/round/outbox_messages.go @@ -213,7 +213,7 @@ type SubmitVTXOForfeitSigsToServer struct { actor.BaseMessage // RoundID identifies the round. - RoundID string + RoundID RoundID // ForfeitSigs maps VTXO outpoints to their forfeit transaction // signatures. Each signature is the client's schnorr signature for the diff --git a/round/transitions.go b/round/transitions.go index 199120914..690b22279 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -1028,7 +1028,7 @@ func (s *ForfeitSignaturesCollectingState) ProcessEvent( outboxMsgs := []ClientOutMsg{ &SubmitVTXOForfeitSigsToServer{ - RoundID: s.RoundID.String(), + RoundID: s.RoundID, ForfeitSigs: forfeitSigs, ForfeitTxs: forfeitTxs, }, From 4d107bde8da3eb88b880309075f2cfe761df2298 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 02:08:12 -0500 Subject: [PATCH 07/11] round+vtxo: add TriggerRefreshEvent for manual VTXO refresh Add TriggerRefreshEvent which is sent to VTXO actors to manually trigger a refresh request. This bypasses the automatic expiry-based refresh and immediately transitions the VTXO to RefreshRequested state. The event is defined in round/vtxo_messages.go (where all VTXO actor messages live) and type-aliased in vtxo/events.go. The LiveState handles this event by emitting RefreshRequest to the round actor via outbox. This enables user-initiated refresh through the wallet actor without waiting for automatic expiry thresholds. --- round/vtxo_messages.go | 20 ++++++++++++++++++++ vtxo/events.go | 3 +++ vtxo/transitions.go | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/round/vtxo_messages.go b/round/vtxo_messages.go index 73002d505..01ab1f907 100644 --- a/round/vtxo_messages.go +++ b/round/vtxo_messages.go @@ -181,6 +181,26 @@ func (e *ResumeVTXOEvent) VTXOActorMsg() {} // MessageType returns the message type for logging. func (e *ResumeVTXOEvent) MessageType() string { return "ResumeVTXOEvent" } +// TriggerRefreshEvent is sent to a VTXO actor to manually trigger a refresh +// request. This bypasses the automatic expiry-based refresh and immediately +// transitions the VTXO to RefreshRequested state. Used by the wallet actor +// when the user explicitly requests a refresh. +type TriggerRefreshEvent struct { + actor.BaseMessage + + // ForceRefresh indicates this is a user-initiated refresh that should + // proceed regardless of expiry status. + ForceRefresh bool +} + +// VTXOActorMsg implements actormsg.VTXOActorMsg marker interface. +func (e *TriggerRefreshEvent) VTXOActorMsg() {} + +// MessageType returns the message type for logging. +func (e *TriggerRefreshEvent) MessageType() string { + return "TriggerRefreshEvent" +} + // ============================================================================= // Messages TO VTXO Manager // ============================================================================= diff --git a/vtxo/events.go b/vtxo/events.go index 1434c2738..6fb8f86e3 100644 --- a/vtxo/events.go +++ b/vtxo/events.go @@ -40,4 +40,7 @@ type ( // ResumeVTXOEvent is sent when resuming a VTXO actor from persisted // state. ResumeVTXOEvent = round.ResumeVTXOEvent + + // TriggerRefreshEvent is sent to manually trigger a refresh request. + TriggerRefreshEvent = round.TriggerRefreshEvent ) diff --git a/vtxo/transitions.go b/vtxo/transitions.go index 62c0c7801..232c8af6c 100644 --- a/vtxo/transitions.go +++ b/vtxo/transitions.go @@ -24,6 +24,9 @@ func (s *LiveState) ProcessEvent( case *ForfeitRequestEvent: return s.handleForfeitRequest(ctx, evt, env) + case *TriggerRefreshEvent: + return s.handleTriggerRefresh(ctx, evt, env) + case *ResumeVTXOEvent: // On resume, stay in LiveState and re-check expiry on next // block. @@ -189,6 +192,35 @@ func (s *LiveState) handleForfeitRequest( }, nil } +// handleTriggerRefresh handles a manual refresh request from the wallet. This +// bypasses the automatic expiry-based refresh and immediately transitions the +// VTXO to RefreshRequested state, emitting a RefreshRequest to the round actor. +func (s *LiveState) handleTriggerRefresh( + _ context.Context, _ *TriggerRefreshEvent, _ *VTXOEnvironment, +) (*VTXOStateTransition, error) { + + outbox := []VTXOOutMsg{ + &RefreshRequest{ + VTXOOutpoint: s.VTXO.Outpoint, + Amount: int64(s.VTXO.Amount), + Urgency: RefreshUrgencyNormal, + }, + &VTXOStatusUpdate{ + Outpoint: s.VTXO.Outpoint, + NewStatus: VTXOStatusRefreshRequested, + }, + } + + return &VTXOStateTransition{ + NextState: &RefreshRequestedState{ + VTXO: s.VTXO, + // Manual trigger has no height context. + RequestedAtHeight: 0, + }, + NewEvents: fn.Some(VTXOEmittedEvent{Outbox: outbox}), + }, nil +} + // signForfeitVTXOInput produces the client's schnorr signature for the VTXO // input of a forfeit transaction. The VTXO uses a tapscript with a 2-of-2 // collaborative spend path, so both client and operator signatures are needed. From 4e3784f9609b06723bf21c85c18a7efc62984448 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 02:08:31 -0500 Subject: [PATCH 08/11] actormsg: add TriggerVTXORefreshMsg for wallet-to-round refresh Add TriggerVTXORefreshMsg which is sent from the wallet actor to the round actor to trigger refresh of specific VTXOs. This message is defined in actormsg to avoid an import cycle between wallet and round packages (round imports wallet for BoardingAddress). The message implements RoundReceivable so it can be sent via the wallet's TellOnlyRef[RoundReceivable] reference to the round actor. --- lib/actormsg/interfaces.go | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lib/actormsg/interfaces.go b/lib/actormsg/interfaces.go index baca09a9e..879ad77c5 100644 --- a/lib/actormsg/interfaces.go +++ b/lib/actormsg/interfaces.go @@ -1,6 +1,9 @@ package actormsg -import "github.com/lightninglabs/darepo-client/baselib/actor" +import ( + "github.com/btcsuite/btcd/wire" + "github.com/lightninglabs/darepo-client/baselib/actor" +) // VTXOActorMsg is the message type for VTXO actors. Messages sent TO VTXO // actors implement this interface via the exported marker method. This enables @@ -31,3 +34,25 @@ type VTXOManagerMsg interface { actor.Message VTXOManagerMsg() } + +// TriggerVTXORefreshMsg is sent from the wallet actor to the round actor to +// request refresh of specific VTXOs. Defined in actormsg to avoid import cycle +// between wallet and round packages. +type TriggerVTXORefreshMsg struct { + actor.BaseMessage + + // TargetOutpoints specifies which VTXOs to refresh. + TargetOutpoints []wire.OutPoint + + // ForceRefresh indicates this is a user-initiated refresh that should + // proceed regardless of expiry status. + ForceRefresh bool +} + +// RoundReceivable implements the RoundReceivable marker interface. +func (m *TriggerVTXORefreshMsg) RoundReceivable() {} + +// MessageType returns the message type for logging. +func (m *TriggerVTXORefreshMsg) MessageType() string { + return "TriggerVTXORefreshMsg" +} From 08b1318391ee09588e653f5bdbe82e24b7a79d04 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Sat, 24 Jan 2026 02:08:41 -0500 Subject: [PATCH 09/11] round: handle wallet-initiated VTXO refresh via actor system Add handleTriggerVTXORefresh which processes TriggerVTXORefreshMsg from the wallet actor. For each target outpoint, we look up the VTXO actor via its service key and send TriggerRefreshEvent. The VTXO actors then emit RefreshVTXORequest back to us through their outbox. This completes the wallet -> round -> vtxo refresh triggering flow. --- round/actor.go | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/round/actor.go b/round/actor.go index 932ca004d..5eb71e7c0 100644 --- a/round/actor.go +++ b/round/actor.go @@ -621,6 +621,9 @@ func (a *RoundClientActor) Receive(ctx context.Context, case *ForfeitSignatureResponse: return a.handleForfeitSignatureResponse(ctx, m) + case *actormsg.TriggerVTXORefreshMsg: + return a.handleTriggerVTXORefresh(ctx, m) + default: return fn.Err[ClientResp](fmt.Errorf( "unknown message type: %T", msg)) @@ -1393,3 +1396,36 @@ func (a *RoundClientActor) handleForfeitSignatureResponse(ctx context.Context, return fn.Ok[ClientResp](nil) } + +// handleTriggerVTXORefresh processes a refresh trigger request from the wallet +// actor. For each target outpoint, we send TriggerRefreshEvent to the VTXO +// actor via its service key. The VTXO actor then emits RefreshVTXORequest back +// to us through its outbox. +func (a *RoundClientActor) handleTriggerVTXORefresh(ctx context.Context, + cmd *actormsg.TriggerVTXORefreshMsg) fn.Result[ClientResp] { + + if a.cfg.ActorSystem == nil { + return fn.Err[ClientResp](fmt.Errorf( + "ActorSystem not configured, cannot trigger VTXO refresh", + )) + } + + triggeredCount := 0 + for _, outpoint := range cmd.TargetOutpoints { + serviceKey := actormsg.VTXOActorServiceKey(outpoint) + serviceKey.Ref(a.cfg.ActorSystem).Tell(ctx, &TriggerRefreshEvent{ + ForceRefresh: cmd.ForceRefresh, + }) + + a.log.InfoS(ctx, "Sent refresh trigger to VTXO actor", + slog.String("outpoint", outpoint.String()), + slog.Bool("force", cmd.ForceRefresh)) + + triggeredCount++ + } + + a.log.InfoS(ctx, "Triggered VTXO refresh", + slog.Int("count", triggeredCount)) + + return fn.Ok[ClientResp](nil) +} From 639531e372266a4be07f726875771c69bcff53cb Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 29 Jan 2026 16:50:18 -0400 Subject: [PATCH 10/11] round+vtxo: build VTXORequest for refresh, add debug logging When handling RefreshVTXORequest events, create a corresponding VTXORequest to ensure the refresh has an output destination. Without this, refresh-only rounds would fail validation due to zero total output amount. Also add debug logging to both round and VTXO actors to aid in tracing the forfeit request flow and state transitions during refresh operations. --- round/actor.go | 26 +++++++++++++++++++++++--- round/transitions.go | 31 +++++++++++++++++++++++++++++-- vtxo/actor.go | 30 ++++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/round/actor.go b/round/actor.go index 5eb71e7c0..321f83be8 100644 --- a/round/actor.go +++ b/round/actor.go @@ -484,7 +484,16 @@ func (a *RoundClientActor) askEventAndProcessOutbox( return err } + a.log.DebugS(ctx, "askEventAndProcessOutbox: FSM returned outbox events", + slog.Int("event_count", len(events)), + slog.String("input_event_type", fmt.Sprintf("%T", event))) + if len(events) > 0 { + for i, e := range events { + a.log.DebugS(ctx, "askEventAndProcessOutbox: outbox event", + slog.Int("index", i), + slog.String("type", fmt.Sprintf("%T", e))) + } if err := a.processOutbox(ctx, events); err != nil { return fmt.Errorf("failed to process outbox: %w", err) } @@ -1269,10 +1278,18 @@ func (a *RoundClientActor) processOutbox(ctx context.Context, // Route forfeit request to VTXO actor via service key. // The VTXO actor will sign the forfeit tx and respond // with ForfeitSignatureResponse. + a.log.DebugS(ctx, "Processing ForfeitRequestToVTXO", + slog.String("outpoint", m.VTXOOutpoint.String()), + slog.String("round_id", m.RoundID), + slog.Bool("actor_system_nil", a.cfg.ActorSystem == nil)) + if a.cfg.ActorSystem != nil { serviceKey := actormsg.VTXOActorServiceKey( m.VTXOOutpoint, ) + a.log.DebugS(ctx, "Looking up VTXO actor by service key", + slog.String("outpoint", m.VTXOOutpoint.String())) + serviceKey.Ref(a.cfg.ActorSystem).Tell( ctx, &ForfeitRequestEvent{ RoundID: m.RoundID, @@ -1282,9 +1299,12 @@ func (a *RoundClientActor) processOutbox(ctx context.Context, ServerForfeitPkScript: m.ServerForfeitPkScript, }, ) - log.InfoS(ctx, "Sent forfeit request to VTXO actor", - "outpoint", m.VTXOOutpoint.String(), - "round_id", m.RoundID) + a.log.InfoS(ctx, "Sent forfeit request to VTXO actor", + slog.String("outpoint", m.VTXOOutpoint.String()), + slog.String("round_id", m.RoundID)) + } else { + a.log.WarnS(ctx, "Cannot send forfeit request: ActorSystem is nil", nil, + slog.String("outpoint", m.VTXOOutpoint.String())) } case *ForfeitConfirmedToVTXO: diff --git a/round/transitions.go b/round/transitions.go index 690b22279..c7b605b23 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -27,6 +27,20 @@ func buildBoardingRequest(intent BoardingIntent) types.BoardingRequest { return intent.Request } +// buildVTXORequestFromRefresh constructs a types.VTXORequest from a +// RefreshVTXORequest. The refresh request contains all info needed to create +// the new VTXO output in the round. +func buildVTXORequestFromRefresh(req *RefreshVTXORequest) types.VTXORequest { + return types.VTXORequest{ + Amount: btcutil.Amount(req.Amount), + PkScript: req.PkScript, + Expiry: req.Expiry, + ClientKey: req.NewVTXOKey, + OperatorKey: req.OperatorKey, + SigningKey: req.SigningKey, + } +} + // failWithNotification creates a state transition to ClientFailedState and // emits a RoundFailedNotification. This is the standard pattern for handling // internal errors without returning an error to the FSM (which would halt it). @@ -267,13 +281,19 @@ func (s *Idle) ProcessEvent(ctx context.Context, event ClientEvent, // A VTXO actor requested refresh. Start assembling a round with // this refresh request. Similar to boarding intents, we track // refreshing VTXOs in the PendingRoundAssembly state. + // + // The refresh request contains all info to build both the + // RefreshRequest (forfeit) and VTXORequest (new output). refreshMap := make(map[wire.OutPoint]*RefreshVTXORequest) refreshMap[evt.VTXOOutpoint] = evt + // Create the VTXORequest for the new VTXO output. + vtxoReq := buildVTXORequestFromRefresh(evt) + return &ClientStateTransition{ NextState: &PendingRoundAssembly{ Boarding: nil, - VTXOs: nil, + VTXOs: []types.VTXORequest{vtxoReq}, RefreshingVTXOs: refreshMap, }, }, nil @@ -400,6 +420,8 @@ func (s *PendingRoundAssembly) ProcessEvent(ctx context.Context, case *RefreshVTXORequest: // A VTXO actor requested refresh. Add to our refreshing map. // We stay in this state and accumulate refresh requests. + // + // Also create a VTXORequest for the new VTXO output. updatedRefreshing := maps.Clone(s.RefreshingVTXOs) if updatedRefreshing == nil { updatedRefreshing = make( @@ -408,10 +430,15 @@ func (s *PendingRoundAssembly) ProcessEvent(ctx context.Context, } updatedRefreshing[evt.VTXOOutpoint] = evt + // Add the VTXORequest for this refresh to the VTXOs list. + vtxoReq := buildVTXORequestFromRefresh(evt) + updatedVTXOs := slices.Clone(s.VTXOs) + updatedVTXOs = append(updatedVTXOs, vtxoReq) + return &ClientStateTransition{ NextState: &PendingRoundAssembly{ Boarding: slices.Clone(s.Boarding), - VTXOs: slices.Clone(s.VTXOs), + VTXOs: updatedVTXOs, RefreshingVTXOs: updatedRefreshing, }, }, nil diff --git a/vtxo/actor.go b/vtxo/actor.go index 148fc4bc3..d5d17aea7 100644 --- a/vtxo/actor.go +++ b/vtxo/actor.go @@ -103,6 +103,11 @@ func (a *VTXOActor) Stop(ctx context.Context) { func (a *VTXOActor) Receive(ctx context.Context, event actormsg.VTXOActorMsg) fn.Result[actormsg.VTXOActorResp] { + a.cfg.Logger.DebugS(ctx, "VTXO actor received event", + slog.String("event_type", fmt.Sprintf("%T", event)), + slog.String("outpoint", a.cfg.VTXO.Outpoint.String()), + slog.String("current_state", fmt.Sprintf("%T", a.state))) + vtxoEvent, ok := event.(VTXOEvent) if !ok { return fn.Err[actormsg.VTXOActorResp]( @@ -112,11 +117,24 @@ func (a *VTXOActor) Receive(ctx context.Context, transition, err := a.state.ProcessEvent(ctx, vtxoEvent, a.env) if err != nil { + a.cfg.Logger.ErrorS(ctx, "VTXO FSM ProcessEvent failed", err, + slog.String("event_type", fmt.Sprintf("%T", vtxoEvent)), + slog.String("outpoint", a.cfg.VTXO.Outpoint.String())) + return fn.Err[actormsg.VTXOActorResp]( fmt.Errorf("process event: %w", err), ) } + // Log transition details. + var outboxLen int + transition.NewEvents.WhenSome(func(emitted VTXOEmittedEvent) { + outboxLen = len(emitted.Outbox) + }) + a.cfg.Logger.DebugS(ctx, "VTXO FSM transition completed", + slog.String("next_state", fmt.Sprintf("%T", transition.NextState)), + slog.Int("outbox_len", outboxLen)) + priorState := a.state // Type assert the next state to VTXOState. @@ -164,10 +182,22 @@ func (a *VTXOActor) processOutbox(ctx context.Context, outbox []VTXOOutMsg) { m.NewStatus == VTXOStatusForfeiting && m.ForfeitTx != nil + a.cfg.Logger.DebugS(ctx, "Processing VTXOStatusUpdate", + slog.String("outpoint", m.Outpoint.String()), + slog.String("new_status", m.NewStatus.String()), + slog.Bool("has_forfeit_tx", m.ForfeitTx != nil), + slog.Bool("is_forfeiting_with_tx", isForfeitingWithTx)) + if isForfeitingWithTx { err = a.cfg.Store.MarkForfeiting( ctx, m.Outpoint, m.RoundID, m.ForfeitTx, ) + a.cfg.Logger.DebugS( + ctx, "Called MarkForfeiting", + slog.String("outpoint", m.Outpoint.String()), + slog.String("round_id", m.RoundID), + slog.Bool("error", err != nil), + ) } else { err = a.cfg.Store.UpdateVTXOStatus( ctx, m.Outpoint, m.NewStatus, From 117022e9393f3d6d8bd5c2fc50a048d96fc7fcd5 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Thu, 29 Jan 2026 18:25:11 -0400 Subject: [PATCH 11/11] round: fix linter issues --- round/actor_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/round/actor_test.go b/round/actor_test.go index 875abbb3e..7ad7bbf41 100644 --- a/round/actor_test.go +++ b/round/actor_test.go @@ -1197,10 +1197,11 @@ func TestHandleForfeitSignatureResponse(t *testing.T) { Hash: chainhash.HashH([]byte("unknown-vtxo")), Index: 0, } + sig := testutils.TestSchnorrSignature(t, "forfeit") response := &ForfeitSignatureResponse{ RoundID: "non-existent-round", VTXOOutpoint: vtxoOutpoint, - Signature: testutils.TestSchnorrSignature(t, "forfeit"), + Signature: sig, ForfeitTx: wire.NewMsgTx(2), }