diff --git a/swapwallet/history.go b/swapwallet/history.go index a1c99ee9c..ae2ea6822 100644 --- a/swapwallet/history.go +++ b/swapwallet/history.go @@ -512,6 +512,10 @@ func (h *history) listOnchain(ctx context.Context, txs := make([]*walletdkrpc.OnchainTx, 0, len(resp.GetTransactions())) for _, t := range resp.GetTransactions() { + if isInternalLedgerSweep(t) { + continue + } + txs = append(txs, onchainTxFromLedgerRow(t)) } @@ -1548,6 +1552,17 @@ func classifyLedgerRow(t *daemonrpc.TransactionHistoryEntry) ( return walletdkrpc.EntryKind_ENTRY_KIND_DEPOSIT, +1, true case "sweep": + // Ledger-sourced sweep rows are internal fee-clearing legs: + // onchain_fee_paid for unilateral exits and + // boarding_sweep_fee_paid for boarding sweeps. User-facing + // sweep rows come from domain sources such as + // boarding_sweep, which carry the aggregate amount, miner fee, + // txid, and terminal sweep status. + if isInternalLedgerSweep(t) { + return walletdkrpc.EntryKind_ENTRY_KIND_UNSPECIFIED, + 0, false + } + return walletdkrpc.EntryKind_ENTRY_KIND_EXIT, -1, true case "oor": @@ -1576,6 +1591,12 @@ func classifyLedgerRow(t *daemonrpc.TransactionHistoryEntry) ( } } +// isInternalLedgerSweep returns true for ledger-sourced sweep rows that +// represent internal fee-clearing legs rather than user-facing exits. +func isInternalLedgerSweep(t *daemonrpc.TransactionHistoryEntry) bool { + return t.GetSource() == "ledger" && t.GetType() == "sweep" +} + // statusFromLedgerConfirmation maps the ledger row's confirmation_status // string onto the flat wallet status. func statusFromLedgerConfirmation(s string) walletdkrpc.EntryStatus { diff --git a/swapwallet/history_test.go b/swapwallet/history_test.go index 4b226df2d..98488b22e 100644 --- a/swapwallet/history_test.go +++ b/swapwallet/history_test.go @@ -193,6 +193,86 @@ func TestHistoryListMergesSwapAndLedgerSources(t *testing.T) { ) } +// TestHistoryHidesBoardingSweepFeeLedgerLeg verifies that the internal +// boarding-sweep chain-cost ledger leg does not surface as a second pending +// EXIT. The canonical activity row is the boarding_sweeps row; the +// boarding_sweep_fee_paid row exists only to balance wallet_clearing. +func TestHistoryHidesBoardingSweepFeeLedgerLeg(t *testing.T) { + t.Parallel() + + h, swap, rpc := newHistoryFixture(t) + swap.listSwapsResp = &swapclientrpc.ListSwapsResponse{} + rpc.listTxResp = &daemonrpc.ListTransactionsResponse{ + Transactions: []*daemonrpc.TransactionHistoryEntry{ + { + Source: "boarding_sweep", + Type: "sweep", + Subtype: "confirmed", + ConfirmationStatus: "confirmed", + AmountSat: 50_000, + FeeSat: 139, + Txid: "sweep-txid", + CreatedAtUnixS: 200, + }, + { + Source: "ledger", + Type: "sweep", + Subtype: ledger.EventBoardingSweepFeePaid, + ConfirmationStatus: "recorded", + AmountSat: 469, + FeeSat: 0, + EntryId: 6, + CreatedAtUnixS: 201, + }, + }, + } + + resp, err := h.List(t.Context(), &walletdkrpc.ListRequest{}) + require.NoError(t, err) + + entries := resp.GetActivity().GetEntries() + require.Len(t, entries, 1) + require.Equal(t, "sweep-txid", entries[0].GetId()) + require.Equal( + t, walletdkrpc.EntryKind_ENTRY_KIND_EXIT, entries[0].GetKind(), + ) + require.Equal( + t, walletdkrpc.EntryStatus_ENTRY_STATUS_COMPLETE, + entries[0].GetStatus(), + ) + require.Equal(t, int64(-50_000), entries[0].GetAmountSat()) + require.Equal(t, int64(139), entries[0].GetFeeSat()) +} + +// TestHistoryHidesUnilateralExitFeeLedgerLeg verifies that unilateral-exit +// fee accounting does not surface as a pending EXIT. The ledger's +// onchain_fee_paid row is internal bookkeeping and has no chain txid or +// confirmation lifecycle of its own. +func TestHistoryHidesUnilateralExitFeeLedgerLeg(t *testing.T) { + t.Parallel() + + h, swap, rpc := newHistoryFixture(t) + swap.listSwapsResp = &swapclientrpc.ListSwapsResponse{} + rpc.listTxResp = &daemonrpc.ListTransactionsResponse{ + Transactions: []*daemonrpc.TransactionHistoryEntry{ + { + Source: "ledger", + Type: "sweep", + Subtype: ledger.EventOnchainFeePaid, + ConfirmationStatus: "recorded", + AmountSat: 812, + FeeSat: 0, + EntryId: 9, + CreatedAtUnixS: 300, + }, + }, + } + + resp, err := h.List(t.Context(), &walletdkrpc.ListRequest{}) + require.NoError(t, err) + require.Empty(t, resp.GetActivity().GetEntries()) +} + // TestHistoryPendingFilterDropsTerminal confirms pending_only=true // drops COMPLETE and FAILED rows from both sources. func TestHistoryPendingFilterDropsTerminal(t *testing.T) { diff --git a/swapwallet/list_view_test.go b/swapwallet/list_view_test.go index 6e2ebb91f..a92c8d34b 100644 --- a/swapwallet/list_view_test.go +++ b/swapwallet/list_view_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/lightninglabs/darepo-client/daemonrpc" + "github.com/lightninglabs/darepo-client/ledger" "github.com/lightninglabs/darepo-client/rpc/walletdkrpc" "github.com/stretchr/testify/require" ) @@ -158,6 +159,24 @@ func TestListViewOnchainFlattensLedgerRows(t *testing.T) { CreatedAtUnixS: 600, Description: "boarding sweep", }, + { + Source: "ledger", + Type: "sweep", + Subtype: ledger.EventBoardingSweepFeePaid, + ConfirmationStatus: "recorded", + AmountSat: 469, + CreatedAtUnixS: 601, + Description: "internal boarding sweep fee", + }, + { + Source: "ledger", + Type: "sweep", + Subtype: ledger.EventOnchainFeePaid, + ConfirmationStatus: "recorded", + AmountSat: 812, + CreatedAtUnixS: 602, + Description: "internal exit fee", + }, }, HasMore: true, }