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
8 changes: 7 additions & 1 deletion db/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/db.<Symb
safety bounds enforced during `DeserializeTree`.
- `resolveInputPackage` / `loadPackageBundleBySessionID` — two-stage
OOR ancestry resolver (`oor_unroll_resolver.go`).
- `LatestMigrationVersion = 14` — current schema version.
- `LatestMigrationVersion = 16` — current schema version.
- `PendingIntentPersistenceStore` — implements `wallet.PendingIntentStore`,
the persistence half of the generic restart-safe intent outbox (header
`pending_intents` + per-kind detail tables + `pending_intent_anchors`).
Expand Down Expand Up @@ -201,3 +201,9 @@ when adding one.
## Deep Docs

- [ARCHITECTURE.md](../ARCHITECTURE.md) — System-wide package map.
- `000016_round_sweep_delay` — adds `rounds.sweep_delay` so a round that
confirms after a restart can still derive each new VTXO's absolute batch
expiry (`confirmation_height + sweep_delay`). `InsertRound` only adopts a
non-zero incoming value; rows predating the column read back zero, which
the confirmation path treats as unknown and leaves the expiry unstamped
rather than wrong.
12 changes: 11 additions & 1 deletion db/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/db.<Symb
safety bounds enforced during `DeserializeTree`.
- `resolveInputPackage` / `loadPackageBundleBySessionID` — two-stage
OOR ancestry resolver (`oor_unroll_resolver.go`).
- `LatestMigrationVersion = 15` — current schema version.
- `LatestMigrationVersion = 16` — current schema version.
- `PendingIntentPersistenceStore` — implements `wallet.PendingIntentStore`,
the persistence half of the generic restart-safe intent outbox (header
`pending_intents` + per-kind detail tables + `pending_intent_anchors`).
Expand Down Expand Up @@ -212,6 +212,16 @@ when adding one.
SetVersion leaves the migration dirty and the next boot fails with
ErrDirty; forcing the version and re-running is safe because the
backfill guards on `round_uuid IS NULL` and re-executes as a no-op.
- `000016_round_sweep_delay` — adds `rounds.sweep_delay`. A round is
checkpointed at `input_sig_sent` and can confirm after a restart; the
confirmation handler derives each new VTXO's absolute batch expiry as
`confirmation_height + sweep_delay`, so a delay held only in memory made
a resumed round stamp `BatchExpiry == CreatedHeight` and the wallet read
the VTXO back as already expired. `InsertRound` only adopts a non-zero
incoming value, since the delay is fixed for the life of a round and a
later checkpoint must not clear what an earlier one recorded. Rows
predating the column read back zero, which the confirmation path treats
as "unknown" and leaves the expiry unstamped rather than wrong.

## Deep Docs

Expand Down
2 changes: 1 addition & 1 deletion db/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const (
// daemon.
//
// NOTE: This MUST be updated when a new migration is added.
LatestMigrationVersion uint = 15
LatestMigrationVersion uint = 16
)

