mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
Merge 72f9b885ec into 037765794d
This commit is contained in:
commit
216c214f40
8 changed files with 213 additions and 0 deletions
35
api/api.go
35
api/api.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
86
api/transaction_stats_test.go
Normal file
86
api/transaction_stats_test.go
Normal 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)
|
||||
}
|
||||
47
frontend/src/components/home/widgets/FeeRateWidget.tsx
Normal file
47
frontend/src/components/home/widgets/FeeRateWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/src/hooks/useTransactionStats.ts
Normal file
11
frontend/src/hooks/useTransactionStats.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -763,3 +763,11 @@ export type GetForwardsResponse = {
|
|||
totalFeeEarnedMsat: number;
|
||||
numForwards: number;
|
||||
};
|
||||
|
||||
export type GetTransactionStatsResponse = {
|
||||
totalVolumeSat: number;
|
||||
totalVolumeMsat: number;
|
||||
totalFeesPaidSat: number;
|
||||
totalFeesPaidMsat: number;
|
||||
numPayments: number;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue