This commit is contained in:
René Aaron 2026-08-10 09:01:14 +00:00 committed by GitHub
commit 216c214f40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 213 additions and 0 deletions

View file

@ -2198,3 +2198,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
}

View file

@ -81,6 +81,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")
@ -726,6 +727,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

View file

@ -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)
}

View file

@ -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 (
<Card>
<CardHeader>
<CardTitle>Lightning fees</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground text-xs">Average fee rate</p>
<p className="text-3xl font-semibold">{formatFeeRate(feeRate)}</p>
<p className="text-muted-foreground text-sm mt-3">
You've sent{" "}
<FormattedBitcoinAmount amountMsat={stats.totalVolumeMsat} /> across{" "}
{stats.numPayments} payment{stats.numPayments === 1 ? "" : "s"} and
paid only{" "}
<FormattedBitcoinAmount amountMsat={stats.totalFeesPaidMsat} /> in
fees.
</p>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,11 @@
import useSWR from "swr";
import { GetTransactionStatsResponse } from "src/types";
import { swrFetcher } from "src/utils/swr";
export function useTransactionStats() {
return useSWR<GetTransactionStatsResponse>(
"/api/transactions/stats",
swrFetcher
);
}

View file

@ -14,6 +14,7 @@ import OnboardingChecklist from "src/screens/wallet/OnboardingChecklist";
import React from "react";
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";
@ -44,6 +45,7 @@ function Home() {
<OnboardingChecklist />
<StoriesWidget />
<WhatsNewWidget />
<FeeRateWidget />
<LatestUsedAppsWidget />
<NewArrivalsWidget />
<AppOfTheDayWidget />

View file

@ -763,3 +763,11 @@ export type GetForwardsResponse = {
totalFeeEarnedMsat: number;
numForwards: number;
};
export type GetTransactionStatsResponse = {
totalVolumeSat: number;
totalVolumeMsat: number;
totalFeesPaidSat: number;
totalFeesPaidMsat: number;
numPayments: number;
};

View file

@ -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)
@ -1613,3 +1614,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)
}