From 6dfa23a52c7da411e8e1dca8bd17495893f29341 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:09:31 -0500 Subject: [PATCH 01/12] round: derive operator fee from sealed VTXO amounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we fix the client-side operator fee reconciliation to count locally owned outputs from the BUILT VTXOs rather than the intent requests. Under the seal-time fee handshake, an intent's amount is the pre-fee target: the server's quote shaves the operator fee off the leaf at seal time, and the FSM intentionally no longer subtracts it client side. Summing intent amounts therefore cancels the round's inputs exactly, computeClientOperatorFee returns zero for every fee-charging round, and the FeePaidMsg emission is silently suppressed — no boarding_fee_paid or refresh_fee_paid row was ever landing in the fee ledger. The built ClientVTXO carries the sealed leaf value (the quote residual extracted by leafNonAnchorAmount), so input minus output over the sealed amounts yields the true fee. Foreign directed-send recipient slots never materialize as owned VTXOs, so their intent amount remains the only local record of their value and still counts as-is. A new regression test pins the shape observed on regtest: a leave round forfeiting 149,745 sats into a 10,000 sat leave plus a 139,204 sat sealed change VTXO must book the 541 sat operator fee, and a fully funded foreign recipient slot must leave that fee unchanged. --- round/fees_invariants_test.go | 66 +++++++++++++++++++++++++++++++++++ round/transitions.go | 34 ++++++++++++------ 2 files changed, 89 insertions(+), 11 deletions(-) diff --git a/round/fees_invariants_test.go b/round/fees_invariants_test.go index eeeaa2d8f..a406f42b7 100644 --- a/round/fees_invariants_test.go +++ b/round/fees_invariants_test.go @@ -3,9 +3,11 @@ package round import ( "testing" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil/v2" "github.com/btcsuite/btcd/wire/v2" "github.com/lightninglabs/wavelength/lib/types" + "github.com/lightningnetwork/lnd/keychain" "github.com/stretchr/testify/require" "pgregory.net/rapid" ) @@ -289,3 +291,67 @@ func sumLeaves(in Intents) int64 { return out } + +// TestComputeClientOperatorFeeUsesSealedOwnedAmounts pins the seal-time +// fee-handshake (#270) regression: a local-owner VTXO request carries the +// PRE-fee target amount, while the built ClientVTXO carries the server's +// sealed quote residual. The fee must be derived from the sealed amount — +// summing the intent amount cancels the inputs exactly and silently +// suppresses the round's fee ledger row (issue #988). Foreign recipient +// requests never materialize as owned VTXOs, so their intent amount is +// counted as-is. +func TestComputeClientOperatorFeeUsesSealedOwnedAmounts(t *testing.T) { + t.Parallel() + + localKey, err := btcec.NewPrivateKey() + require.NoError(t, err) + + const ( + forfeitedSat = int64(149_745) + leaveSat = int64(10_000) + sealedChange = int64(139_204) + operatorFee = forfeitedSat - leaveSat - sealedChange + ) + + intents := Intents{ + Forfeits: []types.ForfeitRequest{{ + VTXOOutpoint: &wire.OutPoint{}, + Amount: btcutil.Amount(forfeitedSat), + }}, + // The change request still carries the pre-fee target: the + // server's quote shaves the operator fee off at seal time. + VTXOs: []types.VTXORequest{{ + Amount: btcutil.Amount(forfeitedSat - leaveSat), + OwnerKey: keychain.KeyDescriptor{ + PubKey: localKey.PubKey(), + }, + }}, + Leaves: []*types.LeaveRequest{{ + Output: &wire.TxOut{ + Value: leaveSat, + }, + }}, + } + owned := []*ClientVTXO{{ + Amount: btcutil.Amount(sealedChange), + }} + + require.Equal( + t, operatorFee, computeClientOperatorFee(intents, owned), + "fee must come from the sealed owned amount, not the "+ + "pre-fee intent target", + ) + + // A foreign recipient slot (no local owner) is paid from our inputs + // and must count as an output at its intent amount. + const foreignSat = int64(20_000) + intents.Forfeits[0].Amount += btcutil.Amount(foreignSat) + intents.VTXOs = append(intents.VTXOs, types.VTXORequest{ + Amount: btcutil.Amount(foreignSat), + }) + + require.Equal( + t, operatorFee, computeClientOperatorFee(intents, owned), + "a fully-funded foreign recipient slot must not change the fee", + ) +} diff --git a/round/transitions.go b/round/transitions.go index 6b5935def..cfaf64267 100644 --- a/round/transitions.go +++ b/round/transitions.go @@ -4087,18 +4087,30 @@ func computeClientOperatorFee(intents Intents, ownedVTXOs []*ClientVTXO) int64 { var outputsSat int64 - if len(intents.VTXOs) > 0 { - for i := range intents.VTXOs { - amt := int64(intents.VTXOs[i].Amount) - if amt > 0 { - outputsSat += amt - } + // Locally owned outputs are counted from the BUILT VTXOs, not the + // intent requests. Under the seal-time fee handshake (#270) an intent's + // Amount is the pre-fee target — the server's quote residual is what + // actually seals into the leaf — so summing intent amounts cancels the + // inputs exactly and computes a zero fee for every fee-charging round. + // The built ClientVTXO carries the sealed leaf value + // (leafNonAnchorAmount), making input − output the true operator fee. + // Foreign outputs (directed-send recipient slots) never materialize as + // owned VTXOs, so their intent amount remains the only local record of + // their value and is used as-is. + for i := range intents.VTXOs { + if intents.VTXOs[i].HasLocalOwner() { + continue } - } else { - for _, v := range ownedVTXOs { - if v != nil { - outputsSat += int64(v.Amount) - } + + amt := int64(intents.VTXOs[i].Amount) + if amt > 0 { + outputsSat += amt + } + } + + for _, v := range ownedVTXOs { + if v != nil { + outputsSat += int64(v.Amount) } } From 9489f3d1fdea194dae5f59e25b2bf3fd2c3770e4 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:09:39 -0500 Subject: [PATCH 02/12] ledger: credit boarding fees from wallet_balance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we move the boarding fee leg's credit side from vtxo_balance to wallet_balance so the chart of accounts nets correctly against the amounts the round actually books. The boarding vtxo_received leg carries the SEALED (post-fee) VTXO value under the seal-time fee handshake, which means vtxo_balance already lands on the true VTXO holding without any fee adjustment. Crediting the fee from vtxo_balance on top of that would understate the VTXO layer by the fee; crediting it from wallet_balance instead completes the gross wallet outflow — deposit in, sealed value plus fee out — leaving every account at its true balance. Refresh fees keep their vtxo_balance credit: a refresh fee is carved out of forfeited VTXO value, and the paired gross send/receive legs cancel, so the fee leg is the only real movement there. The fee_ledger doc's boarding and refresh walkthroughs are updated to describe the sealed-amount legs and the per-flow credit accounts. --- docs/fee_ledger.md | 37 ++++++++++++++++++++++++++++--------- ledger/actor.go | 9 ++++++--- ledger/handlers.go | 11 ++++++++--- ledger/handlers_test.go | 7 ++++++- 4 files changed, 48 insertions(+), 16 deletions(-) diff --git a/docs/fee_ledger.md b/docs/fee_ledger.md index 826e9f54c..41fddcbcc 100644 --- a/docs/fee_ledger.md +++ b/docs/fee_ledger.md @@ -149,19 +149,32 @@ Event 1 (wallet_utxo_created) — emitted when the wallet UTXO first confirmed: credit opening_balance += gross Event 2 (vtxo_received, SourceRoundBoarding) — emitted when the round confirms: - debit vtxo_balance += gross - credit wallet_balance += gross (asset down) + debit vtxo_balance += sealed (gross − fee: the server's seal-time + credit wallet_balance += sealed quote residual that lands in the leaf) Event 3 (boarding_fee_paid) — if the round charged an operator fee: debit fees_paid += fee - credit vtxo_balance += fee + credit wallet_balance += fee ``` +The received leg books the SEALED leaf value (what the VTXO is +actually worth), so the fee leg credits `wallet_balance` — the +account the fee was truly paid from — completing the gross +wallet outflow without disturbing `vtxo_balance`. + +The fee typing is round-level: a composed round mixing boarding +inputs with forfeited VTXOs emits ONE fee row typed boarding +(boarding takes precedence), so the refresh-carved share of that +round's fee is also credited from `wallet_balance`. The drift is +bounded by one round's fee and only affects the per-account audit +split, not any balance an RPC consumer reads today; a per-type +fee split proportioned by input source would eliminate it. + Per-account net effect: - `opening_balance` ↑ by gross (tracks that these funds originated externally). -- `wallet_balance` unchanged (deposit in, boarding out). -- `vtxo_balance` ↑ by gross - fee. +- `wallet_balance` unchanged (deposit in; sealed value + fee out). +- `vtxo_balance` ↑ by gross - fee (the sealed VTXO value). - `fees_paid` ↑ by fee when a boarding operator fee exists. The scenario test @@ -179,18 +192,24 @@ emitter: round.RoundClientActor.emitVTXOsReceived (VTXOOriginRoundRefresh branch round.RoundClientActor.emitRoundFee Event 1 (vtxo_sent): - debit transfers_out += gross - credit vtxo_balance += gross (asset down) + debit transfers_out += sealed + credit vtxo_balance += sealed (asset down) Event 2 (vtxo_received, SourceRoundRefresh): - debit vtxo_balance += gross - credit transfers_out += gross (expense down, cancelling Event 1's debit) + debit vtxo_balance += sealed + credit transfers_out += sealed (expense down, cancelling Event 1's debit) Event 3 (refresh_fee_paid): debit fees_paid += fee credit vtxo_balance += fee (asset down by fee) ``` +Both paired legs carry the same amount — the sealed value of the +replacement VTXO — so they cancel exactly and the fee leg is the +only real movement. A refresh fee is carved out of forfeited VTXO +value, so (unlike the boarding fee above) it credits +`vtxo_balance`. + Per-account net effect across all three events: - `transfers_out` ↓ 0 (+gross from event 1 debit, −gross from event 2 credit). - `vtxo_balance` ↓ by fee only. diff --git a/ledger/actor.go b/ledger/actor.go index 69999a386..1bdb7016a 100644 --- a/ledger/actor.go +++ b/ledger/actor.go @@ -93,9 +93,12 @@ const ( // of silently misclassifying the entry. // // FeeTypeBoarding and FeeTypeRefresh book operator (Ark protocol) -// fees: debit fees_paid, credit vtxo_balance. They MUST be paired -// with a same-RoundID VTXOReceivedMsg carrying the gross pre-fee -// amount. +// fees, debiting fees_paid. The credit side names the account the +// fee was paid from: wallet_balance for boarding (the fee comes out +// of the wallet funds entering the Ark layer, alongside a +// same-RoundID VTXOReceivedMsg carrying the SEALED post-fee VTXO +// value) and vtxo_balance for refresh (the fee is carved out of +// forfeited VTXO value). // // FeeTypeOnchainSweep books wallet-level sweep chain cost // (currently emitted by the boarding-sweep flow): debit diff --git a/ledger/handlers.go b/ledger/handlers.go index 382ae7e8c..9fa122ef5 100644 --- a/ledger/handlers.go +++ b/ledger/handlers.go @@ -67,8 +67,13 @@ func (a *LedgerActor) handleFeePaid(ctx context.Context, msg *FeePaidMsg, roundID := roundIDOrNil(msg.RoundID) - // Operator-fee types share the same accounts (fees_paid / - // vtxo_balance). Onchain-sweep fees book against onchain_fees / + // The credit side names the account the fee was actually paid from. + // A boarding fee comes out of the on-chain wallet funds entering the + // Ark layer: the boarding vtxo_received leg books the SEALED (net of + // fee) VTXO value, so crediting wallet_balance here completes the + // gross wallet outflow while leaving vtxo_balance equal to the sealed + // VTXO sum. A refresh fee is carved out of forfeited VTXO value, so it + // credits vtxo_balance. Onchain-sweep fees book against onchain_fees / // wallet_clearing instead — they are L1 chain costs paid by a // wallet-internal sweep, not Ark protocol operator fees, and the // fee is settled through wallet clearing rather than VTXO balance. @@ -83,7 +88,7 @@ func (a *LedgerActor) handleFeePaid(ctx context.Context, msg *FeePaidMsg, case FeeTypeBoarding: eventType = EventBoardingFeePaid debitAccount = AccountFeesPaid - creditAccount = AccountVTXOBalance + creditAccount = AccountWalletBalance description = fmt.Sprintf("%s fee paid in round %x", msg.FeeType, msg.RoundID) diff --git a/ledger/handlers_test.go b/ledger/handlers_test.go index 5b0949fab..7427bc90e 100644 --- a/ledger/handlers_test.go +++ b/ledger/handlers_test.go @@ -197,7 +197,12 @@ func TestHandleFeePaidBoarding(t *testing.T) { entries := store.getEntries() require.Len(t, entries, 1) require.Equal(t, AccountFeesPaid, entries[0].DebitAccount) - require.Equal(t, AccountVTXOBalance, + + // The boarding fee is paid from the on-chain wallet funds entering the + // Ark layer: the boarding vtxo_received leg books the sealed (net of + // fee) VTXO value, so the fee leg must credit wallet_balance to + // complete the gross wallet outflow. + require.Equal(t, AccountWalletBalance, entries[0].CreditAccount) require.Equal(t, int64(1500), entries[0].AmountSat) require.Equal(t, EventBoardingFeePaid, From a7aa3cdf68acd15495a3f6ecbac1cba3a2ee3ac6 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:10:12 -0500 Subject: [PATCH 03/12] db: mirror ledger round ids into a joinable round_uuid column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we add migration 000015, which grows ledger_entries a round_uuid TEXT column mirroring the raw 16-byte round_id BLOB in the canonical lowercase UUID form that rounds.round_id and vtxos.forfeit_round_id already store. The two subsystems historically persisted the same identifier in different encodings, and no BLOB to TEXT conversion exists in the SQL dialect subset shared by SQLite and Postgres (hex() vs encode()), so nothing could join ledger rows against the round-adjacent tables in plain SQL. The TEXT mirror closes that gap once, for every present and future join. New inserts stamp the column in the LedgerStoreDB adapter via roundUUIDText, so no ledger actor or message change is needed. Existing rows are converted by a Go post-migration step registered for version 15 — the string formatting is the part SQL cannot express portably — wired into both store constructors through the previously dormant makePostStepCallbacks machinery. The per-round backfill UPDATE guards on round_uuid IS NULL, so a crash-interrupted run re-executes as a no-op. The generated sqlc code for the accounting queries is regenerated alongside: the insert gains the new column, the ledger list queries return it, and the two backfill helper queries land. --- db/ledger_store.go | 30 ++++- db/ledger_store_test.go | 105 ++++++++++++++++++ db/migrations.go | 2 +- db/post_migration_checks.go | 58 +++++++++- db/postgres.go | 9 +- db/sqlc/fee_accounting.sql.go | 69 +++++++++++- .../000015_ledger_round_uuid.down.sql | 2 + .../000015_ledger_round_uuid.up.sql | 20 ++++ db/sqlc/models.go | 1 + db/sqlc/querier.go | 11 ++ db/sqlc/queries/fee_accounting.sql | 30 ++++- db/sqlc/schemas/generated_schema.sql | 6 +- db/sqlite.go | 9 +- 13 files changed, 330 insertions(+), 22 deletions(-) create mode 100644 db/sqlc/migrations/000015_ledger_round_uuid.down.sql create mode 100644 db/sqlc/migrations/000015_ledger_round_uuid.up.sql diff --git a/db/ledger_store.go b/db/ledger_store.go index d5b6c82f7..4339efa82 100644 --- a/db/ledger_store.go +++ b/db/ledger_store.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" + "github.com/google/uuid" "github.com/lightninglabs/wavelength/db/sqlc" "github.com/lightninglabs/wavelength/ledger" ) @@ -58,10 +59,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, @@ -80,6 +84,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 { diff --git a/db/ledger_store_test.go b/db/ledger_store_test.go index 131107dca..98fa3da90 100644 --- a/db/ledger_store_test.go +++ b/db/ledger_store_test.go @@ -8,6 +8,7 @@ import ( "time" "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" @@ -232,6 +233,110 @@ 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) +} + +// 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 diff --git a/db/migrations.go b/db/migrations.go index 00c2d10f1..66fd076c8 100644 --- a/db/migrations.go +++ b/db/migrations.go @@ -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 diff --git a/db/post_migration_checks.go b/db/post_migration_checks.go index 937930350..9e3bd5518 100644 --- a/db/post_migration_checks.go +++ b/db/post_migration_checks.go @@ -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" ) @@ -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 + } + + 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 { diff --git a/db/postgres.go b/db/postgres.go index d85eae4f3..861151eed 100644 --- a/db/postgres.go +++ b/db/postgres.go @@ -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) diff --git a/db/sqlc/fee_accounting.sql.go b/db/sqlc/fee_accounting.sql.go index 8a2d75511..35f2b4ae9 100644 --- a/db/sqlc/fee_accounting.sql.go +++ b/db/sqlc/fee_accounting.sql.go @@ -10,6 +10,27 @@ import ( "database/sql" ) +const BackfillLedgerRoundUuid = `-- name: BackfillLedgerRoundUuid :exec +UPDATE ledger_entries +SET round_uuid = $1 +WHERE round_id = $2 + AND round_uuid IS NULL +` + +type BackfillLedgerRoundUuidParams struct { + RoundUuid sql.NullString + RoundID []byte +} + +// BackfillLedgerRoundUuid stamps the canonical UUID string form onto every +// entry carrying the given raw round_id that does not have one yet. The +// round_uuid IS NULL guard makes re-running the backfill (e.g. after a crash +// mid-migration) a no-op for already-converted rows. +func (q *Queries) BackfillLedgerRoundUuid(ctx context.Context, arg BackfillLedgerRoundUuidParams) error { + _, err := q.db.ExecContext(ctx, BackfillLedgerRoundUuid, arg.RoundUuid, arg.RoundID) + return err +} + const CountClientLedgerEntries = `-- name: CountClientLedgerEntries :one SELECT COUNT(*) FROM ledger_entries ` @@ -78,8 +99,9 @@ INSERT INTO ledger_entries ( debit_account, credit_account, amount_sat, round_id, session_id, idempotency_key, event_type, description, created_at, - chain_txid, chain_vout, confirmation_height -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + chain_txid, chain_vout, confirmation_height, + round_uuid +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT DO NOTHING ` @@ -96,6 +118,7 @@ type InsertClientLedgerEntryParams struct { ChainTxid []byte ChainVout sql.NullInt32 ConfirmationHeight sql.NullInt32 + RoundUuid sql.NullString } // Column order matches the ledger_entries CREATE TABLE layout @@ -130,6 +153,7 @@ func (q *Queries) InsertClientLedgerEntry(ctx context.Context, arg InsertClientL arg.ChainTxid, arg.ChainVout, arg.ConfirmationHeight, + arg.RoundUuid, ) return err } @@ -221,7 +245,7 @@ const ListClientLedgerEntries = `-- name: ListClientLedgerEntries :many SELECT entry_id, debit_account, credit_account, amount_sat, round_id, session_id, idempotency_key, event_type, description, created_at, - chain_txid, chain_vout, confirmation_height + chain_txid, chain_vout, confirmation_height, round_uuid FROM ledger_entries ORDER BY created_at DESC LIMIT $1 OFFSET $2 @@ -255,6 +279,7 @@ func (q *Queries) ListClientLedgerEntries(ctx context.Context, arg ListClientLed &i.ChainTxid, &i.ChainVout, &i.ConfirmationHeight, + &i.RoundUuid, ); err != nil { return nil, err } @@ -273,7 +298,7 @@ const ListClientLedgerEntriesByType = `-- name: ListClientLedgerEntriesByType :m SELECT entry_id, debit_account, credit_account, amount_sat, round_id, session_id, idempotency_key, event_type, description, created_at, - chain_txid, chain_vout, confirmation_height + chain_txid, chain_vout, confirmation_height, round_uuid FROM ledger_entries WHERE event_type = $1 ORDER BY created_at DESC @@ -309,6 +334,7 @@ func (q *Queries) ListClientLedgerEntriesByType(ctx context.Context, arg ListCli &i.ChainTxid, &i.ChainVout, &i.ConfirmationHeight, + &i.RoundUuid, ); err != nil { return nil, err } @@ -361,6 +387,41 @@ func (q *Queries) ListClientLedgerEventTotals(ctx context.Context) ([]ListClient return items, nil } +const ListLedgerRoundIDsMissingUuid = `-- name: ListLedgerRoundIDsMissingUuid :many +SELECT DISTINCT round_id +FROM ledger_entries +WHERE round_id IS NOT NULL + AND round_uuid IS NULL +` + +// ListLedgerRoundIDsMissingUuid returns the distinct raw round_id BLOBs that +// have not yet been mirrored into the round_uuid TEXT column. The BLOB-to-UUID +// string conversion is not expressible in the SQL dialect subset shared by +// SQLite and Postgres, so the migration-015 post-step performs it in Go and +// writes the result back via BackfillLedgerRoundUuid. +func (q *Queries) ListLedgerRoundIDsMissingUuid(ctx context.Context) ([][]byte, error) { + rows, err := q.db.QueryContext(ctx, ListLedgerRoundIDsMissingUuid) + if err != nil { + return nil, err + } + defer rows.Close() + var items [][]byte + for rows.Next() { + var round_id []byte + if err := rows.Scan(&round_id); err != nil { + return nil, err + } + items = append(items, round_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const ListTransactionHistory = `-- name: ListTransactionHistory :many SELECT source, entry_id, txid, transaction_type, subtype, amount_sat, fee_sat, created_at, status, description, diff --git a/db/sqlc/migrations/000015_ledger_round_uuid.down.sql b/db/sqlc/migrations/000015_ledger_round_uuid.down.sql new file mode 100644 index 000000000..6fd576115 --- /dev/null +++ b/db/sqlc/migrations/000015_ledger_round_uuid.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_client_ledger_round_uuid; +ALTER TABLE ledger_entries DROP COLUMN round_uuid; diff --git a/db/sqlc/migrations/000015_ledger_round_uuid.up.sql b/db/sqlc/migrations/000015_ledger_round_uuid.up.sql new file mode 100644 index 000000000..d752db902 --- /dev/null +++ b/db/sqlc/migrations/000015_ledger_round_uuid.up.sql @@ -0,0 +1,20 @@ +-- round_uuid is the canonical lowercase UUID string form of round_id. The +-- ledger stores round_id as a raw 16-byte BLOB while every round-adjacent +-- table (rounds.round_id, vtxos.forfeit_round_id) stores the TEXT UUID, and +-- no BLOB<->TEXT conversion exists in the SQL dialect subset shared by +-- SQLite and Postgres. Materializing the TEXT form as its own column makes +-- ledger rows joinable against those tables in plain SQL (e.g. attributing a +-- round's operator fee to the VTXOs it forfeited). +-- +-- The column is nullable: rows without a round linkage stay NULL, mirroring +-- round_id. Existing rows are backfilled by the Go post-migration step +-- registered for this version (see db/post_migration_checks.go), since the +-- BLOB-to-UUID-string conversion is not expressible in portable SQL. +ALTER TABLE ledger_entries ADD COLUMN round_uuid TEXT; + +-- The composite (round_uuid, event_type) key fully covers the settlement fee +-- lookup (a correlated per-round SUM filtered by fee event types) without a +-- residual filter step. +CREATE INDEX IF NOT EXISTS idx_client_ledger_round_uuid + ON ledger_entries(round_uuid, event_type) + WHERE round_uuid IS NOT NULL; diff --git a/db/sqlc/models.go b/db/sqlc/models.go index 4f1d4557c..f91f0705f 100644 --- a/db/sqlc/models.go +++ b/db/sqlc/models.go @@ -183,6 +183,7 @@ type LedgerEntry struct { ChainTxid []byte ChainVout sql.NullInt32 ConfirmationHeight sql.NullInt32 + RoundUuid sql.NullString } type LedgerEventType struct { diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 1a99b65f7..10f32337f 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -14,6 +14,11 @@ type Querier interface { // returns the event_seq the database assigned (monotonic, not necessarily // contiguous). Callers use it as the resumable-subscribe cursor for the update. AppendActivityEvent(ctx context.Context, arg AppendActivityEventParams) (int64, error) + // BackfillLedgerRoundUuid stamps the canonical UUID string form onto every + // entry carrying the given raw round_id that does not have one yet. The + // round_uuid IS NULL guard makes re-running the backfill (e.g. after a crash + // mid-migration) a no-op for already-converted rows. + BackfillLedgerRoundUuid(ctx context.Context, arg BackfillLedgerRoundUuidParams) error CancelVHTLCRecoveryJob(ctx context.Context, arg CancelVHTLCRecoveryJobParams) (int64, error) ClearPendingIntentAnchorByOutpoint(ctx context.Context, arg ClearPendingIntentAnchorByOutpointParams) error CompleteVHTLCRecoveryJob(ctx context.Context, arg CompleteVHTLCRecoveryJobParams) (int64, error) @@ -199,6 +204,12 @@ type Querier interface { // rows) instead of decoding the whole activity feed, and the canonical_id // cursor is strictly monotonic (a full page always advances it). ListEntriesByKindStatus(ctx context.Context, arg ListEntriesByKindStatusParams) ([]ActivityEntry, error) + // ListLedgerRoundIDsMissingUuid returns the distinct raw round_id BLOBs that + // have not yet been mirrored into the round_uuid TEXT column. The BLOB-to-UUID + // string conversion is not expressible in the SQL dialect subset shared by + // SQLite and Postgres, so the migration-015 post-step performs it in Go and + // writes the result back via BackfillLedgerRoundUuid. + ListLedgerRoundIDsMissingUuid(ctx context.Context) ([][]byte, error) // ListLiveVTXOAncestryPaths returns every ancestry row whose parent VTXO // is non-terminal, mirroring the filter on ListLiveVTXOs. Used as a // single batched companion query so descriptor materialization across diff --git a/db/sqlc/queries/fee_accounting.sql b/db/sqlc/queries/fee_accounting.sql index 70767dd00..21191b7cc 100644 --- a/db/sqlc/queries/fee_accounting.sql +++ b/db/sqlc/queries/fee_accounting.sql @@ -20,15 +20,16 @@ INSERT INTO ledger_entries ( debit_account, credit_account, amount_sat, round_id, session_id, idempotency_key, event_type, description, created_at, - chain_txid, chain_vout, confirmation_height -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + chain_txid, chain_vout, confirmation_height, + round_uuid +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) ON CONFLICT DO NOTHING; -- name: ListClientLedgerEntries :many SELECT entry_id, debit_account, credit_account, amount_sat, round_id, session_id, idempotency_key, event_type, description, created_at, - chain_txid, chain_vout, confirmation_height + chain_txid, chain_vout, confirmation_height, round_uuid FROM ledger_entries ORDER BY created_at DESC LIMIT $1 OFFSET $2; @@ -37,7 +38,7 @@ LIMIT $1 OFFSET $2; SELECT entry_id, debit_account, credit_account, amount_sat, round_id, session_id, idempotency_key, event_type, description, created_at, - chain_txid, chain_vout, confirmation_height + chain_txid, chain_vout, confirmation_height, round_uuid FROM ledger_entries WHERE event_type = $1 ORDER BY created_at DESC @@ -339,3 +340,24 @@ SELECT CAST(COUNT(*) AS BIGINT) AS entry_count, CAST(COALESCE(MIN(created_at), 0) AS BIGINT) AS first_created_at, CAST(COALESCE(MAX(created_at), 0) AS BIGINT) AS last_created_at FROM ledger_entries; + +-- name: ListLedgerRoundIDsMissingUuid :many +-- ListLedgerRoundIDsMissingUuid returns the distinct raw round_id BLOBs that +-- have not yet been mirrored into the round_uuid TEXT column. The BLOB-to-UUID +-- string conversion is not expressible in the SQL dialect subset shared by +-- SQLite and Postgres, so the migration-015 post-step performs it in Go and +-- writes the result back via BackfillLedgerRoundUuid. +SELECT DISTINCT round_id +FROM ledger_entries +WHERE round_id IS NOT NULL + AND round_uuid IS NULL; + +-- name: BackfillLedgerRoundUuid :exec +-- BackfillLedgerRoundUuid stamps the canonical UUID string form onto every +-- entry carrying the given raw round_id that does not have one yet. The +-- round_uuid IS NULL guard makes re-running the backfill (e.g. after a crash +-- mid-migration) a no-op for already-converted rows. +UPDATE ledger_entries +SET round_uuid = $1 +WHERE round_id = $2 + AND round_uuid IS NULL; diff --git a/db/sqlc/schemas/generated_schema.sql b/db/sqlc/schemas/generated_schema.sql index 615ab603f..42335cca2 100644 --- a/db/sqlc/schemas/generated_schema.sql +++ b/db/sqlc/schemas/generated_schema.sql @@ -494,6 +494,10 @@ CREATE UNIQUE INDEX idx_client_ledger_idempotent_session CREATE INDEX idx_client_ledger_round ON ledger_entries(round_id); +CREATE INDEX idx_client_ledger_round_uuid + ON ledger_entries(round_uuid, event_type) + WHERE round_uuid IS NOT NULL; + CREATE INDEX idx_client_tree_txids_tree ON client_tree_txids(round_id, client_key, tree_level); @@ -694,7 +698,7 @@ CREATE TABLE ledger_entries ( -- UTXO idempotency keys on every query. chain_txid BLOB, chain_vout INTEGER, - confirmation_height INTEGER, + confirmation_height INTEGER, round_uuid TEXT, -- Debit and credit must target different accounts. CHECK (debit_account != credit_account) diff --git a/db/sqlite.go b/db/sqlite.go index 90d57af20..17b537fe2 100644 --- a/db/sqlite.go +++ b/db/sqlite.go @@ -230,7 +230,14 @@ func NewSqliteStore(cfg *SqliteConfig, if !cfg.SkipMigrations { storeLog.InfoS(ctx, "Starting SQLite schema migrations") - err := s.ExecuteMigrations(s.backupAndMigrate) + err := s.ExecuteMigrations( + s.backupAndMigrate, + WithPostStepCallbacks( + makePostStepCallbacks( + s, storeLog, postMigrationChecks, + ), + ), + ) if err != nil { return nil, fmt.Errorf("error executing migrations: %w", err) From 41f4de648961efbee91706ebe67a5cc1e07eca1b Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:10:24 -0500 Subject: [PATCH 04/12] db+vtxo: join the forfeit round's operator fee onto settlements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we extend the ListVTXOsByStatus settlement join with the forfeit round's operator fee. The query already LEFT JOINs the round that forfeited each VTXO to surface the settling commitment txid and confirmation height; a second LEFT JOIN against a fee-totals subquery (SUM of boarding_fee_paid and refresh_fee_paid grouped by round_uuid, keyed on forfeit_round_id) now rides along in the same single query, so the read costs no extra roundtrip. The figure is round-level: every VTXO forfeited in the same round reports the same total, so consumers must attribute it once, never sum it across VTXOs. It reads zero for fee-free rounds and for ledger rows predating the round_uuid backfill. vtxo.Settlement grows a FeeSat field populated by the by-status row converter on both the full and light read paths, and the settlement store test now proves the sum covers exactly the settling round's fee event types — the vtxo_sent row in the same round and a fee booked against an unrelated round contribute nothing. --- db/sqlc/querier.go | 13 ++++++++++ db/sqlc/queries/vtxo.sql | 21 +++++++++++++++- db/sqlc/vtxo.sql.go | 23 +++++++++++++++++- db/vtxo_store.go | 7 +++--- db/vtxo_store_test.go | 52 +++++++++++++++++++++++++++++++++++++++- vtxo/interfaces.go | 8 +++++++ 6 files changed, 118 insertions(+), 6 deletions(-) diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index 10f32337f..e2d8beccf 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -292,6 +292,19 @@ type Querier interface { // height. The join columns are NULL for every VTXO whose forfeit round is // unknown (all non-forfeited VTXOs, and forfeited ones whose round row is // absent), so consumers must treat them as optional. + // + // settlement_fee_sat is the TOTAL operator fee the client's ledger booked for + // the forfeit round (boarding_fee_paid + refresh_fee_paid, joined via the + // round_uuid TEXT mirror of the ledger's BLOB round_id). Every VTXO forfeited + // in the same round reports the same round-level figure — consumers must not + // sum it across VTXOs. Zero when the forfeit round is unknown or its fee rows + // are absent (e.g. rows written before the round_uuid backfill ran). + // + // The fee lookup is a correlated scalar subquery rather than a grouped join: + // the planner then resolves it as a per-row probe of the + // idx_client_ledger_round_uuid index for the (few) forfeited rows that carry + // a forfeit_round_id, instead of aggregating every fee row in the ledger on + // every call. ListVTXOsByStatus(ctx context.Context, status int32) ([]ListVTXOsByStatusRow, error) ListWalletUTXOLog(ctx context.Context, arg ListWalletUTXOLogParams) ([]WalletUtxoLog, error) ListWalletUTXOLogByBlock(ctx context.Context, blockHeight int32) ([]WalletUtxoLog, error) diff --git a/db/sqlc/queries/vtxo.sql b/db/sqlc/queries/vtxo.sql index 3cb140041..3dc10103d 100644 --- a/db/sqlc/queries/vtxo.sql +++ b/db/sqlc/queries/vtxo.sql @@ -9,9 +9,28 @@ -- height. The join columns are NULL for every VTXO whose forfeit round is -- unknown (all non-forfeited VTXOs, and forfeited ones whose round row is -- absent), so consumers must treat them as optional. +-- +-- settlement_fee_sat is the TOTAL operator fee the client's ledger booked for +-- the forfeit round (boarding_fee_paid + refresh_fee_paid, joined via the +-- round_uuid TEXT mirror of the ledger's BLOB round_id). Every VTXO forfeited +-- in the same round reports the same round-level figure — consumers must not +-- sum it across VTXOs. Zero when the forfeit round is unknown or its fee rows +-- are absent (e.g. rows written before the round_uuid backfill ran). +-- +-- The fee lookup is a correlated scalar subquery rather than a grouped join: +-- the planner then resolves it as a per-row probe of the +-- idx_client_ledger_round_uuid index for the (few) forfeited rows that carry +-- a forfeit_round_id, instead of aggregating every fee row in the ledger on +-- every call. SELECT sqlc.embed(vtxos), rounds.commitment_txid AS settlement_txid, - rounds.confirmation_height AS settlement_height + rounds.confirmation_height AS settlement_height, + CAST(COALESCE(( + SELECT SUM(le.amount_sat) + FROM ledger_entries AS le + WHERE le.round_uuid = vtxos.forfeit_round_id + AND le.event_type IN ('boarding_fee_paid', 'refresh_fee_paid') + ), 0) AS BIGINT) AS settlement_fee_sat FROM vtxos LEFT JOIN rounds ON vtxos.forfeit_round_id = rounds.round_id WHERE vtxos.status = $1 diff --git a/db/sqlc/vtxo.sql.go b/db/sqlc/vtxo.sql.go index 193df828c..bd8de9959 100644 --- a/db/sqlc/vtxo.sql.go +++ b/db/sqlc/vtxo.sql.go @@ -199,7 +199,13 @@ const ListVTXOsByStatus = `-- name: ListVTXOsByStatus :many SELECT vtxos.outpoint_hash, vtxos.outpoint_index, vtxos.round_id, vtxos.amount, vtxos.pk_script, vtxos.expiry, vtxos.policy_template, vtxos.client_key_id, vtxos.operator_pubkey, vtxos.batch_expiry, vtxos.created_height, vtxos.commitment_txid, vtxos.spent, vtxos.status, vtxos.forfeit_round_id, vtxos.forfeit_tx, vtxos.forfeit_txid, vtxos.replaced_by_hash, vtxos.replaced_by_index, vtxos.creation_time, vtxos.last_update_time, vtxos.chain_depth, vtxos.construction_version, rounds.commitment_txid AS settlement_txid, - rounds.confirmation_height AS settlement_height + rounds.confirmation_height AS settlement_height, + CAST(COALESCE(( + SELECT SUM(le.amount_sat) + FROM ledger_entries AS le + WHERE le.round_uuid = vtxos.forfeit_round_id + AND le.event_type IN ('boarding_fee_paid', 'refresh_fee_paid') + ), 0) AS BIGINT) AS settlement_fee_sat FROM vtxos LEFT JOIN rounds ON vtxos.forfeit_round_id = rounds.round_id WHERE vtxos.status = $1 @@ -210,6 +216,7 @@ type ListVTXOsByStatusRow struct { Vtxo Vtxo SettlementTxid []byte SettlementHeight sql.NullInt32 + SettlementFeeSat int64 } // VTXO status and lifecycle queries. @@ -221,6 +228,19 @@ type ListVTXOsByStatusRow struct { // height. The join columns are NULL for every VTXO whose forfeit round is // unknown (all non-forfeited VTXOs, and forfeited ones whose round row is // absent), so consumers must treat them as optional. +// +// settlement_fee_sat is the TOTAL operator fee the client's ledger booked for +// the forfeit round (boarding_fee_paid + refresh_fee_paid, joined via the +// round_uuid TEXT mirror of the ledger's BLOB round_id). Every VTXO forfeited +// in the same round reports the same round-level figure — consumers must not +// sum it across VTXOs. Zero when the forfeit round is unknown or its fee rows +// are absent (e.g. rows written before the round_uuid backfill ran). +// +// The fee lookup is a correlated scalar subquery rather than a grouped join: +// the planner then resolves it as a per-row probe of the +// idx_client_ledger_round_uuid index for the (few) forfeited rows that carry +// a forfeit_round_id, instead of aggregating every fee row in the ledger on +// every call. func (q *Queries) ListVTXOsByStatus(ctx context.Context, status int32) ([]ListVTXOsByStatusRow, error) { rows, err := q.db.QueryContext(ctx, ListVTXOsByStatus, status) if err != nil { @@ -256,6 +276,7 @@ func (q *Queries) ListVTXOsByStatus(ctx context.Context, status int32) ([]ListVT &i.Vtxo.ConstructionVersion, &i.SettlementTxid, &i.SettlementHeight, + &i.SettlementFeeSat, ); err != nil { return nil, err } diff --git a/db/vtxo_store.go b/db/vtxo_store.go index 54d4d81b0..adf5fc0ed 100644 --- a/db/vtxo_store.go +++ b/db/vtxo_store.go @@ -400,15 +400,16 @@ func (s *VTXOPersistenceStore) byStatusRowsToDescriptors(ctx context.Context, // settlement_txid is a 32-byte BLOB when the forfeit round row // exists; treat any other length (NULL join, short/legacy) as - // unset so the descriptor's Settlement stays None. The txid and - // height come from one round row, so they are attached - // together. + // unset so the descriptor's Settlement stays None. The txid, + // height, and round-level operator fee are attached together, + // as they all describe the same forfeit round. if len(row.SettlementTxid) == chainhash.HashSize { var settle vtxo.Settlement copy(settle.TxID[:], row.SettlementTxid) if row.SettlementHeight.Valid { settle.Height = row.SettlementHeight.Int32 } + settle.FeeSat = row.SettlementFeeSat desc.Settlement = fn.Some(settle) } diff --git a/db/vtxo_store_test.go b/db/vtxo_store_test.go index 15c334e80..48b7d0de5 100644 --- a/db/vtxo_store_test.go +++ b/db/vtxo_store_test.go @@ -10,7 +10,9 @@ import ( "github.com/btcsuite/btcd/chainhash/v2" "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/lib/arkscript" "github.com/lightninglabs/wavelength/lib/tree" "github.com/lightninglabs/wavelength/lib/types" @@ -1122,7 +1124,7 @@ func TestVTXOPersistenceStoreListVTXOsByStatusBatchedAncestry(t *testing.T) { func TestVTXOPersistenceStoreListVTXOsByStatusSettlement(t *testing.T) { t.Parallel() - vtxoStore, roundStore, _ := newVTXOStoreForTest(t) + vtxoStore, roundStore, baseDB := newVTXOStoreForTest(t) ctx := t.Context() // The round that CREATED the VTXOs. Its own commitment txid must not be @@ -1160,6 +1162,48 @@ func TestVTXOPersistenceStoreListVTXOsByStatusSettlement(t *testing.T) { ), ) + // Ledger fee rows for the settling round: the join must SUM the two + // operator fee event types, ignore the non-fee row in the same round, + // and not leak the fee booked against the unrelated create round. + ledgerStore := &LedgerStoreDB{ + TransactionExecutor: NewTransactionExecutor( + baseDB, + func(tx *sql.Tx) *sqlc.Queries { + return baseDB.WithTx(tx) + }, + btclog.Disabled, + ), + } + settleRoundBytes := uuid.UUID(settleRoundID) + createRoundBytes := uuid.UUID(createRoundID) + const ( + refreshFeeSat = int64(350) + boardingFeeSat = int64(150) + ) + feeEntries := []ledger.LedgerEntry{ + makeLedgerEntry( + ledger.AccountFeesPaid, ledger.AccountVTXOBalance, + refreshFeeSat, ledger.EventRefreshFeePaid, + settleRoundBytes[:], 1_000, + ), + makeLedgerEntry( + ledger.AccountFeesPaid, ledger.AccountVTXOBalance, + boardingFeeSat, ledger.EventBoardingFeePaid, + settleRoundBytes[:], 1_001, + ), + makeLedgerEntry( + ledger.AccountTransfersOut, ledger.AccountVTXOBalance, + 5_000, ledger.EventVTXOSent, settleRoundBytes[:], 1_002, + ), + makeLedgerEntry( + ledger.AccountFeesPaid, ledger.AccountVTXOBalance, 999, + ledger.EventRefreshFeePaid, createRoundBytes[:], 1_003, + ), + } + for _, entry := range feeEntries { + require.NoError(t, ledgerStore.InsertLedgerEntry(ctx, entry)) + } + // A live VTXO (no forfeit round) and a forfeited VTXO whose forfeit // round is the confirmed leave round above. liveDesc := createTestVTXODescriptor(t, createRoundID, 1) @@ -1195,6 +1239,11 @@ func TestVTXOPersistenceStoreListVTXOsByStatusSettlement(t *testing.T) { require.Equal(t, settlementTxid, settle.TxID) require.Equal(t, settlementHeight, settle.Height) + // The settlement fee is the SUM of the settling round's operator fee + // rows only: the vtxo_sent row in the same round and the fee booked + // against the create round must not contribute. + require.Equal(t, refreshFeeSat+boardingFeeSat, settle.FeeSat) + // The live VTXO has no forfeit round, so its settlement is None. live, err := vtxoStore.ListVTXOsByStatus(ctx, vtxo.VTXOStatusLive) require.NoError(t, err) @@ -1210,6 +1259,7 @@ func TestVTXOPersistenceStoreListVTXOsByStatusSettlement(t *testing.T) { settleLight := forfeitedLight[0].Settlement.UnwrapOrFail(t) require.Equal(t, settlementTxid, settleLight.TxID) require.Equal(t, settlementHeight, settleLight.Height) + require.Equal(t, refreshFeeSat+boardingFeeSat, settleLight.FeeSat) } // TestGroupAncestryRowsPreservesOrder is a unit test on the grouping diff --git a/vtxo/interfaces.go b/vtxo/interfaces.go index 8fe0b841b..d7e420be9 100644 --- a/vtxo/interfaces.go +++ b/vtxo/interfaces.go @@ -436,6 +436,14 @@ type Settlement struct { // Height is the block height at which TxID confirmed. Height int32 + + // FeeSat is the TOTAL operator fee the client's ledger booked for the + // forfeit round. It is a round-level figure: every VTXO forfeited in + // the same round carries the same value, so consumers must not sum it + // across VTXOs. Zero when the ledger has no fee row for the round + // (fee-free rounds, and rows predating the ledger round_uuid + // backfill). + FeeSat int64 } // MaxTreeDepth returns the largest TreeDepth across the Descriptor's From c333c730b8e2a29606c56c1fe04583a9f183c982 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:10:34 -0500 Subject: [PATCH 05/12] waverpc+waved: surface the settlement fee on ListVTXOs In this commit, we add fee_sat to the VTXOSettlement message and populate it in descriptorToProto from the settlement the store join now carries. A FORFEITED VTXO's settlement thereby reports where the forfeit round confirmed AND what the round cost, giving wallet-layer consumers a single carrier for completing a cooperative-leave row with its on-chain coordinates and fee. The field mirrors the store contract: a round-level total repeated on every VTXO the round forfeited, zero against fee-free rounds and ledgers predating fee-by-round attribution. --- waved/rpc_server.go | 1 + waverpc/daemon.pb.go | 20 +++++++++++++++++--- waverpc/daemon.proto | 7 +++++++ 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/waved/rpc_server.go b/waved/rpc_server.go index b070b8a60..d3b15fe5b 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -1418,6 +1418,7 @@ func descriptorToProto(v *vtxo.Descriptor) *waverpc.VTXO { proto.Settlement = &waverpc.VTXOSettlement{ Txid: s.TxID.String(), Height: s.Height, + FeeSat: s.FeeSat, } }) diff --git a/waverpc/daemon.pb.go b/waverpc/daemon.pb.go index d4c5194ed..bf638d649 100644 --- a/waverpc/daemon.pb.go +++ b/waverpc/daemon.pb.go @@ -2072,7 +2072,13 @@ type VTXOSettlement struct { // the VTXO (the leave/cooperative-forfeit round). Txid string `protobuf:"bytes,1,opt,name=txid,proto3" json:"txid,omitempty"` // height is the block height at which txid confirmed. - Height int32 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + Height int32 `protobuf:"varint,2,opt,name=height,proto3" json:"height,omitempty"` + // fee_sat is the TOTAL operator fee the client's ledger booked for the + // forfeit round. It is a round-level figure: every VTXO forfeited in the + // same round reports the same value, so consumers must not sum it across + // VTXOs. Zero when the ledger has no fee row for the round (fee-free + // rounds, and daemons whose ledger predates fee-by-round attribution). + FeeSat int64 `protobuf:"varint,3,opt,name=fee_sat,json=feeSat,proto3" json:"fee_sat,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2121,6 +2127,13 @@ func (x *VTXOSettlement) GetHeight() int32 { return 0 } +func (x *VTXOSettlement) GetFeeSat() int64 { + if x != nil { + return x.FeeSat + } + return 0 +} + type ListVTXOsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // status_filter restricts the response to VTXOs matching this status. @@ -9994,10 +10007,11 @@ const file_daemon_proto_rawDesc = "" + "expiryInfo\x127\n" + "\n" + "settlement\x18\x0e \x01(\v2\x17.waverpc.VTXOSettlementR\n" + - "settlement\"<\n" + + "settlement\"U\n" + "\x0eVTXOSettlement\x12\x12\n" + "\x04txid\x18\x01 \x01(\tR\x04txid\x12\x16\n" + - "\x06height\x18\x02 \x01(\x05R\x06height\"\xac\x01\n" + + "\x06height\x18\x02 \x01(\x05R\x06height\x12\x17\n" + + "\afee_sat\x18\x03 \x01(\x03R\x06feeSat\"\xac\x01\n" + "\x10ListVTXOsRequest\x128\n" + "\rstatus_filter\x18\x01 \x01(\x0e2\x13.waverpc.VTXOStatusR\fstatusFilter\x12$\n" + "\x0emin_amount_sat\x18\x02 \x01(\x03R\fminAmountSat\x128\n" + diff --git a/waverpc/daemon.proto b/waverpc/daemon.proto index 4ccc2c00b..56671b454 100644 --- a/waverpc/daemon.proto +++ b/waverpc/daemon.proto @@ -727,6 +727,13 @@ message VTXOSettlement { // height is the block height at which txid confirmed. int32 height = 2; + + // fee_sat is the TOTAL operator fee the client's ledger booked for the + // forfeit round. It is a round-level figure: every VTXO forfeited in the + // same round reports the same value, so consumers must not sum it across + // VTXOs. Zero when the ledger has no fee row for the round (fee-free + // rounds, and daemons whose ledger predates fee-by-round attribution). + int64 fee_sat = 3; } message ListVTXOsRequest { From d1824ef6ee110b2c7bb5f127868ac906ce9d0588 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:11:26 -0500 Subject: [PATCH 06/12] swapwallet: stamp settled fees onto cooperative-leave EXIT rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we complete the fee plumbing the activity surface was missing: when a cooperative-leave EXIT row completes off its forfeited source VTXO, the settlement now carries the forfeit round's operator fee, and applyCooperativeLeaveForfeited stamps it onto the row's fee_sat. The projection pass then persists it into the canonical activity store, so both the live table and the durable row agree. Sweep-all sends need one more step. A sweep's pending amount is the gross drained balance with the fee still baked in — the binding fee is unknown until the round seals — so displaying a fee next to that gross amount would double-count it. The onchain request now records a sweep_all marker (set by leaveEntryStub from the prepared intent), and completion nets the settled fee back out of the amount. Every completed EXIT thus reads the same way: amount is the value delivered to the destination, fee is the cost on top, and their sum is the true outflow. A bounded send's amount is already the exact destination value, so it is left untouched. Verified end-to-end on an arktest regtest topology: a 10,000 sat bounded send completes as amount -10,000 / fee 541, and a sweep-all of a 139,204 sat wallet completes as amount -138,816 / fee 388, with the destination wallet receiving exactly those amounts on chain. --- rpc/wavewalletrpc/wallet.pb.go | 22 ++++++-- rpc/wavewalletrpc/wallet.proto | 9 ++++ swapwallet/history.go | 34 ++++++++++++ swapwallet/history_test.go | 96 +++++++++++++++++++++++++++++++--- swapwallet/normalize.go | 14 +++-- swapwallet/normalize_test.go | 8 +-- swapwallet/router.go | 2 +- swapwallet/runtime_test.go | 2 +- 8 files changed, 168 insertions(+), 19 deletions(-) diff --git a/rpc/wavewalletrpc/wallet.pb.go b/rpc/wavewalletrpc/wallet.pb.go index d0954cab8..2f0db913b 100644 --- a/rpc/wavewalletrpc/wallet.pb.go +++ b/rpc/wavewalletrpc/wallet.pb.go @@ -5489,7 +5489,15 @@ func (x *LightningInvoiceRequest) GetPaymentHash() string { type OnchainAddressRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // address is the bech32 onchain address originally issued or targeted. - Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + Address string `protobuf:"bytes,1,opt,name=address,proto3" json:"address,omitempty"` + // sweep_all marks an onchain send that drained the selected VTXOs + // entirely (wavecli send --send-all). A sweep's pending amount is the + // gross outflow with the operator fee still baked in (the fee is only + // known once the leave round seals), so completion uses this marker to + // net the settled fee back out of the displayed amount. Bounded sends + // leave it false: their amount is the exact destination value and the + // fee is paid on top out of change. + SweepAll bool `protobuf:"varint,2,opt,name=sweep_all,json=sweepAll,proto3" json:"sweep_all,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -5531,6 +5539,13 @@ func (x *OnchainAddressRequest) GetAddress() string { return "" } +func (x *OnchainAddressRequest) GetSweepAll() bool { + if x != nil { + return x.SweepAll + } + return false +} + // ArkAddressRequest captures the Ark address associated with an activity entry. type ArkAddressRequest struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -6036,9 +6051,10 @@ const file_wallet_proto_rawDesc = "" + "\arequest\"V\n" + "\x17LightningInvoiceRequest\x12\x18\n" + "\ainvoice\x18\x01 \x01(\tR\ainvoice\x12!\n" + - "\fpayment_hash\x18\x02 \x01(\tR\vpaymentHash\"1\n" + + "\fpayment_hash\x18\x02 \x01(\tR\vpaymentHash\"N\n" + "\x15OnchainAddressRequest\x12\x18\n" + - "\aaddress\x18\x01 \x01(\tR\aaddress\"-\n" + + "\aaddress\x18\x01 \x01(\tR\aaddress\x12\x1b\n" + + "\tsweep_all\x18\x02 \x01(\bR\bsweepAll\"-\n" + "\x11ArkAddressRequest\x12\x18\n" + "\aaddress\x18\x01 \x01(\tR\aaddress\"\x96\x02\n" + "\x13WalletEntryProgress\x125\n" + diff --git a/rpc/wavewalletrpc/wallet.proto b/rpc/wavewalletrpc/wallet.proto index 4ec2a5e14..b8d8480b6 100644 --- a/rpc/wavewalletrpc/wallet.proto +++ b/rpc/wavewalletrpc/wallet.proto @@ -1610,6 +1610,15 @@ message LightningInvoiceRequest { message OnchainAddressRequest { // address is the bech32 onchain address originally issued or targeted. string address = 1; + + // sweep_all marks an onchain send that drained the selected VTXOs + // entirely (wavecli send --send-all). A sweep's pending amount is the + // gross outflow with the operator fee still baked in (the fee is only + // known once the leave round seals), so completion uses this marker to + // net the settled fee back out of the displayed amount. Bounded sends + // leave it false: their amount is the exact destination value and the + // fee is paid on top out of change. + bool sweep_all = 2; } // ArkAddressRequest captures the Ark address associated with an activity entry. diff --git a/swapwallet/history.go b/swapwallet/history.go index 9ec69090b..dcedfdb21 100644 --- a/swapwallet/history.go +++ b/swapwallet/history.go @@ -852,6 +852,12 @@ func (h *history) hasWalletLocalExitEntries() bool { type settlement struct { txid string height int32 + + // feeSat is the round-level operator fee the daemon's ledger booked + // for the forfeit round; zero against an old daemon or a fee-free + // round. Every VTXO forfeited in the same round reports the same + // figure, so it is stamped onto a leave row, never summed. + feeSat int64 } // collectForfeitedVTXOSettlements returns the terminal VTXO outpoints used to @@ -890,6 +896,7 @@ func (h *history) collectForfeitedVTXOSettlements(ctx context.Context) ( settlements[vtxo.GetOutpoint()] = settlement{ txid: vtxo.GetSettlement().GetTxid(), height: vtxo.GetSettlement().GetHeight(), + feeSat: vtxo.GetSettlement().GetFeeSat(), } } @@ -1037,6 +1044,16 @@ func decorateCooperativeLeaveEntry(entry *wavewalletrpc.WalletEntry, // settling round's txid, it is stamped onto the row's progress so the completed // leave can be reconciled against the chain; an empty txid (old daemon) leaves // the row complete but without on-chain coordinates, preserving prior behavior. +// +// The settlement also carries the forfeit round's operator fee from the +// daemon's ledger, which is stamped onto the row's fee_sat. A sweep-all row's +// pending amount is the gross outflow with that fee still baked in (the fee is +// unknown until the round seals), so the fee is additionally netted back out +// of the amount, leaving amount = value delivered to the destination and +// fee = cost on top — the same shape a bounded send already has. The +// adjustment operates on the per-derive clone from pendingSnapshot and is +// guarded by the caller's PENDING check, so it applies exactly once per +// derived row. func applyCooperativeLeaveForfeited(entry *wavewalletrpc.WalletEntry, settle settlement) { @@ -1047,6 +1064,23 @@ func applyCooperativeLeaveForfeited(entry *wavewalletrpc.WalletEntry, entry.Status = wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE entry.FailureReason = "" + // A zero settlement fee (old daemon, fee-free round) leaves any + // already-carried fee untouched rather than clobbering it. + if settle.feeSat > 0 { + entry.FeeSat = settle.feeSat + + // Clamp at zero so a fee exceeding the gross amount (which no + // sane round produces, but the figure crosses an RPC boundary) + // can never flip an outflow row's sign positive. + sweepAll := entry.GetRequest().GetOnchainAddress().GetSweepAll() + if sweepAll && entry.AmountSat < 0 { + entry.AmountSat += settle.feeSat + if entry.AmountSat > 0 { + entry.AmountSat = 0 + } + } + } + progress := entry.GetProgress() if progress == nil { progress = &wavewalletrpc.WalletEntryProgress{} diff --git a/swapwallet/history_test.go b/swapwallet/history_test.go index 16e03e56f..84458e066 100644 --- a/swapwallet/history_test.go +++ b/swapwallet/history_test.go @@ -1198,7 +1198,7 @@ func TestHistoryIncludesWalletLocalPendingExit(t *testing.T) { "", []string{ "leave-outpoint:1", }, "bcrt1qdest", - 50_000, "user note", + 50_000, "user note", false, ) h.runtime.trackPendingEntry(pending) @@ -1254,7 +1254,7 @@ func TestHistoryCompletesWalletLocalExitWhenVTXOForfeited(t *testing.T) { "", []string{ "leave-outpoint:1", }, "bcrt1qdest", - 50_000, "user note", + 50_000, "user note", false, ) h.runtime.trackPendingEntryWithoutTimeout(pending) @@ -1307,6 +1307,7 @@ func TestHistoryStampsSettlementOnForfeitedLeave(t *testing.T) { settlementTxid = "aa000000000000000000000000000000" + "00000000000000000000000000000000" settlementHeight = int32(812345) + settlementFeeSat = int64(275) ) h, swap, rpc := newHistoryFixture(t) @@ -1328,6 +1329,7 @@ func TestHistoryStampsSettlementOnForfeitedLeave(t *testing.T) { Settlement: &waverpc.VTXOSettlement{ Txid: settlementTxid, Height: settlementHeight, + FeeSat: settlementFeeSat, }, }, }, @@ -1338,7 +1340,7 @@ func TestHistoryStampsSettlementOnForfeitedLeave(t *testing.T) { "", []string{ "leave-outpoint:1", }, "bcrt1qdest", - 50_000, "user note", + 50_000, "user note", false, ) h.runtime.trackPendingEntryWithoutTimeout(pending) @@ -1362,6 +1364,86 @@ func TestHistoryStampsSettlementOnForfeitedLeave(t *testing.T) { t, settlementHeight, entries[0].GetProgress().GetConfirmationHeight(), ) + + // The forfeit round's operator fee lands on the row. A bounded send's + // amount is the exact destination value with the fee paid on top out + // of change, so the amount must NOT be adjusted. + require.Equal(t, settlementFeeSat, entries[0].GetFeeSat()) + require.Equal(t, int64(-50_000), entries[0].GetAmountSat()) +} + +// TestHistoryNetsFeeOutOfSweepAllLeaveAmount confirms a completed sweep-all +// leave nets the settled operator fee back out of its gross pending amount. +// A sweep's pending row carries the whole drained balance (the fee is only +// known once the round seals), so without the adjustment the completed row +// would double-count the fee next to the new fee_sat column: displayed +// amount+fee would exceed the true outflow (issue #988's --send-all row). +func TestHistoryNetsFeeOutOfSweepAllLeaveAmount(t *testing.T) { + t.Parallel() + + const ( + settlementTxid = "bb000000000000000000000000000000" + + "00000000000000000000000000000000" + settlementHeight = int32(812346) + settlementFeeSat = int64(423) + grossSweepSat = int64(32_323) + ) + + h, swap, rpc := newHistoryFixture(t) + swap.listSwapsResp = &swapclientrpc.ListSwapsResponse{} + rpc.listTxResp = &waverpc.ListTransactionsResponse{} + rpc.unrollStatusResp = &waverpc.GetUnrollStatusResponse{ + Found: false, + } + rpc.listVTXOsByStatus = map[waverpc.VTXOStatus]*waverpc. + ListVTXOsResponse{ + + waverpc.VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT: {}, + waverpc.VTXOStatus_VTXO_STATUS_FORFEITED: { + Vtxos: []*waverpc.VTXO{ + { + Outpoint: "leave-outpoint:1", + Status: waverpc. + VTXOStatus_VTXO_STATUS_FORFEITED, + Settlement: &waverpc.VTXOSettlement{ + Txid: settlementTxid, + Height: settlementHeight, + FeeSat: settlementFeeSat, + }, + }, + }, + }, + } + + pending := leaveEntryStub( + "", []string{ + "leave-outpoint:1", + }, "bcrt1qdest", + grossSweepSat, "sweep note", true, + ) + h.runtime.trackPendingEntryWithoutTimeout(pending) + + resp, err := h.List(t.Context(), &wavewalletrpc.ListRequest{}) + require.NoError(t, err) + + entries := resp.GetActivity().GetEntries() + require.Len(t, entries, 1) + require.Equal( + t, wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + entries[0].GetStatus(), + ) + + // amount = destination-received, fee = cost on top: together they + // reconstruct the gross outflow exactly once. + require.Equal(t, settlementFeeSat, entries[0].GetFeeSat()) + require.Equal( + t, -(grossSweepSat - settlementFeeSat), + entries[0].GetAmountSat(), + ) + require.True( + t, entries[0].GetRequest().GetOnchainAddress().GetSweepAll(), + "sweep marker survives onto the served row", + ) } // TestHistoryKeepsWalletLocalExitPendingForUnmatchedForfeitedVTXO confirms the @@ -1399,7 +1481,7 @@ func TestHistoryKeepsWalletLocalExitPendingForUnmatchedForfeitedVTXO( "", []string{ "leave-outpoint:1", }, "bcrt1qdest", - 50_000, "user note", + 50_000, "user note", false, ) h.runtime.trackPendingEntryWithoutTimeout(pending) @@ -1456,7 +1538,7 @@ func TestHistoryScansForfeitedVTXOsOnceForWalletLocalExits(t *testing.T) { "", []string{ "leave-outpoint:1", }, "bcrt1qdest", - 50_000, "first note", + 50_000, "first note", false, ), ) h.runtime.trackPendingEntryWithoutTimeout( @@ -1464,7 +1546,7 @@ func TestHistoryScansForfeitedVTXOsOnceForWalletLocalExits(t *testing.T) { "", []string{ "other-leave-outpoint:0", }, "bcrt1qdest", - 25_000, "second note", + 25_000, "second note", false, ), ) @@ -1558,7 +1640,7 @@ func TestHistoryExitKindFilterGatesWalletLocalPendingRows(t *testing.T) { "", []string{ "leave-outpoint:1", }, "bcrt1qdest", - 50_000, "", + 50_000, "", false, ), ) diff --git a/swapwallet/normalize.go b/swapwallet/normalize.go index 911cb38cb..e94b3e6b7 100644 --- a/swapwallet/normalize.go +++ b/swapwallet/normalize.go @@ -542,8 +542,8 @@ func paginateVTXOs(vtxos []*wavewalletrpc.WalletVTXO, offset, // (the pre-#610 behavior). The first consumed outpoint is retained in // vtxo_outpoint so the forfeit-driven completion can still correlate the row. func leaveEntryStub(leaveJobID string, queuedOutpoints []string, - destination string, amtSat int64, - note string) *wavewalletrpc.WalletEntry { + destination string, amtSat int64, note string, + sweepAll bool) *wavewalletrpc.WalletEntry { var firstOutpoint string if len(queuedOutpoints) > 0 { @@ -556,6 +556,14 @@ func leaveEntryStub(leaveJobID string, queuedOutpoints []string, } createdAt := nowUnix() + // The sweep-all marker is persisted on the request so completion can + // net the seal-time operator fee back out of the gross sweep amount + // once the fee becomes known (see applyCooperativeLeaveForfeited). + request := requestFromOnchainAddress(destination) + if sweepAll && request.GetOnchainAddress() != nil { + request.GetOnchainAddress().SweepAll = true + } + return &wavewalletrpc.WalletEntry{ Id: id, Kind: wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, @@ -565,7 +573,7 @@ func leaveEntryStub(leaveJobID string, queuedOutpoints []string, CreatedAtUnix: createdAt, UpdatedAtUnix: createdAt, Note: note, - Request: requestFromOnchainAddress(destination), + Request: request, Progress: &wavewalletrpc.WalletEntryProgress{ Phase: wavewalletrpc.WalletEntryPhase_WALLET_ENTRY_PHASE_REQUEST_CREATED, PhaseLabel: "request_created", diff --git a/swapwallet/normalize_test.go b/swapwallet/normalize_test.go index 80ca79687..d6303b73d 100644 --- a/swapwallet/normalize_test.go +++ b/swapwallet/normalize_test.go @@ -547,7 +547,7 @@ func TestLeaveEntryStub(t *testing.T) { "abc:0", "def:1", }, "bcrt1q...", - 5_000, "rent", + 5_000, "rent", false, ) require.Equal(t, "abc:0", out.GetId()) require.Equal(t, wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, out.GetKind()) @@ -561,7 +561,7 @@ func TestLeaveEntryStub(t *testing.T) { require.Equal(t, out.GetCreatedAtUnix(), out.GetUpdatedAtUnix()) // No leave-job id and no queued outpoints → id is empty. - out = leaveEntryStub("", nil, "bcrt1q...", 1_000, "") + out = leaveEntryStub("", nil, "bcrt1q...", 1_000, "", false) require.Equal(t, "", out.GetId()) } @@ -576,7 +576,7 @@ func TestLeaveEntryStubUsesLeaveJobID(t *testing.T) { "abc:0", "def:1", }, "bcrt1q...", - 5_000, "rent", + 5_000, "rent", false, ) require.Equal( t, "sendjob-abc", out.GetId(), @@ -589,7 +589,7 @@ func TestLeaveEntryStubUsesLeaveJobID(t *testing.T) { // Empty leave-job id falls back to the first outpoint (pre-#610). fallback := leaveEntryStub( - "", []string{"abc:0"}, "bcrt1q...", 5_000, "", + "", []string{"abc:0"}, "bcrt1q...", 5_000, "", false, ) require.Equal(t, "abc:0", fallback.GetId()) require.Equal(t, "abc:0", fallback.GetProgress().GetVtxoOutpoint()) diff --git a/swapwallet/router.go b/swapwallet/router.go index a261f8f51..e98cc90e2 100644 --- a/swapwallet/router.go +++ b/swapwallet/router.go @@ -617,7 +617,7 @@ func (r *router) sendOnchainIntent(ctx context.Context, } entry := leaveEntryStub( sendResp.GetSendJobId(), sendResp.GetSelectedOutpoints(), - intent.onchainAddress, entryAmt, intent.note, + intent.onchainAddress, entryAmt, intent.note, intent.sweepAll, ) // The row is keyed by the daemon's stable leave-job id (id above). diff --git a/swapwallet/runtime_test.go b/swapwallet/runtime_test.go index 58434e16d..7c2024ea4 100644 --- a/swapwallet/runtime_test.go +++ b/swapwallet/runtime_test.go @@ -366,7 +366,7 @@ func TestDeadlineWatcherSkipsNoTimeoutEntries(t *testing.T) { defer r.stop() sub := r.subscribe() - entry := leaveEntryStub("", []string{"exit:0"}, "bcrt1qdest", 1_000, "") + entry := leaveEntryStub("", []string{"exit:0"}, "bcrt1qdest", 1_000, "", false) r.trackPendingEntryWithoutTimeout(entry) r.applyDeadlines(time.Now().Add(2 * deadline)) From fdd08ae4add46e0396388540f58bc732fc655a7d Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:11:35 -0500 Subject: [PATCH 07/12] swapwallet: hide the boarding fee accounting leg from activity In this commit, we keep the newly emitted boarding_fee_paid ledger rows out of the wallet activity view. The daemon's unified history typed them 'boarding' all along, but the mapping was dead code while the round actor's fee computation suppressed every fee row; with fee emission fixed, each boarding round's fee leg surfaced as a phantom PENDING DEPOSIT row for the fee amount (observed as a 255 sat 'ledger-N' deposit on regtest). Only the wallet_utxo_created subtype is a user-facing deposit. The fee already reaches the user through the real deposit row's fee_sat attribution, so the accounting leg is classified out of the wallet surface entirely. --- swapwallet/history.go | 24 ++++++++++++++ swapwallet/history_test.go | 66 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/swapwallet/history.go b/swapwallet/history.go index dcedfdb21..75a65bf49 100644 --- a/swapwallet/history.go +++ b/swapwallet/history.go @@ -1756,9 +1756,33 @@ func classifyLedgerRow(t *waverpc.TransactionHistoryEntry) ( switch t.GetType() { case "boarding": + // The boarding type covers both the confirmed-deposit row + // (wallet_utxo_created) and the round's boarding_fee_paid + // accounting leg. Only the former is a user-facing deposit — + // surfacing the fee leg would fabricate a phantom DEPOSIT row + // for the fee amount; the fee itself is already attributed to + // the real deposit row's fee_sat by the history SQL. + if t.GetSubtype() == ledger.EventBoardingFeePaid { + return wavewalletrpc.EntryKind_ENTRY_KIND_UNSPECIFIED, + 0, false + } + return wavewalletrpc.EntryKind_ENTRY_KIND_DEPOSIT, +1, true case "sweep": + // The sweep type covers tracked boarding-sweep transactions AND + // two pure fee accounting legs (the unilateral exit cost and + // the boarding-sweep chain cost). The fee legs carry no chain + // txid, key as ledger-N, and would surface as permanently + // PENDING phantom EXIT rows for the fee amount — while the + // real EXIT/DEPOSIT rows already carry those costs in fee_sat. + switch t.GetSubtype() { + case ledger.EventOnchainFeePaid, + ledger.EventBoardingSweepFeePaid: + return wavewalletrpc.EntryKind_ENTRY_KIND_UNSPECIFIED, + 0, false + } + return wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, -1, true case "oor": diff --git a/swapwallet/history_test.go b/swapwallet/history_test.go index 84458e066..a7ae23a35 100644 --- a/swapwallet/history_test.go +++ b/swapwallet/history_test.go @@ -416,6 +416,72 @@ func TestHistoryKeepsUnpairedOORSend(t *testing.T) { require.Equal(t, int64(-1_000), entries[0].GetAmountSat()) } +// TestClassifyLedgerRowHidesBoardingFeeLeg confirms the boarding_fee_paid +// accounting leg the round books alongside a boarding deposit does not +// fabricate a phantom DEPOSIT row: only the wallet_utxo_created subtype maps +// to a user-facing deposit, and the fee reaches the user via the real deposit +// row's fee_sat attribution instead. +func TestClassifyLedgerRowHidesBoardingFeeLeg(t *testing.T) { + t.Parallel() + + feeLeg := &waverpc.TransactionHistoryEntry{ + Type: "boarding", + Subtype: ledger.EventBoardingFeePaid, + AmountSat: 255, + DebitAccount: ledger.AccountFeesPaid, + CreditAccount: ledger.AccountWalletBalance, + } + _, ok := walletEntryFromLedgerRow(feeLeg) + require.False(t, ok, "boarding fee leg must stay hidden") + + deposit := &waverpc.TransactionHistoryEntry{ + Type: "boarding", + Subtype: ledger.EventWalletUTXOCreated, + AmountSat: 150_000, + Txid: strings.Repeat("b", 64), + } + entry, ok := walletEntryFromLedgerRow(deposit) + require.True(t, ok) + require.Equal( + t, wavewalletrpc.EntryKind_ENTRY_KIND_DEPOSIT, entry.GetKind(), + ) + + // The sweep-typed pure fee legs (unilateral exit cost, boarding-sweep + // chain cost) are hidden the same way: the real EXIT/DEPOSIT rows + // already carry those costs in fee_sat, so surfacing the legs would + // double-represent the fee as a dangling pending EXIT row. + for _, subtype := range []string{ + ledger.EventOnchainFeePaid, ledger.EventBoardingSweepFeePaid, + } { + feeLeg := &waverpc.TransactionHistoryEntry{ + Type: "sweep", + Subtype: subtype, + AmountSat: 623, + DebitAccount: ledger.AccountOnchainFees, + CreditAccount: ledger.AccountVTXOBalance, + } + _, ok := walletEntryFromLedgerRow(feeLeg) + require.False( + t, ok, "sweep fee leg %s must stay hidden", subtype, + ) + } + + // A tracked boarding-sweep transaction row (subtype = sweep status) + // remains a real EXIT row. + sweepTx := &waverpc.TransactionHistoryEntry{ + Type: "sweep", + Subtype: "confirmed", + AmountSat: 50_000, + FeeSat: 500, + Txid: strings.Repeat("c", 64), + } + entry, ok = walletEntryFromLedgerRow(sweepTx) + require.True(t, ok) + require.Equal( + t, wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, entry.GetKind(), + ) +} + // TestOORSendSessionIDRequiresHashSizedSession confirms malformed OOR session // IDs are ignored instead of being normalized into correlation keys. func TestOORSendSessionIDRequiresHashSizedSession(t *testing.T) { From e503d56868211501b01cefec16716ac3b2d5fb5d Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:11:35 -0500 Subject: [PATCH 08/12] docs: update per-package docs for round fee attribution In this commit, we bring the per-package agent docs in line with the fee-attribution changes: the db package map documents migration 000015 and the round_uuid mirror column, the ledger doc describes the per-flow fee credit accounts and the joinable round linkage, and the swapwallet doc records the cooperative-leave EXIT fee stamping and sweep-all amount netting invariant. --- db/CLAUDE.md | 17 ++++++++++++++++- ledger/CLAUDE.md | 10 +++++++++- swapwallet/CLAUDE.md | 8 ++++++++ 3 files changed, 33 insertions(+), 2 deletions(-) diff --git a/db/CLAUDE.md b/db/CLAUDE.md index 675c6584e..d170e22f7 100644 --- a/db/CLAUDE.md +++ b/db/CLAUDE.md @@ -71,7 +71,7 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/db. Date: Fri, 17 Jul 2026 18:43:21 -0500 Subject: [PATCH 09/12] ledger+db: expose the confirmed exit cost by outpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we add a read path for the unilateral exit cost the ledger already records: handleExitCost books an onchain_fee_paid leg after the final sweep confirms, keyed by the exited VTXO's outpoint-derived idempotency key. ExitIdempotencyKey exports that key derivation, and LedgerStoreDB.GetConfirmedExitCost sums the fee legs under it — the partial unique index scopes on (key, event_type, accounts), so the send leg sharing the same key never contributes. An exit that has not confirmed (or predates exit-cost accounting) reads zero. --- db/ledger_store.go | 24 ++++++++++++++++ db/ledger_store_test.go | 45 ++++++++++++++++++++++++++++++ db/sqlc/fee_accounting.sql.go | 19 +++++++++++++ db/sqlc/querier.go | 6 ++++ db/sqlc/queries/fee_accounting.sql | 11 ++++++++ ledger/handlers.go | 8 ++++++ 6 files changed, 113 insertions(+) diff --git a/db/ledger_store.go b/db/ledger_store.go index 4339efa82..3f2cf523f 100644 --- a/db/ledger_store.go +++ b/db/ledger_store.go @@ -4,6 +4,7 @@ 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" @@ -115,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, diff --git a/db/ledger_store_test.go b/db/ledger_store_test.go index 98fa3da90..3d4b1f0ad 100644 --- a/db/ledger_store_test.go +++ b/db/ledger_store_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/wire/v2" "github.com/btcsuite/btclog/v2" "github.com/google/uuid" "github.com/lightninglabs/wavelength/db/sqlc" @@ -277,6 +278,50 @@ func TestLedgerStoreInsertStampsRoundUUID(t *testing.T) { 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. diff --git a/db/sqlc/fee_accounting.sql.go b/db/sqlc/fee_accounting.sql.go index 35f2b4ae9..ca08809d8 100644 --- a/db/sqlc/fee_accounting.sql.go +++ b/db/sqlc/fee_accounting.sql.go @@ -79,6 +79,25 @@ func (q *Queries) GetClientLedgerStats(ctx context.Context) (GetClientLedgerStat return i, err } +const GetConfirmedExitCost = `-- name: GetConfirmedExitCost :one +SELECT CAST(COALESCE(SUM(amount_sat), 0) AS BIGINT) AS exit_cost_sat +FROM ledger_entries +WHERE event_type = 'onchain_fee_paid' + AND idempotency_key = $1 +` + +// GetConfirmedExitCost returns the confirmed on-chain cost of a unilateral +// exit: the onchain_fee_paid leg the ledger booked after the exit's final +// sweep confirmed, keyed by the exit's outpoint-derived idempotency key +// (ledger.ExitIdempotencyKey). Zero when the exit has not confirmed (or +// predates exit-cost accounting). +func (q *Queries) GetConfirmedExitCost(ctx context.Context, idempotencyKey []byte) (int64, error) { + row := q.db.QueryRowContext(ctx, GetConfirmedExitCost, idempotencyKey) + var exit_cost_sat int64 + err := row.Scan(&exit_cost_sat) + return exit_cost_sat, err +} + const GetTotalOperatorFeesPaid = `-- name: GetTotalOperatorFeesPaid :one SELECT CAST(COALESCE(SUM(amount_sat), 0) AS BIGINT) AS total_fees FROM ledger_entries diff --git a/db/sqlc/querier.go b/db/sqlc/querier.go index e2d8beccf..d9b8027a0 100644 --- a/db/sqlc/querier.go +++ b/db/sqlc/querier.go @@ -83,6 +83,12 @@ type Querier interface { GetClientTreeByTxid(ctx context.Context, txid []byte) (RoundClientTree, error) GetClientTreeTxidInfo(ctx context.Context, txid []byte) (ClientTreeTxid, error) GetClientTreeTxids(ctx context.Context, arg GetClientTreeTxidsParams) ([]GetClientTreeTxidsRow, error) + // GetConfirmedExitCost returns the confirmed on-chain cost of a unilateral + // exit: the onchain_fee_paid leg the ledger booked after the exit's final + // sweep confirmed, keyed by the exit's outpoint-derived idempotency key + // (ledger.ExitIdempotencyKey). Zero when the exit has not confirmed (or + // predates exit-cost accounting). + GetConfirmedExitCost(ctx context.Context, idempotencyKey []byte) (int64, error) GetCreditOperation(ctx context.Context, opID string) (CreditOperation, error) // Exit funding address persistence queries (wavelength#893). GetExitFundingAddress(ctx context.Context, arg GetExitFundingAddressParams) (ExitFundingAddress, error) diff --git a/db/sqlc/queries/fee_accounting.sql b/db/sqlc/queries/fee_accounting.sql index 21191b7cc..bb97e2385 100644 --- a/db/sqlc/queries/fee_accounting.sql +++ b/db/sqlc/queries/fee_accounting.sql @@ -341,6 +341,17 @@ SELECT CAST(COUNT(*) AS BIGINT) AS entry_count, CAST(COALESCE(MAX(created_at), 0) AS BIGINT) AS last_created_at FROM ledger_entries; +-- name: GetConfirmedExitCost :one +-- GetConfirmedExitCost returns the confirmed on-chain cost of a unilateral +-- exit: the onchain_fee_paid leg the ledger booked after the exit's final +-- sweep confirmed, keyed by the exit's outpoint-derived idempotency key +-- (ledger.ExitIdempotencyKey). Zero when the exit has not confirmed (or +-- predates exit-cost accounting). +SELECT CAST(COALESCE(SUM(amount_sat), 0) AS BIGINT) AS exit_cost_sat +FROM ledger_entries +WHERE event_type = 'onchain_fee_paid' + AND idempotency_key = $1; + -- name: ListLedgerRoundIDsMissingUuid :many -- ListLedgerRoundIDsMissingUuid returns the distinct raw round_id BLOBs that -- have not yet been mirrored into the round_uuid TEXT column. The BLOB-to-UUID diff --git a/ledger/handlers.go b/ledger/handlers.go index 9fa122ef5..2f9b71866 100644 --- a/ledger/handlers.go +++ b/ledger/handlers.go @@ -515,6 +515,14 @@ func exitIdempotencyKey(hash [32]byte, index uint32) []byte { return out } +// ExitIdempotencyKey exposes the exit-leg dedup key derivation to read-side +// consumers: the unilateral exit send and fee legs booked by handleExitCost +// share this outpoint-derived key, so a store can look up the confirmed exit +// cost for a given VTXO outpoint without text-parsing descriptions. +func ExitIdempotencyKey(hash [32]byte, index uint32) []byte { + return exitIdempotencyKey(hash, index) +} + // handleUTXOCreated records a new wallet UTXO in two places: // // 1. The wallet_utxo_log audit trail via UTXOAuditStore, tagged From 743ceefc2945bc1e2e08cb426eba023bbc97ae73 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:43:34 -0500 Subject: [PATCH 10/12] waverpc+waved: report the settled exit cost on GetUnrollStatus In this commit, we add exit_cost_sat to GetUnrollStatusResponse and stamp it in the daemon on both status paths (live registry and the persisted-job fallback) whenever a job reads COMPLETED. Unlike the estimate breakdown a detailed probe projects via enrichExitFees, this is the settled figure from the ledger's confirmed onchain_fee_paid exit leg, so it needs no fee-rate estimation or lineage resolution and is cheap enough for the plain per-row status lookups the activity surface issues. The stamp is best-effort: a missing ledger store or a read failure logs at debug and never fails the status query. --- waved/rpc_server.go | 38 ++++++++++++++++++++++++++++++++++++++ waverpc/daemon.pb.go | 18 ++++++++++++++++-- waverpc/daemon.proto | 7 +++++++ 3 files changed, 61 insertions(+), 2 deletions(-) diff --git a/waved/rpc_server.go b/waved/rpc_server.go index d3b15fe5b..91654d33b 100644 --- a/waved/rpc_server.go +++ b/waved/rpc_server.go @@ -4841,6 +4841,8 @@ func (r *RPCServer) GetUnrollStatus(ctx context.Context, return nil, err } if found { + r.stampConfirmedExitCost(ctx, resp, outpoint) + return resp, nil } } @@ -4892,9 +4894,45 @@ func (r *RPCServer) GetUnrollStatus(ctx context.Context, r.enrichExitFees(ctx, resp, outpoint, fn.None[int64](), nil) } + r.stampConfirmedExitCost(ctx, resp, outpoint) + return resp, nil } +// stampConfirmedExitCost attaches the ledger's settled exit-cost figure to a +// COMPLETED unroll status. Unlike the estimate breakdown a detailed probe +// projects, this is the onchain_fee_paid leg unroll booked after the final +// sweep confirmed, so it is only meaningful once the job is terminal-complete +// and is left zero otherwise. Best-effort: a missing ledger store or a read +// failure never fails the status query. +func (r *RPCServer) stampConfirmedExitCost(ctx context.Context, + resp *waverpc.GetUnrollStatusResponse, outpoint wire.OutPoint) { + + if resp == nil || !resp.GetFound() || r.server.ledgerStore == nil { + return + } + + completed := waverpc.UnrollJobStatus_UNROLL_JOB_STATUS_COMPLETED + if resp.GetStatus() != completed { + return + } + + // A read failure on a wired ledger store is not an expected condition: + // it silently zeroes exit_cost_sat on a COMPLETED response, so it + // warrants more than debug visibility. + cost, err := r.server.ledgerStore.GetConfirmedExitCost(ctx, outpoint) + if err != nil { + r.server.log.WarnS(ctx, "Confirmed exit cost lookup failed", + err, + slog.String("outpoint", outpoint.String()), + ) + + return + } + + resp.ExitCostSat = cost +} + // queryUnrollRegistry asks the live unroll registry actor for the // status of a target outpoint. It returns the proto response, whether // the job was found, and any RPC error. diff --git a/waverpc/daemon.pb.go b/waverpc/daemon.pb.go index bf638d649..6400af656 100644 --- a/waverpc/daemon.pb.go +++ b/waverpc/daemon.pb.go @@ -8856,6 +8856,12 @@ type GetUnrollStatusResponse struct { // current_height is the best block height the unroll actor has observed. // Set only on a detailed query against a live job. CurrentHeight int32 `protobuf:"varint,10,opt,name=current_height,json=currentHeight,proto3" json:"current_height,omitempty"` + // exit_cost_sat is the CONFIRMED on-chain cost of the exit: the ledger's + // onchain_fee_paid leg booked after the final sweep confirmed. Unlike the + // estimates in fees, this is the settled figure, so it is set (on both + // plain and detailed queries) only once the job is COMPLETED, and reads + // zero for exits predating exit-cost accounting. + ExitCostSat int64 `protobuf:"varint,11,opt,name=exit_cost_sat,json=exitCostSat,proto3" json:"exit_cost_sat,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -8960,6 +8966,13 @@ func (x *GetUnrollStatusResponse) GetCurrentHeight() int32 { return 0 } +func (x *GetUnrollStatusResponse) GetExitCostSat() int64 { + if x != nil { + return x.ExitCostSat + } + return 0 +} + type ArmVHTLCRecoveryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // request_id is the caller-owned idempotency key. Retrying the same @@ -10484,7 +10497,7 @@ const file_daemon_proto_rawDesc = "" + "\x11net_recovered_sat\x18\x05 \x01(\x03R\x0fnetRecoveredSat\x12+\n" + "\x12fee_rate_sat_vbyte\x18\x06 \x01(\x03R\x0ffeeRateSatVbyte\x12(\n" + "\x10sweep_fee_actual\x18\a \x01(\bR\x0esweepFeeActual\x12'\n" + - "\x10spent_so_far_sat\x18\b \x01(\x03R\rspentSoFarSat\"\xaa\x03\n" + + "\x10spent_so_far_sat\x18\b \x01(\x03R\rspentSoFarSat\"\xce\x03\n" + "\x17GetUnrollStatusResponse\x12\x14\n" + "\x05found\x18\x01 \x01(\bR\x05found\x120\n" + "\x06status\x18\x02 \x01(\x0e2\x18.waverpc.UnrollJobStatusR\x06status\x12\x1d\n" + @@ -10498,7 +10511,8 @@ const file_daemon_proto_rawDesc = "" + "\x04fees\x18\b \x01(\v2\x13.waverpc.UnrollFeesR\x04fees\x12;\n" + "\x1abest_case_blocks_remaining\x18\t \x01(\x05R\x17bestCaseBlocksRemaining\x12%\n" + "\x0ecurrent_height\x18\n" + - " \x01(\x05R\rcurrentHeight\"\xd4\x06\n" + + " \x01(\x05R\rcurrentHeight\x12\"\n" + + "\rexit_cost_sat\x18\v \x01(\x03R\vexitCostSat\"\xd4\x06\n" + "\x17ArmVHTLCRecoveryRequest\x12\x1d\n" + "\n" + "request_id\x18\x01 \x01(\tR\trequestId\x12\x17\n" + diff --git a/waverpc/daemon.proto b/waverpc/daemon.proto index 56671b454..dc78aad0e 100644 --- a/waverpc/daemon.proto +++ b/waverpc/daemon.proto @@ -2438,6 +2438,13 @@ message GetUnrollStatusResponse { // current_height is the best block height the unroll actor has observed. // Set only on a detailed query against a live job. int32 current_height = 10; + + // exit_cost_sat is the CONFIRMED on-chain cost of the exit: the ledger's + // onchain_fee_paid leg booked after the final sweep confirmed. Unlike the + // estimates in fees, this is the settled figure, so it is set (on both + // plain and detailed queries) only once the job is COMPLETED, and reads + // zero for exits predating exit-cost accounting. + int64 exit_cost_sat = 11; } // VHTLCRecoveryDirection records which side owns a recovery job. From 215281f135169003ab78f1dab2ea60b095f86810 Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 18:43:34 -0500 Subject: [PATCH 11/12] swapwallet: stamp exit costs onto completed unilateral EXIT rows In this commit, we close the remaining FEE 0 gap on the activity surface: a completed unilateral exit now carries the settled exit cost the daemon reports on GetUnrollStatus. applyUnrollStatus stamps it onto the row's fee_sat and nets it back out of the gross VTXO amount, so a unilateral EXIT reads the same way as a completed cooperative leave: amount is the value delivered on chain, fee is the cost on top, and their sum is the gross VTXO value that left Ark custody. A zero cost (old daemon, or an exit predating exit-cost accounting) leaves the row exactly as before, preserving prior behavior for historical exits. --- swapwallet/CLAUDE.md | 5 ++ swapwallet/history_test.go | 94 ++++++++++++++++++++++++++++++++++++++ swapwallet/normalize.go | 24 ++++++++++ 3 files changed, 123 insertions(+) diff --git a/swapwallet/CLAUDE.md b/swapwallet/CLAUDE.md index 7d681a192..25873a89c 100644 --- a/swapwallet/CLAUDE.md +++ b/swapwallet/CLAUDE.md @@ -137,6 +137,11 @@ default builds avoid the swap executor's dependency graph. `OnchainAddressRequest.sweep_all`, set by `leaveEntryStub`) also nets the fee back out of its gross pending amount, so every completed EXIT reads amount = destination-received, fee = cost on top. +- **Unilateral EXIT fee**: `applyUnrollStatus` applies the same shape on + a COMPLETED unroll: `GetUnrollStatusResponse.exit_cost_sat` (the + ledger's confirmed onchain_fee_paid exit leg) becomes `fee_sat` and is + netted out of the row's gross VTXO amount. Zero cost (old daemon, or + an exit predating exit-cost accounting) leaves the row untouched. - **Onchain SEND is a one-shot**: after the intent is accepted the router immediately calls `JoinNextRound` so the queued leave intent is committed to the next round without a separate CLI step. If the implicit join fails, diff --git a/swapwallet/history_test.go b/swapwallet/history_test.go index a7ae23a35..cc12533e8 100644 --- a/swapwallet/history_test.go +++ b/swapwallet/history_test.go @@ -1638,6 +1638,100 @@ func TestHistoryScansForfeitedVTXOsOnceForWalletLocalExits(t *testing.T) { ) } +// TestHistoryStampsExitCostOnCompletedUnilateralExit confirms a completed +// unilateral exit surfaces the ledger's settled exit cost: the daemon reports +// it on GetUnrollStatus once the final sweep confirmed, and the row is +// completed with amount = value delivered on chain and fee = cost on top — +// the same shape a completed cooperative leave has. The mirror of +// TestHistoryStampsSettlementOnForfeitedLeave for the unroll path. +func TestHistoryStampsExitCostOnCompletedUnilateralExit(t *testing.T) { + t.Parallel() + + const ( + sweepTxid = "cc000000000000000000000000000000" + + "00000000000000000000000000000000" + grossVTXOSat = int64(7_000) + exitCostSat = int64(623) + ) + + h, swap, rpc := newHistoryFixture(t) + swap.listSwapsResp = &swapclientrpc.ListSwapsResponse{} + rpc.listTxResp = &waverpc.ListTransactionsResponse{} + rpc.listVTXOsResp = &waverpc.ListVTXOsResponse{ + Vtxos: []*waverpc.VTXO{ + { + Outpoint: "exit-outpoint:0", + AmountSat: grossVTXOSat, + Status: waverpc. + VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT, + }, + }, + } + rpc.unrollStatusResp = &waverpc.GetUnrollStatusResponse{ + Found: true, + Status: waverpc.UnrollJobStatus_UNROLL_JOB_STATUS_COMPLETED, + SweepTxid: sweepTxid, + ExitCostSat: exitCostSat, + } + + resp, err := h.List(t.Context(), &wavewalletrpc.ListRequest{}) + require.NoError(t, err) + + entries := resp.GetActivity().GetEntries() + require.Len(t, entries, 1) + require.Equal(t, "exit-outpoint:0", entries[0].GetId()) + require.Equal( + t, wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + entries[0].GetStatus(), + ) + require.Equal(t, sweepTxid, entries[0].GetProgress().GetTxid()) + + // The gross VTXO value is netted down to the value delivered on chain, + // with the confirmed exit cost carried separately. + require.Equal(t, exitCostSat, entries[0].GetFeeSat()) + require.Equal( + t, -(grossVTXOSat - exitCostSat), entries[0].GetAmountSat(), + ) +} + +// TestHistoryKeepsUnilateralExitFeeUntouchedWithoutExitCost confirms an old +// daemon (or an exit predating exit-cost accounting) that reports a zero +// exit_cost_sat leaves the completed row exactly as before: gross amount, +// zero fee. +func TestHistoryKeepsUnilateralExitFeeUntouchedWithoutExitCost(t *testing.T) { + t.Parallel() + + h, swap, rpc := newHistoryFixture(t) + swap.listSwapsResp = &swapclientrpc.ListSwapsResponse{} + rpc.listTxResp = &waverpc.ListTransactionsResponse{} + rpc.listVTXOsResp = &waverpc.ListVTXOsResponse{ + Vtxos: []*waverpc.VTXO{ + { + Outpoint: "exit-outpoint:0", + AmountSat: 7_000, + Status: waverpc. + VTXOStatus_VTXO_STATUS_UNILATERAL_EXIT, + }, + }, + } + rpc.unrollStatusResp = &waverpc.GetUnrollStatusResponse{ + Found: true, + Status: waverpc.UnrollJobStatus_UNROLL_JOB_STATUS_COMPLETED, + } + + resp, err := h.List(t.Context(), &wavewalletrpc.ListRequest{}) + require.NoError(t, err) + + entries := resp.GetActivity().GetEntries() + require.Len(t, entries, 1) + require.Equal( + t, wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + entries[0].GetStatus(), + ) + require.Zero(t, entries[0].GetFeeSat()) + require.Equal(t, int64(-7_000), entries[0].GetAmountSat()) +} + // TestHistoryKeepsCSVPendingUnilateralExitPendingAfterDeadline confirms the // wallet-local timeout overlay cannot clobber the unroll subsystem's // authoritative non-terminal status. Unilateral exits normally wait through a diff --git a/swapwallet/normalize.go b/swapwallet/normalize.go index e94b3e6b7..1216fc345 100644 --- a/swapwallet/normalize.go +++ b/swapwallet/normalize.go @@ -628,6 +628,30 @@ func applyUnrollStatus(entry *wavewalletrpc.WalletEntry, WalletEntryPhase_WALLET_ENTRY_PHASE_CONFIRMED progress.PhaseLabel = "confirmed" + // A completed exit reports the settled on-chain cost from the + // ledger. The row's pending amount is the gross VTXO value, so + // the cost is netted back out, leaving amount = value delivered + // on chain and fee = cost on top — the same shape a completed + // cooperative leave has. Zero cost (old daemon, or an exit + // predating exit-cost accounting) leaves the row untouched. + // The mutation operates on the per-derive clone from + // pendingSnapshot / the per-VTXO derived row, so it applies + // exactly once per derived row. + if cost := resp.GetExitCostSat(); cost > 0 { + entry.FeeSat = cost + + // Clamp at zero so a cost exceeding the gross amount + // (impossible for a sane exit, but the figure crosses + // an RPC boundary) can never flip the outflow row's + // sign positive. + if entry.AmountSat < 0 { + entry.AmountSat += cost + if entry.AmountSat > 0 { + entry.AmountSat = 0 + } + } + } + case waverpc.UnrollJobStatus_UNROLL_JOB_STATUS_FAILED: entry.Status = wavewalletrpc.EntryStatus_ENTRY_STATUS_FAILED entry.FailureReason = resp.GetLastError() From 6b7bff8ca0443762aad8ab32146c6cb4a50e4cdd Mon Sep 17 00:00:00 2001 From: Olaoluwa Osuntokun Date: Fri, 17 Jul 2026 19:10:33 -0500 Subject: [PATCH 12/12] swapwallet: tolerate fee-ledger commit lag on EXIT completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In this commit, we harden the EXIT completion paths against the ordering gap between the status that completes a row and the ledger commit that carries its fee. The forfeit status (VTXO actor) and the round's FeePaidMsg (durable ledger actor) are independent fire-and-forget Tells from the same round-actor turn, and the unroll path likewise only guarantees the ExitCostMsg is enqueued before the terminal handoff. A reconcile pass landing in that window would complete the row at fee 0, durably project it, and drop the pending record — freezing the exact fee-0 symptom this series eliminates. Two guards close the window. First, a COMPLETE projection carrying a zero fee retains its pending record for a bounded number of passes (feeZeroClearGracePasses) before clearing, so the row stays derivable long enough for a fee that commits milliseconds later to be re-read and re-projected; genuinely fee-free rounds pay only a few redundant decorations. Second, mergeActivityContext treats a recorded fee as sticky: no producer legitimately moves a settled fee back to zero, so a later fee-0 projection (racing pass, transient ledger read error) restores the stored fee together with its coupled netted amount instead of regressing the row to gross/fee-0. The reconciler scenario test drives the race end to end: pass one completes at fee 0 and retains the record, pass two observes the late-committed fee, heals the stored row, and clears. --- swapwallet/projector.go | 34 +++++++++- swapwallet/projector_test.go | 116 ++++++++++++++++++++++++++++++++++ swapwallet/reconciler_test.go | 62 ++++++++++++++++++ swapwallet/runtime.go | 37 +++++++++++ 4 files changed, 247 insertions(+), 2 deletions(-) diff --git a/swapwallet/projector.go b/swapwallet/projector.go index 999ea8561..dea02915d 100644 --- a/swapwallet/projector.go +++ b/swapwallet/projector.go @@ -104,6 +104,20 @@ func mergeActivityContext( next.CreatedAtUnix = existing.GetCreatedAtUnix() } + // A recorded fee is sticky: no producer ever legitimately moves a + // row's fee from a settled value back to zero, but a completion pass + // that races the fee ledger's durable commit (or hits a transient + // read error) re-derives the row with fee 0 and would otherwise + // regress the stored figure. The amount is restored together with the + // fee because the two are coupled: the fee-carrying projection also + // netted the fee out of the amount (sweep-all leaves, unilateral + // exits), so keeping the fee while adopting the re-derived gross + // amount would double-count it. + if next.GetFeeSat() == 0 && existing.GetFeeSat() > 0 { + next.FeeSat = existing.GetFeeSat() + next.AmountSat = existing.GetAmountSat() + } + existingProgress := existing.GetProgress() if existingProgress == nil { return next @@ -308,15 +322,31 @@ func (r *Runtime) reprojectRecentActivity(ctx context.Context, // row, and dropping the record stops later passes from re-decorating a settled // row. It is a no-op for ids not in the map (e.g. unilateral rows synthesized // from ListVTXOs) and for non-terminal or non-EXIT rows. +// +// A COMPLETE row carrying a zero fee is retained for a bounded number of +// passes (deferFeeZeroClear) before its record is dropped. The fee is read +// back from the ledger actor's durable commit, which the forfeit/exit status +// this row completes off does not wait for, so the first terminal pass can +// race a fee row that lands milliseconds later; clearing immediately would +// freeze the projected row at fee 0 forever — the exact symptom the fee +// plumbing exists to eliminate. Retention keeps the row re-derivable until +// either the fee appears (re-projected, then cleared on the next pass) or the +// grace is exhausted (a genuinely fee-free round). func (r *Runtime) clearProjectedTerminalExit(entry *wavewalletrpc.WalletEntry) { if entry.GetKind() != wavewalletrpc.EntryKind_ENTRY_KIND_EXIT { return } switch entry.GetStatus() { - case wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, - wavewalletrpc.EntryStatus_ENTRY_STATUS_FAILED: + case wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE: + if entry.GetFeeSat() == 0 && + !r.deferFeeZeroClear(entry.GetId()) { + return + } + + r.clearPending(entry.GetId()) + case wavewalletrpc.EntryStatus_ENTRY_STATUS_FAILED: r.clearPending(entry.GetId()) } } diff --git a/swapwallet/projector_test.go b/swapwallet/projector_test.go index a858a631d..c9d70a6a2 100644 --- a/swapwallet/projector_test.go +++ b/swapwallet/projector_test.go @@ -561,3 +561,119 @@ func TestRowToWalletEntryDiscardsUnknownRequestFields(t *testing.T) { _, err = rowToWalletEntry(corrupt) require.Error(t, err, "a corrupt request row must fail loudly") } + +// TestClearProjectedTerminalExitRetainsFeeZeroRows proves a completed EXIT +// projected with a zero fee keeps its pending record for the bounded grace +// window, so a fee ledger row that commits just after the forfeit status +// becomes terminal is still picked up by a later derive pass, while a +// fee-carrying completion and a failure clear the record immediately. +func TestClearProjectedTerminalExitRetainsFeeZeroRows(t *testing.T) { + t.Parallel() + + r := newRuntime(t.Context(), &Deps{}) + + track := func(id string) { + r.trackPendingEntryWithoutTimeout(&wavewalletrpc.WalletEntry{ + Id: id, + Kind: wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, + Status: wavewalletrpc. + EntryStatus_ENTRY_STATUS_PENDING, + }) + } + tracked := func(id string) bool { + r.pendingMu.Lock() + defer r.pendingMu.Unlock() + _, ok := r.pending[id] + + return ok + } + + // A zero-fee completion survives the grace window and is cleared only + // on the pass that exhausts it. + track("exit-a") + feeZero := &wavewalletrpc.WalletEntry{ + Id: "exit-a", + Kind: wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, + Status: wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + } + for i := 0; i < feeZeroClearGracePasses-1; i++ { + r.clearProjectedTerminalExit(feeZero) + require.True( + t, tracked("exit-a"), + "record must survive pass %d of the grace window", i+1, + ) + } + r.clearProjectedTerminalExit(feeZero) + require.False( + t, tracked("exit-a"), + "record must clear once the grace window is exhausted", + ) + + // A completion that carries a fee clears immediately. + track("exit-b") + r.clearProjectedTerminalExit(&wavewalletrpc.WalletEntry{ + Id: "exit-b", + Kind: wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, + Status: wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + FeeSat: 541, + }) + require.False(t, tracked("exit-b")) + + // A failure clears immediately regardless of fee. + track("exit-c") + r.clearProjectedTerminalExit(&wavewalletrpc.WalletEntry{ + Id: "exit-c", + Kind: wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, + Status: wavewalletrpc.EntryStatus_ENTRY_STATUS_FAILED, + }) + require.False(t, tracked("exit-c")) + + // A fee observed mid-grace clears via the normal fee-carrying path. + track("exit-d") + r.clearProjectedTerminalExit(&wavewalletrpc.WalletEntry{ + Id: "exit-d", + Kind: wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, + Status: wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + }) + require.True(t, tracked("exit-d")) + r.clearProjectedTerminalExit(&wavewalletrpc.WalletEntry{ + Id: "exit-d", + Kind: wavewalletrpc.EntryKind_ENTRY_KIND_EXIT, + Status: wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + FeeSat: 388, + }) + require.False(t, tracked("exit-d")) +} + +// TestMergeActivityContextKeepsSettledFee proves a stored fee is sticky: a +// later projection carrying fee 0 (a completion pass that raced the fee +// ledger commit or hit a transient read error) must not regress the stored +// fee, and the amount is restored together with it since the fee-carrying +// projection also netted the fee out of the amount. +func TestMergeActivityContextKeepsSettledFee(t *testing.T) { + t.Parallel() + + existing := &wavewalletrpc.WalletEntry{ + Id: "exit-a", + AmountSat: -138_816, + FeeSat: 388, + } + next := &wavewalletrpc.WalletEntry{ + Id: "exit-a", + AmountSat: -139_204, + } + + merged := mergeActivityContext(existing, next) + require.Equal(t, int64(388), merged.GetFeeSat()) + require.Equal(t, int64(-138_816), merged.GetAmountSat()) + + // A projection that carries its own fee stays authoritative. + fresh := &wavewalletrpc.WalletEntry{ + Id: "exit-a", + AmountSat: -138_816, + FeeSat: 400, + } + merged = mergeActivityContext(existing, fresh) + require.Equal(t, int64(400), merged.GetFeeSat()) + require.Equal(t, int64(-138_816), merged.GetAmountSat()) +} diff --git a/swapwallet/reconciler_test.go b/swapwallet/reconciler_test.go index f688ecbae..46839245b 100644 --- a/swapwallet/reconciler_test.go +++ b/swapwallet/reconciler_test.go @@ -314,12 +314,18 @@ func trackForfeitedCooperativeExit(runtime *Runtime, // A cooperative leave has no unroll job, and its retained outpoint is // in the forfeited set — so decorateExitEntry flips it to COMPLETE. + // The settlement carries the round's fee: a fee-carrying completion is + // the one-pass clear path (a fee-zero completion is retained for the + // bounded ledger-lag grace window instead). rpc.unrollStatusResp = &waverpc.GetUnrollStatusResponse{Found: false} rpc.listVTXOsByStatus = map[waverpc.VTXOStatus]*waverpc.ListVTXOsResponse{ waverpc.VTXOStatus_VTXO_STATUS_FORFEITED: { Vtxos: []*waverpc.VTXO{ { Outpoint: outpoint, + Settlement: &waverpc.VTXOSettlement{ + FeeSat: 541, + }, }, }, }, @@ -389,6 +395,58 @@ func TestReconcileRetainsPendingExitOnProjectFailure(t *testing.T) { ) } +// TestReconcileHealsFeeAfterLedgerLag models the forfeit-vs-fee-ledger race: +// the forfeit status flips the leave COMPLETE while the round's fee row has +// not committed yet, so the first reconcile pass projects fee 0. The pending +// record is retained through the bounded grace window, and once the daemon +// reports the fee on a later pass, the stored row is re-projected with the +// fee and the record finally clears. +func TestReconcileHealsFeeAfterLedgerLag(t *testing.T) { + t.Parallel() + + ctx := context.Background() + runtime, store, rpc := newReconcileFixture(t) + + jobID := trackForfeitedCooperativeExit(runtime, rpc) + + // Pass 1: the settlement carries no fee yet (ledger commit lag). The + // row lands COMPLETE at fee 0 but the record survives. + forfeited := rpc.listVTXOsByStatus[waverpc. + VTXOStatus_VTXO_STATUS_FORFEITED] + forfeited.Vtxos[0].Settlement = nil + + runtime.reconcileActivity(ctx) + + entry, err := store.GetEntry(ctx, jobID) + require.NoError(t, err) + require.EqualValues( + t, wavewalletrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + entry.Status, + ) + require.Zero(t, entry.FeeSat) + require.Contains( + t, pendingSnapshotIDs(runtime), jobID, "a fee-zero "+ + "completion must be retained through the grace window", + ) + + // Pass 2: the ledger row committed and the daemon now reports the fee. + // The stored row heals and the record clears. + forfeited.Vtxos[0].Settlement = &waverpc.VTXOSettlement{FeeSat: 541} + + runtime.reconcileActivity(ctx) + + entry, err = store.GetEntry(ctx, jobID) + require.NoError(t, err) + require.EqualValues( + t, 541, entry.FeeSat, + "the late-committed fee must be re-projected onto the row", + ) + require.NotContains( + t, pendingSnapshotIDs(runtime), jobID, + "the record clears once the completion carries the fee", + ) +} + // failingProjectStore wraps a real activity store but fails every ProjectEntry, // to exercise the reconciler's must-not-clear-on-write-failure path. type failingProjectStore struct { @@ -422,12 +480,16 @@ func TestReconcileCompletesLeaveAfterRestart(t *testing.T) { // The retained outpoint is reported forfeited (the round sealed) and // the leave has no unroll job, so the correlation flips it COMPLETE. + // The settlement fee keeps the completion on the one-pass clear path. rpc.unrollStatusResp = &waverpc.GetUnrollStatusResponse{Found: false} rpc.listVTXOsByStatus = map[waverpc.VTXOStatus]*waverpc.ListVTXOsResponse{ waverpc.VTXOStatus_VTXO_STATUS_FORFEITED: { Vtxos: []*waverpc.VTXO{ { Outpoint: outpoint, + Settlement: &waverpc.VTXOSettlement{ + FeeSat: 541, + }, }, }, }, diff --git a/swapwallet/runtime.go b/swapwallet/runtime.go index bc3bbd54a..1c46a39aa 100644 --- a/swapwallet/runtime.go +++ b/swapwallet/runtime.go @@ -30,6 +30,16 @@ type pendingEntry struct { deadline time.Time noTimeout bool entry *wavewalletrpc.WalletEntry + + // feeZeroClears counts terminal-projection passes that observed this + // EXIT row complete with a zero fee. The round's fee ledger row is + // booked by a separate durable actor whose commit races the forfeit + // status this row completes off, so a zero fee on the first terminal + // pass may mean "ledger commit lag" rather than "fee-free round". The + // record is retained for a bounded number of passes so a later + // decoration re-reads the fee before the row is frozen; see + // clearProjectedTerminalExit. + feeZeroClears int } // Runtime owns the swapwallet package's background lifecycle: the unified @@ -451,6 +461,33 @@ func (r *Runtime) clearPending(id string) { delete(r.overlay, id) } +// feeZeroClearGracePasses is how many terminal-projection passes a completed +// EXIT row observed with a zero fee stays in the pending map before its record +// is cleared. Each retained pass re-derives and re-decorates the row, so a fee +// ledger row that commits shortly after the forfeit/exit status becomes +// terminal is still picked up and re-projected. Genuinely fee-free rounds pay +// only this many redundant decorations before the record is dropped. +const feeZeroClearGracePasses = 3 + +// deferFeeZeroClear records one more terminal pass that saw the entry complete +// with a zero fee and reports whether the bounded grace is exhausted (true +// means the caller should clear the record now). An id with no live record +// reports true so the caller's clear degrades to a no-op. +func (r *Runtime) deferFeeZeroClear(id string) bool { + r.pendingMu.Lock() + defer r.pendingMu.Unlock() + + record, ok := r.pending[id] + if !ok { + return true + } + + record.feeZeroClears++ + r.pending[id] = record + + return record.feeZeroClears >= feeZeroClearGracePasses +} + // overlayFor returns the wallet-layer overlay for an entry id, if any. The // history merger calls it when computing WalletEntry.status so a stuck row // surfaces as FAILED at the wallet layer even when the swap row is still