feat: track forwards to display on a home page widget (#1623)

* feat: track forwards to display on a home page widget

* fix: test

* chore: improve copy

* feat: configure PPM routing fees
This commit is contained in:
Roland 2025-08-21 18:09:03 +07:00 committed by GitHub
parent 0ac8cc5e25
commit 16eff34b8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 331 additions and 14 deletions

View file

@ -565,6 +565,7 @@ Internally Alby Hub uses a basic implementation of the pubsub messaging pattern
- `nwc_alby_account_connected` - user connects alby account for first time
- `nwc_swap_succeeded` - successfully made a boltz swap
- `nwc_rebalance_succeeded` - successfully rebalanced channels
- `nwc_payment_forwarded` - successfully forwarded a payment and earned routing fees
### NIP-47 Handlers

View file

@ -554,6 +554,7 @@ func (api *api) ListChannels(ctx context.Context) ([]Channel, error) {
Confirmations: channel.Confirmations,
ConfirmationsRequired: channel.ConfirmationsRequired,
ForwardingFeeBaseMsat: channel.ForwardingFeeBaseMsat,
ForwardingFeeProportionalMillionths: channel.ForwardingFeeProportionalMillionths,
UnspendablePunishmentReserve: channel.UnspendablePunishmentReserve,
CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserve,
Error: channel.Error,
@ -1582,3 +1583,27 @@ func (api *api) parseExpiresAt(expiresAtString string) (*time.Time, error) {
}
return expiresAt, nil
}
func (api *api) GetForwards() (*GetForwardsResponse, error) {
var forwards []db.Forward
err := api.db.Find(&forwards).Error
if err != nil {
return nil, err
}
var totalOutboundAmount uint64
var totalFeeEarned uint64
for _, forward := range forwards {
totalOutboundAmount += forward.OutboundAmountForwardedMsat
totalFeeEarned += forward.TotalFeeEarnedMsat
}
numForwards := len(forwards)
return &GetForwardsResponse{
OutboundAmountForwardedMsat: totalOutboundAmount,
TotalFeeEarnedMsat: totalFeeEarned,
NumForwards: uint64(numForwards),
}, nil
}

View file

@ -79,6 +79,7 @@ type API interface {
GetCustomNodeCommands() (*CustomNodeCommandsResponse, error)
ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error)
SendEvent(event string)
GetForwards() (*GetForwardsResponse, error)
}
type App struct {
@ -489,6 +490,7 @@ type Channel struct {
Confirmations *uint32 `json:"confirmations"`
ConfirmationsRequired *uint32 `json:"confirmationsRequired"`
ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"`
ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"`
UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"`
CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"`
Error *string `json:"error"`
@ -544,3 +546,9 @@ type CustomNodeCommandsResponse struct {
type ExecuteCustomNodeCommandRequest struct {
Command string `json:"command"`
}
type GetForwardsResponse struct {
OutboundAmountForwardedMsat uint64 `json:"outboundAmountForwardedMsat"`
TotalFeeEarnedMsat uint64 `json:"totalFeeEarnedMsat"`
NumForwards uint64 `json:"numForwards"`
}

View file

@ -23,6 +23,7 @@ var expectedTables = []string{
"swaps",
"user_configs",
"migrations",
"forwards",
}
func main() {

View file

@ -0,0 +1,36 @@
package migrations
import (
_ "embed"
"text/template"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const forwardsMigration = `
CREATE TABLE forwards(
id {{ .AutoincrementPrimaryKey }},
outbound_amount_forwarded_msat bigint,
total_fee_earned_msat bigint,
created_at {{ .Timestamp }},
updated_at {{ .Timestamp }}
);
`
var forwardsMigrationTmpl = template.Must(template.New("forwardsMigration").Parse(forwardsMigration))
var _202508192137_forwards = &gormigrate.Migration{
ID: "202508192137_forwards",
Migrate: func(tx *gorm.DB) error {
if err := exec(tx, forwardsMigrationTmpl); err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

View file

@ -36,6 +36,7 @@ func Migrate(gormDB *gorm.DB) error {
_202508041738_app_last_used,
_202508041739_response_events_index,
_202508151405_swap_xpub,
_202508192137_forwards,
})
return m.Migrate()

View file

@ -114,6 +114,14 @@ type Swap struct {
UpdatedAt time.Time
}
type Forward struct {
ID uint
OutboundAmountForwardedMsat uint64
TotalFeeEarnedMsat uint64
CreatedAt time.Time
UpdatedAt time.Time
}
const (
REQUEST_EVENT_STATE_HANDLER_EXECUTING = "executing"
REQUEST_EVENT_STATE_HANDLER_EXECUTED = "executed"

View file

@ -22,18 +22,26 @@ type Props = {
};
export function RoutingFeeDialogContent({ channel }: Props) {
const currentFee: number = React.useMemo(() => {
return Math.floor(channel.forwardingFeeBaseMsat / 1000);
}, [channel.forwardingFeeBaseMsat]);
const [forwardingFee, setForwardingFee] = React.useState(
currentFee ? currentFee.toString() : ""
const currentBaseFeeSats: number = Math.floor(
channel.forwardingFeeBaseMsat / 1000
);
const currentFeePPM: number = channel.forwardingFeeProportionalMillionths;
const [baseFeeSats, setBaseFeeSats] = React.useState(
currentBaseFeeSats !== undefined ? currentBaseFeeSats.toString() : ""
);
const [
forwardingFeeProportionalMillionths,
setForwardingFeeProportionalMillionths,
] = React.useState(
currentFeePPM !== undefined ? currentFeePPM.toString() : ""
);
const { toast } = useToast();
const { mutate: reloadChannels } = useChannels();
async function updateFee() {
try {
const forwardingFeeBaseMsat = +forwardingFee * 1000;
const forwardingFeeBaseMsat = +baseFeeSats * 1000;
console.info(
`🎬 Updating channel ${channel.id} with ${channel.remotePubkey}`
@ -48,6 +56,8 @@ export function RoutingFeeDialogContent({ channel }: Props) {
},
body: JSON.stringify({
forwardingFeeBaseMsat: forwardingFeeBaseMsat,
forwardingFeeProportionalMillionths:
+forwardingFeeProportionalMillionths,
} as UpdateChannelRequest),
}
);
@ -74,7 +84,7 @@ export function RoutingFeeDialogContent({ channel }: Props) {
unwanted routing. No matter the fee, you can still receive payments.{" "}
</p>
<Label htmlFor="fee" className="block mb-2">
Routing Fee (sats)
Base Routing Fee (sats)
</Label>
<Input
id="fee"
@ -83,9 +93,24 @@ export function RoutingFeeDialogContent({ channel }: Props) {
required
autoFocus
min={0}
value={forwardingFee}
value={baseFeeSats}
onChange={(e) => {
setForwardingFee(e.target.value.trim());
setBaseFeeSats(e.target.value.trim());
}}
/>
<Label htmlFor="fee" className="block mt-4 mb-2">
PPM Fee (1 PPM = 1 per 1 million sats)
</Label>
<Input
id="fee"
name="fee"
type="number"
required
autoFocus
min={0}
value={forwardingFeeProportionalMillionths}
onChange={(e) => {
setForwardingFeeProportionalMillionths(e.target.value.trim());
}}
/>
<ExternalLink
@ -100,7 +125,11 @@ export function RoutingFeeDialogContent({ channel }: Props) {
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={(parseInt(forwardingFee) || 0) == currentFee}
disabled={
(parseInt(baseFeeSats) || 0) === currentBaseFeeSats &&
(parseInt(forwardingFeeProportionalMillionths) || 0) ===
currentFeePPM
}
onClick={updateFee}
>
Confirm

View file

@ -0,0 +1,69 @@
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
import { useForwards } from "src/hooks/useForwards";
export function ForwardsWidget() {
const { data: forwards } = useForwards();
if (!forwards) {
return null;
}
return (
<Card>
<CardHeader>
<CardTitle>Routing</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-6">
<div>
<p className="text-muted-foreground text-xs">Fees Earned</p>
<p className="text-xl font-semibold">
{new Intl.NumberFormat().format(
Math.floor(forwards.totalFeeEarnedMsat / 1000)
)}{" "}
sats
<FormattedFiatAmount
amount={Math.floor(forwards.totalFeeEarnedMsat / 1000)}
/>
</p>
</div>
<div>
<p className="text-muted-foreground text-xs">Total Routed</p>
<p className="text-xl font-semibold">
{new Intl.NumberFormat().format(
Math.floor(forwards.outboundAmountForwardedMsat / 1000)
)}{" "}
sats
<FormattedFiatAmount
amount={Math.floor(forwards.outboundAmountForwardedMsat / 1000)}
/>
</p>
</div>
<div>
<p className="text-muted-foreground text-xs">Transactions Routed</p>
<p className="text-xl font-semibold">{forwards.numForwards}</p>
</div>
</div>
<div className="flex justify-end mt-4 items-end">
<p className="text-muted-foreground text-sm">
Earn and support the lightning network by routing payments. To route
payments you need public channels and set competitive fees.
</p>
<ExternalLinkButton
variant="secondary"
to="https://guides.getalby.com/user-guide/alby-hub/faq/how-can-i-change-routing-fees#changing-the-routing-fee"
>
Learn more
</ExternalLinkButton>
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,8 @@
import useSWR from "swr";
import { GetForwardsResponse } from "src/types";
import { swrFetcher } from "src/utils/swr";
export function useForwards() {
return useSWR<GetForwardsResponse>("/api/forwards", swrFetcher);
}

View file

@ -23,6 +23,7 @@ import albyGo from "src/assets/suggested-apps/alby-go.png";
import zapplanner from "src/assets/suggested-apps/zapplanner.png";
import { AppOfTheDayWidget } from "src/components/home/widgets/AppOfTheDayWidget";
import { BlockHeightWidget } from "src/components/home/widgets/BlockHeightWidget";
import { ForwardsWidget } from "src/components/home/widgets/ForwardsWidget";
import { LatestUsedAppsWidget } from "src/components/home/widgets/LatestUsedAppsWidget";
import { LightningMessageboardWidget } from "src/components/home/widgets/LightningMessageboardWidget";
import { NodeStatusWidget } from "src/components/home/widgets/NodeStatusWidget";
@ -204,6 +205,7 @@ function Home() {
<NodeStatusWidget />
<BlockHeightWidget />
<OnchainFeesWidget />
<ForwardsWidget />
</div>
</CardContent>
)}

View file

@ -297,6 +297,7 @@ export type Channel = {
confirmations?: number;
confirmationsRequired?: number;
forwardingFeeBaseMsat: number;
forwardingFeeProportionalMillionths: number;
unspendablePunishmentReserve: number;
counterpartyUnspendablePunishmentReserve: number;
error?: string;
@ -647,3 +648,9 @@ export type NewChannelOrder = OnchainOrder | LightningOrder;
export type AuthTokenResponse = {
token: string;
};
export type GetForwardsResponse = {
outboundAmountForwardedMsat: number;
totalFeeEarnedMsat: number;
numForwards: number;
};

View file

@ -181,6 +181,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
restrictedApiGroup.POST("/autoswap", httpSvc.enableAutoSwapOutHandler)
restrictedApiGroup.DELETE("/autoswap", httpSvc.disableAutoSwapOutHandler)
restrictedApiGroup.POST("/node/alias", httpSvc.setNodeAliasHandler)
restrictedApiGroup.GET("/forwards", httpSvc.forwardsHandler)
httpSvc.albyHttpSvc.RegisterSharedRoutes(restrictedApiGroup, e)
}
@ -1491,3 +1492,14 @@ func (httpSvc *HttpService) setNodeAliasHandler(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
}
func (httpSvc *HttpService) forwardsHandler(c echo.Context) error {
forwards, err := httpSvc.api.GetForwards()
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to get forwards: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, forwards)
}

View file

@ -972,6 +972,7 @@ func (ls *LDKService) ListChannels(ctx context.Context) ([]lnclient.Channel, err
Confirmations: ldkChannel.Confirmations,
ConfirmationsRequired: ldkChannel.ConfirmationsRequired,
ForwardingFeeBaseMsat: ldkChannel.Config.ForwardingFeeBaseMsat,
ForwardingFeeProportionalMillionths: ldkChannel.Config.ForwardingFeeProportionalMillionths,
UnspendablePunishmentReserve: unspendablePunishmentReserve,
CounterpartyUnspendablePunishmentReserve: ldkChannel.CounterpartyUnspendablePunishmentReserve,
Error: channelError,
@ -1121,6 +1122,7 @@ func (ls *LDKService) UpdateChannel(ctx context.Context, updateChannelRequest *l
existingConfig := foundChannel.Config
existingConfig.ForwardingFeeBaseMsat = updateChannelRequest.ForwardingFeeBaseMsat
existingConfig.ForwardingFeeProportionalMillionths = updateChannelRequest.ForwardingFeeProportionalMillionths
if updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier > 0 {
existingConfig.MaxDustHtlcExposure = ldk_node.MaxDustHtlcExposureFeeRateMultiplier{
@ -1765,6 +1767,20 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
"total_fee_earned_msat": eventType.TotalFeeEarnedMsat,
"outbound_amount_forwarded_msat": eventType.OutboundAmountForwardedMsat,
}).Info("LDK Payment forwarded")
if eventType.TotalFeeEarnedMsat == nil || eventType.OutboundAmountForwardedMsat == nil {
logger.Logger.WithFields(logrus.Fields{
"earned_msat": eventType.TotalFeeEarnedMsat,
"outbound_amount_forwarded_msat": eventType.OutboundAmountForwardedMsat,
}).Error("forwarded payment has missing required fields")
return
}
ls.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_forwarded",
Properties: &lnclient.PaymentForwardedEventProperties{
TotalFeeEarnedMsat: *eventType.TotalFeeEarnedMsat,
OutboundAmountForwardedMsat: *eventType.OutboundAmountForwardedMsat,
},
})
case ldk_node.EventPaymentClaimable:
if eventType.ClaimDeadline == nil {

View file

@ -96,12 +96,45 @@ func NewLNDService(ctx context.Context, eventPublisher events.EventPublisher, ln
go lndService.subscribeInvoices(lndCtx)
go lndService.subscribeChannelEvents(lndCtx)
go lndService.subscribeOpenHoldInvoices(lndCtx)
go lndService.trackForwardedPayments(lndCtx)
logger.Logger.WithField("alias", nodeInfo.Alias).Info("Connected to LND")
return lndService, nil
}
func (svc *LNDService) trackForwardedPayments(ctx context.Context) {
// NOTE: this only tracks payments when hub is online and attached
lastTime := time.Now()
for {
select {
case <-ctx.Done():
return
default:
time.Sleep(1 * time.Minute)
nextTime := time.Now()
forwardedPayments, err := svc.client.ForwardingHistory(ctx, &lnrpc.ForwardingHistoryRequest{
StartTime: uint64(lastTime.Unix()),
EndTime: uint64(nextTime.Unix()),
})
if err != nil {
logger.Logger.WithError(err).Error("failed to read forwarding history")
continue
}
for _, forwardingEvent := range forwardedPayments.ForwardingEvents {
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_payment_forwarded",
Properties: &lnclient.PaymentForwardedEventProperties{
TotalFeeEarnedMsat: forwardingEvent.FeeMsat,
OutboundAmountForwardedMsat: forwardingEvent.AmtOutMsat,
},
})
}
lastTime = nextTime
}
}
}
func (svc *LNDService) subscribePayments(ctx context.Context) {
for {
select {
@ -867,7 +900,8 @@ func (svc *LNDService) ListChannels(ctx context.Context) ([]lnclient.Channel, er
channelOpeningBlockHeight := lndChannel.ChanId >> 40
confirmations := nodeInfo.BlockHeight - uint32(channelOpeningBlockHeight) + 1
var forwardingFee uint32
var forwardingFeeBaseMsat uint32
var forwardingFeeProportionalMillionths uint32
if !lndChannel.Private {
channelEdge, err := svc.client.GetChanInfo(ctx, &lnrpc.ChanInfoRequest{
ChanId: lndChannel.ChanId,
@ -883,7 +917,8 @@ func (svc *LNDService) ListChannels(ctx context.Context) ([]lnclient.Channel, er
policy = channelEdge.Node2Policy
}
if policy != nil {
forwardingFee = uint32(policy.FeeBaseMsat)
forwardingFeeBaseMsat = uint32(policy.FeeBaseMsat)
forwardingFeeProportionalMillionths = uint32(policy.FeeRateMilliMsat)
}
}
@ -903,7 +938,8 @@ func (svc *LNDService) ListChannels(ctx context.Context) ([]lnclient.Channel, er
UnspendablePunishmentReserve: lndChannel.LocalConstraints.ChanReserveSat,
CounterpartyUnspendablePunishmentReserve: lndChannel.RemoteConstraints.ChanReserveSat,
IsOutbound: lndChannel.Initiator,
ForwardingFeeBaseMsat: forwardingFee,
ForwardingFeeBaseMsat: forwardingFeeBaseMsat,
ForwardingFeeProportionalMillionths: forwardingFeeProportionalMillionths,
}
}
@ -1101,7 +1137,7 @@ func (svc *LNDService) UpdateChannel(ctx context.Context, updateChannelRequest *
ChanPoint: channelPoint,
},
BaseFeeMsat: int64(updateChannelRequest.ForwardingFeeBaseMsat),
FeeRatePpm: uint32(nodePolicy.FeeRateMilliMsat),
FeeRatePpm: updateChannelRequest.ForwardingFeeProportionalMillionths,
TimeLockDelta: nodePolicy.TimeLockDelta,
MaxHtlcMsat: nodePolicy.MaxHtlcMsat,
})

View file

@ -243,3 +243,7 @@ func (wrapper *LNDWrapper) DisconnectPeer(ctx context.Context, req *lnrpc.Discon
func (wrapper *LNDWrapper) SubscribeChannelEvents(ctx context.Context, in *lnrpc.ChannelEventSubscription, options ...grpc.CallOption) (lnrpc.Lightning_SubscribeChannelEventsClient, error) {
return wrapper.client.SubscribeChannelEvents(ctx, in, options...)
}
func (wrapper *LNDWrapper) ForwardingHistory(ctx context.Context, in *lnrpc.ForwardingHistoryRequest, options ...grpc.CallOption) (*lnrpc.ForwardingHistoryResponse, error) {
return wrapper.client.ForwardingHistory(ctx, in, options...)
}

View file

@ -111,6 +111,7 @@ type Channel struct {
Confirmations *uint32
ConfirmationsRequired *uint32
ForwardingFeeBaseMsat uint32
ForwardingFeeProportionalMillionths uint32
UnspendablePunishmentReserve uint64
CounterpartyUnspendablePunishmentReserve uint64
Error *string
@ -148,6 +149,7 @@ type UpdateChannelRequest struct {
ChannelId string `json:"channelId"`
NodeId string `json:"nodeId"`
ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"`
ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"`
MaxDustHtlcExposureFromFeeRateMultiplier uint64 `json:"maxDustHtlcExposureFromFeeRateMultiplier"`
}
@ -214,6 +216,11 @@ type PaymentFailedEventProperties struct {
Reason string
}
type PaymentForwardedEventProperties struct {
TotalFeeEarnedMsat uint64
OutboundAmountForwardedMsat uint64
}
type CustomNodeCommandArgDef struct {
Name string
Description string

View file

@ -0,0 +1,38 @@
package service
import (
"context"
"gorm.io/gorm"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
)
type paymentForwardedConsumer struct {
events.EventSubscriber
db *gorm.DB
}
// When a new app is created, subscribe to it on the relay
func (c *paymentForwardedConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
if event.Event != "nwc_payment_forwarded" {
return
}
properties, ok := event.Properties.(*lnclient.PaymentForwardedEventProperties)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event.Properties to payment forwarded event properties")
return
}
forward := &db.Forward{
OutboundAmountForwardedMsat: properties.OutboundAmountForwardedMsat,
TotalFeeEarnedMsat: properties.TotalFeeEarnedMsat,
}
err := c.db.Create(forward).Error
if err != nil {
logger.Logger.WithError(err).Error("failed to save forward to db")
}
}

View file

@ -137,6 +137,9 @@ func NewService(ctx context.Context) (*service, error) {
eventPublisher.RegisterSubscriber(svc.transactionsService)
eventPublisher.RegisterSubscriber(svc.nip47Service)
eventPublisher.RegisterSubscriber(svc.albyOAuthSvc)
eventPublisher.RegisterSubscriber(&paymentForwardedConsumer{
db: gormDB,
})
eventPublisher.Publish(&events.Event{
Event: "nwc_started",

View file

@ -1258,6 +1258,12 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
}
return WailsRequestRouterResponse{Body: nil, Error: ""}
}
case "/api/forwards":
forwards, err := app.api.GetForwards()
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: forwards, Error: ""}
}
lightningAddressRegex := regexp.MustCompile(