mirror of
https://github.com/getAlby/hub.git
synced 2026-08-13 12:33:39 +02:00
feat: add on-chain transactions to node page
This commit is contained in:
parent
b0b92db7eb
commit
153da0ee12
14 changed files with 267 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
136
frontend/src/components/channels/OnchainTransactionsTable.tsx
Normal file
136
frontend/src/components/channels/OnchainTransactionsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
frontend/src/hooks/useOnchainTransactions.ts
Normal file
15
frontend/src/hooks/useOnchainTransactions.ts
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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 />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue