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.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..d9b8027a0 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) @@ -78,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) @@ -199,6 +210,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 @@ -281,6 +298,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/fee_accounting.sql b/db/sqlc/queries/fee_accounting.sql index 70767dd00..bb97e2385 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,35 @@ 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: 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 +-- 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/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/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/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/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) 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/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/CLAUDE.md b/ledger/CLAUDE.md index dacc8a02a..ea99f06a4 100644 --- a/ledger/CLAUDE.md +++ b/ledger/CLAUDE.md @@ -59,6 +59,9 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/ledger.< round ID, session ID, event type, description, created_at, optional `IdempotencyKey []byte` for outpoint-keyed dedup, and structured `ChainTxid`/`ChainVout` columns stamped on chain-anchored events). + The db adapter additionally mirrors a 16-byte `RoundID` into the + `round_uuid` TEXT column (migration 000015) so ledger rows join + against `rounds.round_id` / `vtxos.forfeit_round_id` in portable SQL. - `exitIdempotencyKey(hash, index)` / `walletUTXOIdempotencyKey` — derive 36-byte `outpoint_hash || outpoint_index` dedup keys distinct from round-keyed and session-keyed entries (separate @@ -67,7 +70,12 @@ For field-level detail, use `go doc github.com/lightninglabs/wavelength/ledger.< (outpoint, amount, event, block height, classification). Implemented by `db.UTXOAuditStoreDB`. - `LedgerMsg` / `LedgerResp` — mailbox constraint types. -- `FeePaidMsg` — boarding/refresh fee payments. +- `FeePaidMsg` — boarding/refresh fee payments. Both debit `fees_paid`; + the credit side names the account the fee was paid from: + `wallet_balance` for boarding (the paired boarding `VTXOReceivedMsg` + books the SEALED post-fee VTXO value, so the fee completes the gross + wallet outflow) and `vtxo_balance` for refresh (fee carved out of + forfeited VTXO value). - `VTXOReceivedMsg` — incoming VTXOs. `Source` must be one of `SourceRoundBoarding` (own wallet → VTXO; offsets wallet_balance), `SourceRoundRefresh` (refresh / directed-send self-change; offsets 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..2f9b71866 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) @@ -510,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 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, 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) } } 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/CLAUDE.md b/swapwallet/CLAUDE.md index 1a24612a7..25873a89c 100644 --- a/swapwallet/CLAUDE.md +++ b/swapwallet/CLAUDE.md @@ -129,6 +129,19 @@ default builds avoid the swap executor's dependency graph. calls `listLiveVTXOsForLeave` for sweep-all enumeration. - `SendResponse.actual_amount_sat` carries the true outflow for sweep-all sends and SHOULD be echoed back before the send is treated as confirmed. +- **Cooperative-leave EXIT fee**: at completion + (`applyCooperativeLeaveForfeited`), the forfeited source VTXO's + settlement carries the forfeit round's operator fee (from the daemon + ledger via the `ListVTXOsByStatus` fee join), which is stamped onto + `WalletEntry.fee_sat`. A sweep-all row (marked via + `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.go b/swapwallet/history.go index 9ec69090b..75a65bf49 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{} @@ -1722,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 16e03e56f..cc12533e8 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) { @@ -1198,7 +1264,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 +1320,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 +1373,7 @@ func TestHistoryStampsSettlementOnForfeitedLeave(t *testing.T) { settlementTxid = "aa000000000000000000000000000000" + "00000000000000000000000000000000" settlementHeight = int32(812345) + settlementFeeSat = int64(275) ) h, swap, rpc := newHistoryFixture(t) @@ -1328,6 +1395,7 @@ func TestHistoryStampsSettlementOnForfeitedLeave(t *testing.T) { Settlement: &waverpc.VTXOSettlement{ Txid: settlementTxid, Height: settlementHeight, + FeeSat: settlementFeeSat, }, }, }, @@ -1338,7 +1406,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 +1430,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 +1547,7 @@ func TestHistoryKeepsWalletLocalExitPendingForUnmatchedForfeitedVTXO( "", []string{ "leave-outpoint:1", }, "bcrt1qdest", - 50_000, "user note", + 50_000, "user note", false, ) h.runtime.trackPendingEntryWithoutTimeout(pending) @@ -1456,7 +1604,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 +1612,7 @@ func TestHistoryScansForfeitedVTXOsOnceForWalletLocalExits(t *testing.T) { "", []string{ "other-leave-outpoint:0", }, "bcrt1qdest", - 25_000, "second note", + 25_000, "second note", false, ), ) @@ -1490,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 @@ -1558,7 +1800,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..1216fc345 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", @@ -620,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() 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/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/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.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 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)) 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 diff --git a/waved/rpc_server.go b/waved/rpc_server.go index b070b8a60..91654d33b 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, } }) @@ -4840,6 +4841,8 @@ func (r *RPCServer) GetUnrollStatus(ctx context.Context, return nil, err } if found { + r.stampConfirmedExitCost(ctx, resp, outpoint) + return resp, nil } } @@ -4891,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 d4c5194ed..6400af656 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. @@ -8843,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 } @@ -8947,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 @@ -9994,10 +10020,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" + @@ -10470,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" + @@ -10484,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 4ccc2c00b..dc78aad0e 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 { @@ -2431,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.