Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions db/round_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,12 @@ type RoundStore interface {
ctx context.Context, arg sqlc.MarkVTXOForfeitingParams,
) error

// ListForfeitingVTXOsByRound returns the Forfeiting VTXOs bound to a
// round, used to rebuild a reloaded round's forfeit set on restart.
ListForfeitingVTXOsByRound(ctx context.Context,
forfeitRoundID sql.NullString) (
[]sqlc.ListForfeitingVTXOsByRoundRow, error)

GetVTXOForfeitTx(ctx context.Context,
arg sqlc.GetVTXOForfeitTxParams) (
sqlc.GetVTXOForfeitTxRow,
Expand Down Expand Up @@ -922,6 +928,43 @@ func (s *RoundPersistenceStore) MarkVTXOSpent(ctx context.Context,
})
}

// roundForfeitRequests rebuilds the forfeit set of a reloaded round from the
// VTXO table. Standard wallet forfeits are the only entries that can survive
// a restart: each Forfeiting VTXO row carries the binding forfeit_round_id,
// while custom (caller-supplied) forfeit inputs never enter the wallet store
// and their in-memory signing contexts die with the process. The rebuilt
// requests carry just the outpoint and amount, which is exactly what the
// status-reconcile release path needs to return the inputs to LiveState.
func roundForfeitRequests(ctx context.Context, q RoundStore,
roundID string) ([]types.ForfeitRequest, error) {

rows, err := q.ListForfeitingVTXOsByRound(ctx, sql.NullString{
String: roundID,
Valid: true,
})
if err != nil {
return nil, fmt.Errorf("list forfeiting vtxos: %w", err)
}

if len(rows) == 0 {
return nil, nil
}

forfeits := make([]types.ForfeitRequest, 0, len(rows))
for _, row := range rows {
var op wire.OutPoint
copy(op.Hash[:], row.OutpointHash)
op.Index = uint32(row.OutpointIndex)

forfeits = append(forfeits, types.ForfeitRequest{
VTXOOutpoint: &op,
Amount: btcutil.Amount(row.Amount),
})
}

return forfeits, nil
}

// dbRoundToDomainRound converts a database round row to a domain Round struct.
func (s *RoundPersistenceStore) dbRoundToDomainRound(ctx context.Context,
q RoundStore, dbRound RoundRow, dbIntents []RoundBoardingIntentRow) (
Expand Down Expand Up @@ -994,6 +1037,17 @@ func (s *RoundPersistenceStore) dbRoundToDomainRound(ctx context.Context,
r.Intents.Boarding = intents
}

// Rebuild the forfeit set from the VTXO table. Without this, a
// reloaded forfeit-bearing round looks boarding-only: the actor's
// restart re-arm guard never arms the status-reconcile timer, and a
// DEAD verdict would release an empty set, silently reopening the
// wavelength#844 strand across every restart.
forfeits, err := roundForfeitRequests(ctx, q, dbRound.RoundID)
if err != nil {
return nil, err
}
r.Intents.Forfeits = forfeits

return r, nil
}

Expand Down Expand Up @@ -1268,9 +1322,19 @@ func (s *RoundPersistenceStore) reconstructInputSigSentState(
}
}

// Rebuild the forfeit set alongside the boarding intents. The
// reconstructed InputSigSentState gates every status-reconcile
// decision on its forfeit count, so leaving Forfeits empty here would
// turn the post-restart reconcile into a no-op.
forfeits, err := roundForfeitRequests(ctx, q, dbRound.RoundID)
if err != nil {
return nil, err
}

state.Intents = round.Intents{
Boarding: intents,
VTXOs: vtxos,
Forfeits: forfeits,
}
state.InputSigs = inputSigs

Expand Down
85 changes: 85 additions & 0 deletions db/round_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"github.com/lightninglabs/wavelength/lib/tree"
"github.com/lightninglabs/wavelength/lib/types"
"github.com/lightninglabs/wavelength/round"
"github.com/lightninglabs/wavelength/vtxo"
"github.com/lightninglabs/wavelength/wallet"
"github.com/lightningnetwork/lnd/clock"
fn "github.com/lightningnetwork/lnd/fn/v2"
Expand Down Expand Up @@ -303,6 +304,90 @@ func TestRoundStoreListActiveRounds(t *testing.T) {
require.Len(t, activeRounds, 3)
}

