diff --git a/api/api.go b/api/api.go index 47ca130bb..38ec705bc 100644 --- a/api/api.go +++ b/api/api.go @@ -810,10 +810,18 @@ func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error { } func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) { + if api.svc.GetSwapsService() == nil { + return nil, errors.New("SwapsService not started") + } + swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "") swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "") swapOutDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "") + if xpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub(); xpub != "" { + swapOutDestination = xpub + } + swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != "" var swapOutBalanceThreshold, swapOutAmount uint64 if swapOutEnabled { @@ -984,6 +992,30 @@ func (api *api) InitiateSwapIn(ctx context.Context, initiateSwapInRequest *Initi } func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *EnableAutoSwapRequest) error { + if api.svc.GetSwapsService() == nil { + return errors.New("SwapsService not started") + } + + encryptionKey := "" + if enableAutoSwapsRequest.Destination != "" { + switch enableAutoSwapsRequest.DestinationType { + case "address": + if err := api.svc.GetSwapsService().ValidateAddress(enableAutoSwapsRequest.Destination); err != nil { + return err + } + case "xpub": + if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) { + return errors.New("invalid unlock password") + } + if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err != nil { + return err + } + encryptionKey = enableAutoSwapsRequest.UnlockPassword + default: + return errors.New("destination type must be address or xpub") + } + } + err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(enableAutoSwapsRequest.BalanceThreshold, 10), "") if err != nil { logger.Logger.WithError(err).Error("Failed to save autoswap balance threshold to config") @@ -996,16 +1028,13 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E return err } - err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, "") + err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey) if err != nil { logger.Logger.WithError(err).Error("Failed to save autoswap destination to config") return err } - if api.svc.GetSwapsService() == nil { - return errors.New("SwapsService not started") - } - return api.svc.GetSwapsService().EnableAutoSwapOut() + return api.svc.GetSwapsService().EnableAutoSwapOut(enableAutoSwapsRequest.UnlockPassword) } func (api *api) DisableAutoSwap() error { diff --git a/api/models.go b/api/models.go index b4c77accf..4d2151253 100644 --- a/api/models.go +++ b/api/models.go @@ -168,6 +168,8 @@ type EnableAutoSwapRequest struct { BalanceThreshold uint64 `json:"balanceThreshold"` SwapAmount uint64 `json:"swapAmount"` Destination string `json:"destination"` + DestinationType string `json:"destinationType"` + UnlockPassword string `json:"unlockPassword"` } type GetAutoSwapConfigResponse struct { diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index 19803fe52..8b56c5c63 100644 --- a/frontend/src/screens/wallet/swap/AutoSwap.tsx +++ b/frontend/src/screens/wallet/swap/AutoSwap.tsx @@ -2,6 +2,7 @@ import { ArrowDownUpIcon, ClipboardPasteIcon, ClockIcon, + CopyIcon, MoveRightIcon, XCircleIcon, } from "lucide-react"; @@ -11,14 +12,24 @@ import AppHeader from "src/components/AppHeader"; import ExternalLink from "src/components/ExternalLink"; import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount"; import Loading from "src/components/Loading"; +import PasswordInput from "src/components/password/PasswordInput"; import ResponsiveLinkButton from "src/components/ResponsiveLinkButton"; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "src/components/ui/alert-dialog"; import { Button } from "src/components/ui/button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group"; -import { useBalances } from "src/hooks/useBalances"; import { useAutoSwapsConfig, useSwapInfo } from "src/hooks/useSwaps"; +import { copyToClipboard } from "src/lib/clipboard"; import { AutoSwapConfig } from "src/types"; import { request } from "src/utils/request"; @@ -54,7 +65,6 @@ export default function AutoSwap() { } function AutoSwapOutForm() { - const { data: balances } = useBalances(); const { mutate } = useAutoSwapsConfig(); const { data: swapInfo } = useSwapInfo("out"); @@ -65,18 +75,35 @@ function AutoSwapOutForm() { const [externalType, setExternalType] = useState<"address" | "xpub">( "address" ); + const [unlockPassword, setUnlockPassword] = useState(""); + const [showUnlockPasswordDialog, setShowUnlockPasswordDialog] = + useState(false); const [loading, setLoading] = useState(false); const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); - if (swapAmount > balanceThreshold) { + if (Number(swapAmount) > Number(balanceThreshold)) { toast.info( "Balance threshold must be greater than or equal to swap amount" ); return; } + if (externalType === "xpub" && !isInternalSwap) { + setShowUnlockPasswordDialog(true); + return; + } + + await submitAutoSwap(); + }; + + const onConfirmUnlockPassword = async (e: React.FormEvent) => { + e.preventDefault(); + await submitAutoSwap(unlockPassword); + }; + + const submitAutoSwap = async (password?: string) => { try { setLoading(true); await request("/api/autoswap", { @@ -88,8 +115,12 @@ function AutoSwapOutForm() { swapAmount: parseInt(swapAmount), balanceThreshold: parseInt(balanceThreshold), destination, + destinationType: !isInternalSwap ? externalType : undefined, + unlockPassword: password, }), }); + setUnlockPassword(""); + setShowUnlockPasswordDialog(false); toast("Auto swap enabled successfully"); await mutate(); } catch (error) { @@ -106,198 +137,238 @@ function AutoSwapOutForm() { setDestination(text.trim()); }; - if (!balances || !swapInfo) { + if (!swapInfo) { return ; } return ( -
-
-

- Lightning On-chain -

-

- Setup automatic swap of lightning funds into your on-chain balance - every time a set threshold is reached. -

-

- - Swaps will be made once per hour -

-
+ <> + +
+

+ Lightning On-chain +

+

+ Setup automatic swap of lightning funds into your on-chain balance + every time a set threshold is reached. +

+

+ + Swaps will be made once per hour +

+
-
- - setBalanceThreshold(e.target.value)} - required - /> -

- Swap out as soon as this amount is reached -

-
+
+ + setBalanceThreshold(e.target.value)} + required + /> +

