Merge branch 'master' into feat/auto-swaps

This commit is contained in:
im-adithya 2025-05-06 12:48:26 +05:30
commit 6caa2aa7d0
35 changed files with 1096 additions and 568 deletions

View file

@ -1113,6 +1113,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

@ -39,7 +39,13 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64,
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
transactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, api.svc.GetLNClient(), appId, true)
forceFilterByAppId := false
if appId != nil {
forceFilterByAppId = true
}
transactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, api.svc.GetLNClient(), appId, forceFilterByAppId)
if err != nil {
return nil, err
}

View file

@ -0,0 +1,44 @@
package migrations
import (
_ "embed"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
var _202504231037_add_indexes = &gormigrate.Migration{
ID: "202504231037_add_indexes",
Migrate: func(db *gorm.DB) error {
if err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`
DROP INDEX IF EXISTS idx_transactions_request_event_id;
DROP INDEX IF EXISTS idx_transactions_created_at;
DROP INDEX IF EXISTS idx_transactions_settled_at;
DROP INDEX IF EXISTS idx_transactions_app_id_type_state_created_at_settled_at_payment_hash;
`).Error; err != nil {
return err
}
if err := tx.Exec(`
CREATE INDEX idx_transactions_state_type ON transactions(state, type);
CREATE INDEX idx_transactions_state_type_updated_at ON transactions(state, type, updated_at);
CREATE INDEX idx_transactions_app_id_state_type_updated_at ON transactions(app_id, state, type, updated_at);
CREATE INDEX idx_transactions_payment_hash_settled_at_created_at ON transactions(payment_hash, settled_at, created_at);
`).Error; err != nil {
return err
}
return nil
}); err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

View file

@ -28,6 +28,7 @@ func Migrate(gormDB *gorm.DB) error {
_202408291715_app_metadata,
_202410141503_add_wallet_pubkey,
_202412212345_fix_types,
_202504231037_add_indexes,
})
return m.Migrate()

View file

@ -6,7 +6,8 @@
app = 'nwc'
primary_region = 'lax'
swap_size_mb = 2048
kill_timeout = 120
# Add a kill timeout 30s longer than LDK node shutdown timeout
kill_timeout = 330
[build]
image = 'ghcr.io/getalby/hub:latest'

View file