// TestRoundStoreReloadRebuildsForfeitSet pins the restart half of the
// wavelength#844 fix: a forfeit-bearing round reloaded from the database must
// carry a forfeit set rebuilt from the Forfeiting VTXO rows bound to it via
// forfeit_round_id. Without the rebuild, the reloaded round looks
// boarding-only, so the actor never re-arms the status-reconcile timeout
// after a restart and a dead verdict would release an empty set, silently
// reopening the strand across every restart.
func TestRoundStoreReloadRebuildsForfeitSet(t *testing.T) {
t.Parallel()

vtxoStore, roundStore, _ := newVTXOStoreForTest(t)
ctx := t.Context()

roundID := testRoundIDDB("forfeit-reload-round")
testRound := createTestRound(t, roundID)
state := &round.InputSigSentState{
RoundID: roundID,
ClientTrees: make(map[round.SignerKey]*tree.Tree),
}
require.NoError(t, roundStore.CommitState(ctx, testRound, state))

// Bind two wallet VTXOs to the round as in-flight forfeits, exactly
// as the forfeit flow does when the signatures leave the box. A third
// VTXO stays Live to prove the rebuild only picks up Forfeiting rows
// for this round.
descA := createTestVTXODescriptor(t, roundID, 1)
descB := createTestVTXODescriptor(t, roundID, 2)
descLive := createTestVTXODescriptor(t, roundID, 3)
for _, desc := range []*vtxo.Descriptor{descA, descB, descLive} {
require.NoError(t, vtxoStore.SaveVTXO(ctx, desc))
}

for _, desc := range []*vtxo.Descriptor{descA, descB} {
require.NoError(
t,
vtxoStore.MarkForfeiting(
ctx, desc.Outpoint, roundID.String(), nil,
),
)
}

wantOutpoints := map[wire.OutPoint]btcutil.Amount{
descA.Outpoint: descA.Amount,
descB.Outpoint: descB.Amount,
}

// assertForfeitSet checks a rebuilt forfeit set covers exactly the
// two Forfeiting outpoints with their amounts.
assertForfeitSet := func(forfeits []types.ForfeitRequest) {
t.Helper()

require.Len(t, forfeits, 2)
for _, req := range forfeits {
require.NotNil(t, req.VTXOOutpoint)

amount, ok := wantOutpoints[*req.VTXOOutpoint]
require.True(t, ok)
require.Equal(t, amount, req.Amount)

// The rebuilt requests are standard wallet forfeits:
// no custom spend paths survive a restart, so the
// release path must classify them as standard.
require.Nil(t, req.AuthSpend)
require.Nil(t, req.ForfeitSpend)
}
}

// The restart reload path (ListActiveRounds) must surface the
// forfeit set on the domain round.
activeRounds, err := roundStore.ListActiveRounds(ctx)
require.NoError(t, err)
require.Len(t, activeRounds, 1)
assertForfeitSet(activeRounds[0].Intents.Forfeits)

// The reconstructed FSM state must carry the same set, since every
// status-reconcile decision gates on its forfeit count.
_, fsmState, err := roundStore.FetchState(ctx, roundID)
require.NoError(t, err)

inputSigState, ok := fsmState.(*round.InputSigSentState)
require.True(t, ok)
assertForfeitSet(inputSigState.Intents.Forfeits)
}

// TestRoundStoreFinalizeRound tests finalizing a round.
func TestRoundStoreFinalizeRound(t *testing.T) {
t.Parallel()
Expand Down
6 changes: 6 additions & 0 deletions db/sqlc/querier.go

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

11 changes: 11 additions & 0 deletions db/sqlc/queries/vtxo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,17 @@ SET status = 2, -- Forfeiting
last_update_time = $5
WHERE outpoint_hash = $1 AND outpoint_index = $2;

-- name: ListForfeitingVTXOsByRound :many
-- ListForfeitingVTXOsByRound returns the outpoint and amount of every VTXO
-- sitting in Forfeiting status whose forfeit reservation is bound to the
-- given round. Used during restart recovery to rebuild a reloaded round's
-- forfeit set, so the status-reconcile release path has real outpoints to
-- return to Live rather than the empty in-memory set the crash discarded.
SELECT outpoint_hash, outpoint_index, amount
FROM vtxos
WHERE status = 2 -- Forfeiting
AND forfeit_round_id = $1;

-- name: GetVTXOForfeitTx :one
-- GetVTXOForfeitTx retrieves the persisted forfeit transaction for a VTXO.
-- Used during recovery to restore the ForfeitingState with its tx.
Expand Down
41 changes: 41 additions & 0 deletions db/sqlc/vtxo.sql.go

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

Loading
Loading