+ Swap out as soon as this amount is reached +

+
-
- - setSwapAmount(e.target.value)} - required - /> -

- Minimum -

-
-
- - { - setDestination(""); - setInternalSwap(!isInternalSwap); - }} - className="flex gap-4 flex-row" - > -
- - -
-
- - -
-
-
- {!isInternalSwap && ( -
-
- - { - setExternalType(value as "address" | "xpub"); - setDestination(""); - }} - className="flex gap-4 flex-row" - > -
- -
- -

- Send to the same address each time -

+
+ + setSwapAmount(e.target.value)} + required + /> +

+ Minimum{" "} + +

+
+
+ + { + setDestination(""); + setInternalSwap(!isInternalSwap); + }} + className="flex gap-4 flex-row" + > +
+ + +
+
+ + +
+
+
+ {!isInternalSwap && ( +
+
+ + { + setExternalType(value as "address" | "xpub"); + setDestination(""); + }} + className="flex gap-4 flex-row" + > +
+ +
+ +

+ Send to the same address each time +

+
-
-
- -
- -

- Generate new addresses from extended public key -

+
+ +
+ +

+ Generate new addresses from extended public key +

+
+ +
+
+ +
+ setDestination(e.target.value)} + required + /> +
- -
-
- -
- setDestination(e.target.value)} - required - /> - +

+ {externalType === "address" + ? "Enter a Bitcoin address to receive swapped funds" + : "Enter an XPUB to automatically generate new addresses for each swap. You will be asked to enter your unlock password to encrypt it safely"} +

-

- {externalType === "address" - ? "Enter a Bitcoin address to receive swapped funds" - : "Enter an XPUB to automatically generate new addresses for each swap"} -

-
- )} + )} -
- - {swapInfo ? ( +
+

{swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain fees

- ) : ( - - )} -
-
- - Begin Auto Swap - -

- powered by{" "} - - boltz.exchange - -

-
- +
+
+ + Begin Auto Swap + +

+ powered by{" "} + + boltz.exchange + +

+
+ + { + setShowUnlockPasswordDialog(open); + if (!open) { + setUnlockPassword(""); + } + }} + > + +
+ + Confirm Auto Swap Setup + +
+

+ Please enter your unlock password to encrypt and securely + store the XPUB. +