// MigrationTarget is a functional option that can be passed to applyMigrations
Expand Down
19 changes: 19 additions & 0 deletions db/round_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,11 @@ type RoundStore interface {
// VTXO lifecycle status queries.
ListLiveVTXOs(ctx context.Context) ([]VTXORow, error)

// ListRecoverableVTXOs returns the non-terminal set plus expired
// VTXOs, whose actors must still be restored so their value can be
// reclaimed by forfeiting them in a round.
ListRecoverableVTXOs(ctx context.Context) ([]VTXORow, error)

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

Expand Down Expand Up @@ -319,6 +324,13 @@ func (s *RoundPersistenceStore) CommitState(ctx context.Context, r *round.Round,
// zero-indexed, so an unstamped round reads as V1
// (the zero value) with no normalization needed.
FlowVersion: int32(r.FlowVersion),

// Persist the per-round sweep delay so a round that
// confirms after a restart can still derive each new
// VTXO's absolute batch expiry. Without it the
// resumed round stamps BatchExpiry == CreatedHeight,
// which reads back as already expired.
SweepDelay: int32(r.SweepDelay),
}
if err := q.InsertRound(ctx, roundParams); err != nil {
return fmt.Errorf("insert round: %w", err)
Expand Down Expand Up @@ -980,6 +992,7 @@ func (s *RoundPersistenceStore) dbRoundToDomainRound(ctx context.Context,
RoundID: roundID,
StartHeight: uint32(dbRound.StartHeight),
FlowVersion: roundpb.FlowVersion(dbRound.FlowVersion),
SweepDelay: uint32(dbRound.SweepDelay),
}

// Populate confirmation info if present.
Expand Down Expand Up @@ -1237,6 +1250,12 @@ func (s *RoundPersistenceStore) reconstructInputSigSentState(
// Carry the persisted flow version onto the reconstructed
// state so a mid-round resume does not silently downgrade it.
FlowVersion: roundpb.FlowVersion(dbRound.FlowVersion),

// Restore the sweep delay the operator set for this round.
// The confirmation handler derives batch expiry from it, so
// losing it across a restart would stamp every VTXO the
// resumed round produces as already expired.
SweepDelay: uint32(dbRound.SweepDelay),
}

// Deserialize commitment tx.
Expand Down
62 changes: 62 additions & 0 deletions db/round_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,68 @@ func TestRoundStoreCommitAndFetch(t *testing.T) {
require.Equal(t, testRound.RoundID, inputSigState.RoundID)
}

// TestRoundStoreSweepDelayRoundTrip asserts the per-round sweep delay
// survives the checkpoint/restore cycle on both the round record and the
// restored FSM state.
//
// A round is checkpointed at input_sig_sent and can confirm after a daemon
// restart. The confirmation handler derives every new VTXO's absolute batch
// expiry as confirmation_height + sweep_delay, so losing the delay makes the
// resumed round stamp BatchExpiry == CreatedHeight, which the wallet reads
// back as already expired.
func TestRoundStoreSweepDelayRoundTrip(t *testing.T) {
t.Parallel()

const sweepDelay = uint32(1008)

store, _ := newRoundStoreForTest(t)
ctx := t.Context()

testRound := createTestRound(t, testRoundIDDB("sweep-delay-round"))
testRound.SweepDelay = sweepDelay

state := &round.InputSigSentState{
RoundID: testRound.RoundID,
SweepDelay: sweepDelay,
ClientTrees: make(map[round.SignerKey]*tree.Tree),
}
testRound.CommitmentTx.WhenSome(func(packet *psbt.Packet) {
state.CommitmentTx = packet
})
testRound.VTXOTreePaths.WhenSome(func(paths map[int]*tree.Tree) {
state.VTXOTreePaths = paths
})

require.NoError(t, store.CommitState(ctx, testRound, state))

fetchedRound, fetchedState, err := store.FetchState(
ctx, testRound.RoundID,
)
require.NoError(t, err)

require.Equal(t, sweepDelay, fetchedRound.SweepDelay)

inputSigState, ok := fetchedState.(*round.InputSigSentState)
require.True(t, ok)
require.Equal(t, sweepDelay, inputSigState.SweepDelay)

// A later checkpoint that carries no delay must not erase the
// recorded one: the value is fixed for the life of the round.
testRound.SweepDelay = 0
state.SweepDelay = 0
require.NoError(t, store.CommitState(ctx, testRound, state))

fetchedRound, fetchedState, err = store.FetchState(
ctx, testRound.RoundID,
)
require.NoError(t, err)
require.Equal(t, sweepDelay, fetchedRound.SweepDelay)

inputSigState, ok = fetchedState.(*round.InputSigSentState)
require.True(t, ok)
require.Equal(t, sweepDelay, inputSigState.SweepDelay)
}

// TestRoundStoreLookupByTxid tests looking up a round by commitment txid.
func TestRoundStoreLookupByTxid(t *testing.T) {
t.Parallel()
Expand Down
1 change: 1 addition & 0 deletions db/sqlc/migrations/000016_round_sweep_delay.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE rounds DROP COLUMN sweep_delay;
14 changes: 14 additions & 0 deletions db/sqlc/migrations/000016_round_sweep_delay.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- Persist the per-round sweep delay alongside the round checkpoint.
--
-- A round is checkpointed at input_sig_sent and can confirm after a daemon
-- restart. The confirmation handler derives each new VTXO's absolute batch
-- expiry as confirmation_height + sweep_delay, but the delay lived only in
-- the in-memory FSM state, so a resumed round rebuilt it as zero and stamped
-- BatchExpiry == CreatedHeight. That VTXO reads back as already expired the
-- moment it is created.
--
-- Zero remains the default for rows written before this migration. Those
-- rounds have no recorded delay, so the confirmation path treats zero as
-- "unknown" and refuses to stamp an expiry rather than stamping a wrong one.
ALTER TABLE rounds
ADD COLUMN sweep_delay INTEGER NOT NULL DEFAULT 0;
1 change: 1 addition & 0 deletions db/sqlc/models.go

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

9 changes: 9 additions & 0 deletions db/sqlc/querier.go

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

13 changes: 10 additions & 3 deletions db/sqlc/queries/round.sql
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@
INSERT INTO rounds (
round_id, confirmation_height, confirmation_block_hash, commitment_tx,
commitment_txid, vtxt_tree, status, creation_time, last_update_time,
start_height, flow_version
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
start_height, flow_version, sweep_delay
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
ON CONFLICT (round_id) DO UPDATE SET
confirmation_height = COALESCE(excluded.confirmation_height, rounds.confirmation_height),
confirmation_block_hash = COALESCE(excluded.confirmation_block_hash, rounds.confirmation_block_hash),
commitment_tx = COALESCE(excluded.commitment_tx, rounds.commitment_tx),
commitment_txid = COALESCE(excluded.commitment_txid, rounds.commitment_txid),
vtxt_tree = COALESCE(excluded.vtxt_tree, rounds.vtxt_tree),
status = excluded.status,
last_update_time = excluded.last_update_time;
last_update_time = excluded.last_update_time,
-- The sweep delay is fixed for the life of a round, so a later
-- checkpoint must never clear a value an earlier one recorded. Only
-- adopt the incoming value when it is actually set.
sweep_delay = CASE
WHEN excluded.sweep_delay > 0 THEN excluded.sweep_delay
ELSE rounds.sweep_delay
END;

-- name: GetRound :one
SELECT * FROM rounds WHERE round_id = $1;
Expand Down
13 changes: 13 additions & 0 deletions db/sqlc/queries/vtxo.sql
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,19 @@ SELECT * FROM vtxos
WHERE (status < 3 OR status = 7) AND spent = FALSE
ORDER BY creation_time DESC;

-- name: ListRecoverableVTXOs :many
-- ListRecoverableVTXOs returns every VTXO whose actor must be restored at
-- startup: the non-terminal set of ListLiveVTXOs plus Expired (8).
--
-- Expired is deliberately absent from ListLiveVTXOs, which feeds spendable
-- balance and refresh estimation, because an expired VTXO holds no spendable
-- value until it has been reissued. Its actor still has to exist though: the
-- value is recoverable by forfeiting the VTXO in an ordinary round, and the
-- actor is what holds the descriptor and signing material that forfeit needs.
SELECT * FROM vtxos
WHERE (status < 3 OR status = 7 OR status = 8) 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.
Expand Down
30 changes: 22 additions & 8 deletions db/sqlc/round.sql.go

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

2 changes: 1 addition & 1 deletion db/sqlc/schemas/generated_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -1292,7 +1292,7 @@ CREATE TABLE rounds (
-- today is 0 (V1); a future, genuinely different round flow is added
-- additively (V2 == 1, and so on). NOT NULL DEFAULT 0 keeps every row a
-- valid V1 round.
flow_version INTEGER NOT NULL DEFAULT 0,
flow_version INTEGER NOT NULL DEFAULT 0, sweep_delay INTEGER NOT NULL DEFAULT 0,

FOREIGN KEY (status) REFERENCES round_statuses(status_name)
);
Expand Down
Loading
Loading