@ -1,5 +1,11 @@
import { ListTodoIcon, LucideIcon, ZapIcon } from "lucide-react";
import { ReactElement } from "react";
import {
HeartIcon,
ListTodoIcon,
LucideIcon,
XIcon,
ZapIcon,
} from "lucide-react";
import React, { ReactElement } from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "src/components/ui/button";
import {
@ -9,13 +15,24 @@ import {
CardTitle,
} from "src/components/ui/card";
import { Progress } from "src/components/ui/progress";
import { useToast } from "src/components/ui/use-toast";
import { localStorageKeys, SUPPORT_ALBY_CONNECTION_NAME } from "src/constants";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApps } from "src/hooks/useApps";
import { useOnboardingData } from "src/hooks/useOnboardingData";
import useChannelOrderStore from "src/state/ChannelOrderStore";
function SidebarHint() {
const { isLoading, checklistItems } = useOnboardingData();
const { data: apps } = useApps();
const { data: albyMe } = useAlbyMe();
const { order } = useChannelOrderStore();
const location = useLocation();
const { toast } = useToast();
const [hiddenUntil, setHiddenUntil] = React.useState(
localStorage.getItem(localStorageKeys.supportAlbySidebarHintHiddenUntil)
);
// User has a channel order
if (
@ -67,6 +84,42 @@ function SidebarHint() {
/>
);
}
const showSupport =
apps &&
apps.filter((x) => x.name == SUPPORT_ALBY_CONNECTION_NAME).length === 0 &&
!albyMe?.subscription.plan_code;
if (
!location.pathname.startsWith("/support-alby") &&
showSupport &&
(!hiddenUntil || new Date() >= new Date(hiddenUntil))
) {
return (
<SidebarHintCard
onClose={() => {
// Set the date to the next 21st of the month
const now = new Date();
const next21st = new Date(
now.getFullYear(),
now.getMonth() + (now.getDate() >= 21 ? 1 : 0),
21
).toString();
localStorage.setItem(
localStorageKeys.supportAlbySidebarHintHiddenUntil,
next21st
);
setHiddenUntil(next21st);
toast({ title: "No worries, we'll remind you again!" });
}}
icon={HeartIcon}
title="Support Alby Hub"
description="See how you can support the development of Alby Hub"
buttonText="Become a Supporter"
buttonLink="/support-alby"
/>
);
}
}
type SidebarHintCardProps = {
@ -75,6 +128,7 @@ type SidebarHintCardProps = {
buttonText: string;
buttonLink: string;
icon: LucideIcon;
onClose?: () => void;
};
function SidebarHintCard({
title,
@ -82,12 +136,21 @@ function SidebarHintCard({
icon: Icon,
buttonText,
buttonLink,
onClose,
}: SidebarHintCardProps) {
return (
<Card>
<CardHeader className="p-4">
<Icon className="h-8 w-8 mb-4" />
<CardTitle>{title}</CardTitle>
{onClose && (
<button
className="absolute top-4 right-4 text-muted-foreground hover:text-primary"
onClick={onClose}
>
<XIcon name="X" />
</button>
)}
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="text-muted-foreground mb-4 text-sm">{description}</div>

View file

@ -35,11 +35,20 @@ type Props = {
tx: Transaction;
};
function safeNpubEncode(hex: string): string | undefined {
try {
return nip19.npubEncode(hex);
} catch {
return undefined;
}
}
function TransactionItem({ tx }: Props) {
const { data: apps } = useApps();
const { toast } = useToast();
const [showDetails, setShowDetails] = React.useState(false);
const type = tx.type;
const typeStateText =
type == "incoming"
? "Received"
@ -48,17 +57,31 @@ function TransactionItem({ tx }: Props) {
: tx.state === "pending"
? "Sending"
: "Failed";
const Icon =
tx.state === "failed"
? XIcon
: tx.type == "outgoing"
? ArrowUpIcon
: ArrowDownIcon;
const app =
tx.appId !== undefined
? apps?.find((app) => app.id === tx.appId)
const app = React.useMemo(
() =>
tx.appId != null ? apps?.find((app) => app.id === tx.appId) : undefined,
[apps, tx.appId]
);
const pubkey = tx.metadata?.nostr?.pubkey;
const npub = pubkey ? safeNpubEncode(pubkey) : undefined;
const from = tx.metadata?.payer_data?.name
? `from ${tx.metadata.payer_data.name}`
: npub
? `zap from ${npub.substring(0, 12)}...`
: undefined;
const eventId = tx.metadata?.nostr?.tags?.find((t) => t[0] === "e")?.[1];
const copy = (text: string) => {
copyToClipboard(text, toast);
};
@ -105,17 +128,6 @@ function TransactionItem({ tx }: Props) {
</div>
);
let from;
if (tx.metadata?.payer_data?.name) {
from = "from " + tx.metadata.payer_data.name;
} else if (tx.metadata?.nostr) {
const npub = nip19.npubEncode(tx.metadata.nostr.pubkey);
from = "zap from " + npub.substring(0, 12) + "...";
}
const eventId = tx.metadata?.nostr?.tags.find((t) => t[0] === "e")?.[1];
return (
<Dialog
onOpenChange={(open) => {
@ -144,7 +156,7 @@ function TransactionItem({ tx }: Props) {
</span>
</p>
</div>
<p className="text-sm md:text-base text-muted-foreground break-all w-full truncate">
<p className="text-sm md:text-base text-muted-foreground break-all line-clamp-1">
{tx.description}
</p>
</div>
@ -231,7 +243,7 @@ function TransactionItem({ tx }: Props) {
</p>
</div>
)}
{tx.metadata?.nostr && eventId && (
{tx.metadata?.nostr && eventId && npub && (
<div className="mt-6">
<p>
<ExternalLink
@ -243,7 +255,7 @@ function TransactionItem({ tx }: Props) {
Nostr Zap
</ExternalLink>{" "}
<span className="text-muted-foreground break-all">
from {nip19.npubEncode(tx.metadata.nostr.pubkey)}
from {npub}
</span>
</p>
</div>

View file

@ -8,7 +8,7 @@ function UserAvatar({ className }: { className?: string }) {
return (
<Avatar className={cn("h-8 w-8 rounded-lg", className)}>
<AvatarImage src={albyMe?.avatar} alt="Avatar" />
<AvatarFallback className="rounded-lg">
<AvatarFallback className="font-medium rounded-lg">
{(albyMe?.name || albyMe?.email || "SN").substring(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>

View file

@ -0,0 +1,121 @@
import dayjs from "dayjs";
import { ArrowDownIcon, ArrowUpIcon } from "lucide-react";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
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 } = useOnchainTransactions();
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.createdAt * 1000)
.local()
.format("D MMMM YYYY, HH:mm")}
>
{dayjs(tx.createdAt * 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

@ -1,7 +1,5 @@
import { ArrowDownIcon } from "lucide-react";
import { Outlet } from "react-router-dom";
import AppHeader from "src/components/AppHeader";
import BalanceCard from "src/components/BalanceCard";
import Loading from "src/components/Loading";
import { useBalances } from "src/hooks/useBalances";
import { useChannels } from "src/hooks/useChannels";
@ -19,22 +17,23 @@ export default function ReceiveLayout() {
return (
<div className="grid gap-5">
<AppHeader title="Receive" />
<div className="flex gap-12 w-full">
<div className="w-full max-w-lg">
<Outlet />
</div>
{hasChannelManagement && (
<BalanceCard
balance={balances.lightning.totalReceivable}
title="Receiving Capacity"
buttonTitle="Increase"
buttonLink="/channels/incoming"
BalanceCardIcon={ArrowDownIcon}
hasChannelManagement
/>
)}
</div>
<AppHeader
title="Receive"
contentRight={
hasChannelManagement && (
<div className="flex items-center gap-4">
<span className="text-muted-foreground">Receive Limit:</span>
<div className="balance sensitive slashed-zero">
{new Intl.NumberFormat().format(
Math.floor(balances.lightning.totalReceivable / 1000)
)}{" "}
sats
</div>
</div>
)
}
/>
<Outlet />
</div>
);
}

View file

@ -3,6 +3,7 @@ export const localStorageKeys = {
setupReturnTo: "setupReturnTo",
channelOrder: "channelOrder",
authToken: "authToken",
supportAlbySidebarHintHiddenUntil: "supportAlbySidebarHintHiddenUntil",
};
export const ONCHAIN_DUST_SATS = 1000;

View file

@ -1,6 +1,5 @@
// src/hooks/useOnboardingData.ts
import { SUPPORT_ALBY_CONNECTION_NAME } from "src/constants";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useApps } from "src/hooks/useApps";
import { useChannels } from "src/hooks/useChannels";
@ -55,12 +54,6 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
const hasCustomApp =
apps && apps.find((x) => x.name !== "getalby.com") !== undefined;
const hasTransaction = transactions.totalCount > 0;
const hasSetupSupportPayment = !!(
(apps &&
apps.find((x) => x.name === SUPPORT_ALBY_CONNECTION_NAME) !==
undefined) ||
albyMe?.subscription.plan_code
);
const checklistItems: Omit<ChecklistItem, "disabled">[] = [
...(hasChannelManagement
@ -110,17 +103,6 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
},
]
: []),
...(!info.oauthRedirect
? [
{
title: "Support Alby",
description:
"Set up a monthly contribution to help us build the future of digital payments.",
checked: hasSetupSupportPayment,
to: "/support-alby",
},
]
: []),
];
const nextStep = checklistItems.find((x) => !x.checked);

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

@ -1,4 +1,4 @@
import { ExternalLinkIcon } from "lucide-react";
import { ExternalLinkIcon, HeartIcon } from "lucide-react";
import { Link } from "react-router-dom";
import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
@ -10,6 +10,7 @@ import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "src/components/ui/card";
@ -28,7 +29,6 @@ import { LightningMessageboardWidget } from "src/components/home/widgets/Lightni
import { NodeStatusWidget } from "src/components/home/widgets/NodeStatusWidget";
import { OnchainFeesWidget } from "src/components/home/widgets/OnchainFeesWidget";
import { WhatsNewWidget } from "src/components/home/widgets/WhatsNewWidget";
import { UpgradeDialog } from "src/components/UpgradeDialog";
function getGreeting(name: string | undefined) {
const hours = new Date().getHours();
@ -60,19 +60,29 @@ function Home() {
return (
<>
<AppHeader
title={getGreeting(albyMe?.name)}
contentRight={
<UpgradeDialog>
<Button variant="premium">Upgrade</Button>
</UpgradeDialog>
}
/>
<AppHeader title={getGreeting(albyMe?.name)} />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5 items-start justify-start">
{/* LEFT */}
<div className="grid gap-5">
<OnboardingChecklist />
<WhatsNewWidget />
<Card>
<CardHeader>
<CardTitle>Support Alby</CardTitle>
<CardDescription>
Upgrade to Pro or setup a recurring payment to support the
development of Alby Hub, Alby Go and the NWC ecosystem.
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-end">
<Link to="/support-alby">
<Button variant="outline">
<HeartIcon className="w-4 h-4 mr-2" />
Become a Supporter
</Button>
</Link>
</CardFooter>
</Card>
{info.albyAccountConnected && (
<ExternalLink to="https://www.getalby.com/dashboard">
<Card>

View file

@ -1,14 +1,15 @@
import {
CodeIcon,
HandCoins,
PlusCircleIcon,
RefreshCwIcon,
SparklesIcon,
Sparkles,
} from "lucide-react";
import React from "react";
import { useNavigate } from "react-router-dom";
import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
import { Badge } from "src/components/ui/badge";
import { Button, LinkButton } from "src/components/ui/button";
import { Button } from "src/components/ui/button";
import {
Card,
CardContent,
@ -155,208 +156,187 @@ function SupportAlby() {
return (
<>
<div className="h-full w-full max-w-screen-md mx-auto flex flex-col justify-center">
<div className="flex flex-col items-center justify-center gap-6">
<section className="text-center">
<h2 className="text-3xl font-semibold mb-4 max-sm:mt-8">
Your Support Matters
</h2>
<p className="text-muted-foreground text-balance">
We are committed to elevating the Bitcoin ecosystem by offering
reliable, efficient, and user-friendly software solutions for
seamless transactions. With your help, we can keep pushing
boundaries and evolving Alby Hub into something even more
extraordinary.
</p>
</section>
<Card className="w-full">
<CardHeader>
<CardTitle>Why Your Contribution Is Important</CardTitle>
</CardHeader>
<CardContent>
<ul className="flex flex-col gap-5 text-sm">
<li className="flex flex-col">
<div className="flex flex-row items-center">
<PlusCircleIcon className="w-4 h-4 mr-2" />
Unlock New Features
</div>
<div className="text-muted-foreground text-xs">
Your support empowers us to design and implement
cutting-edge{" "}
<ExternalLink
className="underline"
to="https://github.com/getAlby/hub/issues"
>
features
</ExternalLink>{" "}
that enhance your experience and keep us at the forefront of
technology.
</div>
</li>
<li className="flex flex-col ">
<div className="flex flex-row items-center">
<RefreshCwIcon className="w-4 h-4 mr-2" />
Ensure Continuous Improvement
</div>
<div className="text-muted-foreground text-xs">
With your contributions, we can provide{" "}
<ExternalLink
className="underline"
to="https://github.com/getAlby/hub/releases"
>
regular updates
</ExternalLink>{" "}
and ongoing maintenance, ensuring everything runs smoothly
and efficiently for all users.
</div>
</li>
<li className="flex flex-col ">
<div className="flex flex-row items-center">
<CodeIcon className="w-4 h-4 mr-2" />
Support Open-Source Freedom
</div>
<div className="text-muted-foreground text-xs">
Your support helps us keep Alby Hub true to the principles
of{" "}
<ExternalLink
className="underline"
to="https://github.com/getAlby/hub/blob/master/LICENSE"
>
free and open-source software
</ExternalLink>{" "}
and remains accessible for everyone to use, modify and
improve.
</div>
</li>
</ul>
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-3 w-full">
<Card className="w-full">
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex gap-2">
<SparklesIcon className="h-4 w-4" />
<div>Unlock Pro</div>
</div>
<Badge variant="outline">ACCOUNT REQUIRED</Badge>
</CardTitle>
<CardDescription>
Upgrade your Alby Account to Pro, support Alby and enjoy
additional perks.
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-center">
<UpgradeDialog>
<Button variant="premium">Unlock Pro</Button>
</UpgradeDialog>
</CardFooter>
</Card>
<Card className="w-full">
<CardHeader>
<CardTitle>Become a Supporter</CardTitle>
<CardDescription>
Support Alby with recurring #value4value payments.
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-center">
<Dialog open={open} onOpenChange={setOpen}>
<div className="flex flex-col items-center justify-center gap-2">
<DialogTrigger asChild>
<Button>Become a Supporter</Button>
</DialogTrigger>
</div>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Become a Supporter</DialogTitle>
<DialogDescription>
A new app connection will be established to facilitate
monthly payments to Alby. You can cancel it anytime
through the connections page.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 my-5">
<div className="grid grid-cols-4 gap-4">
<Label htmlFor="amount" className="text-right mt-2">
Amount <br></br>
<span className="font-normal text-muted-foreground">
(sats / month)
</span>
</Label>
<div className="col-span-3">
<Input
id="amount"
value={amount}
required
onChange={(e) => setAmount(e.target.value)}
/>
<div className="grid grid-cols-3 gap-1 mt-1">
<Button
type="button"
variant="outline"
onClick={() => setAmount("3000")}
>
🙏 3000
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAmount("6000")}
>
💪 6000
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAmount("10000")}
>
10000
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="comment" className="text-right">
Name{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</Label>
<Input
id="sender-name"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
placeholder={`Nickname, npub, @twitter, etc.`}
className="col-span-3"
/>
<AppHeader
title="Support Alby Hub"
description="We are committed to elevating the Bitcoin ecosystem by offering reliable, efficient, and user-friendly software solutions for seamless transactions. With your help, we can keep pushing boundaries and evolving Alby Hub into something extraordinary."
/>
<h2 className="text-2xl font-semibold">Become a Supporter</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card className="flex flex-col">
<CardHeader className="grow">
<CardTitle>Upgrade to Pro</CardTitle>
<CardDescription>
Upgrade your Alby Account to Pro for a small fee and enjoy
additional perks that come with it!
</CardDescription>
</CardHeader>
<CardFooter className="flex justify-end">
<UpgradeDialog>
<Button>
<Sparkles className="w-4 h-4 mr-2" />
Upgrade to Pro
</Button>
</UpgradeDialog>
</CardFooter>
</Card>
<Card className="flex flex-col">
<CardHeader className="grow">
<CardTitle>Donate to Alby Hub development</CardTitle>
<CardDescription>
Set up a recurring value4value payment to support the development
of Alby Hub, Alby Go, and the NWC ecosystem.
</CardDescription>
</CardHeader>
<CardContent className="flex-1 grow" />
<CardFooter className="flex justify-end">
<Dialog open={open} onOpenChange={setOpen}>
<div className="flex flex-col items-center justify-center gap-2">
<DialogTrigger asChild>
<Button>
<HandCoins className="w-4 h-4 mr-2" />
Setup Donation
</Button>
</DialogTrigger>
</div>
<DialogContent>
<form onSubmit={handleSubmit}>
<DialogHeader>
<DialogTitle>Become a Supporter</DialogTitle>
<DialogDescription>
A new app connection will be established to facilitate
monthly payments to Alby. You can cancel it anytime
through the connections page.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 my-5">
<div className="grid grid-cols-4 gap-4">
<Label htmlFor="amount" className="text-right mt-2">
Amount <br></br>
<span className="font-normal text-muted-foreground">
(sats / month)
</span>
</Label>
<div className="col-span-3">
<Input
id="amount"
value={amount}
required
onChange={(e) => setAmount(e.target.value)}
/>
<div className="grid grid-cols-3 gap-1 mt-1">
<Button
type="button"
variant="outline"
onClick={() => setAmount("3000")}
>
🙏 3000
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAmount("6000")}
>
💪 6000
</Button>
<Button
type="button"
variant="outline"
onClick={() => setAmount("10000")}
>
10000
</Button>
</div>
</div>
</div>
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="comment" className="text-right">
Name{" "}
<span className="font-normal text-muted-foreground">
(optional)
</span>
</Label>
<div className="col-span-3">
<Input
id="sender-name"
value={senderName}
onChange={(e) => setSenderName(e.target.value)}
placeholder={`Nickname, npub, @twitter, etc.`}
/>
</div>
</div>
</div>
<DialogFooter>
<LoadingButton
type="submit"
disabled={!!isSubmitting}
loading={isSubmitting}
>
Complete Setup
</LoadingButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
</div>
<LinkButton
size="sm"
variant="link"
to="/"
className="text-muted-foreground"
>
Maybe later
</LinkButton>
</div>
<DialogFooter>
<LoadingButton
type="submit"
disabled={!!isSubmitting}
loading={isSubmitting}
>
Complete Setup
</LoadingButton>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</CardFooter>
</Card>
</div>
<div className="mt-4">
<h2 className="text-2xl font-semibold mb-4">
Why Your Contribution Is Important
</h2>
<ul className="flex flex-col gap-5">
<li className="flex flex-col">
<div className="flex flex-row items-center">
<PlusCircleIcon className="w-4 h-4 mr-2" />
Unlock New Features
</div>
<div className="text-muted-foreground text-sm">
Your support empowers us to design and implement cutting-edge{" "}
<ExternalLink
className="underline"
to="https://github.com/getAlby/hub/issues"
>
features
</ExternalLink>{" "}
that enhance your experience and keep us at the forefront of
technology.
</div>
</li>
<li className="flex flex-col ">
<div className="flex flex-row items-center">
<RefreshCwIcon className="w-4 h-4 mr-2" />
Ensure Continuous Improvement
</div>
<div className="text-muted-foreground text-sm">
With your contributions, we can provide{" "}
<ExternalLink
className="underline"
to="https://github.com/getAlby/hub/releases"
>
regular updates
</ExternalLink>{" "}
and ongoing maintenance, ensuring everything runs smoothly and
efficiently for all users.
</div>
</li>
<li className="flex flex-col ">
<div className="flex flex-row items-center">
<CodeIcon className="w-4 h-4 mr-2" />
Support Open-Source Freedom
</div>
<div className="text-muted-foreground text-sm">
Your support helps us keep Alby Hub true to the principles of{" "}
<ExternalLink
className="underline"
to="https://github.com/getAlby/hub/blob/master/LICENSE"
>
free and open-source software
</ExternalLink>{" "}
and remains accessible for everyone to use, modify and improve.
</div>
</li>
</ul>
</div>
</>
);

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

@ -1,4 +1,4 @@
import { CopyIcon, EditIcon } from "lucide-react";
import { CopyIcon, PencilIcon } from "lucide-react";
import React from "react";
import { useNavigate } from "react-router-dom";
import Loading from "src/components/Loading";
@ -35,34 +35,38 @@ export default function Receive() {
}
return (
<div className="grid gap-5">
{info?.albyAccountConnected && me?.lightning_address && (
<div className="flex flex-col items-center justify-center gap-6 w-full sm:w-64">
<div className="relative flex flex-col items-center justify-center w-full">
<QRCode value={me.lightning_address} className="w-full h-auto" />
<UserAvatar className="w-14 h-auto absolute border-4 border-white" />
<div className="w-full max-w-lg">
<div className="grid gap-5">
{info?.albyAccountConnected && me?.lightning_address && (
<div className="flex flex-col items-center justify-center gap-6 border rounded-xl w-full md:max-w-xs p-4 md:p-6">
<div className="relative flex flex-col items-center justify-center">
<QRCode value={me.lightning_address} className="w-full h-auto" />
<UserAvatar className="w-14 h-14 absolute border-4 border-white bg-white" />
</div>
<p className="text-center font-semibold break-all">
{me.lightning_address}
</p>
<div className="flex gap-4 w-full">
<LinkButton
to="invoice"
variant="outline"
className="flex-1 flex gap-2 items-center justify-center"
>
<PencilIcon className="w-4 h-4" /> Amount
</LinkButton>
<Button
variant="secondary"
onClick={() => {
copyToClipboard(me.lightning_address, toast);
}}
className="flex-1 flex gap-2 items-center justify-center"
>
<CopyIcon className="w-4 h-4" /> Copy
</Button>
</div>
</div>
<p className="font-semibold break-all">{me.lightning_address}</p>
<div className="flex gap-4 w-full">
<LinkButton
to="invoice"
variant="outline"
className="flex-1 flex gap-2 items-center justify-center"
>
<EditIcon className="w-4 h-4" /> Amount
</LinkButton>
<Button
variant="secondary"
onClick={() => {
copyToClipboard(me.lightning_address, toast);
}}
className="flex-1 flex gap-2 items-center justify-center"
>
<CopyIcon className="w-4 h-4" /> Copy
</Button>
</div>
</div>
)}
)}
</div>
</div>
);
}

View file

@ -1,6 +1,7 @@
import { AlertTriangleIcon, CircleCheckIcon, CopyIcon } from "lucide-react";
import React from "react";
import { Link } from "react-router-dom";
import ExternalLink from "src/components/ExternalLink";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import Loading from "src/components/Loading";
import QRCode from "src/components/QRCode";
@ -13,6 +14,7 @@ import { Button } from "src/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
CardTitle,
} from "src/components/ui/card";
@ -20,6 +22,7 @@ import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
import { LoadingButton } from "src/components/ui/loading-button";
import { useToast } from "src/components/ui/use-toast";
import { useAlbyMe } from "src/hooks/useAlbyMe";
import { useBalances } from "src/hooks/useBalances";
import { useInfo } from "src/hooks/useInfo";
@ -29,7 +32,8 @@ import { CreateInvoiceRequest, Transaction } from "src/types";
import { request } from "src/utils/request";
export default function ReceiveInvoice() {
const { hasChannelManagement } = useInfo();
const { data: info, hasChannelManagement } = useInfo();
const { data: me } = useAlbyMe();
const { data: balances } = useBalances();
const { toast } = useToast();
@ -51,7 +55,7 @@ export default function ReceiveInvoice() {
}
}, [invoiceData, toast]);
if (!balances) {
if (!balances || !info || (info.albyAccountConnected && !me)) {
return <Loading />;
}
@ -94,136 +98,157 @@ export default function ReceiveInvoice() {
};
return (
<div className="grid gap-5">
{hasChannelManagement &&
parseInt(amount || "0") * 1000 >=
0.8 * balances.lightning.totalReceivable && (
<Alert>
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Low receiving capacity</AlertTitle>
<AlertDescription>
You likely won't be able to receive payments until you{" "}
<Link className="underline" to="/channels/incoming">
increase your receiving capacity.
</Link>
</AlertDescription>
</Alert>
)}
<div className="flex gap-12 w-full">
<div className="w-full max-w-lg">
{transaction ? (
<>
<Card className="w-full">
<CardHeader>
<CardTitle className="text-center">
{paymentDone ? "Payment Received" : "Invoice"}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">
{paymentDone ? (
<>
<CircleCheckIcon className="w-32 h-32 mb-1" />
<div className="flex flex-col gap-2 items-center">
<p>
Received{" "}
{Math.floor((invoiceData?.amount ?? 0) / 1000)} sats
</p>
<FormattedFiatAmount
amount={Math.floor((invoiceData?.amount ?? 0) / 1000)}
/>
</div>
</>
) : (
<>
<div className="flex flex-col gap-2 items-center">
<p className="text-xl slashed-zero">
{new Intl.NumberFormat().format(parseInt(amount))}{" "}
sats
</p>
<FormattedFiatAmount amount={parseInt(amount)} />
</div>
<div className="flex flex-row items-center gap-2 text-sm">
<Loading className="w-4 h-4" />
<p>Waiting for payment</p>
</div>
<QRCode value={transaction.invoice} className="w-full" />
<div>
<Button onClick={copy} variant="outline">
<CopyIcon className="w-4 h-4 mr-2" />
Copy Invoice
</Button>
</div>
</>
)}
</CardContent>
</Card>
{paymentDone && (
<>
<Button
className="mt-4 w-full"
onClick={() => {
setPaymentDone(false);
setTransaction(null);
}}
>
Receive Another Payment
</Button>
<Link to="/wallet">
<Button
className="mt-4 w-full"
onClick={() => {
setPaymentDone(false);
}}
variant="secondary"
>
Back To Wallet
</Button>
<div className="flex flex-col md:flex-row gap-6">
<div className="w-full md:max-w-xl">
<div className="grid gap-5">
{hasChannelManagement &&
parseInt(amount || "0") * 1000 >=
0.8 * balances.lightning.totalReceivable && (
<Alert>
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Low receiving capacity</AlertTitle>
<AlertDescription>
You likely won't be able to receive payments until you{" "}
<Link className="underline" to="/channels/incoming">
increase your receiving capacity.
</Link>
</>
)}
</>
) : (
<form onSubmit={handleSubmit} className="grid gap-5">
<div>
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
value={amount?.toString()}
placeholder="Amount in Satoshi..."
onChange={(e) => {
setAmount(e.target.value.trim());
}}
min={1}
autoFocus
/>
<FormattedFiatAmount amount={+amount} className="mt-2" />
</AlertDescription>
</Alert>
)}
<div>
{transaction ? (
<div className="flex flex-col items-center justify-center gap-6 border rounded-xl w-full md:max-w-xs p-4 md:p-6">
{!paymentDone ? (
<>
<div className="flex flex-row items-center gap-2 font-medium">
<Loading className="w-4 h-4" />
<p>Waiting for payment</p>
</div>
<div className="relative flex flex-col items-center justify-center">
<QRCode value={transaction.invoice} className="w-full" />
</div>
<div className="flex flex-col gap-2 items-center">
<p className="text-xl font-semibold slashed-zero">
{new Intl.NumberFormat().format(parseInt(amount))} sats
</p>
<FormattedFiatAmount amount={parseInt(amount)} />
</div>
<div>
<Button onClick={copy} variant="outline">
<CopyIcon className="w-4 h-4 mr-2" />
Copy Invoice
</Button>
</div>
</>
) : (
<>
<div className="text-center font-medium">
Payment Received!
</div>
<div className="relative flex flex-col items-center justify-center">
<CircleCheckIcon className="w-64 h-64 mb-1" />
</div>
<div className="flex flex-col gap-2 items-center">
<p className="text-xl font-semibold slashed-zero">
{new Intl.NumberFormat().format(parseInt(amount))} sats
</p>
<FormattedFiatAmount amount={parseInt(amount)} />
</div>
<div>
<Button
variant="outline"
onClick={() => {
setPaymentDone(false);
setTransaction(null);
}}
>
Receive Another Payment
</Button>
</div>
</>
)}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
placeholder="For e.g. who is sending this payment?"
onChange={(e) => {
setDescription(e.target.value);
}}
/>
</div>
<div>
<LoadingButton
loading={isLoading}
type="submit"
disabled={!amount}
>
Create Invoice
</LoadingButton>
</div>
</form>
)}
) : (
<form onSubmit={handleSubmit} className="grid gap-5">
<div>
<Label htmlFor="amount">Amount</Label>
<Input
id="amount"
type="number"
value={amount?.toString()}
placeholder="Amount in Satoshi..."
onChange={(e) => {
setAmount(e.target.value.trim());
}}
min={1}
autoFocus
/>
<FormattedFiatAmount amount={+amount} className="mt-2" />
</div>
<div>
<Label htmlFor="description">Description</Label>
<Input
id="description"
type="text"
value={description}
placeholder="For e.g. who is sending this payment?"
onChange={(e) => {
setDescription(e.target.value);
}}
/>
</div>
<div>
<LoadingButton
className="w-full md:w-auto"
loading={isLoading}
type="submit"
disabled={!amount}
>
Create Invoice
</LoadingButton>
</div>
</form>
)}
</div>
</div>
</div>
{!transaction &&
(!info?.albyAccountConnected || !me?.lightning_address) && (
<LightningAddressCard />
)}
</div>
);
}
function LightningAddressCard() {
return (
<Card className="w-full self-start">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="font-semibold text-lg">
Get Your Free Lightning Address
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 text-muted-foreground">
<p>
Create free Alby Account and link it with your Alby Hub to get a
convenient <span className="text-foreground">@getalby.com</span>{" "}
lightning address and other perks:
</p>
<ul className="flex flex-col gap-1">
<li> Lightning address & Nostr identifier,</li>
<li> Personal tipping page,</li>
<li> Access to podcasting 2.0 apps,</li>
<li> Buy bitcoin directly to your wallet,</li>
<li> Useful email Alby Hub notifications.</li>
</ul>
</div>
</CardContent>
<CardFooter className="flex justify-end">
<ExternalLink to="https://getalby.com/auth/users/new">
<Button variant="secondary">Create Alby Account</Button>
</ExternalLink>
</CardFooter>
</Card>
);
}

View file

@ -83,7 +83,7 @@ export default function ConfirmPayment() {
<FormattedFiatAmount amount={amount || invoice.satoshi} />
</div>
{invoice.description && (
<div className="mt-2">
<div className="mt-2 break-all">
<Label>Description</Label>
<p className="text-muted-foreground">{invoice.description}</p>
</div>

View file

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

2
go.mod
View file

@ -5,7 +5,7 @@ go 1.24.2
require (
github.com/adrg/xdg v0.5.3
github.com/elnosh/gonuts v0.4.0
github.com/getAlby/ldk-node-go v0.0.0-20250409032721-a0b2d497fc2c
github.com/getAlby/ldk-node-go v0.0.0-20250503035148-4f935f853d83
github.com/go-gormigrate/gormigrate/v2 v2.1.4
github.com/labstack/echo/v4 v4.13.3
github.com/nbd-wtf/go-nostr v0.51.10

4
go.sum
View file

@ -221,8 +221,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/getAlby/ldk-node-go v0.0.0-20250409032721-a0b2d497fc2c h1:16rFwWZ9W3Ru0nUUgcZoyRxkykJjZx0okhTXscLhRRk=
github.com/getAlby/ldk-node-go v0.0.0-20250409032721-a0b2d497fc2c/go.mod h1:8BRjtKcz8E0RyYTPEbMS8VIdgredcGSLne8vHDtcRLg=
github.com/getAlby/ldk-node-go v0.0.0-20250503035148-4f935f853d83 h1:eOkG4g/8IFSK7zyNDr9X10yxBsqCgVfU2WDd70RLfBw=
github.com/getAlby/ldk-node-go v0.0.0-20250503035148-4f935f853d83/go.mod h1:8BRjtKcz8E0RyYTPEbMS8VIdgredcGSLne8vHDtcRLg=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=

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)
@ -597,6 +598,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

@ -32,7 +32,6 @@ import (
"github.com/getAlby/hub/lsp"
"github.com/getAlby/hub/service/keys"
"github.com/getAlby/hub/transactions"
"github.com/getAlby/hub/utils"
)
type LDKService struct {
@ -69,8 +68,6 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
return nil, err
}
logDirPath := filepath.Join(newpath, "./logs")
ldkConfig := ldk_node.DefaultConfig()
listeningAddresses := strings.Split(cfg.GetEnv().LDKListeningAddresses, ",")
@ -111,15 +108,13 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
}
ldkConfig.ListeningAddresses = &listeningAddresses
ldkConfig.LogDirPath = &logDirPath
logLevel, err := strconv.Atoi(cfg.GetEnv().LDKLogLevel)
if err == nil {
// LogLevelGossip is added due to bug in go bindings which uses an enum that starts at 1 instead of 0
// If LogLevelGossip is changed to 0, this addition can be removed
ldkConfig.LogLevel = ldk_node.LogLevel(logLevel) + ldk_node.LogLevelGossip
}
logLevel, _ := strconv.Atoi(cfg.GetEnv().LDKLogLevel)
// LogLevelGossip is added due to bug in go bindings which uses an enum that starts at 1 instead of 0
ldkLogger := NewLDKLogger(ldk_node.LogLevel(logLevel) + ldk_node.LogLevelGossip)
ldkConfig.TransientNetworkGraph = cfg.GetEnv().LDKTransientNetworkGraph
builder := ldk_node.BuilderFromConfig(ldkConfig)
builder.SetCustomLogger(ldkLogger)
builder.SetNodeAlias("Alby Hub") // TODO: allow users to customize
builder.SetEntropyBip39Mnemonic(mnemonic, nil)
builder.SetNetwork(network)
@ -130,11 +125,6 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
}
builder.SetStorageDirPath(filepath.Join(newpath, "./storage"))
// TODO: remove when https://github.com/lightningdevkit/rust-lightning/issues/2914 is merged
// LDK default HTLC inflight value is 10% of the channel size. If an LSPS service is configured this will be set to 0.
// The liquidity source below is not used because we do not use the native LDK-node LSPS2 API.
builder.SetLiquiditySourceLsps2("52.88.33.119:9735", lsp.OlympusLSP().Pubkey, nil)
migrateStorage, _ := cfg.Get("LdkMigrateStorage", "")
if migrateStorage == "VSS" {
err = cfg.SetUpdate("LdkMigrateStorage", "", "")
@ -192,20 +182,9 @@ func NewLDKService(ctx context.Context, cfg config.Config, eventPublisher events
eventPublisher.RegisterSubscriber(&ls)
// TODO: remove when LDK supports this
deleteOldLDKLogs(logDirPath)
go func() {
// delete old LDK logs every 24 hours
ticker := time.NewTicker(24 * time.Hour)
for {
select {
case <-ticker.C:
deleteOldLDKLogs(logDirPath)
case <-ldkCtx.Done():
return
}
}
}()
// TODO: remove after 2026-01-01 - we now log to app logs rather than ldk log files
// this line is just left to cleanup old logs after the update
deleteOldLDKLogs(filepath.Join(newpath, "./logs"))
// check for and forward new LDK events to LDKEventBroadcaster (through ldkEventConsumer)
go func() {
@ -438,8 +417,8 @@ func (ls *LDKService) Shutdown() error {
} else {
logger.Logger.Info("LDK stop node succeeded")
}
case <-time.After(120 * time.Second):
logger.Logger.Error("Timeout shutting down LDK node after 120 seconds")
case <-time.After(5 * time.Minute):
logger.Logger.Error("Timeout shutting down LDK node after 5 minutes")
}
logger.Logger.Debug("Destroying LDK node object")
@ -520,19 +499,12 @@ func (ls *LDKService) SendPaymentSync(ctx context.Context, invoice string, amoun
return nil, errors.New("payment not found")
}
bolt11PaymentKind, ok := payment.Kind.(ldk_node.PaymentKindBolt11)
if !ok {
logger.Logger.WithFields(logrus.Fields{
"payment": payment,
}).Error("Payment is not a bolt11 kind")
}
if bolt11PaymentKind.Preimage == nil {
logger.Logger.WithField("payment_hash", paymentHash).Error("No payment preimage for payment hash")
if eventPaymentSuccessful.PaymentPreimage == nil {
logger.Logger.WithField("payment_hash", paymentHash).Error("No payment preimage in payment success event")
return nil, errors.New("payment preimage not found")
}
preimage = *bolt11PaymentKind.Preimage
preimage = *eventPaymentSuccessful.PaymentPreimage
if eventPaymentSuccessful.FeePaidMsat != nil {
fee = *eventPaymentSuccessful.FeePaidMsat
@ -591,7 +563,7 @@ func (ls *LDKService) SendKeysend(ctx context.Context, amount uint64, destinatio
MaxTotalRoutingFeeMsat: &maxTotalRoutingFeeMsat,
}
paymentHash, err := checkLDKErr(ls.node.SpontaneousPayment().Send(amount, destination, sendingParams, customTlvs, &preimage))
paymentHash, err := checkLDKErr(ls.node.SpontaneousPayment().SendWithTlvsAndPreimage(amount, destination, sendingParams, customTlvs, &preimage))
if err != nil {
logger.Logger.WithError(err).Error("Keysend failed")
return nil, err
@ -664,7 +636,7 @@ func (ls *LDKService) getMaxSpendable() uint64 {
return spendable
}
func (ls *LDKService) MakeInvoice(ctx context.Context, amount int64, description string, _descriptionHash string, expiry int64) (transaction *lnclient.Transaction, err error) {
func (ls *LDKService) MakeInvoice(ctx context.Context, amount int64, description string, descriptionHash string, expiry int64) (transaction *lnclient.Transaction, err error) {
if time.Duration(expiry)*time.Second > maxInvoiceExpiry {
return nil, errors.New("expiry is too long")
@ -688,9 +660,18 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amount int64, description
expiry = lnclient.DEFAULT_INVOICE_EXPIRY
}
// TODO: support passing description hash
var descriptionType ldk_node.Bolt11InvoiceDescription
descriptionType = ldk_node.Bolt11InvoiceDescriptionDirect{
Description: description,
}
if description == "" && descriptionHash != "" {
descriptionType = ldk_node.Bolt11InvoiceDescriptionHash{
Hash: descriptionHash,
}
}
invoice, err := checkLDKErr(ls.node.Bolt11Payment().Receive(uint64(amount),
description,
descriptionType,
uint32(expiry)))
if err != nil {
@ -710,7 +691,7 @@ func (ls *LDKService) MakeInvoice(ctx context.Context, amount int64, description
expiresAtUnix := time.UnixMilli(int64(paymentRequest.CreatedAt) * 1000).Add(time.Duration(paymentRequest.Expiry) * time.Second).Unix()
expiresAt = &expiresAtUnix
description = paymentRequest.Description
descriptionHash := paymentRequest.DescriptionHash
descriptionHash = paymentRequest.DescriptionHash
payment := ls.node.Payment(paymentRequest.PaymentHash)
@ -747,9 +728,10 @@ func (ls *LDKService) LookupInvoice(ctx context.Context, paymentHash string) (tr
return transaction, nil
}
// TODO: throw an error if this method is called (it shouldn't be any more because this LNClient supports notifications)
func (ls *LDKService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaid bool, invoiceType string) (transactions []lnclient.Transaction, err error) {
transactions = []lnclient.Transaction{}
// this method shouldn't be any more because this LNClient supports notifications
return nil, errors.New("this method should not be called")
/*transactions = []lnclient.Transaction{}
// TODO: support pagination
payments := ls.node.ListPayments()
@ -797,6 +779,57 @@ func (ls *LDKService) ListTransactions(ctx context.Context, from, until, limit,
// logger.Logger.WithField("transactions", transactions).Debug("Listed transactions")
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"
}
createdAt := payment.CreatedAt
if createdAt == 0 {
createdAt = payment.LatestUpdateTimestamp
}
transactions = append(transactions, lnclient.OnchainTransaction{
AmountSat: amountMsat / 1000,
CreatedAt: createdAt,
State: status,
Type: transactionType,
NumConfirmations: numConfirmations,
TxId: onchainPaymentKind.Txid,
})
}
sort.SliceStable(transactions, func(i, j int) bool {
return transactions[i].CreatedAt > transactions[j].CreatedAt
})
return transactions, nil
}
@ -1151,7 +1184,7 @@ func (ls *LDKService) RedeemOnchainFunds(ctx context.Context, toAddress string,
if !sendAll {
// NOTE: this may fail if user does not reserve enough for the onchain transaction
// and can also drain the anchor reserves if the user provides a too high amount.
txId, err := checkLDKErr(ls.node.OnchainPayment().SendToAddress(toAddress, amount))
txId, err := checkLDKErr(ls.node.OnchainPayment().SendToAddress(toAddress, amount, nil))
if err != nil {
logger.Logger.WithError(err).Error("SendToAddress failed")
return "", err
@ -1159,8 +1192,7 @@ func (ls *LDKService) RedeemOnchainFunds(ctx context.Context, toAddress string,
return txId, nil
}
// TODO: this could be improved to preserve anchor reserves once LDK supports this
txId, err := checkLDKErr(ls.node.OnchainPayment().SendAllToAddress(toAddress))
txId, err := checkLDKErr(ls.node.OnchainPayment().SendAllToAddress(toAddress, false, nil))
if err != nil {
logger.Logger.WithError(err).Error("SendAllToAddress failed")
return "", err
@ -1224,8 +1256,8 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails)
preimage = *bolt11PaymentKind.Preimage
}
settledAt = &createdAt // fallback settledAt to created at time
if payment.LastUpdate > 0 {
lastUpdate := int64(payment.LastUpdate)
if payment.LatestUpdateTimestamp > 0 {
lastUpdate := int64(payment.LatestUpdateTimestamp)
settledAt = &lastUpdate
}
}
@ -1235,7 +1267,7 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails)
spontaneousPaymentKind, isSpontaneousPaymentKind := payment.Kind.(ldk_node.PaymentKindSpontaneous)
if isSpontaneousPaymentKind {
// keysend payment
lastUpdate := int64(payment.LastUpdate)
lastUpdate := int64(payment.LatestUpdateTimestamp)
createdAt = int64(payment.CreatedAt)
// TODO: remove this check some point in the future
// all payments after v0.6.2 will have createdAt set
@ -1266,8 +1298,8 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails)
}
var fee uint64 = 0
if payment.FeeMsat != nil {
fee = *payment.FeeMsat
if payment.FeePaidMsat != nil {
fee = *payment.FeePaidMsat
}
return &lnclient.Transaction{
@ -1363,36 +1395,7 @@ func (ls *LDKService) GetNetworkGraph(ctx context.Context, nodeIds []string) (ln
}
func (ls *LDKService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) {
config := ls.node.Config()
logPath := ""
if config.LogDirPath != nil {
logPath = *config.LogDirPath
} else {
// Default log path if not set explicitly in the config.
logPath = filepath.Join(config.StorageDirPath, "logs")
}
allLogFiles, err := filepath.Glob(filepath.Join(logPath, "ldk_node_*.log"))
if err != nil {
logger.Logger.WithError(err).Error("GetLogOutput failed to list log files")
return nil, err
}
if len(allLogFiles) == 0 {
return []byte{}, nil
}
// Log filenames are formatted as ldk_node_YYYY_MM_DD.log, hence they
// naturally sort by date.
lastLogFileName := slices.Max(allLogFiles)
logData, err := utils.ReadFileTail(lastLogFileName, maxLen)
if err != nil {
logger.Logger.WithError(err).Error("GetLogOutput failed to read log file")
return nil, err
}
return logData, nil
return []byte("Node logs are now included in application logs"), nil
}
func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
@ -1433,7 +1436,6 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
return
}
// also
maxDustHtlcExposureFromFeeRateMultiplier := uint64(0)
if isTrusted {
// avoid closures like "ProcessingError: Peer sent update_fee with a feerate (62500)
@ -1580,6 +1582,11 @@ func (ls *LDKService) handleLdkEvent(event *ldk_node.Event) {
Reason: reason,
},
})
case ldk_node.EventPaymentForwarded:
logger.Logger.WithFields(logrus.Fields{
"total_fee_earned_msat": eventType.TotalFeeEarnedMsat,
"outbound_amount_forwarded_msat": eventType.OutboundAmountForwardedMsat,
}).Info("LDK Payment forwarded")
}
}
@ -1725,6 +1732,22 @@ func (ls *LDKService) deleteOldLDKPayments() {
now := time.Now()
for _, payment := range payments {
paymentCreatedAt := time.Unix(int64(payment.CreatedAt), 0)
deletablePaymentKind := false
switch (payment.Kind).(type) {
case ldk_node.PaymentKindBolt11:
deletablePaymentKind = true
case ldk_node.PaymentKindSpontaneous:
deletablePaymentKind = true
}
if !deletablePaymentKind {
logger.Logger.WithFields(logrus.Fields{
"created_at": paymentCreatedAt,
"payment_id": payment.Id,
}).Debug("Skipping undeletable payment kind")
continue
}
if paymentCreatedAt.Add(maxInvoiceExpiry).Before(now) {
logger.Logger.WithFields(logrus.Fields{
"created_at": paymentCreatedAt,
@ -1811,6 +1834,14 @@ func (ls *LDKService) getPaymentFailReason(eventPaymentFailed *ldk_node.EventPay
failureReasonMessage = "RouteNotFound"
case ldk_node.PaymentFailureReasonUnexpectedError:
failureReasonMessage = "UnexpectedError"
case ldk_node.PaymentFailureReasonUnknownRequiredFeatures:
failureReasonMessage = "UnknownRequiredFeatures"
case ldk_node.PaymentFailureReasonInvoiceRequestExpired:
failureReasonMessage = "InvoiceRequestExpired"
case ldk_node.PaymentFailureReasonInvoiceRequestRejected:
failureReasonMessage = "InvoiceRequestRejected"
case ldk_node.PaymentFailureReasonBlindedPathCreationFailed:
failureReasonMessage = "BlindedPathCreationFailed"
default:
failureReasonMessage = "UnknownError"
}

View file

@ -0,0 +1,48 @@
package ldk
import (
// "github.com/getAlby/hub/ldk_node"
"github.com/getAlby/hub/logger"
"github.com/getAlby/ldk-node-go/ldk_node"
"github.com/sirupsen/logrus"
)
type ldkLogger struct {
logLevel ldk_node.LogLevel
}
func NewLDKLogger(logLevel ldk_node.LogLevel) ldk_node.LogWriter {
return &ldkLogger{
logLevel: logLevel,
}
}
func (ldkLogger *ldkLogger) Log(record ldk_node.LogRecord) {
if record.Level >= ldkLogger.logLevel {
logger.Logger.WithFields(logrus.Fields{
"log_type": "LDK-node",
"line": record.Line,
"module_path": record.ModulePath,
}).Log(mapLogLevel(record.Level), record.Args)
}
}
func mapLogLevel(logLevel ldk_node.LogLevel) logrus.Level {
switch logLevel {
case ldk_node.LogLevelGossip:
return logrus.TraceLevel
case ldk_node.LogLevelTrace:
return logrus.TraceLevel
case ldk_node.LogLevelDebug:
return logrus.DebugLevel
case ldk_node.LogLevelInfo:
return logrus.InfoLevel
case ldk_node.LogLevelWarn:
return logrus.WarnLevel
case ldk_node.LogLevelError:
return logrus.ErrorLevel
}
logger.Logger.WithField("log_level", logLevel).Error("Unknown LDK log level")
return logrus.ErrorLevel
}

View file

@ -1318,3 +1318,39 @@ 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) {
resp, err := svc.client.GetTransactions(ctx, &lnrpc.GetTransactionsRequest{})
if err != nil {
logger.Logger.WithError(err).Error("Failed to get onchain transactions")
return nil, err
}
transactions := []lnclient.OnchainTransaction{}
for _, tx := range resp.Transactions {
state := "unconfirmed"
if tx.NumConfirmations > 0 {
state = "confirmed"
}
amountSat := tx.Amount
txType := "incoming"
if tx.Amount < 0 {
amountSat = -amountSat
txType = "outgoing"
}
transactions = append(transactions, lnclient.OnchainTransaction{
AmountSat: uint64(amountSat),
CreatedAt: uint64(tx.TimeStamp),
State: state,
Type: txType,
NumConfirmations: uint32(tx.NumConfirmations),
TxId: tx.TxHash,
})
}
sort.SliceStable(transactions, func(i, j int) bool {
return transactions[i].CreatedAt > transactions[j].CreatedAt
})
return transactions, nil
}

View file

@ -40,6 +40,15 @@ type Transaction struct {
Metadata Metadata
}
type OnchainTransaction struct {
AmountSat uint64 `json:"amountSat"`
CreatedAt uint64 `json:"createdAt"`
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 +63,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

@ -120,8 +120,10 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
"encryption": encryption,
}).WithError(err).Error("Failed to initialize cipher")
requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR
err = svc.db.Save(&requestEvent).Error
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
@ -161,8 +163,10 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
return
}
requestEvent.AppId = &app.ID
err = svc.db.Save(&requestEvent).Error
err = svc.db.
Model(&requestEvent).
Update("app_id", app.ID).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
@ -183,8 +187,10 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
}
svc.publishResponseEvent(ctx, relay, &requestEvent, resp, &app)
requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR
err = svc.db.Save(&requestEvent).Error
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
@ -203,8 +209,10 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
"appId": app.ID,
}).WithError(err).Error("Failed to decrypt content")
requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR
err = svc.db.Save(&requestEvent).Error
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
@ -251,8 +259,10 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
"eventKind": event.Kind,
}).WithError(err).Error("Failed to process event")
requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR
err = svc.db.Save(&requestEvent).Error
err = svc.db.
Model(&requestEvent).
Update("state", db.REQUEST_EVENT_STATE_HANDLER_ERROR).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
@ -262,13 +272,15 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
return
}
requestEvent.Method = nip47Request.Method
requestEvent.ContentData = payload
svc.db.Save(&requestEvent) // we ignore potential DB errors here as this only saves the method and content data
// we ignore potential DB errors here as this only saves the method and content data
svc.db.Model(&requestEvent).Updates(map[string]interface{}{
"method": nip47Request.Method,
"content_data": payload,
})
// TODO: replace with a channel
// TODO: update all previous occurrences of svc.publishResponseEvent to also use the channel
publishResponse := func(nip47Response *models.Response, tags nostr.Tags) {
var state string
resp, err := svc.CreateResponse(event, nip47Response, tags, nip47Cipher, appWalletPrivKey)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
@ -276,7 +288,7 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
"eventKind": event.Kind,
"appId": app.ID,
}).WithError(err).Error("Failed to create response")
requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR
state = db.REQUEST_EVENT_STATE_HANDLER_ERROR
} else {
err = svc.publishResponseEvent(ctx, relay, &requestEvent, resp, &app)
if err != nil {
@ -286,18 +298,21 @@ func (svc *nip47Service) HandleEvent(ctx context.Context, relay nostrmodels.Rela
"eventKind": event.Kind,
"appId": app.ID,
}).WithError(err).Error("Failed to publish event")
requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_ERROR
state = db.REQUEST_EVENT_STATE_HANDLER_ERROR
} else {
requestEvent.State = db.REQUEST_EVENT_STATE_HANDLER_EXECUTED
logger.Logger.WithFields(logrus.Fields{
"requestEventNostrId": event.ID,
"responseEventNostrId": resp.ID,
"eventKind": event.Kind,
"appId": app.ID,
}).Debug("Published response")
state = db.REQUEST_EVENT_STATE_HANDLER_EXECUTED
}
}
err = svc.db.Save(&requestEvent).Error
err = svc.db.
Model(&requestEvent).
Update("state", state).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"appPubkey": event.PubKey,
@ -471,9 +486,10 @@ func (svc *nip47Service) publishResponseEvent(ctx context.Context, relay nostrmo
return err
}
updateColumns := make(map[string]interface{})
err = relay.Publish(ctx, *resp)
if err != nil {
responseEvent.State = db.RESPONSE_EVENT_STATE_PUBLISH_FAILED
updateColumns["state"] = db.RESPONSE_EVENT_STATE_PUBLISH_FAILED
logger.Logger.WithFields(logrus.Fields{
"requestEventId": requestEvent.ID,
"requestNostrEventId": requestEvent.NostrId,
@ -482,8 +498,8 @@ func (svc *nip47Service) publishResponseEvent(ctx context.Context, relay nostrmo
"responseNostrEventId": resp.ID,
}).WithError(err).Error("Failed to publish reply")
} else {
responseEvent.State = db.RESPONSE_EVENT_STATE_PUBLISH_CONFIRMED
responseEvent.RepliedAt = time.Now()
updateColumns["state"] = db.RESPONSE_EVENT_STATE_PUBLISH_CONFIRMED
updateColumns["replied_at"] = time.Now()
logger.Logger.WithFields(logrus.Fields{
"requestEventId": requestEvent.ID,
"requestNostrEventId": requestEvent.NostrId,
@ -493,7 +509,10 @@ func (svc *nip47Service) publishResponseEvent(ctx context.Context, relay nostrmo
}).Info("Published reply")
}
err = svc.db.Save(&responseEvent).Error
err = svc.db.
Model(&responseEvent).
Updates(updateColumns).
Error
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"requestEventId": requestEvent.ID,

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

@ -998,6 +998,64 @@ func (_c *MockLNClient_ListChannels_Call) RunAndReturn(run func(context.Context)
return _c
}
// ListOnchainTransactions provides a mock function with given fields: ctx
func (_m *MockLNClient) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
ret := _m.Called(ctx)
if len(ret) == 0 {
panic("no return value specified for ListOnchainTransactions")
}
var r0 []lnclient.OnchainTransaction
var r1 error
if rf, ok := ret.Get(0).(func(context.Context) ([]lnclient.OnchainTransaction, error)); ok {
return rf(ctx)
}
if rf, ok := ret.Get(0).(func(context.Context) []lnclient.OnchainTransaction); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]lnclient.OnchainTransaction)
}
}
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockLNClient_ListOnchainTransactions_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListOnchainTransactions'
type MockLNClient_ListOnchainTransactions_Call struct {
*mock.Call
}
// ListOnchainTransactions is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockLNClient_Expecter) ListOnchainTransactions(ctx interface{}) *MockLNClient_ListOnchainTransactions_Call {
return &MockLNClient_ListOnchainTransactions_Call{Call: _e.mock.On("ListOnchainTransactions", ctx)}
}
func (_c *MockLNClient_ListOnchainTransactions_Call) Run(run func(ctx context.Context)) *MockLNClient_ListOnchainTransactions_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockLNClient_ListOnchainTransactions_Call) Return(_a0 []lnclient.OnchainTransaction, _a1 error) *MockLNClient_ListOnchainTransactions_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockLNClient_ListOnchainTransactions_Call) RunAndReturn(run func(context.Context) ([]lnclient.OnchainTransaction, error)) *MockLNClient_ListOnchainTransactions_Call {
_c.Call.Return(run)
return _c
}
// ListPeers provides a mock function with given fields: ctx
func (_m *MockLNClient) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) {
ret := _m.Called(ctx)

View file

@ -502,19 +502,25 @@ func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHa
tx := svc.db
var isIsolatedApp bool
if appId != nil {
var app db.App
result := svc.db.Limit(1).Find(&app, &db.App{
ID: *appId,
})
if result.RowsAffected == 0 {
return nil, NewNotFoundError()
}
if app.Isolated {
tx = tx.Where("app_id = ?", *appId)
err := svc.db.
Model(&db.App{}).
Where("id", *appId).
Pluck("isolated", &isIsolatedApp).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewNotFoundError()
}
return nil, err
}
}
if isIsolatedApp {
tx = tx.Where("app_id = ?", *appId)
}
if transactionType != nil {
tx = tx.Where("type = ?", *transactionType)
}
@ -549,16 +555,33 @@ func (svc *transactionsService) LookupTransaction(ctx context.Context, paymentHa
func (svc *transactionsService) ListTransactions(ctx context.Context, from, until, limit, offset uint64, unpaidOutgoing bool, unpaidIncoming bool, transactionType *string, lnClient lnclient.LNClient, appId *uint, forceFilterByAppId bool) (transactions []Transaction, totalCount uint64, err error) {
svc.checkUnsettledTransactions(ctx, lnClient)
var isIsolatedApp bool
if appId != nil {
err := svc.db.
Model(&db.App{}).
Where("id", *appId).
Pluck("isolated", &isIsolatedApp).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, 0, NewNotFoundError()
}
return nil, 0, err
}
}
tx := svc.db
if isIsolatedApp || forceFilterByAppId {
tx = tx.Where("app_id = ?", *appId)
}
if !unpaidOutgoing && !unpaidIncoming {
tx = tx.Where("state = ?", constants.TRANSACTION_STATE_SETTLED)
} else if unpaidOutgoing && !unpaidIncoming {
tx = tx.Where(tx.Where("state = ?", constants.TRANSACTION_STATE_SETTLED).
Or("type = ?", constants.TRANSACTION_TYPE_OUTGOING))
tx = tx.Where("state = ? OR type = ?", constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_TYPE_OUTGOING)
} else if unpaidIncoming && !unpaidOutgoing {
tx = tx.Where(tx.Where("state = ?", constants.TRANSACTION_STATE_SETTLED).
Or("type = ?", constants.TRANSACTION_TYPE_INCOMING))
tx = tx.Where("state = ? OR type = ?", constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_TYPE_INCOMING)
}
if transactionType != nil {
@ -572,21 +595,6 @@ func (svc *transactionsService) ListTransactions(ctx context.Context, from, unti
tx = tx.Where("updated_at <= ?", time.Unix(int64(until), 0))
}
if appId != nil {
var app db.App
result := svc.db.Limit(1).Find(&app, &db.App{
ID: *appId,
})
if result.RowsAffected == 0 {
return nil, 0, NewNotFoundError()
}
if app.Isolated || forceFilterByAppId {
tx = tx.Where("app_id = ?", *appId)
}
}
tx = tx.Order("updated_at desc")
var totalCount64 int64
result := tx.Model(&db.Transaction{}).Count(&totalCount64)
if result.Error != nil {
@ -595,6 +603,8 @@ func (svc *transactionsService) ListTransactions(ctx context.Context, from, unti
}
totalCount = uint64(totalCount64)
tx = tx.Order("updated_at desc")
if limit > 0 {
tx = tx.Limit(int(limit))
}

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 {