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
17 changes: 16 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 = 14` — current schema version.
- `LatestMigrationVersion = 15` — 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 @@ -197,6 +197,21 @@ when adding one.
constraint on `vtxo_ancestry_paths`: fragment identity is
(commitment_txid, tree_path), so an OOR spend of inputs at different
leaves of one commitment tree persists one row per leaf.
- `000015_ledger_round_uuid` — adds `ledger_entries.round_uuid`, the
canonical TEXT UUID mirror of the raw 16-byte `round_id` BLOB, plus a
partial index. The ledger and the round tables historically stored the
same identifier in different encodings, and no BLOB↔TEXT conversion
exists in the SQL dialect subset shared by SQLite and Postgres; the
TEXT mirror makes ledger rows joinable against `rounds.round_id` /
`vtxos.forfeit_round_id` (e.g. the `ListVTXOsByStatus` settlement fee
join). New inserts stamp it via `roundUUIDText`; existing rows are
backfilled by the version-15 Go post-migration step
(`backfillLedgerRoundUUIDs` in `post_migration_checks.go`), wired into
both store constructors via `makePostStepCallbacks` (its first
production user). A crash between the post-step and the clean
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.

## Deep Docs

Expand Down
54 changes: 50 additions & 4 deletions db/ledger_store.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"
"database/sql"

"github.com/btcsuite/btcd/wire/v2"
"github.com/google/uuid"
"github.com/lightninglabs/wavelength/db/sqlc"
"github.com/lightninglabs/wavelength/ledger"
)
Expand Down Expand Up @@ -58,10 +60,13 @@ func (s *LedgerStoreDB) InsertLedgerEntry(ctx context.Context,
func(qtx *sqlc.Queries) error {
return qtx.InsertClientLedgerEntry(
ctx, sqlc.InsertClientLedgerEntryParams{
DebitAccount: entry.DebitAccount,
CreditAccount: entry.CreditAccount,
AmountSat: entry.AmountSat,
RoundID: entry.RoundID,
DebitAccount: entry.DebitAccount,
CreditAccount: entry.CreditAccount,
AmountSat: entry.AmountSat,
RoundID: entry.RoundID,
RoundUuid: roundUUIDText(
entry.RoundID,
),
SessionID: entry.SessionID,
EventType: entry.EventType,
Description: entry.Description,
Expand All @@ -80,6 +85,24 @@ func (s *LedgerStoreDB) InsertLedgerEntry(ctx context.Context,
)
}

// roundUUIDText mirrors a raw 16-byte ledger round_id into the canonical
// lowercase UUID string stored by rounds.round_id and vtxos.forfeit_round_id,
// so ledger rows are joinable against those tables in portable SQL. A nil or
// non-16-byte input yields NULL, mirroring the round_id column's nullability.
func roundUUIDText(roundID []byte) sql.NullString {
if len(roundID) != 16 {
return sql.NullString{}
}

var id uuid.UUID
copy(id[:], roundID)

return sql.NullString{
String: id.String(),
Valid: true,
}
}

// sqlInt32Ptr converts an optional int32 pointer to the nullable sqlc shape
// used by ledger chain metadata columns.
func sqlInt32Ptr(v *int32) sql.NullInt32 {
Expand All @@ -93,6 +116,29 @@ func sqlInt32Ptr(v *int32) sql.NullInt32 {
}
}

// GetConfirmedExitCost returns the confirmed on-chain cost the ledger booked
// for a unilateral exit of the given VTXO outpoint (the onchain_fee_paid leg
// unroll emits after the final sweep confirms). Zero when no exit-cost leg
// exists — the exit has not confirmed, or predates exit-cost accounting.
func (s *LedgerStoreDB) GetConfirmedExitCost(ctx context.Context,
outpoint wire.OutPoint) (int64, error) {

key := ledger.ExitIdempotencyKey(outpoint.Hash, outpoint.Index)

var cost int64
err := s.ExecTx(
ctx, ReadTxOption(),
func(qtx *sqlc.Queries) error {
var txErr error
cost, txErr = qtx.GetConfirmedExitCost(ctx, key)

return txErr
},
)

return cost, err
}

// GetAccountBalance returns the net balance (debits minus credits) for
// the given account within a read transaction.
func (s *LedgerStoreDB) GetAccountBalance(ctx context.Context,
Expand Down
150 changes: 150 additions & 0 deletions db/ledger_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import (
"testing"
"time"

"github.com/btcsuite/btcd/wire/v2"
"github.com/btcsuite/btclog/v2"
"github.com/google/uuid"
"github.com/lightninglabs/wavelength/db/sqlc"
"github.com/lightninglabs/wavelength/ledger"
"github.com/lightninglabs/wavelength/wallet"
Expand Down Expand Up @@ -232,6 +234,154 @@ func TestLedgerStoreInsertAndRetrieve(t *testing.T) {
require.Equal(t, entry.CreatedAt, got.CreatedAt)
}

// TestLedgerStoreInsertStampsRoundUUID proves the insert adapter mirrors a
// 16-byte round_id into the canonical round_uuid TEXT column, and leaves it
// NULL for entries without a well-formed round linkage.
func TestLedgerStoreInsertStampsRoundUUID(t *testing.T) {
t.Parallel()

ctx := t.Context()
store := newLedgerStoreForTest(t)

roundID := uuid.New()
require.NoError(
t,
store.InsertLedgerEntry(
ctx, makeLedgerEntry(
"fees_paid", "vtxo_balance", 500,
"refresh_fee_paid", roundID[:], 1_000,
),
),
)

// A non-16-byte round id (nothing produces one, but the column is
// unconstrained) must degrade to a NULL round_uuid, not an error.
require.NoError(
t,
store.InsertLedgerEntry(
ctx,
makeLedgerEntry(
"fees_paid", "vtxo_balance", 600,
"boarding_fee_paid", []byte("short-round-id"),
1_001,
),
),
)

entries, err := store.ListLedgerEntries(ctx, 10, 0)
require.NoError(t, err)
require.Len(t, entries, 2)

// Newest first: the malformed-round entry, then the UUID-keyed one.
require.False(t, entries[0].RoundUuid.Valid)
require.True(t, entries[1].RoundUuid.Valid)
require.Equal(t, roundID.String(), entries[1].RoundUuid.String)
}

// TestLedgerStoreGetConfirmedExitCost proves the exit-cost lookup returns
// exactly the onchain_fee_paid leg keyed by the exited VTXO's outpoint: the
// send leg sharing the same idempotency key and a fee leg for a different
// outpoint contribute nothing, and an unknown outpoint reads zero.
func TestLedgerStoreGetConfirmedExitCost(t *testing.T) {
t.Parallel()

ctx := t.Context()
store := newLedgerStoreForTest(t)

var exited, other wire.OutPoint
exited.Hash[0] = 0xaa
exited.Index = 1
other.Hash[0] = 0xbb

insert := func(debit, credit, eventType string, amount int64,
op wire.OutPoint) {

entry := makeLedgerEntry(
debit, credit, amount, eventType, nil, 1_000,
)
entry.IdempotencyKey = ledger.ExitIdempotencyKey(
op.Hash, op.Index,
)
require.NoError(t, store.InsertLedgerEntry(ctx, entry))
}

// The two legs handleExitCost books for the exited outpoint, plus an
// unrelated exit's fee leg.
insert("transfers_out", "vtxo_balance", "vtxo_sent", 6_377, exited)
insert("onchain_fees", "vtxo_balance", "onchain_fee_paid", 623, exited)
insert("onchain_fees", "vtxo_balance", "onchain_fee_paid", 999, other)

cost, err := store.GetConfirmedExitCost(ctx, exited)
require.NoError(t, err)
require.Equal(t, int64(623), cost)

var unknown wire.OutPoint
unknown.Hash[0] = 0xcc
cost, err = store.GetConfirmedExitCost(ctx, unknown)
require.NoError(t, err)
require.Zero(t, cost)
}

// TestBackfillLedgerRoundUUIDs proves the migration-15 post-step converts
// pre-existing raw round_id BLOBs into their canonical UUID text form,
// skips rows without a round linkage, and re-runs as a no-op.
func TestBackfillLedgerRoundUUIDs(t *testing.T) {
t.Parallel()

ctx := t.Context()
_, db := newLedgerStoreAndDBForTest(t)

// Insert directly through the generated query with a NULL round_uuid,
// simulating rows written before migration 15 existed.
roundA := uuid.New()
roundB := uuid.New()
insert := func(roundID []byte, eventType string, amount int64) {
err := db.InsertClientLedgerEntry(
ctx, sqlc.InsertClientLedgerEntryParams{
DebitAccount: "fees_paid",
CreditAccount: "vtxo_balance",
AmountSat: amount,
RoundID: roundID,
EventType: eventType,
Description: "backfill test entry",
CreatedAt: 1_000,
},
)
require.NoError(t, err)
}
insert(roundA[:], "refresh_fee_paid", 100)
insert(roundB[:], "boarding_fee_paid", 200)
insert(nil, "onchain_fee_paid", 300)

require.NoError(t, backfillLedgerRoundUUIDs(ctx, db.Queries))

entries, err := db.ListClientLedgerEntries(
ctx, sqlc.ListClientLedgerEntriesParams{
Limit: 10,
},
)
require.NoError(t, err)
require.Len(t, entries, 3)

gotByEvent := make(map[string]sqlc.LedgerEntry, len(entries))
for _, entry := range entries {
gotByEvent[entry.EventType] = entry
}

require.Equal(
t, roundA.String(),
gotByEvent["refresh_fee_paid"].RoundUuid.String,
)
require.Equal(
t, roundB.String(),
gotByEvent["boarding_fee_paid"].RoundUuid.String,
)
require.False(t, gotByEvent["onchain_fee_paid"].RoundUuid.Valid)

// Re-running the backfill (crash-recovery shape) is a no-op.
require.NoError(t, backfillLedgerRoundUUIDs(ctx, db.Queries))
}

// TestLedgerStoreTransactionHistoryFiltersBeforePagination verifies the
// unified transaction-history query applies type and date filters before
// LIMIT/OFFSET. A filtered page should find matching older rows instead of
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 = 14
LatestMigrationVersion uint = 15
)

// MigrationTarget is a functional option that can be passed to applyMigrations
Expand Down
58 changes: 52 additions & 6 deletions db/post_migration_checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/btcsuite/btclog/v2"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
"github.com/google/uuid"
"github.com/lightninglabs/wavelength/db/sqlc"
)

Expand All @@ -21,14 +22,59 @@ var (
// database migration with the version specified in the key has been
// applied. These functions are used to perform additional checks on the
// database state that are not fully expressible in SQL.
//
// NOTE: This is empty for now, but can be populated with custom
// migration checks as needed.
//
//nolint:unused
postMigrationChecks = map[uint]postMigrationCheck{}
postMigrationChecks = map[uint]postMigrationCheck{
// Migration 15 adds the round_uuid TEXT mirror of the ledger's
// raw round_id BLOB; the string conversion itself is only
// expressible in Go.
15: backfillLedgerRoundUUIDs,
}
)

// backfillLedgerRoundUUIDs mirrors every distinct raw 16-byte round_id in
// ledger_entries into the round_uuid TEXT column added by migration 15, using
// the same canonical lowercase form that rounds.round_id and
// vtxos.forfeit_round_id store. Rows whose round_id is not exactly 16 bytes
// (which no writer produces) are left NULL rather than failing the whole
// migration. The per-round UPDATE is guarded on round_uuid IS NULL, so a
// crash-interrupted backfill re-runs as a no-op for already-converted rows.
func backfillLedgerRoundUUIDs(ctx context.Context, q sqlc.Querier) error {
roundIDs, err := q.ListLedgerRoundIDsMissingUuid(ctx)
if err != nil {
return fmt.Errorf("list ledger round ids missing uuid: %w", err)
}

for _, rawID := range roundIDs {
// Abort early on a cancelled migration context rather than
// issuing further per-round writes.
if err := ctx.Err(); err != nil {
return err
}

if len(rawID) != 16 {
continue
}
Comment on lines +46 to +55

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For long-running loops that perform database operations, it is a good practice to check if the context has been cancelled (ctx.Err()) at the start of each iteration to abort early and avoid unnecessary database writes.

Suggested change
for _, rawID := range roundIDs {
if len(rawID) != 16 {
continue
}
for _, rawID := range roundIDs {
if err := ctx.Err(); err != nil {
return err
}
if len(rawID) != 16 {
continue
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added — the backfill loop now checks ctx.Err() per iteration and aborts early; the round_uuid IS NULL guard already makes the re-run a no-op.


var id uuid.UUID
copy(id[:], rawID)

err := q.BackfillLedgerRoundUuid(
ctx, sqlc.BackfillLedgerRoundUuidParams{
RoundUuid: sql.NullString{
String: id.String(),
Valid: true,
},
RoundID: rawID,
},
)
if err != nil {
return fmt.Errorf("backfill ledger round uuid %s: %w",
id, err)
}
}

return nil
}

// DatabaseBackend is an interface that contains all methods our different
// database backends implement.
type DatabaseBackend interface {
Expand Down
9 changes: 8 additions & 1 deletion db/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,14 @@ func NewPostgresStore(cfg *PostgresConfig,
if !cfg.SkipMigrations {
storeLog.InfoS(ctx, "Starting Postgres schema migrations")

err := s.ExecuteMigrations(TargetLatest)
err := s.ExecuteMigrations(
TargetLatest,
WithPostStepCallbacks(
makePostStepCallbacks(
s, storeLog, postMigrationChecks,
),
),
)
if err != nil {
return nil, fmt.Errorf("error executing migrations: %w",
err)
Expand Down
Loading
Loading