feat: rebalance into channel (#1364)

* feat: rebalance into channel (WIP)

* feat: rebalancing

- add mainnet service URL
- add loading state
- add validation to avoid bad rebalancing
- add rebalance warnings

* chore: improve rebalance dialog copy

* chore: add rebalance endpoint to wails handler

* chore: change fee to 0.2%

* fix: local spendable balance check

* fix: local spendable balance check

* fix: local spendable balance check, close dialog on successful swap

* chore: use min/max for rebalancing amounts rather than errors

* docs: change rebalancing fee copy

* fix: only show rebalance option for channels that are less than half full

* fix: rebalance fee copy

* fix: reload balances after swapping
This commit is contained in:
Roland 2025-07-09 16:00:51 +07:00 committed by GitHub
parent ec590faa49
commit b1484228ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 410 additions and 2 deletions

View file

@ -14,6 +14,8 @@ AUTO_LINK_ALBY_ACCOUNT=false
# Development settings (yarn dev:http)
FRONTEND_URL=http://localhost:5173
#REBALANCE_SERVICE_URL=https://lsp1.mutiny.megalith-node.com
#AUTO_UNLOCK_PASSWORD=123
#WORK_DIR=.data
#DATABASE_URI=nwc.db

View file

@ -172,7 +172,8 @@ The following configuration options can be set as environment variables or in a
- `LOG_LEVEL`: Log level for the application. Higher is more verbose. Default: 4 (info)
- `AUTO_UNLOCK_PASSWORD`: Provide unlock password to auto-unlock Alby Hub on startup (e.g. after a machine restart). Unlock password still be required to access the interface.
- `BOLTZ_API`: The api which provides auto swaps functionality. Default: "https://api.boltz.exchange"
- `NETWORK`: On-chain network used for auto swaps. Should match the backend network. Default: "bitcoin"
- `NETWORK`: On-chain network used for the node. Default: "bitcoin"
- `REBALANCE_SERVICE_URL`: service url for rebalancing existing channels.
### Migrating the database (Sqlite <-> Postgres)

View file

@ -31,6 +31,7 @@ type API interface {
ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error
DisconnectPeer(ctx context.Context, peerId string) error
OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error)
RebalanceChannel(ctx context.Context, rebalanceChannelRequest *RebalanceChannelRequest) (*RebalanceChannelResponse, error)
CloseChannel(ctx context.Context, peerId, channelId string, force bool) (*CloseChannelResponse, error)
UpdateChannel(ctx context.Context, updateChannelRequest *UpdateChannelRequest) error
MakeOffer(ctx context.Context, description string) (string, error)
@ -254,6 +255,14 @@ type OpenChannelResponse = lnclient.OpenChannelResponse
type CloseChannelResponse = lnclient.CloseChannelResponse
type UpdateChannelRequest = lnclient.UpdateChannelRequest
type RebalanceChannelRequest struct {
ReceiveThroughNodePubkey string `json:"receiveThroughNodePubkey"`
AmountSat uint64 `json:"amountSat"`
}
type RebalanceChannelResponse struct {
TotalFeeSat uint64 `json:"totalFeeSat"`
}
type RedeemOnchainFundsRequest struct {
ToAddress string `json:"toAddress"`
Amount uint64 `json:"amount"`

133
api/rebalance.go Normal file
View file

@ -0,0 +1,133 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
"github.com/getAlby/hub/logger"
decodepay "github.com/nbd-wtf/ln-decodepay"
"github.com/sirupsen/logrus"
)
func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *RebalanceChannelRequest) (*RebalanceChannelResponse, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
receiveMetadata := map[string]interface{}{
"receive_through": rebalanceChannelRequest.ReceiveThroughNodePubkey,
"amount_sat": rebalanceChannelRequest.AmountSat,
}
receiveInvoice, err := api.svc.GetTransactionsService().MakeInvoice(ctx, rebalanceChannelRequest.AmountSat*1000, "Alby Hub Rebalance through "+rebalanceChannelRequest.ReceiveThroughNodePubkey, "", 0, receiveMetadata, api.svc.GetLNClient(), nil, nil)
if err != nil {
logger.Logger.WithError(err).Error("failed to generate rebalance receive invoice")
return nil, err
}
type rspCreateOrderRequest struct {
Token string `json:"token"`
PayRequest string `json:"pay_request"`
PayThroughThisPublicKey string `json:"pay_through_this_public_key"`
}
newRspCreateOrderRequest := rspCreateOrderRequest{
Token: "alby-hub",
PayRequest: receiveInvoice.PaymentRequest,
PayThroughThisPublicKey: rebalanceChannelRequest.ReceiveThroughNodePubkey,
}
payloadBytes, err := json.Marshal(newRspCreateOrderRequest)
if err != nil {
return nil, err
}
bodyReader := bytes.NewReader(payloadBytes)
req, err := http.NewRequest(http.MethodPost, api.cfg.GetEnv().RebalanceServiceUrl+"/api/rebalance/v1/create_order", bodyReader)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"request": newRspCreateOrderRequest,
}).Error("Failed to create new rebalance request")
return nil, err
}
setDefaultRequestHeaders(req)
req.Header.Set("Content-Type", "application/json")
client := http.Client{
Timeout: time.Second * 60,
}
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"request": newRspCreateOrderRequest,
}).Error("Failed to request new rebalance order")
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"request": newRspCreateOrderRequest,
}).Error("Failed to read response body")
return nil, errors.New("failed to read response body")
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"request": newRspCreateOrderRequest,
"body": string(body),
"statusCode": res.StatusCode,
}).Error("rebalance create_order endpoint returned non-success code")
return nil, fmt.Errorf("rebalance create_order endpoint returned non-success code: %s", string(body))
}
type rspRebalanceCreateOrderResponse struct {
OrderId string `json:"order_id"`
PayRequest string `json:"pay_request"`
}
var rebalanceCreateOrderResponse rspRebalanceCreateOrderResponse
err = json.Unmarshal(body, &rebalanceCreateOrderResponse)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"request": newRspCreateOrderRequest,
}).Error("Failed to deserialize json")
return nil, fmt.Errorf("failed to deserialize json from rebalance create order response: %s", string(body))
}
logger.Logger.WithField("response", rebalanceCreateOrderResponse).Info("New rebalance order created")
paymentRequest, err := decodepay.Decodepay(rebalanceCreateOrderResponse.PayRequest)
if err != nil {
logger.Logger.WithError(err).Error("Failed to decode bolt11 invoice")
return nil, err
}
payMetadata := map[string]interface{}{
"receive_through": rebalanceChannelRequest.ReceiveThroughNodePubkey,
"amount_sat": rebalanceChannelRequest.AmountSat,
"order_id": rebalanceCreateOrderResponse.OrderId,
}
payRebalanceInvoiceResponse, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, rebalanceCreateOrderResponse.PayRequest, nil, payMetadata, api.svc.GetLNClient(), nil, nil, nil)
if err != nil {
logger.Logger.WithError(err).Error("failed to pay rebalance invoice")
return nil, err
}
return &RebalanceChannelResponse{
TotalFeeSat: uint64(paymentRequest.MSatoshi)/1000 + payRebalanceInvoiceResponse.FeeMsat/1000 - rebalanceChannelRequest.AmountSat,
}, nil
}

