diff --git a/README.md b/README.md index d109c6cf..27dd7fac 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/api.go b/api/api.go index 32d08690..5b2f9e76 100644 --- a/api/api.go +++ b/api/api.go @@ -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 +} diff --git a/api/models.go b/api/models.go index ff0633d6..fa7a7259 100644 --- a/api/models.go +++ b/api/models.go @@ -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"` +} diff --git a/cmd/db_migrate/main.go b/cmd/db_migrate/main.go index 43aa9ae5..c10f4a3e 100644 --- a/cmd/db_migrate/main.go +++ b/cmd/db_migrate/main.go @@ -23,6 +23,7 @@ var expectedTables = []string{ "swaps", "user_configs", "migrations", + "forwards", } func main() { diff --git a/db/migrations/202508192137_forwards.go b/db/migrations/202508192137_forwards.go new file mode 100644 index 00000000..174fe783 --- /dev/null +++ b/db/migrations/202508192137_forwards.go @@ -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 + }, +} diff --git a/db/migrations/migrate.go b/db/migrations/migrate.go index 7f304cc4..4ddcacb6 100644 --- a/db/migrations/migrate.go +++ b/db/migrations/migrate.go @@ -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() diff --git a/db/models.go b/db/models.go index 31b73302..65567f3a 100644 --- a/db/models.go +++ b/db/models.go @@ -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" diff --git a/frontend/src/components/RoutingFeeDialogContent.tsx b/frontend/src/components/RoutingFeeDialogContent.tsx index 0f0021b9..080b1fe0 100644 --- a/frontend/src/components/RoutingFeeDialogContent.tsx +++ b/frontend/src/components/RoutingFeeDialogContent.tsx @@ -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.{" "}

{ - setForwardingFee(e.target.value.trim()); + setBaseFeeSats(e.target.value.trim()); + }} + /> + + { + setForwardingFeeProportionalMillionths(e.target.value.trim()); }} /> Cancel Confirm diff --git a/frontend/src/components/home/widgets/ForwardsWidget.tsx b/frontend/src/components/home/widgets/ForwardsWidget.tsx new file mode 100644 index 00000000..2636e307 --- /dev/null +++ b/frontend/src/components/home/widgets/ForwardsWidget.tsx @@ -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 ( + + + Routing + + +
+
+

Fees Earned

+

+ {new Intl.NumberFormat().format( + Math.floor(forwards.totalFeeEarnedMsat / 1000) + )}{" "} + sats + +

+
+
+

Total Routed

+

+ {new Intl.NumberFormat().format( + Math.floor(forwards.outboundAmountForwardedMsat / 1000) + )}{" "} + sats + +

+
+
+

Transactions Routed

+

{forwards.numForwards}

+
+
+
+

+ Earn and support the lightning network by routing payments. To route + payments you need public channels and set competitive fees. +

+ + Learn more + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useForwards.ts b/frontend/src/hooks/useForwards.ts new file mode 100644 index 00000000..d491bbf1 --- /dev/null +++ b/frontend/src/hooks/useForwards.ts @@ -0,0 +1,8 @@ +import useSWR from "swr"; + +import { GetForwardsResponse } from "src/types"; +import { swrFetcher } from "src/utils/swr"; + +export function useForwards() { + return useSWR("/api/forwards", swrFetcher); +} diff --git a/frontend/src/screens/Home.tsx b/frontend/src/screens/Home.tsx index fdc54f16..367701d4 100644 --- a/frontend/src/screens/Home.tsx +++ b/frontend/src/screens/Home.tsx @@ -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() { + )} diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5c353e6e..dcea21ee 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -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; +}; diff --git a/http/http_service.go b/http/http_service.go index 1fb98abd..661c6a40 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -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) +} diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index 0e5d65d0..93454879 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -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 { diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index be0d5903..1a2b266c 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -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, }) diff --git a/lnclient/lnd/wrapper/lnd.go b/lnclient/lnd/wrapper/lnd.go index c50d1b98..4efd0f12 100644 --- a/lnclient/lnd/wrapper/lnd.go +++ b/lnclient/lnd/wrapper/lnd.go @@ -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...) +} diff --git a/lnclient/models.go b/lnclient/models.go index 027bafe8..09b1393d 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -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 diff --git a/service/payment_forwarded_consumer.go b/service/payment_forwarded_consumer.go new file mode 100644 index 00000000..16914626 --- /dev/null +++ b/service/payment_forwarded_consumer.go @@ -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") + } +} diff --git a/service/service.go b/service/service.go index f4b6c67a..ffce2138 100644 --- a/service/service.go +++ b/service/service.go @@ -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", diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go index 83402f95..1c6ac5be 100644 --- a/wails/wails_handlers.go +++ b/wails/wails_handlers.go @@ -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(