diff --git a/api/api.go b/api/api.go index 480d04cb..e66b09fe 100644 --- a/api/api.go +++ b/api/api.go @@ -575,11 +575,11 @@ func (api *api) SignMessage(ctx context.Context, message string) (*SignMessageRe }, nil } -func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string) (*RedeemOnchainFundsResponse, error) { +func (api *api) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) { if api.svc.GetLNClient() == nil { return nil, errors.New("LNClient not started") } - txId, err := api.svc.GetLNClient().RedeemOnchainFunds(ctx, toAddress) + txId, err := api.svc.GetLNClient().RedeemOnchainFunds(ctx, toAddress, amount, sendAll) if err != nil { return nil, err } diff --git a/api/models.go b/api/models.go index c6c72f55..b7279305 100644 --- a/api/models.go +++ b/api/models.go @@ -32,7 +32,7 @@ type API interface { GetNewOnchainAddress(ctx context.Context) (string, error) GetUnusedOnchainAddress(ctx context.Context) (string, error) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) - RedeemOnchainFunds(ctx context.Context, toAddress string) (*RedeemOnchainFundsResponse, error) + RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (*RedeemOnchainFundsResponse, error) GetBalances(ctx context.Context) (*BalancesResponse, error) ListTransactions(ctx context.Context, limit uint64, offset uint64) (*ListTransactionsResponse, error) SendPayment(ctx context.Context, invoice string) (*SendPaymentResponse, error) @@ -187,6 +187,8 @@ type UpdateChannelRequest = lnclient.UpdateChannelRequest type RedeemOnchainFundsRequest struct { ToAddress string `json:"toAddress"` + Amount uint64 `json:"amount"` + SendAll bool `json:"sendAll"` } type RedeemOnchainFundsResponse struct { diff --git a/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx b/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx index ae5bc780..ecb70999 100644 --- a/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx +++ b/frontend/src/screens/wallet/WithdrawOnchainFunds.tsx @@ -4,6 +4,16 @@ import AppHeader from "src/components/AppHeader"; import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "src/components/ui/alert-dialog"; +import { Button } from "src/components/ui/button"; import { Checkbox } from "src/components/ui/checkbox"; import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; @@ -21,72 +31,63 @@ export default function WithdrawOnchainFunds() { const { toast } = useToast(); const { data: balances } = useBalances(); const [onchainAddress, setOnchainAddress] = React.useState(""); - const [confirmOnchainAddress, setConfirmOnchainAddress] = React.useState(""); - const [checkedConfirmation, setCheckedConfirmation] = React.useState(false); + const [amount, setAmount] = React.useState(""); + const [sendAll, setSendAll] = React.useState(false); const [transactionId, setTransactionId] = React.useState(""); + const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false); const copy = (text: string) => { copyToClipboard(text, toast); }; - const redeemFunds = React.useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - setLoading(true); - try { - await new Promise((resolve) => setTimeout(resolve, 100)); - if (!onchainAddress) { - throw new Error("No onchain address"); - } - - if (onchainAddress !== confirmOnchainAddress) { - throw new Error( - "Onchain addresses do not match. Please check the onchain addresses you provided" - ); - } - - if (!checkedConfirmation) { - throw new Error("Please confirm"); - } - } catch (error) { - console.error(error); - toast({ - title: "Something went wrong", - description: "" + error, - variant: "destructive", - }); - setLoading(false); - return; + const redeemFunds = React.useCallback(async () => { + setLoading(true); + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (!onchainAddress) { + throw new Error("No onchain address"); } + } catch (error) { + console.error(error); + toast({ + title: "Something went wrong", + description: "" + error, + variant: "destructive", + }); + setLoading(false); + return; + } - try { - const response = await request( - "/api/wallet/redeem-onchain-funds", - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ toAddress: onchainAddress }), - } - ); - console.info("Redeemed onchain funds", response); - if (!response?.txId) { - throw new Error("No address in response"); + try { + const response = await request( + "/api/wallet/redeem-onchain-funds", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + toAddress: onchainAddress, + amount: +amount, + sendAll, + }), } - setTransactionId(response.txId); - } catch (error) { - console.error(error); - toast({ - variant: "destructive", - title: "Failed to redeem onchain funds", - description: "" + error, - }); + ); + console.info("Redeemed onchain funds", response); + if (!response?.txId) { + throw new Error("No address in response"); } - setLoading(false); - }, - [checkedConfirmation, confirmOnchainAddress, onchainAddress, toast] - ); + setTransactionId(response.txId); + } catch (error) { + console.error(error); + toast({ + variant: "destructive", + title: "Failed to redeem onchain funds", + description: "" + error, + }); + } + setLoading(false); + }, [amount, onchainAddress, sendAll, toast]); if (transactionId) { return ( @@ -139,26 +140,93 @@ export default function WithdrawOnchainFunds() { />
- {!!balances?.onchain.reserved && ( - - - Channel Anchor Reserves will be depleted - - You have channels open and this withdrawal will use some or all of - your anchor reserves to publish the transaction, which may make it - harder to close channels without depositing additional onchain - funds to your savings balance. - - - )}

Your savings balance will be withdrawn to the onchain bitcoin wallet - address you specify below. Please make sure you are the owner of this - address and that it is for an{" "} - external wallet that you control - and have the seed phrase for. + address you specify below.

-
+ { + e.preventDefault(); + setConfirmDialogOpen(true); + }} + className="grid gap-5 mt-4" + > +
+ +
+

+ Current onchain balance:{" "} + {new Intl.NumberFormat().format(balances.onchain.spendable)}{" "} + sats +

+
+ setSendAll(!sendAll)} + /> + +
+
+ {!sendAll && ( + { + setAmount(e.target.value); + }} + /> + )} + {sendAll && ( + + + Entire wallet balance will be sent + + Your entire wallet balance + {balances.onchain.reserved > 0 && ( + <> + {" "} + including reserves ( + {new Intl.NumberFormat().format( + balances.onchain.reserved + )}{" "} + sats) + + )}{" "} + will be sent minus onchain transaction fees. The exact amount + cannot be determined until the payment is made. + {balances.onchain.reserved && ( + <> + {" "} + You have channels open and this withdrawal will deplete + your anchor reserves, which may make it harder to close + channels without depositing additional onchain funds to + your savings balance. + + )} + + + )} + {!!balances?.onchain.reserved && + !sendAll && + +amount > balances.onchain.spendable * 0.9 && ( + + + + Channel Anchor Reserves may be depleted + + + You have channels open and this withdrawal may deplete your + anchor reserves, which may make it harder to close channels + without depositing additional onchain funds to your savings + balance. + + + )} +
-
- - { - setConfirmOnchainAddress(e.target.value); - }} - /> -
-
-
- - setCheckedConfirmation(!checkedConfirmation) - } - /> - -
-
+ +

