Skip to content

Commit 153da0e

Browse files
committed
feat: add on-chain transactions to node page
1 parent b0b92db commit 153da0e

File tree

14 files changed

+267
-0
lines changed

14 files changed

+267
-0
lines changed

api/api.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1035,6 +1035,12 @@ func (api *api) SyncWallet() error {
10351035
api.svc.GetLNClient().UpdateLastWalletSyncRequest()
10361036
return nil
10371037
}
1038+
func (api *api) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
1039+
if api.svc.GetLNClient() == nil {
1040+
return nil, errors.New("LNClient not started")
1041+
}
1042+
return api.svc.GetLNClient().ListOnchainTransactions(ctx)
1043+
}
10381044

10391045
func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
10401046
var err error

api/models.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type API interface {
3737
RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (*RedeemOnchainFundsResponse, error)
3838
GetBalances(ctx context.Context) (*BalancesResponse, error)
3939
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
40+
ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error)
4041
SendPayment(ctx context.Context, invoice string, amountMsat *uint64) (*SendPaymentResponse, error)
4142
CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error)
4243
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import dayjs from "dayjs";
2+
import { AlertCircle, ArrowDownIcon, ArrowUpIcon } from "lucide-react";
3+
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
4+
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
5+
import {
6+
Card,
7+
CardContent,
8+
CardHeader,
9+
CardTitle,
10+
} from "src/components/ui/card";
11+
import { Table, TableBody, TableCell, TableRow } from "src/components/ui/table";
12+
import { useOnchainTransactions } from "src/hooks/useOnchainTransactions";
13+
import { cn } from "src/lib/utils";
14+
15+
export function OnchainTransactionsTable() {
16+
const { data: transactions, error, isLoading } = useOnchainTransactions();
17+
18+
if (isLoading) {
19+
return null;
20+
}
21+
22+
if (error) {
23+
return (
24+
<Alert variant="destructive" className="mt-4">
25+
<AlertCircle className="h-4 w-4" />
26+
<AlertTitle>Error loading on-chain transactions</AlertTitle>
27+
<AlertDescription>{error.message}</AlertDescription>
28+
</Alert>
29+
);
30+
}
31+
32+
if (!transactions?.length) {
33+
return null;
34+
}
35+
36+
return (
37+
<Card className="mt-6">
38+
<CardHeader>
39+
<CardTitle className="text-2xl">On-Chain Transactions</CardTitle>
40+
</CardHeader>
41+
<CardContent>
42+
<Table>
43+
<TableBody>
44+
{transactions.map((tx) => {
45+
const Icon = tx.type == "outgoing" ? ArrowUpIcon : ArrowDownIcon;
46+
return (
47+
<TableRow
48+
key={tx.txId}
49+
className="cursor-pointer"
50+
onClick={() => {
51+
window.open(
52+
`https://mempool.space/tx/${tx.txId}`,
53+
"_blank"
54+
);
55+
}}
56+
>
57+
<TableCell className="flex items-center gap-2">
58+
<div
59+
className={cn(
60+
"flex justify-center items-center rounded-full w-10 h-10 relative",
61+
tx.state === "unconfirmed"
62+
? "bg-blue-100 dark:bg-sky-950 animate-pulse"
63+
: tx.type === "outgoing"
64+
? "bg-orange-100 dark:bg-amber-950"
65+
: "bg-green-100 dark:bg-emerald-950"
66+
)}
67+
title={`${tx.numConfirmations} confirmations`}
68+
>
69+
<Icon
70+
strokeWidth={3}
71+
className={cn(
72+
"w-6 h-6",
73+
tx.state === "unconfirmed"
74+
? "stroke-blue-500 dark:stroke-sky-500"
75+
: tx.type === "outgoing"
76+
? "stroke-orange-500 dark:stroke-amber-500"
77+
: "stroke-green-500 dark:stroke-teal-500"
78+
)}
79+
/>
80+
</div>
81+
<div className="md:flex md:gap-2 md:items-center">
82+
<p className="font-semibold text-lg">
83+
{tx.type == "outgoing"
84+
? tx.state === "confirmed"
85+
? "Sent"
86+
: "Sending"
87+
: tx.state === "confirmed"
88+
? "Received"
89+
: "Receiving"}
90+
</p>
91+
<p
92+
className="text-muted-foreground"
93+
title={dayjs(tx.updatedAt * 1000)
94+
.local()
95+
.format("D MMMM YYYY, HH:mm")}
96+
>
97+
{dayjs(tx.updatedAt * 1000)
98+
.local()
99+
.fromNow()}
100+
</p>
101+
</div>
102+
</TableCell>
103+
104+
<TableCell>
105+
<div className="flex flex-col items-end">
106+
<div className="flex flex-row gap-1">
107+
<p
108+
className={cn(
109+
tx.type == "incoming" &&
110+
"text-green-600 dark:text-emerald-500"
111+
)}
112+
>
113+
{tx.type == "outgoing" ? "-" : "+"}
114+
<span className="font-medium">
115+
{new Intl.NumberFormat().format(tx.amountSat)}
116+
</span>
117+
</p>
118+
<p className="text-muted-foreground">
119+
{tx.amountSat == 1 ? "sat" : "sats"}
120+
</p>
121+
</div>
122+
<FormattedFiatAmount
123+
className="text-xs"
124+
amount={tx.amountSat}
125+
/>
126+
</div>
127+
</TableCell>
128+
</TableRow>
129+
);
130+
})}
131+
</TableBody>
132+
</Table>
133+
</CardContent>
134+
</Card>
135+
);
136+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { OnchainTransaction } from "src/types";
2+
import { swrFetcher } from "src/utils/swr";
3+
import useSWR, { SWRConfiguration } from "swr";
4+
5+
const pollConfiguration: SWRConfiguration = {
6+
refreshInterval: 30000,
7+
};
8+
9+
export function useOnchainTransactions() {
10+
return useSWR<OnchainTransaction[]>(
11+
"/api/node/transactions",
12+
swrFetcher,
13+
pollConfiguration
14+
);
15+
}

frontend/src/screens/channels/Channels.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import AppHeader from "src/components/AppHeader.tsx";
1717
import { ChannelsCards } from "src/components/channels/ChannelsCards.tsx";
1818
import { ChannelsTable } from "src/components/channels/ChannelsTable.tsx";
1919
import { HealthCheckAlert } from "src/components/channels/HealthcheckAlert";
20+
import { OnchainTransactionsTable } from "src/components/channels/OnchainTransactionsTable.tsx";
2021
import { SwapDialogs } from "src/components/channels/SwapDialogs";
2122
import EmptyState from "src/components/EmptyState.tsx";
2223
import ExternalLink from "src/components/ExternalLink";
@@ -572,6 +573,7 @@ export default function Channels() {
572573

573574
<ChannelsTable channels={channels} nodes={nodes} />
574575
<ChannelsCards channels={channels} nodes={nodes} />
576+
<OnchainTransactionsTable />
575577
</>
576578
)}
577579
</>