View file

@ -36,6 +36,7 @@ type AppConfig struct {
LDKVssUrl string `envconfig:"LDK_VSS_URL" default:"https://vss.getalbypro.com/vss"`
LDKListeningAddresses string `envconfig:"LDK_LISTENING_ADDRESSES" default:"0.0.0.0:9735,[::]:9735"`
LDKTransientNetworkGraph bool `envconfig:"LDK_TRANSIENT_NETWORK_GRAPH" default:"false"`
RebalanceServiceUrl string `envconfig:"REBALANCE_SERVICE_URL" default:"https://megalithic.me"`
LDKBitcoindRpcHost string `envconfig:"LDK_BITCOIND_RPC_HOST"`
LDKBitcoindRpcPort string `envconfig:"LDK_BITCOIND_RPC_PORT"`
LDKBitcoindRpcUser string `envconfig:"LDK_BITCOIND_RPC_USER"`

View file

@ -0,0 +1,202 @@
import { AlertTriangleIcon, ExternalLinkIcon } from "lucide-react";
import React from "react";
import ExternalLink from "src/components/ExternalLink";
import Loading from "src/components/Loading";
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
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 { useBalances } from "src/hooks/useBalances";
import { useChannels } from "src/hooks/useChannels";
import { request } from "src/utils/request";
import {
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "./ui/alert-dialog";
type Props = {
receiveThroughNodePubkey: string;
closeDialog(): void;
};
export function RebalanceChannelDialogContent({
receiveThroughNodePubkey,
closeDialog,
}: Props) {
const [amount, setAmount] = React.useState("");
const { toast } = useToast();
const { data: channels, mutate: reloadChannels } = useChannels();
const { mutate: reloadBalances } = useBalances();
const [isRebalancing, setRebalancing] = React.useState(false);
const inputRef = React.useRef<HTMLInputElement>(null);
React.useEffect(() => {
setTimeout(() => {
// for some reason `autoFocus` is not working on this input
inputRef.current?.focus();
}, 100);
}, [inputRef]);
if (!channels) {
return <Loading />;
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setRebalancing(true);
try {
if (!channels) {
throw new Error("channels not loaded");
}
const response = await request<{ totalFeeSat: number }>(
`/api/channels/rebalance`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
receiveThroughNodePubkey,
amountSat: parseInt(amount),
}),
}
);
if (!response) {
throw new Error("No rebalance response received");
}
await Promise.all([reloadChannels(), reloadBalances()]);
toast({
title:
"Successfully rebalanced channels. Total fee: " +
response.totalFeeSat +
" sats",
});
closeDialog();
} catch (error) {
console.error(error);
toast({
variant: "destructive",
description: "" + error,
});
}
setRebalancing(false);
}
return (
<AlertDialogContent>
<form onSubmit={handleSubmit}>
<AlertDialogHeader>
<AlertDialogTitle>Rebalance In</AlertDialogTitle>
<AlertDialogDescription>
<p className="mb-4">
Rebalance funds from other channels into this channel.
</p>
<Label htmlFor="fee" className="block mb-2">
Rebalance amount (sats)
</Label>
<Input
ref={inputRef}
id="amount"
name="amount"
type="number"
required
autoFocus
min={Math.max(
10000,
Math.floor(
Math.min(
...channels
.filter(
(channel) =>
channel.remotePubkey === receiveThroughNodePubkey
)
.map((channel) => channel.localSpendableBalance / 1000)
) + 1
)
)}
max={Math.floor(
Math.max(
...channels
.filter(
(channel) =>
channel.remotePubkey === receiveThroughNodePubkey
)
.map((channel) => channel.remoteBalance / 1000)
)
)}
value={amount}
onChange={(e) => {
setAmount(e.target.value.trim());
}}
/>
<p className="mt-2 text-xs text-muted-foreground">
Fee: ~0.2%
{!!amount && (
<> ({Math.floor(parseInt(amount || "0") * 0.01)} sats)</>
)}{" "}
+ routing fees
</p>
<ExternalLink
to="https://guides.getalby.com/user-guide/alby-hub/faq/can-i-rebalance-funds-from-one-of-my-channels-to-another"
className="underline flex items-center mt-4"
>
Learn more about rebalancing between channels
<ExternalLinkIcon className="w-4 h-4 ml-2" />
</ExternalLink>
<Alert className="mt-2">
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Rebalancing is in beta</AlertTitle>
<AlertDescription>
Funds may be rebalanced out of unexpected channels.
</AlertDescription>
</Alert>
{channels.filter(
(channel) => channel.remotePubkey === receiveThroughNodePubkey
).length > 1 && (
<Alert className="mt-2">
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>
Multiple channels with same counterparty
</AlertTitle>
<AlertDescription>
Funds may be rebalanced to an unexpected channel.
</AlertDescription>
</Alert>
)}
{channels.some(
(channel) =>
channel.remotePubkey !== receiveThroughNodePubkey &&
channel.localSpendableBalance <
(channels.find(
(other) => other.remotePubkey === receiveThroughNodePubkey
)?.localSpendableBalance || 0)
) && (
<Alert className="mt-2">
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>
You have another channel with less funds
</AlertTitle>
<AlertDescription>
Consider choosing a channel with less spending balance to
rebalance into.
</AlertDescription>
</Alert>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<LoadingButton loading={isRebalancing}>Confirm</LoadingButton>
</AlertDialogFooter>
</form>
</AlertDialogContent>
);
}

View file

@ -2,12 +2,14 @@ import {
ExternalLinkIcon,
HandCoinsIcon,
MoreHorizontalIcon,
ScaleIcon,
Trash2Icon,
} from "lucide-react";
import React from "react";
import { useSearchParams } from "react-router-dom";
import { CloseChannelDialogContent } from "src/components/CloseChannelDialogContent";
import ExternalLink from "src/components/ExternalLink";
import { RebalanceChannelDialogContent } from "src/components/RebalanceChannelDialogContent";
import { RoutingFeeDialogContent } from "src/components/RoutingFeeDialogContent";
import {
AlertDialog,
@ -34,7 +36,9 @@ export function ChannelDropdownMenu({
}: ChannelDropdownMenuProps) {
const { data: info } = useInfo();
const [searchParams] = useSearchParams();
const [dialog, setDialog] = React.useState<"closeChannel" | "routingFee">();
const [dialog, setDialog] = React.useState<
"closeChannel" | "routingFee" | "rebalance"
>();
React.useEffect(() => {
// when opening the swap dialog, close existing dialog
@ -58,6 +62,18 @@ export function ChannelDropdownMenu({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{channel.status == "online" &&
channel.remoteBalance > channel.localSpendableBalance && (
<AlertDialogTrigger asChild>
<DropdownMenuItem
className="flex flex-row items-center gap-2 cursor-pointer"
onClick={() => setDialog("rebalance")}
>
<ScaleIcon className="h-4 w-4" />
Rebalance In
</DropdownMenuItem>
</AlertDialogTrigger>
)}
<DropdownMenuItem className="flex flex-row items-center gap-2 cursor-pointer">
<ExternalLink
to={`${info?.mempoolUrl}/tx/${channel.fundingTxId}#flow=&vout=${channel.fundingTxVout}`}
@ -102,6 +118,12 @@ export function ChannelDropdownMenu({
<CloseChannelDialogContent alias={alias} channel={channel} />
)}
{dialog === "routingFee" && <RoutingFeeDialogContent channel={channel} />}
{dialog === "rebalance" && (
<RebalanceChannelDialogContent
receiveThroughNodePubkey={channel.remotePubkey}
closeDialog={() => setDialog(undefined)}
/>
)}
</AlertDialog>
);
}

View file

@ -130,6 +130,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) {
restrictedApiGroup.PATCH("/backup-reminder", httpSvc.backupReminderHandler)
restrictedApiGroup.GET("/channels", httpSvc.channelsListHandler)
restrictedApiGroup.POST("/channels", httpSvc.openChannelHandler)
restrictedApiGroup.POST("/channels/rebalance", httpSvc.rebalanceChannelHandler)
restrictedApiGroup.GET("/channels/suggestions", httpSvc.channelPeerSuggestionsHandler)
restrictedApiGroup.POST("/lsp-orders", httpSvc.newInstantChannelInvoiceHandler)
restrictedApiGroup.GET("/node/connection-info", httpSvc.nodeConnectionInfoHandler)
@ -742,6 +743,27 @@ func (httpSvc *HttpService) openChannelHandler(c echo.Context) error {
return c.JSON(http.StatusOK, openChannelResponse)
}
func (httpSvc *HttpService) rebalanceChannelHandler(c echo.Context) error {
ctx := c.Request().Context()
var rebalanceChannelRequest api.RebalanceChannelRequest
if err := c.Bind(&rebalanceChannelRequest); err != nil {
return c.JSON(http.StatusBadRequest, ErrorResponse{
Message: fmt.Sprintf("Bad request: %s", err.Error()),
})
}
rebalanceChannelResponse, err := httpSvc.api.RebalanceChannel(ctx, &rebalanceChannelRequest)
if err != nil {
return c.JSON(http.StatusInternalServerError, ErrorResponse{
Message: fmt.Sprintf("Failed to rebalance channel: %s", err.Error()),
})
}
return c.JSON(http.StatusOK, rebalanceChannelResponse)
}
func (httpSvc *HttpService) disconnectPeerHandler(c echo.Context) error {
ctx := c.Request().Context()

View file

@ -458,6 +458,22 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string
}
res := WailsRequestRouterResponse{Body: suggestions, Error: ""}
return res
case "/api/channels/rebalance":
rebalanceChannelRequest := &api.RebalanceChannelRequest{}
err := json.Unmarshal([]byte(body), rebalanceChannelRequest)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"route": route,
"method": method,
"body": body,
}).WithError(err).Error("Failed to decode request to wails router")
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
rebalanceChannelResponse, err := app.api.RebalanceChannel(ctx, rebalanceChannelRequest)
if err != nil {
return WailsRequestRouterResponse{Body: nil, Error: err.Error()}
}
return WailsRequestRouterResponse{Body: rebalanceChannelResponse, Error: ""}
case "/api/balances":
balancesResponse, err := app.api.GetBalances(ctx)
if err != nil {