+ Please double-check the destination address. This transaction cannot + be reversed. +

+
- - Withdraw - + + + + + + + Confirm Onchain Transaction + + +

+ Please confirm your payment to{" "} + {onchainAddress} +

+

+ Amount:{" "} + + {sendAll ? ( + "entire savings balance" + ) : ( + <>{new Intl.NumberFormat().format(+amount)} sats + )} + +

+
+
+ + Cancel + + redeemFunds()} + > + Confirm + + +
+
diff --git a/http/http_service.go b/http/http_service.go index c2f2cfb4..427284e6 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -720,7 +720,7 @@ func (httpSvc *HttpService) redeemOnchainFundsHandler(c echo.Context) error { }) } - redeemOnchainFundsResponse, err := httpSvc.api.RedeemOnchainFunds(ctx, redeemOnchainFundsRequest.ToAddress) + redeemOnchainFundsResponse, err := httpSvc.api.RedeemOnchainFunds(ctx, redeemOnchainFundsRequest.ToAddress, redeemOnchainFundsRequest.Amount, redeemOnchainFundsRequest.SendAll) if err != nil { return c.JSON(http.StatusInternalServerError, ErrorResponse{ diff --git a/lnclient/breez/breez.go b/lnclient/breez/breez.go index a568f8c1..c8a97f5d 100644 --- a/lnclient/breez/breez.go +++ b/lnclient/breez/breez.go @@ -373,7 +373,11 @@ func (bs *BreezService) GetOnchainBalance(ctx context.Context) (*lnclient.Onchai }, nil } -func (bs *BreezService) RedeemOnchainFunds(ctx context.Context, toAddress string) (txId string, err error) { +func (bs *BreezService) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (txId string, err error) { + if !sendAll { + return "", errors.New("only send all is supported") + } + if toAddress == "" { return "", errors.New("No address provided") } diff --git a/lnclient/cashu/cashu.go b/lnclient/cashu/cashu.go index bc1e156f..23ec9abb 100644 --- a/lnclient/cashu/cashu.go +++ b/lnclient/cashu/cashu.go @@ -232,7 +232,7 @@ func (cs *CashuService) GetOnchainBalance(ctx context.Context) (*lnclient.Onchai }, nil } -func (cs *CashuService) RedeemOnchainFunds(ctx context.Context, toAddress string) (string, error) { +func (cs *CashuService) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (string, error) { return "", nil } diff --git a/lnclient/greenlight/greenlight.go b/lnclient/greenlight/greenlight.go index b6e06b74..e67e7b57 100644 --- a/lnclient/greenlight/greenlight.go +++ b/lnclient/greenlight/greenlight.go @@ -508,7 +508,10 @@ func (gs *GreenlightService) GetOnchainBalance(ctx context.Context) (*lnclient.O }, nil } -func (gs *GreenlightService) RedeemOnchainFunds(ctx context.Context, toAddress string) (string, error) { +func (gs *GreenlightService) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (string, error) { + if !sendAll { + return "", errors.New("only send all is supported") + } amountAll := glalby.AmountOrAll(glalby.AmountOrAllAll{}) txId, err := gs.client.Withdraw(glalby.WithdrawRequest{ Destination: toAddress, diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index b76f26e8..85c9700e 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -1042,24 +1042,19 @@ func (ls *LDKService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainB }, nil } -func (ls *LDKService) RedeemOnchainFunds(ctx context.Context, toAddress string) (string, error) { - - // TODO: LDK will improve SendAllToAddress to preserve anchor funds, then this - // code below can be removed. - /////////////////////////////////////////////////////////////////////////////////// - balances := ls.node.ListBalances() - if balances.TotalAnchorChannelsReserveSats > 0 { - // NOTE: this is not good because it uses anchor reserves for the onchain transaction fee - spendableBalance := balances.SpendableOnchainBalanceSats - txId, err := ls.node.OnchainPayment().SendToAddress(toAddress, spendableBalance) +func (ls *LDKService) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (string, error) { + 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 := ls.node.OnchainPayment().SendToAddress(toAddress, amount) if err != nil { logger.Logger.WithError(err).Error("SendToAddress failed") return "", err } return txId, nil } - /////////////////////////////////////////////////////////////////////////////////// + // TODO: this could be improved to preserve anchor reserves once LDK supports this txId, err := ls.node.OnchainPayment().SendAllToAddress(toAddress) if err != nil { logger.Logger.WithError(err).Error("SendAllToAddress failed") diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index 264e31f9..8c203466 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -762,10 +762,11 @@ func (svc *LNDService) GetOnchainBalance(ctx context.Context) (*lnclient.Onchain }, nil } -func (svc *LNDService) RedeemOnchainFunds(ctx context.Context, toAddress string) (txId string, err error) { +func (svc *LNDService) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (txId string, err error) { resp, err := svc.client.SendCoins(ctx, &lnrpc.SendCoinsRequest{ Addr: toAddress, - SendAll: true, + SendAll: sendAll, + Amount: int64(amount), }) if err != nil { return "", err diff --git a/lnclient/models.go b/lnclient/models.go index b7767e25..276dbeaf 100644 --- a/lnclient/models.go +++ b/lnclient/models.go @@ -67,7 +67,7 @@ type LNClient interface { ResetRouter(key string) error GetOnchainBalance(ctx context.Context) (*OnchainBalanceResponse, error) GetBalances(ctx context.Context) (*BalancesResponse, error) - RedeemOnchainFunds(ctx context.Context, toAddress string) (txId string, err error) + RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (txId string, err error) SendPaymentProbes(ctx context.Context, invoice string) error SendSpontaneousPaymentProbes(ctx context.Context, amountMsat uint64, nodeId string) error ListPeers(ctx context.Context) ([]PeerDetails, error) diff --git a/lnclient/phoenixd/phoenixd.go b/lnclient/phoenixd/phoenixd.go index 18b23cba..32782d2d 100644 --- a/lnclient/phoenixd/phoenixd.go +++ b/lnclient/phoenixd/phoenixd.go @@ -435,7 +435,7 @@ func (svc *PhoenixService) SendKeysend(ctx context.Context, amount uint64, desti return nil, errors.New("not implemented") } -func (svc *PhoenixService) RedeemOnchainFunds(ctx context.Context, toAddress string) (txId string, err error) { +func (svc *PhoenixService) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (txId string, err error) { return "", errors.New("not implemented") } diff --git a/tests/mock_ln_client.go b/tests/mock_ln_client.go index f1a1cc05..72ded535 100644 --- a/tests/mock_ln_client.go +++ b/tests/mock_ln_client.go @@ -138,7 +138,7 @@ func (mln *MockLn) GetBalances(ctx context.Context) (*lnclient.BalancesResponse, func (mln *MockLn) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) { return nil, nil } -func (mln *MockLn) RedeemOnchainFunds(ctx context.Context, toAddress string) (txId string, err error) { +func (mln *MockLn) RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, sendAll bool) (txId string, err error) { return "", nil } func (mln *MockLn) ResetRouter(key string) error { diff --git a/wails/wails_handlers.go b/wails/wails_handlers.go index 77852f51..48a44a58 100644 --- a/wails/wails_handlers.go +++ b/wails/wails_handlers.go @@ -448,7 +448,7 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string return WailsRequestRouterResponse{Body: nil, Error: err.Error()} } - redeemOnchainFundsResponse, err := app.api.RedeemOnchainFunds(ctx, redeemOnchainFundsRequest.ToAddress) + redeemOnchainFundsResponse, err := app.api.RedeemOnchainFunds(ctx, redeemOnchainFundsRequest.ToAddress, redeemOnchainFundsRequest.Amount, redeemOnchainFundsRequest.SendAll) if err != nil { return WailsRequestRouterResponse{Body: nil, Error: err.Error()} }