+
+ + +
+
+
+
+ + Cancel + + +
+
+
+ ); } @@ -350,10 +421,16 @@ function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) {
Destination
-
- {swapConfig.destination - ? swapConfig.destination - : "On-chain Balance"} +
+
+ {swapConfig.destination || "On-chain Balance"} +
+ {swapConfig.destination && ( + copyToClipboard(swapConfig.destination)} + /> + )}
@@ -384,11 +461,7 @@ function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) { )}
- diff --git a/frontend/src/screens/wallet/swap/SwapOutStatus.tsx b/frontend/src/screens/wallet/swap/SwapOutStatus.tsx index 37e78b296..9cba48c5f 100644 --- a/frontend/src/screens/wallet/swap/SwapOutStatus.tsx +++ b/frontend/src/screens/wallet/swap/SwapOutStatus.tsx @@ -56,17 +56,21 @@ export default function SwapOutStatus() { {swapStatus === "PENDING" && } {statusText[swapStatus]} - + {swap.autoSwap && ( - Auto swap{swap.usedXpub && <> to xpub} • +

+ Auto swap{swap.usedXpub && <> to xpub} +

)} - Swap ID: {swap.id}{" "} - { - copyToClipboard(swap.id); - }} - /> +
+ Swap ID: {swap.id}{" "} + { + copyToClipboard(swap.id); + }} + /> +
diff --git a/service/start.go b/service/start.go index ed962506a..912b2778b 100644 --- a/service/start.go +++ b/service/start.go @@ -301,7 +301,7 @@ func (svc *service) StartApp(encryptionKey string) error { return err } - svc.swapsService = swaps.NewSwapsService(ctx, svc.db, svc.cfg, svc.keys, svc.eventPublisher, svc.GetLNClient(), svc.transactionsService) + svc.swapsService = swaps.NewSwapsService(ctx, svc.db, svc.cfg, svc.keys, svc.eventPublisher, svc.GetLNClient(), svc.transactionsService, encryptionKey) svc.publishAllAppInfoEvents() diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index aa708ad28..e4351c4cc 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -37,23 +37,25 @@ import ( type Swap = db.Swap type swapsService struct { - autoSwapOutCancelFn context.CancelFunc - db *gorm.DB - ctx context.Context - lnClient lnclient.LNClient - cfg config.Config - keys keys.Keys - eventPublisher events.EventPublisher - transactionsService transactions.TransactionsService - boltzApi *boltz.Api - boltzWs *boltz.Websocket - swapListeners map[string]chan boltz.SwapUpdate - swapListenersLock sync.Mutex + autoSwapOutCancelFn context.CancelFunc + db *gorm.DB + ctx context.Context + lnClient lnclient.LNClient + cfg config.Config + keys keys.Keys + eventPublisher events.EventPublisher + transactionsService transactions.TransactionsService + boltzApi *boltz.Api + boltzWs *boltz.Websocket + swapListeners map[string]chan boltz.SwapUpdate + swapListenersLock sync.Mutex + autoSwapOutXpubLock sync.Mutex + autoSwapOutDecryptedXpub string } type SwapsService interface { StopAutoSwapOut() - EnableAutoSwapOut() error + EnableAutoSwapOut(encryptionKey string) error SwapOut(amount uint64, destination string, autoSwap, usedXpubDerivation bool) (*SwapResponse, error) SwapIn(amount uint64, autoSwap bool) (*SwapResponse, error) GetSwapOutInfo() (*SwapInfo, error) @@ -61,6 +63,9 @@ type SwapsService interface { RefundSwap(swapId, address string, enableRetries bool) error GetSwap(swapId string) (*Swap, error) ListSwaps() ([]Swap, error) + GetDecryptedAutoSwapXpub() string + ValidateAddress(address string) error + ValidateXpub(xpub string) error } const ( @@ -101,7 +106,7 @@ type SwapResponse struct { } func NewSwapsService(ctx context.Context, db *gorm.DB, cfg config.Config, keys keys.Keys, eventPublisher events.EventPublisher, - lnClient lnclient.LNClient, transactionsService transactions.TransactionsService) SwapsService { + lnClient lnclient.LNClient, transactionsService transactions.TransactionsService, encryptionKey string) SwapsService { boltzApi := &boltz.Api{URL: cfg.GetEnv().BoltzApi} boltzWs := boltzApi.NewWebsocket() @@ -137,7 +142,7 @@ func NewSwapsService(ctx context.Context, db *gorm.DB, cfg config.Config, keys k } }() - err := svc.EnableAutoSwapOut() + err := svc.EnableAutoSwapOut(encryptionKey) if err != nil { logger.Logger.WithError(err).Error("Couldn't enable auto swaps") } @@ -153,13 +158,28 @@ func (svc *swapsService) StopAutoSwapOut() { svc.autoSwapOutCancelFn() logger.Logger.Info("Auto swap out service stopped") } + svc.autoSwapOutXpubLock.Lock() + svc.autoSwapOutDecryptedXpub = "" + svc.autoSwapOutXpubLock.Unlock() } -func (svc *swapsService) EnableAutoSwapOut() error { +func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { svc.StopAutoSwapOut() ctx, cancelFn := context.WithCancel(svc.ctx) - swapDestination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, "") + svc.autoSwapOutXpubLock.Lock() + svc.autoSwapOutDecryptedXpub = "" + svc.autoSwapOutXpubLock.Unlock() + + swapDestination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, encryptionKey) + if swapDestination != "" { + if err := svc.ValidateXpub(swapDestination); err == nil { + svc.autoSwapOutXpubLock.Lock() + svc.autoSwapOutDecryptedXpub = swapDestination + svc.autoSwapOutXpubLock.Unlock() + } + } + balanceThresholdStr, _ := svc.cfg.Get(config.AutoSwapBalanceThresholdKey, "") amountStr, _ := svc.cfg.Get(config.AutoSwapAmountKey, "") @@ -202,15 +222,17 @@ func (svc *swapsService) EnableAutoSwapOut() error { actualDestination := swapDestination var usedXpubDerivation bool - if swapDestination != "" { - if err := svc.validateXpub(swapDestination); err == nil { - actualDestination, err = svc.getNextUnusedAddressFromXpub() - if err != nil { - logger.Logger.WithError(err).Error("Failed to get next address from xpub") - continue - } - usedXpubDerivation = true + // Check if we have a decrypted XPUB in memory + svc.autoSwapOutXpubLock.Lock() + hasDecryptedXpub := svc.autoSwapOutDecryptedXpub != "" + svc.autoSwapOutXpubLock.Unlock() + if hasDecryptedXpub { + actualDestination, err = svc.getNextUnusedAddressFromXpub() + if err != nil { + logger.Logger.WithError(err).Error("Failed to get next address from xpub") + continue } + usedXpubDerivation = true } logger.Logger.WithFields(logrus.Fields{ @@ -1428,7 +1450,7 @@ func (svc *swapsService) bumpAutoswapXpubIndex(swapId uint) { }).Info("Updated xpub index start for swap address") } -func (svc *swapsService) deriveAddressFromXpub(xpub string, index uint32) (string, error) { +func (svc *swapsService) getChainParams() (*chaincfg.Params, error) { var netParams *chaincfg.Params switch svc.cfg.GetNetwork() { case "bitcoin", "mainnet": @@ -1440,7 +1462,16 @@ func (svc *swapsService) deriveAddressFromXpub(xpub string, index uint32) (strin case "signet": netParams = &chaincfg.SigNetParams default: - return "", fmt.Errorf("unsupported network: %s", svc.cfg.GetNetwork()) + return nil, fmt.Errorf("unsupported network: %s", svc.cfg.GetNetwork()) + } + + return netParams, nil +} + +func (svc *swapsService) deriveAddressFromXpub(xpub string, index uint32) (string, error) { + netParams, err := svc.getChainParams() + if err != nil { + return "", err } extPubKey, err := hdkeychain.NewKeyFromString(xpub) @@ -1487,13 +1518,12 @@ func (svc *swapsService) checkAddressHasTransactions(address string, esploraApiR } func (svc *swapsService) getNextUnusedAddressFromXpub() (string, error) { - destination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, "") + // Use the decrypted XPUB from memory (already decrypted during EnableAutoSwapOut) + svc.autoSwapOutXpubLock.Lock() + destination := svc.autoSwapOutDecryptedXpub + svc.autoSwapOutXpubLock.Unlock() if destination == "" { - return "", errors.New("no destination configured") - } - - if err := svc.validateXpub(destination); err != nil { - return "", errors.New("destination is not a valid XPUB") + return "", errors.New("no XPUB configured") } indexStr, err := svc.cfg.Get(config.AutoSwapXpubIndexStart, "") @@ -1559,10 +1589,36 @@ func (svc *swapsService) getNextUnusedAddressFromXpub() (string, error) { return "", fmt.Errorf("could not find unused address within %d addresses starting from index %d", addressLookAheadLimit, index) } -func (svc *swapsService) validateXpub(xpub string) error { - _, err := hdkeychain.NewKeyFromString(xpub) +func (svc *swapsService) ValidateAddress(address string) error { + netParams, err := svc.getChainParams() + if err != nil { + return err + } + + _, err = btcutil.DecodeAddress(address, netParams) + if err != nil { + return fmt.Errorf("invalid bitcoin address: %w", err) + } + + return nil +} + +func (svc *swapsService) ValidateXpub(xpub string) error { + extendedKey, err := hdkeychain.NewKeyFromString(xpub) if err != nil { return fmt.Errorf("invalid xpub: %w", err) } + + if extendedKey.IsPrivate() { + return fmt.Errorf("private extended key not allowed") + } + return nil } + +func (svc *swapsService) GetDecryptedAutoSwapXpub() string { + svc.autoSwapOutXpubLock.Lock() + defer svc.autoSwapOutXpubLock.Unlock() + + return svc.autoSwapOutDecryptedXpub +}