From 72f9b885ecbe0ee4b10236dd3aefd7765910c67b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Sat, 30 May 2026 23:00:09 +0200 Subject: [PATCH] feat: add lightning fees widget to dashboard Adds a GET /api/transactions/stats endpoint that aggregates settled outgoing payments (excluding self-payments) into total volume, total fees paid, and payment count. The new FeeRateWidget on the home dashboard surfaces a volume-weighted average fee rate to highlight how cheap lightning payments are, hidden until there is payment volume. --- api/api.go | 35 ++++++++ api/models.go | 12 +++ api/transaction_stats_test.go | 86 +++++++++++++++++++ .../components/home/widgets/FeeRateWidget.tsx | 47 ++++++++++ frontend/src/hooks/useTransactionStats.ts | 11 +++ frontend/src/screens/Home.tsx | 2 + frontend/src/types.ts | 8 ++ http/http_service.go | 12 +++ 8 files changed, 213 insertions(+) create mode 100644 api/transaction_stats_test.go create mode 100644 frontend/src/components/home/widgets/FeeRateWidget.tsx create mode 100644 frontend/src/hooks/useTransactionStats.ts diff --git a/api/api.go b/api/api.go index 688061ed..4db94919 100644 --- a/api/api.go +++ b/api/api.go @@ -2119,3 +2119,38 @@ func (api *api) GetForwards() (*GetForwardsResponse, error) { NumForwards: uint64(numForwards), }, nil } + +func (api *api) GetTransactionStats() (*GetTransactionStatsResponse, error) { + var stats struct { + TotalVolumeMsat uint64 + TotalFeesPaidMsat uint64 + NumPayments uint64 + } + + // Aggregate settled outgoing payments. Self-payments are excluded because + // they never traverse the network and would dilute the fee rate towards zero. + // + // Scaling note: the WHERE is index-assisted via idx_transactions_state_type + // (no full table scan), but amount_msat/fee_msat are not in any index, so each + // matching row is fetched from the table to compute the SUM. This is fine for + // typical hubs (thousands of payments) but is O(outgoing settled rows) on every + // dashboard load. Before this needs to scale to millions of payments, add a + // covering index on transactions(type, state, self_payment, amount_msat, fee_msat) + // to make it an index-only scan, or maintain a cached running total. + err := api.db.Model(&db.Transaction{}). + Select("COALESCE(SUM(amount_msat), 0) AS total_volume_msat, COALESCE(SUM(fee_msat), 0) AS total_fees_paid_msat, COUNT(*) AS num_payments"). + Where("type = ? AND state = ? AND self_payment = ?", + constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, false). + Scan(&stats).Error + if err != nil { + return nil, err + } + + return &GetTransactionStatsResponse{ + TotalVolumeSat: stats.TotalVolumeMsat / 1000, + TotalVolumeMsat: stats.TotalVolumeMsat, + TotalFeesPaidSat: stats.TotalFeesPaidMsat / 1000, + TotalFeesPaidMsat: stats.TotalFeesPaidMsat, + NumPayments: stats.NumPayments, + }, nil +} diff --git a/api/models.go b/api/models.go index 1761b40d..be947d58 100644 --- a/api/models.go +++ b/api/models.go @@ -82,6 +82,7 @@ type API interface { ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error) SendEvent(event string, properties interface{}) GetForwards() (*GetForwardsResponse, error) + GetTransactionStats() (*GetTransactionStatsResponse, error) } var ErrLNClientNotStarted = errors.New("LNClient not started") @@ -711,6 +712,17 @@ type GetForwardsResponse struct { NumForwards uint64 `json:"numForwards"` } +// GetTransactionStatsResponse aggregates settled outgoing lightning payments +// (excluding self-payments, which never traverse the network) so the frontend +// can show a volume-weighted fee rate: TotalFeesPaidMsat / TotalVolumeMsat. +type GetTransactionStatsResponse struct { + TotalVolumeSat uint64 `json:"totalVolumeSat"` + TotalVolumeMsat uint64 `json:"totalVolumeMsat"` + TotalFeesPaidSat uint64 `json:"totalFeesPaidSat"` + TotalFeesPaidMsat uint64 `json:"totalFeesPaidMsat"` + NumPayments uint64 `json:"numPayments"` +} + func ResolveToSat(satValue *uint64, msatValue *uint64, legacyValueSat *uint64, legacyValueMsat *uint64) (resolvedSatValue *uint64) { if legacyValueSat != nil { resolvedSatValue = legacyValueSat diff --git a/api/transaction_stats_test.go b/api/transaction_stats_test.go new file mode 100644 index 00000000..5afd5707 --- /dev/null +++ b/api/transaction_stats_test.go @@ -0,0 +1,86 @@ +package api + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/getAlby/hub/constants" + "github.com/getAlby/hub/db" + "github.com/getAlby/hub/tests" +) + +func TestGetTransactionStats(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + // Two settled outgoing payments — these count towards the stats. + svc.DB.Create(&db.Transaction{ + State: constants.TRANSACTION_STATE_SETTLED, + Type: constants.TRANSACTION_TYPE_OUTGOING, + PaymentHash: "hash1", + AmountMsat: 1_000_000, + FeeMsat: 3000, + }) + svc.DB.Create(&db.Transaction{ + State: constants.TRANSACTION_STATE_SETTLED, + Type: constants.TRANSACTION_TYPE_OUTGOING, + PaymentHash: "hash2", + AmountMsat: 500_000, + FeeMsat: 2000, + }) + // Excluded: pending outgoing. + svc.DB.Create(&db.Transaction{ + State: constants.TRANSACTION_STATE_PENDING, + Type: constants.TRANSACTION_TYPE_OUTGOING, + PaymentHash: "hash3", + AmountMsat: 999_000, + FeeMsat: 9000, + }) + // Excluded: settled incoming. + svc.DB.Create(&db.Transaction{ + State: constants.TRANSACTION_STATE_SETTLED, + Type: constants.TRANSACTION_TYPE_INCOMING, + PaymentHash: "hash4", + AmountMsat: 777_000, + }) + // Excluded: self-payment (never traverses the network). + svc.DB.Create(&db.Transaction{ + State: constants.TRANSACTION_STATE_SETTLED, + Type: constants.TRANSACTION_TYPE_OUTGOING, + PaymentHash: "hash5", + AmountMsat: 200_000, + FeeMsat: 0, + SelfPayment: true, + }) + + theAPI := &api{db: svc.DB} + + stats, err := theAPI.GetTransactionStats() + require.NoError(t, err) + require.NotNil(t, stats) + + assert.Equal(t, uint64(1_500_000), stats.TotalVolumeMsat) + assert.Equal(t, uint64(1500), stats.TotalVolumeSat) + assert.Equal(t, uint64(5000), stats.TotalFeesPaidMsat) + assert.Equal(t, uint64(5), stats.TotalFeesPaidSat) + assert.Equal(t, uint64(2), stats.NumPayments) +} + +func TestGetTransactionStats_Empty(t *testing.T) { + svc, err := tests.CreateTestService(t) + require.NoError(t, err) + defer svc.Remove() + + theAPI := &api{db: svc.DB} + + stats, err := theAPI.GetTransactionStats() + require.NoError(t, err) + require.NotNil(t, stats) + + assert.Equal(t, uint64(0), stats.TotalVolumeMsat) + assert.Equal(t, uint64(0), stats.TotalFeesPaidMsat) + assert.Equal(t, uint64(0), stats.NumPayments) +} diff --git a/frontend/src/components/home/widgets/FeeRateWidget.tsx b/frontend/src/components/home/widgets/FeeRateWidget.tsx new file mode 100644 index 00000000..8644df81 --- /dev/null +++ b/frontend/src/components/home/widgets/FeeRateWidget.tsx @@ -0,0 +1,47 @@ +import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "src/components/ui/card"; +import { useTransactionStats } from "src/hooks/useTransactionStats"; + +function formatFeeRate(rate: number): string { + if (rate > 0 && rate < 0.01) { + return "<0.01%"; + } + return `${rate.toFixed(2)}%`; +} + +export function FeeRateWidget() { + const { data: stats } = useTransactionStats(); + + // Only show once there's payment volume to talk about — the volume-weighted + // rate is meaningless (and divides by zero) before the first payment. + if (!stats || !stats.totalVolumeMsat || !stats.numPayments) { + return null; + } + + const feeRate = (stats.totalFeesPaidMsat / stats.totalVolumeMsat) * 100; + + return ( + + + Lightning fees + + +

Average fee rate

+

{formatFeeRate(feeRate)}

+

+ You've sent{" "} + across{" "} + {stats.numPayments} payment{stats.numPayments === 1 ? "" : "s"} and + paid only{" "} + in + fees. +

+
+
+ ); +} diff --git a/frontend/src/hooks/useTransactionStats.ts b/frontend/src/hooks/useTransactionStats.ts new file mode 100644 index 00000000..a9f12e8b --- /dev/null +++ b/frontend/src/hooks/useTransactionStats.ts @@ -0,0 +1,11 @@ +import useSWR from "swr"; + +import { GetTransactionStatsResponse } from "src/types"; +import { swrFetcher } from "src/utils/swr"; + +export function useTransactionStats() { + return useSWR( + "/api/transactions/stats", + swrFetcher + ); +} diff --git a/frontend/src/screens/Home.tsx b/frontend/src/screens/Home.tsx index e1f4ede2..c8a03f81 100644 --- a/frontend/src/screens/Home.tsx +++ b/frontend/src/screens/Home.tsx @@ -17,6 +17,7 @@ import { AlbyExtensionWidget } from "src/components/home/widgets/AlbyExtensionWi import { AlbyGoWidget } from "src/components/home/widgets/AlbyGoWidget"; import { AppOfTheDayWidget } from "src/components/home/widgets/AppOfTheDayWidget"; import { BlockHeightWidget } from "src/components/home/widgets/BlockHeightWidget"; +import { FeeRateWidget } from "src/components/home/widgets/FeeRateWidget"; import { ForwardsWidget } from "src/components/home/widgets/ForwardsWidget"; import { LatestUsedAppsWidget } from "src/components/home/widgets/LatestUsedAppsWidget"; import { LightningMessageboardWidget } from "src/components/home/widgets/LightningMessageboardWidget"; @@ -45,6 +46,7 @@ function Home() {
+ diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 225a3dd2..db85f927 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -742,3 +742,11 @@ export type GetForwardsResponse = { totalFeeEarnedMsat: number; numForwards: number; }; + +export type GetTransactionStatsResponse = { + totalVolumeSat: number; + totalVolumeMsat: number; + totalFeesPaidSat: number; + totalFeesPaidMsat: number; + numPayments: number; +}; diff --git a/http/http_service.go b/http/http_service.go index 1714e895..eefbda03 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -138,6 +138,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { readOnlyApiGroup.GET("/wallet/address", httpSvc.onchainAddressHandler) readOnlyApiGroup.GET("/wallet/capabilities", httpSvc.capabilitiesHandler) readOnlyApiGroup.GET("/transactions", httpSvc.listTransactionsHandler) + readOnlyApiGroup.GET("/transactions/stats", httpSvc.transactionStatsHandler) readOnlyApiGroup.GET("/transactions/:paymentHash", httpSvc.lookupTransactionHandler) readOnlyApiGroup.GET("/balances", httpSvc.balancesHandler) readOnlyApiGroup.GET("/mempool", httpSvc.mempoolApiHandler) @@ -1606,3 +1607,14 @@ func (httpSvc *HttpService) forwardsHandler(c echo.Context) error { return c.JSON(http.StatusOK, forwards) } + +func (httpSvc *HttpService) transactionStatsHandler(c echo.Context) error { + stats, err := httpSvc.api.GetTransactionStats() + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: fmt.Sprintf("Failed to get transaction stats: %s", err.Error()), + }) + } + + return c.JSON(http.StatusOK, stats) +}