frontend/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,16 @@ export type Boostagram = {
492492
valueMsatTotal: number;
493493
};
494494

495+
export type OnchainTransaction = {
496+
amountSat: number;
497+
createdAt: number;
498+
updatedAt: number;
499+
type: "incoming" | "outgoing";
500+
state: "confirmed" | "unconfirmed";
501+
numConfirmations: number;
502+
txId: string;
503+
};
504+
495505
export type ListTransactionsResponse = {
496506
transactions: Transaction[];
497507
totalCount: number;

http/http_service.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
132132
restrictedApiGroup.GET("/node/status", httpSvc.nodeStatusHandler)
133133
restrictedApiGroup.GET("/node/network-graph", httpSvc.nodeNetworkGraphHandler)
134134
restrictedApiGroup.POST("/node/migrate-storage", httpSvc.migrateNodeStorageHandler)
135+
restrictedApiGroup.GET("/node/transactions", httpSvc.listOnchainTransactionsHandler)
135136
restrictedApiGroup.GET("/peers", httpSvc.listPeers)
136137
restrictedApiGroup.POST("/peers", httpSvc.connectPeerHandler)
137138
restrictedApiGroup.DELETE("/peers/:peerId", httpSvc.disconnectPeerHandler)
@@ -594,6 +595,20 @@ func (httpSvc *HttpService) listTransactionsHandler(c echo.Context) error {
594595
return c.JSON(http.StatusOK, transactions)
595596
}
596597

598+
func (httpSvc *HttpService) listOnchainTransactionsHandler(c echo.Context) error {
599+
ctx := c.Request().Context()
600+
601+
transactions, err := httpSvc.api.ListOnchainTransactions(ctx)
602+
603+
if err != nil {
604+
return c.JSON(http.StatusInternalServerError, ErrorResponse{
605+
Message: err.Error(),
606+
})
607+
}
608+
609+
return c.JSON(http.StatusOK, transactions)
610+
}
611+
597612
func (httpSvc *HttpService) walletSyncHandler(c echo.Context) error {
598613
httpSvc.api.SyncWallet()
599614

lnclient/cashu/cashu.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -551,3 +551,7 @@ func (cs *CashuService) executeCommandResetWallet() (*lnclient.CustomNodeCommand
551551
},
552552
}, nil
553553
}
554+
555+
func (cs *CashuService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
556+
return nil, errors.ErrUnsupported
557+
}

