Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
12 changes: 12 additions & 0 deletions api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions api/transaction_stats_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
47 changes: 47 additions & 0 deletions frontend/src/components/home/widgets/FeeRateWidget.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
11 changes: 11 additions & 0 deletions frontend/src/hooks/useTransactionStats.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
2 changes: 2 additions & 0 deletions frontend/src/screens/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,6 +46,7 @@ function Home() {
<div className="columns-1 lg:columns-2 gap-3 *:mb-3 *:break-inside-avoid">
<OnboardingChecklist />
<WhatsNewWidget />
<FeeRateWidget />
<LatestUsedAppsWidget />
<NewArrivalsWidget />
<AppOfTheDayWidget />
Expand Down
8 changes: 8 additions & 0 deletions frontend/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -742,3 +742,11 @@ export type GetForwardsResponse = {
totalFeeEarnedMsat: number;
numForwards: number;
};

export type GetTransactionStatsResponse = {
totalVolumeSat: number;
totalVolumeMsat: number;
totalFeesPaidSat: number;
totalFeesPaidMsat: number;
numPayments: number;
};
12 changes: 12 additions & 0 deletions http/http_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Loading