Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] feat: display recently closed channels #590

Draft
wants to merge 1 commit into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -309,21 +309,19 @@ func (api *api) ListChannels(ctx context.Context) ([]Channel, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
channels, err := api.svc.GetLNClient().ListChannels(ctx)

channels, err := api.svc.GetChannelsService().ListChannels(ctx, api.svc.GetLNClient())

if err != nil {
return nil, err
}

apiChannels := []Channel{}
for _, channel := range channels {
status := "offline"
if channel.Active {
status = "online"
} else if channel.Confirmations != nil && channel.ConfirmationsRequired != nil && *channel.ConfirmationsRequired > *channel.Confirmations {
status = "opening"
}

apiChannels = append(apiChannels, Channel{
Open: channel.Open,
ChannelSizeSat: channel.ChannelSizeSat,
LocalBalance: channel.LocalBalance,
LocalSpendableBalance: channel.LocalSpendableBalance,
RemoteBalance: channel.RemoteBalance,
Expand All @@ -340,7 +338,7 @@ func (api *api) ListChannels(ctx context.Context) ([]Channel, error) {
CounterpartyUnspendablePunishmentReserve: channel.CounterpartyUnspendablePunishmentReserve,
Error: channel.Error,
IsOutbound: channel.IsOutbound,
Status: status,
Status: channel.Status,
})
}
return apiChannels, nil
Expand Down
8 changes: 5 additions & 3 deletions api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -315,9 +315,11 @@ type WalletCapabilitiesResponse struct {
}

type Channel struct {
LocalBalance int64 `json:"localBalance"`
LocalSpendableBalance int64 `json:"localSpendableBalance"`
RemoteBalance int64 `json:"remoteBalance"`
Open bool `json:"open"`
ChannelSizeSat uint64 `json:"channelSizeSat"`
LocalBalance uint64 `json:"localBalance"`
LocalSpendableBalance uint64 `json:"localSpendableBalance"`
RemoteBalance uint64 `json:"remoteBalance"`
Id string `json:"id"`
RemotePubkey string `json:"remotePubkey"`
FundingTxId string `json:"fundingTxId"`
Expand Down
157 changes: 157 additions & 0 deletions channels/channels_service.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package channels

import (
"context"
"slices"
"time"

"github.com/getAlby/hub/db"
"github.com/getAlby/hub/lnclient"
"gorm.io/gorm"
)

type channelsService struct {
db *gorm.DB
}

type ChannelsService interface {
// TODO: consume channel events
ListChannels(ctx context.Context, lnClient lnclient.LNClient) (channels []Channel, err error)
}

type Channel struct {
Open bool
ChannelSizeSat uint64
LocalBalance uint64
LocalSpendableBalance uint64
RemoteBalance uint64
Id string
RemotePubkey string
FundingTxId string
Active bool
Public bool
InternalChannel interface{}
Confirmations *uint32
ConfirmationsRequired *uint32
ForwardingFeeBaseMsat uint32
UnspendablePunishmentReserve uint64
CounterpartyUnspendablePunishmentReserve uint64
Error *string
Status string
IsOutbound bool
}

func NewChannelsService(db *gorm.DB) *channelsService {
return &channelsService{
db: db,
}
}

func (svc *channelsService) ListChannels(ctx context.Context, lnClient lnclient.LNClient) (channels []Channel, err error) {

lnClientChannels, err := lnClient.ListChannels(ctx)
if err != nil {
return nil, err
}

channels = []Channel{}
for _, lnClientChannel := range lnClientChannels {
status := "offline"
if lnClientChannel.Active {
status = "online"
} else if lnClientChannel.Confirmations != nil && lnClientChannel.ConfirmationsRequired != nil && *lnClientChannel.ConfirmationsRequired > *lnClientChannel.Confirmations {
status = "opening"
}

// create or update channel in our DB
var dbChannel db.Channel
result := svc.db.Limit(1).Find(&dbChannel, &db.Channel{
ChannelID: lnClientChannel.Id,
})

if result.Error != nil {
return nil, result.Error
}

if result.RowsAffected == 0 {
// channel not saved yet
dbChannel.ChannelID = lnClientChannel.Id
dbChannel.PeerID = lnClientChannel.RemotePubkey
dbChannel.ChannelSizeSat = uint64((lnClientChannel.LocalBalanceMsat + lnClientChannel.RemoteBalanceMsat) / 1000)
dbChannel.FundingTxID = lnClientChannel.FundingTxId
dbChannel.Open = true
svc.db.Create(&dbChannel)
}

// update channel with latest status
svc.db.Model(&dbChannel).Updates(&db.Channel{
Status: status,
})

channels = append(channels, Channel{
Open: true,
ChannelSizeSat: dbChannel.ChannelSizeSat,
LocalBalance: lnClientChannel.LocalBalanceMsat,
LocalSpendableBalance: lnClientChannel.LocalSpendableBalanceMsat,
RemoteBalance: lnClientChannel.RemoteBalanceMsat,
Id: lnClientChannel.Id,
RemotePubkey: lnClientChannel.RemotePubkey,
FundingTxId: lnClientChannel.FundingTxId,
Active: lnClientChannel.Active,
Public: lnClientChannel.Public,
InternalChannel: lnClientChannel.InternalChannel,
Confirmations: lnClientChannel.Confirmations,
ConfirmationsRequired: lnClientChannel.ConfirmationsRequired,
ForwardingFeeBaseMsat: lnClientChannel.ForwardingFeeBaseMsat,
UnspendablePunishmentReserve: lnClientChannel.UnspendablePunishmentReserve,
CounterpartyUnspendablePunishmentReserve: lnClientChannel.CounterpartyUnspendablePunishmentReserve,
Error: lnClientChannel.Error,
IsOutbound: lnClientChannel.IsOutbound,
Status: status,
})
}

// review channels in our db
dbChannels := []db.Channel{}
result := svc.db.Find(&dbChannels)
if result.Error != nil {
return nil, result.Error
}
for _, dbChannel := range dbChannels {
if !slices.ContainsFunc(channels, func(channel Channel) bool { return channel.Id == dbChannel.ChannelID }) {
if dbChannel.Open {
// ideally this should never happen as we should subscribe to events
// but currently we do not
result := svc.db.Model(&dbChannel).Updates(&db.Channel{
Status: "closing",
Open: false,
})
if result.Error != nil {
return nil, result.Error
}
}

// show channels closed within the last 2 weeks
// NOTE: we do not know how much balance the channel had at closing.
if time.Since(dbChannel.UpdatedAt) < 2*7*24*time.Hour {
channels = append(channels, Channel{
ChannelSizeSat: dbChannel.ChannelSizeSat,
LocalBalance: 0,
LocalSpendableBalance: 0,
RemoteBalance: 0,
Id: dbChannel.ChannelID,
RemotePubkey: dbChannel.PeerID,
FundingTxId: dbChannel.FundingTxID,
Active: false,
Status: dbChannel.Status,
})
}
}
}

if result.Error != nil {
return nil, result.Error
}

return channels, nil
}
41 changes: 41 additions & 0 deletions db/migrations/202409021648_channels.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package migrations

import (
_ "embed"

"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)

var _202409021648_channels = &gormigrate.Migration{
ID: "202409021648_channels",
Migrate: func(tx *gorm.DB) error {

if err := tx.Exec(`
CREATE TABLE channels(
id integer PRIMARY KEY AUTOINCREMENT,
status text,
created_at datetime,
updated_at datetime,
channel_id text,
peer_id text,
channel_size_sat integer,
funding_tx_id text,
open boolean
);

CREATE INDEX idx_channels_status ON channels(status);
CREATE INDEX idx_channels_created_at ON channels(created_at);
CREATE INDEX idx_channels_channel_id ON channels(channel_id);
CREATE INDEX idx_channels_peer_id ON channels(peer_id);
CREATE INDEX idx_channels_funding_tx_id ON channels(funding_tx_id);
`).Error; err != nil {
return err
}

return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}
1 change: 1 addition & 0 deletions db/migrations/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ func Migrate(gormDB *gorm.DB) error {
_202407262257_remove_invalid_scopes,
_202408061737_add_boostagrams_and_use_json,
_202408191242_transaction_failure_reason,
_202409021648_channels,
})

return m.Migrate()
Expand Down
14 changes: 14 additions & 0 deletions db/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,20 @@ type Transaction struct {
FailureReason string
}

type Channel struct {
ID uint
Status string
CreatedAt time.Time
UpdatedAt time.Time
ChannelID string
PeerID string
ChannelSizeSat uint64
FundingTxID string
Open bool

// TODO: add other props like PeerAlias so frontend does not need to fetch it
}

type DBService interface {
CreateApp(name string, pubkey string, maxAmountSat uint64, budgetRenewal string, expiresAt *time.Time, scopes []string, isolated bool) (*App, string, error)
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/CloseChannelDialogContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
setStep(step + 1);
}
toast({ title: "Sucessfully closed channel" });
await reloadChannels();
} catch (error) {
console.error(error);
toast({
Expand Down
20 changes: 11 additions & 9 deletions frontend/src/components/channels/ChannelDropdownMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,17 @@ export function ChannelDropdownMenu({
</DropdownMenuItem>
</AlertDialogTrigger>
)}
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="flex flex-row items-center gap-2 cursor-pointer"
onClick={() => setDialog("closeChannel")}
>
<Trash2 className="h-4 w-4 text-destructive" />
Close Channel
</DropdownMenuItem>
</AlertDialogTrigger>
{channel.open && (
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="flex flex-row items-center gap-2 cursor-pointer"
onClick={() => setDialog("closeChannel")}
>
<Trash2 className="h-4 w-4 text-destructive" />
Close Channel
</DropdownMenuItem>
</AlertDialogTrigger>
)}
</DropdownMenuContent>
</DropdownMenu>
{dialog === "closeChannel" && (
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/components/channels/ChannelStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { InfoIcon } from "lucide-react";
import { Badge } from "src/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "src/components/ui/tooltip";
import { Channel } from "src/types";

export function ChannelStatusBadge({ status }: { status: Channel["status"] }) {
return status == "online" ? (
<Badge variant="positive">Online</Badge>
) : status == "opening" ? (
<Badge variant="outline">Opening</Badge>
) : status == "closing" ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Badge variant="outline" className="text-muted-foreground">
Closing&nbsp;
<InfoIcon className="h-4 w-4 shrink-0" />
</Badge>
</TooltipTrigger>
<TooltipContent className="w-[400px]">
Any funds on your side of the channel at the time of closing will be
returned to your savings account once the closing channel transaction
has been broadcast. In case of force closures, this may take up to 2
weeks.
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Badge variant="warning">Offline</Badge>
);
}
12 changes: 3 additions & 9 deletions frontend/src/components/channels/ChannelsCards.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { InfoIcon } from "lucide-react";
import { ChannelDropdownMenu } from "src/components/channels/ChannelDropdownMenu";
import { ChannelStatusBadge } from "src/components/channels/ChannelStatusBadge";
import { ChannelWarning } from "src/components/channels/ChannelWarning";
import { Badge } from "src/components/ui/badge.tsx";
import {
Card,
CardContent,
Expand Down Expand Up @@ -45,7 +45,7 @@ export function ChannelsCards({ channels, nodes }: ChannelsCardsProps) {
(n) => n.public_key === channel.remotePubkey
);
const alias = node?.alias || "Unknown";
const capacity = channel.localBalance + channel.remoteBalance;
const capacity = channel.channelSizeSat * 1000;

return (
<Card>
Expand All @@ -65,13 +65,7 @@ export function ChannelsCards({ channels, nodes }: ChannelsCardsProps) {
<p className="text-muted-foreground font-medium">
Status
</p>
{channel.status == "online" ? (
<Badge variant="positive">Online</Badge>
) : channel.status == "opening" ? (
<Badge variant="outline">Opening</Badge>
) : (
<Badge variant="warning">Offline</Badge>
)}
<ChannelStatusBadge status={channel.status} />
</div>
<div className="flex w-full justify-between items-center">
<p className="text-muted-foreground font-medium">
Expand Down
Loading