lnclient/ldk/ldk.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"path/filepath"
1313
"slices"
14+
"sort"
1415
"strconv"
1516
"strings"
1617
"sync"
@@ -781,6 +782,53 @@ func (ls *LDKService) ListTransactions(ctx context.Context, from, until, limit,
781782
return transactions, nil*/
782783
}
783784

785+
func (ls *LDKService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
786+
transactions := []lnclient.OnchainTransaction{}
787+
for _, payment := range ls.node.ListPayments() {
788+
onchainPaymentKind, isOnchainPaymentKind := payment.Kind.(ldk_node.PaymentKindOnchain)
789+
if !isOnchainPaymentKind {
790+
continue
791+
}
792+
793+
transactionType := "incoming"
794+
if payment.Direction == ldk_node.PaymentDirectionOutbound {
795+
transactionType = "outgoing"
796+
}
797+
798+
var amountMsat uint64
799+
if payment.AmountMsat != nil {
800+
amountMsat = *payment.AmountMsat
801+
}
802+
var status string
803+
var height uint32
804+
var numConfirmations uint32
805+
switch onchainPaymentStatus := onchainPaymentKind.Status.(type) {
806+
case ldk_node.ConfirmationStatusConfirmed:
807+
status = "confirmed"
808+
height = onchainPaymentStatus.Height
809+
nodeStatus := ls.node.Status()
810+
numConfirmations = nodeStatus.CurrentBestBlock.Height - height
811+
case ldk_node.ConfirmationStatusUnconfirmed:
812+
status = "unconfirmed"
813+
}
814+
815+
transactions = append(transactions, lnclient.OnchainTransaction{
816+
AmountSat: amountMsat / 1000,
817+
CreatedAt: payment.CreatedAt,
818+
UpdatedAt: payment.LatestUpdateTimestamp,
819+
State: status,
820+
Type: transactionType,
821+
NumConfirmations: numConfirmations,
822+
TxId: onchainPaymentKind.Txid,
823+
})
824+
825+
}
826+
sort.SliceStable(transactions, func(i, j int) bool {
827+
return transactions[i].UpdatedAt > transactions[j].UpdatedAt
828+
})
829+
return transactions, nil
830+
}
831+
784832
func (ls *LDKService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, err error) {
785833
// TODO: should alias, color be configured in LDK-node? or can we manage them in NWC?
786834
// an alias is only needed if the user has public channels and wants their node to be publicly visible?

lnclient/lnd/lnd.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1318,3 +1318,7 @@ func (svc *LNDService) GetCustomNodeCommandDefinitions() []lnclient.CustomNodeCo
13181318
func (svc *LNDService) ExecuteCustomNodeCommand(ctx context.Context, command *lnclient.CustomNodeCommandRequest) (*lnclient.CustomNodeCommandResponse, error) {
13191319
return nil, nil
13201320
}
1321+
1322+
func (svc *LNDService) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
1323+
return nil, errors.ErrUnsupported
1324+
}

0 commit comments

Comments
 (0)