feat: add on-chain transactions to node page

This commit is contained in:
Roland Bewick 2025-05-02 13:51:37 +07:00
parent b0b92db7eb
commit 153da0ee12
14 changed files with 267 additions and 0 deletions

View file

@ -1035,6 +1035,12 @@ func (api *api) SyncWallet() error {
api.svc.GetLNClient().UpdateLastWalletSyncRequest()
return nil
}
func (api *api) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
return api.svc.GetLNClient().ListOnchainTransactions(ctx)
}
func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
var err error

View file

@ -37,6 +37,7 @@ type API interface {
RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (*RedeemOnchainFundsResponse, error)
GetBalances(ctx context.Context) (*BalancesResponse, error)
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error)
SendPayment(ctx context.Context, invoice string, amountMsat *uint64) (*SendPaymentResponse, error)
CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error)
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)

View file

@ -0,0 +1,136 @@
import dayjs from "dayjs";
import { AlertCircle, ArrowDownIcon, ArrowUpIcon } from "lucide-react";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "src/components/ui/card";
import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
import { useOnchainTransactions } from "src/hooks/useOnchainTransactions";
import { cn } from "src/lib/utils";
export function OnchainTransactionsTable() {
const { data: transactions, error, isLoading } = useOnchainTransactions();
if (isLoading) {
return null;
}
if (error) {
return (
<Alert variant="destructive" className="mt-4">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error loading on-chain transactions</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
if (!transactions?.length) {
return null;
}
return (
<Card className="mt-6">
<CardHeader>
<CardTitle className="text-2xl">On-Chain Transactions</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableBody>
{transactions.map((tx) => {
const Icon = tx.type == "outgoing" ? ArrowUpIcon : ArrowDownIcon;
return (
<TableRow
key={tx.txId}
className="cursor-pointer"
onClick={() => {
window.open(
`https://mempool.space/tx/${tx.txId}`,
"_blank"
);
}}
>
<TableCell className="flex items-center gap-2">
<div
className={cn(
"flex justify-center items-center rounded-full w-10 h-10 relative",
tx.state === "unconfirmed"
? "bg-blue-100 dark:bg-sky-950 animate-pulse"
: tx.type === "outgoing"
? "bg-orange-100 dark:bg-amber-950"
: "bg-green-100 dark:bg-emerald-950"
)}
title={`${tx.numConfirmations} confirmations`}
>
<Icon
strokeWidth={3}
className={cn(
"w-6 h-6",
tx.state === "unconfirmed"
? "stroke-blue-500 dark:stroke-sky-500"
: tx.type === "outgoing"
? "stroke-orange-500 dark:stroke-amber-500"
: "stroke-green-500 dark:stroke-teal-500"
)}
/>
</div>
<div className="md:flex md:gap-2 md:items-center">
<p className="font-semibold text-lg">
{tx.type == "outgoing"
? tx.state === "confirmed"
? "Sent"
: "Sending"
: tx.state === "confirmed"
? "Received"
: "Receiving"}
</p>
<p
className="text-muted-foreground"
title={dayjs(tx.updatedAt * 1000)
.local()
.format("D MMMM YYYY, HH:mm")}
>
{dayjs(tx.updatedAt * 1000)
.local()
.fromNow()}
</p>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col items-end">
<div className="flex flex-row gap-1">
<p
className={cn(
tx.type == "incoming" &&
"text-green-600 dark:text-emerald-500"
)}
>
{tx.type == "outgoing" ? "-" : "+"}
<span className="font-medium">
{new Intl.NumberFormat().format(tx.amountSat)}
</span>
</p>
<p className="text-muted-foreground">
{tx.amountSat == 1 ? "sat" : "sats"}
</p>
</div>
<FormattedFiatAmount
className="text-xs"
amount={tx.amountSat}
/>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,15 @@
import { OnchainTransaction } from "src/types";
import { swrFetcher } from "src/utils/swr";
import useSWR, { SWRConfiguration } from "swr";
const pollConfiguration: SWRConfiguration = {
refreshInterval: 30000,
};
export function useOnchainTransactions() {
return useSWR<OnchainTransaction[]>(
"/api/node/transactions",
swrFetcher,
pollConfiguration
);
}

View file

@ -17,6 +17,7 @@ import AppHeader from "src/components/AppHeader.tsx";
import { ChannelsCards } from "src/components/channels/ChannelsCards.tsx";
import { ChannelsTable } from "src/components/channels/ChannelsTable.tsx";
import { HealthCheckAlert } from "src/components/channels/HealthcheckAlert";
import { OnchainTransactionsTable } from "src/components/channels/OnchainTransactionsTable.tsx";
import { SwapDialogs } from "src/components/channels/SwapDialogs";
import EmptyState from "src/components/EmptyState.tsx";
import ExternalLink from "src/components/ExternalLink";
@ -572,6 +573,7 @@ export default function Channels() {
<ChannelsTable channels={channels} nodes={nodes} />
<ChannelsCards channels={channels} nodes={nodes} />
<OnchainTransactionsTable />
</>
)}
</>

View file

@ -492,6 +492,16 @@ export type Boostagram = {
valueMsatTotal: number;
};
export type OnchainTransaction = {
amountSat: number;
createdAt: number;
updatedAt: number;
type: "incoming" | "outgoing";
state: "confirmed" | "unconfirmed";
numConfirmations: number;
txId: string;
};
export type ListTransactionsResponse = {
transactions: Transaction[];
totalCount: number;

View file

@ -132,6 +132,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
restrictedApiGroup.GET("/node/status", httpSvc.nodeStatusHandler)
restrictedApiGroup.GET("/node/network-graph", httpSvc.nodeNetworkGraphHandler)
restrictedApiGroup.POST("/node/migrate-storage", httpSvc.migrateNodeStorageHandler)
restrictedApiGroup.GET("/node/transactions", httpSvc.listOnchainTransactionsHandler)
restrictedApiGroup.GET("/peers", httpSvc.listPeers)
restrictedApiGroup.POST("/peers", httpSvc.connectPeerHandler)
restrictedApiGroup.DELETE("/peers/:peerId", httpSvc.disconnectPeerHandler)
@ -594,6 +595,20 @@ func (httpSvc *HttpService) listTransactionsHandler(c echo.Context) error {
return c.JSON(http.StatusOK, transactions)
}
func (httpSvc *HttpService) listOnchainTransactionsHandler(c echo.Context) error {
ctx := c.Request().Context()
transactions, err := httpSvc.api.ListOnchainTransactions(ctx)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: err.Error(),
})
}
return c.JSON(http.StatusOK, transactions)
}
func (httpSvc *HttpService) walletSyncHandler(c echo.Context) error {
httpSvc.api.SyncWallet()

View file

@ -551,3 +551,7 @@ func (cs *CashuService) executeCommandResetWallet() (*lnclient.CustomNodeCommand
},
}, nil
}
func (cs *CashuService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
return nil, errors.ErrUnsupported
}

View file

@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"sync"
@ -781,6 +782,53 @@ func (ls *LDKService) ListTransactions(ctx context.Context, from, until, limit,
return transactions, nil*/
}
func (ls *LDKService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
transactions := []lnclient.OnchainTransaction{}
for _, payment := range ls.node.ListPayments() {
onchainPaymentKind, isOnchainPaymentKind := payment.Kind.(ldk_node.PaymentKindOnchain)
if !isOnchainPaymentKind {
continue
}
transactionType := "incoming"
if payment.Direction == ldk_node.PaymentDirectionOutbound {
transactionType = "outgoing"
}
var amountMsat uint64
if payment.AmountMsat != nil {
amountMsat = *payment.AmountMsat
}
var status string
var height uint32
var numConfirmations uint32
switch onchainPaymentStatus := onchainPaymentKind.Status.(type) {
case ldk_node.ConfirmationStatusConfirmed:
status = "confirmed"
height = onchainPaymentStatus.Height
nodeStatus := ls.node.Status()
numConfirmations = nodeStatus.CurrentBestBlock.Height - height
case ldk_node.ConfirmationStatusUnconfirmed:
status = "unconfirmed"
}
transactions = append(transactions, lnclient.OnchainTransaction{
AmountSat: amountMsat / 1000,
CreatedAt: payment.CreatedAt,
UpdatedAt: payment.LatestUpdateTimestamp,
State: status,
Type: transactionType,
NumConfirmations: numConfirmations,
TxId: onchainPaymentKind.Txid,
})
}
sort.SliceStable(transactions, func(i, j int) bool {
return transactions[i].UpdatedAt > transactions[j].UpdatedAt
})
return transactions, nil
}
func (ls *LDKService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, err error) {
// TODO: should alias, color be configured in LDK-node? or can we manage them in NWC?
// an alias is only needed if the user has public channels and wants their node to be publicly visible?

View file

@ -1318,3 +1318,7 @@ func (svc *LNDService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCo
func (svc *LNDService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}
func (svc *LNDService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
return nil, errors.ErrUnsupported
}

View file

@ -40,6 +40,16 @@ type Transaction struct {
Metadata Metadata
}
type OnchainTransaction struct {
AmountSat uint64 `json:"amountSat"`
CreatedAt uint64 `json:"createdAt"`
UpdatedAt uint64 `json:"updatedAt"`
State string `json:"state"`
Type string `json:"type"`
NumConfirmations uint32 `json:"numConfirmations"`
TxId string `json:"txId"`
}
type NodeConnectionInfo struct {
Pubkey string `json:"pubkey"`
Address string `json:"address"`
@ -54,6 +64,7 @@ type LNClient interface {
MakeInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64) (transaction *Transaction, err error)
LookupInvoice(ctx context.Context, paymentHash string) (transaction *Transaction, err error)
ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaid bool, invoiceType string) (transactions []Transaction, err error)
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
Shutdown() error
ListChannels(ctx context.Context) (channels []Channel, err error)
GetNodeConnectionInfo(ctx context.Context) (nodeConnectionInfo *NodeConnectionInfo, err error)

View file

@ -539,3 +539,7 @@ func (svc *PhoenixService) GetCustomNodeCommandDefinitions() []lnclient.CustomNo
func (svc *PhoenixService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}
func (svc *PhoenixService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
return nil, errors.ErrUnsupported
}

View file

@ -2,6 +2,7 @@ package tests
import (
"context"
"errors"
"time"
"github.com/getAlby/hub/lnclient"
@ -211,3 +212,7 @@ func (mln *MockLn) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeComman
func (mln *MockLn) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
return nil, nil
}
func (mln *MockLn) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
return nil, errors.ErrUnsupported
}

View file

@ -582,6 +582,12 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
return WailsRequestRouterResponse{Body: nil, Error: ""}
}
return WailsRequestRouterResponse{Body: *nodeStatus, Error: ""}
case "/api/node/transactions":
transactions, err := app.api.ListOnchainTransactions(ctx)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: transactions, Error: ""}
case "/api/info":
infoResponse, err := app.api.GetInfo(ctx)
if err != nil {