From e021897047be6b67036e91df352373e90955c0d3 Mon Sep 17 00:00:00 2001 From: fmar Date: Thu, 11 Dec 2025 15:37:12 +0100 Subject: [PATCH 01/20] feat: encrypt xpub for auto swap out --- api/api.go | 43 +++++++++++++-- api/models.go | 1 + config/config.go | 26 +++++++-- frontend/src/screens/wallet/swap/AutoSwap.tsx | 27 ++++++++++ service/start.go | 2 +- swaps/swaps_service.go | 53 +++++++++++++------ 6 files changed, 130 insertions(+), 22 deletions(-) diff --git a/api/api.go b/api/api.go index f17b964ee..60c0cefd3 100644 --- a/api/api.go +++ b/api/api.go @@ -762,7 +762,21 @@ func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error { func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) { swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "") swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "") - swapOutDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "") + + // Get the destination - try from memory first (decrypted XPUB), then from config + swapOutDestination := "" + if api.svc.GetSwapsService() != nil { + // If we have a decrypted XPUB in memory, use it + decryptedXpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub() + if decryptedXpub != "" { + swapOutDestination = decryptedXpub + } + } + + // If no decrypted XPUB, try to get the destination from config (could be a regular address) + if swapOutDestination == "" { + swapOutDestination, _ = api.cfg.Get(config.AutoSwapDestinationKey, "") + } swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != "" var swapOutBalanceThreshold, swapOutAmount uint64 @@ -946,7 +960,20 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E return err } - err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, "") + // Check if destination is an xpub - if so, it needs to be encrypted + encryptionKey := "" + if enableAutoSwapsRequest.Destination != "" && api.isXpub(enableAutoSwapsRequest.Destination) { + // Validate the unlock password + if enableAutoSwapsRequest.UnlockPassword == "" { + return errors.New("unlock password is required when using an xpub as destination") + } + if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) { + return errors.New("invalid unlock password") + } + encryptionKey = enableAutoSwapsRequest.UnlockPassword + } + + 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 @@ -955,7 +982,7 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E 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 { @@ -1774,3 +1801,13 @@ func (api *api) GetForwards() (*GetForwardsResponse, error) { NumForwards: uint64(numForwards), }, nil } + +func (api *api) isXpub(destination string) bool { + // Check if the destination starts with xpub, ypub, zpub, tpub, upub, or vpub + return strings.HasPrefix(destination, "xpub") || + strings.HasPrefix(destination, "ypub") || + strings.HasPrefix(destination, "zpub") || + strings.HasPrefix(destination, "tpub") || + strings.HasPrefix(destination, "upub") || + strings.HasPrefix(destination, "vpub") +} diff --git a/api/models.go b/api/models.go index 971d80d1b..ced4f1812 100644 --- a/api/models.go +++ b/api/models.go @@ -166,6 +166,7 @@ type EnableAutoSwapRequest struct { BalanceThreshold uint64 `json:"balanceThreshold"` SwapAmount uint64 `json:"swapAmount"` Destination string `json:"destination"` + UnlockPassword string `json:"unlockPassword"` } type GetAutoSwapConfigResponse struct { diff --git a/config/config.go b/config/config.go index 7d079b41c..5e6da93d8 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -180,10 +181,23 @@ func (cfg *config) GetMempoolUrl() string { return strings.TrimSuffix(mempoolApiUrl, "/api") } +// getCacheKey generates a cache key that includes the encryption key context +// to prevent returning wrong values when the same key is accessed with different encryption keys +func (cfg *config) getCacheKey(key string, encryptionKey string) string { + if encryptionKey == "" { + return key + } + // Hash the encryption key to avoid storing it in plaintext in the cache key + hash := sha256.Sum256([]byte(encryptionKey)) + return key + ":" + hex.EncodeToString(hash[:8]) // Use first 8 bytes for cache key +} + func (cfg *config) Get(key string, encryptionKey string) (string, error) { cfg.cacheMutex.Lock() defer cfg.cacheMutex.Unlock() - cachedValue, ok := cfg.cache[key] + + cacheKey := cfg.getCacheKey(key, encryptionKey) + cachedValue, ok := cfg.cache[cacheKey] if ok { logger.Logger.WithField("key", key).Debug("hit config cache") return cachedValue, nil @@ -194,7 +208,7 @@ func (cfg *config) Get(key string, encryptionKey string) (string, error) { if err != nil { return "", err } - cfg.cache[key] = value + cfg.cache[cacheKey] = value logger.Logger.WithField("key", key).Debug("set config cache") return value, nil } @@ -235,7 +249,13 @@ func (cfg *config) set(key string, value string, clauses clause.OnConflict, encr logger.Logger.WithField("key", key).Debug("clearing config cache") cfg.cacheMutex.Lock() defer cfg.cacheMutex.Unlock() - delete(cfg.cache, key) + // Clear all cache entries for this key (with any encryption key variant) + for cacheKey := range cfg.cache { + // Remove entries that match the key (before the ":" separator) + if cacheKey == key || strings.HasPrefix(cacheKey, key+":") { + delete(cfg.cache, cacheKey) + } + } return nil } diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index 19803fe52..9e853722c 100644 --- a/frontend/src/screens/wallet/swap/AutoSwap.tsx +++ b/frontend/src/screens/wallet/swap/AutoSwap.tsx @@ -11,6 +11,7 @@ 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 { Button } from "src/components/ui/button"; import { LoadingButton } from "src/components/ui/custom/loading-button"; @@ -65,6 +66,7 @@ function AutoSwapOutForm() { const [externalType, setExternalType] = useState<"address" | "xpub">( "address" ); + const [unlockPassword, setUnlockPassword] = useState(""); const [loading, setLoading] = useState(false); const onSubmit = async (e: React.FormEvent) => { @@ -77,6 +79,15 @@ function AutoSwapOutForm() { return; } + // Check if using xpub and password is required + const isXpub = externalType === "xpub" && !isInternalSwap; + if (isXpub && !unlockPassword) { + toast.error("Password required", { + description: "Please enter your unlock password to encrypt the XPUB", + }); + return; + } + try { setLoading(true); await request("/api/autoswap", { @@ -88,6 +99,7 @@ function AutoSwapOutForm() { swapAmount: parseInt(swapAmount), balanceThreshold: parseInt(balanceThreshold), destination, + unlockPassword: isXpub ? unlockPassword : undefined, }), }); toast("Auto swap enabled successfully"); @@ -269,6 +281,21 @@ function AutoSwapOutForm() { : "Enter an XPUB to automatically generate new addresses for each swap"}

+ {externalType === "xpub" && ( +
+ + +

+ Your password is required to encrypt the XPUB for secure storage +

+
+ )} )} diff --git a/service/start.go b/service/start.go index b5360188b..879e4acac 100644 --- a/service/start.go +++ b/service/start.go @@ -291,7 +291,7 @@ func (svc *service) StartApp(encryptionKey string) error { return err } - svc.swapsService = swaps.NewSwapsService(ctx, svc.db, svc.cfg, svc.keys, svc.eventPublisher, svc.lnClient, svc.transactionsService) + svc.swapsService = swaps.NewSwapsService(ctx, svc.db, svc.cfg, svc.keys, svc.eventPublisher, svc.lnClient, svc.transactionsService, encryptionKey) svc.publishAllAppInfoEvents() diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index bc87bc53b..05b75dfe5 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -49,11 +49,12 @@ type swapsService struct { boltzWs *boltz.Websocket swapListeners map[string]chan boltz.SwapUpdate swapListenersLock sync.Mutex + decryptedXpub string // Decrypted XPUB kept in memory (like mnemonic in keys service) } 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 +62,7 @@ type SwapsService interface { RefundSwap(swapId, address string, enableRetries bool) error GetSwap(swapId string) (*Swap, error) ListSwaps() ([]Swap, error) + GetDecryptedAutoSwapXpub() string } const ( @@ -101,7 +103,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() @@ -149,7 +151,9 @@ func NewSwapsService(ctx context.Context, db *gorm.DB, cfg config.Config, keys k } }() - err := svc.EnableAutoSwapOut() + // Decrypt the XPUB once during initialization if it exists + // The encryption key is only used here and then discarded + err := svc.EnableAutoSwapOut(encryptionKey) if err != nil { logger.Logger.WithError(err).Error("Couldn't enable auto swaps") } @@ -167,11 +171,23 @@ func (svc *swapsService) StopAutoSwapOut() { } } -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, "") + + // Try to decrypt the destination with the encryption key + // If it's an XPUB, it will be encrypted and we decrypt it once here + swapDestination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, encryptionKey) + + // Store the decrypted XPUB in memory (encryption key is discarded after this function) + // This follows the same pattern as the mnemonic in the keys service + if swapDestination != "" && svc.validateXpub(swapDestination) == nil { + svc.decryptedXpub = swapDestination + } else { + svc.decryptedXpub = "" // Not an XPUB or empty + } + balanceThresholdStr, _ := svc.cfg.Get(config.AutoSwapBalanceThresholdKey, "") amountStr, _ := svc.cfg.Get(config.AutoSwapAmountKey, "") @@ -214,15 +230,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 + if svc.decryptedXpub != "" { + actualDestination, err = svc.getNextUnusedAddressFromXpub() + if err != nil { + logger.Logger.WithError(err).Error("Failed to get next address from xpub") + continue } + usedXpubDerivation = true + } else if swapDestination != "" { + // Regular address (not XPUB) + actualDestination = swapDestination } logger.Logger.WithFields(logrus.Fields{ @@ -1485,9 +1503,10 @@ 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) + destination := svc.decryptedXpub if destination == "" { - return "", errors.New("no destination configured") + return "", errors.New("no XPUB configured") } if err := svc.validateXpub(destination); err != nil { @@ -1564,3 +1583,7 @@ func (svc *swapsService) validateXpub(xpub string) error { } return nil } + +func (svc *swapsService) GetDecryptedAutoSwapXpub() string { + return svc.decryptedXpub +} From c2569f13bd5ccf7aad4d060907143543ec21e038 Mon Sep 17 00:00:00 2001 From: fmar Date: Thu, 11 Dec 2025 17:04:22 +0100 Subject: [PATCH 02/20] fix: include encryptionKey in the cache key (sha256) --- config/config.go | 21 ++++++++++----------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/config/config.go b/config/config.go index 5e6da93d8..665f5a5ae 100644 --- a/config/config.go +++ b/config/config.go @@ -2,7 +2,6 @@ package config import ( "crypto/rand" - "crypto/sha256" "encoding/hex" "errors" "fmt" @@ -181,21 +180,18 @@ func (cfg *config) GetMempoolUrl() string { return strings.TrimSuffix(mempoolApiUrl, "/api") } -// getCacheKey generates a cache key that includes the encryption key context -// to prevent returning wrong values when the same key is accessed with different encryption keys func (cfg *config) getCacheKey(key string, encryptionKey string) string { if encryptionKey == "" { return key } - // Hash the encryption key to avoid storing it in plaintext in the cache key hash := sha256.Sum256([]byte(encryptionKey)) - return key + ":" + hex.EncodeToString(hash[:8]) // Use first 8 bytes for cache key + return key + ":" + hex.EncodeToString(hash[:8]) } func (cfg *config) Get(key string, encryptionKey string) (string, error) { cfg.cacheMutex.Lock() defer cfg.cacheMutex.Unlock() - + cacheKey := cfg.getCacheKey(key, encryptionKey) cachedValue, ok := cfg.cache[cacheKey] if ok { @@ -249,12 +245,15 @@ func (cfg *config) set(key string, value string, clauses clause.OnConflict, encr logger.Logger.WithField("key", key).Debug("clearing config cache") cfg.cacheMutex.Lock() defer cfg.cacheMutex.Unlock() - // Clear all cache entries for this key (with any encryption key variant) - for cacheKey := range cfg.cache { - // Remove entries that match the key (before the ":" separator) - if cacheKey == key || strings.HasPrefix(cacheKey, key+":") { - delete(cfg.cache, cacheKey) + + if encryptionKey != "" { + for cacheKey := range cfg.cache { + if cacheKey == key || strings.HasPrefix(cacheKey, key+":") { + delete(cfg.cache, cacheKey) + } } + } else { + delete(cfg.cache, key) } return nil From 97b579a78645228471641cffbe0d23931709f564 Mon Sep 17 00:00:00 2001 From: fmar Date: Thu, 11 Dec 2025 18:26:28 +0100 Subject: [PATCH 03/20] fix: make cache 2 level nested map --- config/config.go | 39 ++++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/config/config.go b/config/config.go index 665f5a5ae..181ca6102 100644 --- a/config/config.go +++ b/config/config.go @@ -21,7 +21,7 @@ import ( type config struct { Env *AppConfig db *gorm.DB - cache map[string]string + cache map[string]map[string]string // key -> encryptionKeyHash -> value cacheMutex sync.Mutex } @@ -32,7 +32,7 @@ const ( func NewConfig(env *AppConfig, db *gorm.DB) (*config, error) { cfg := &config{ db: db, - cache: map[string]string{}, + cache: map[string]map[string]string{}, } err := cfg.init(env) if err != nil { @@ -180,23 +180,25 @@ func (cfg *config) GetMempoolUrl() string { return strings.TrimSuffix(mempoolApiUrl, "/api") } -func (cfg *config) getCacheKey(key string, encryptionKey string) string { +func (cfg *config) getEncryptionKeyHash(encryptionKey string) string { if encryptionKey == "" { - return key + return "" } hash := sha256.Sum256([]byte(encryptionKey)) - return key + ":" + hex.EncodeToString(hash[:8]) + return hex.EncodeToString(hash[:8]) } func (cfg *config) Get(key string, encryptionKey string) (string, error) { cfg.cacheMutex.Lock() defer cfg.cacheMutex.Unlock() - cacheKey := cfg.getCacheKey(key, encryptionKey) - cachedValue, ok := cfg.cache[cacheKey] - if ok { - logger.Logger.WithField("key", key).Debug("hit config cache") - return cachedValue, nil + encKeyHash := cfg.getEncryptionKeyHash(encryptionKey) + + if keyCache, ok := cfg.cache[key]; ok { + if cachedValue, ok := keyCache[encKeyHash]; ok { + logger.Logger.WithField("key", key).Debug("hit config cache") + return cachedValue, nil + } } logger.Logger.WithField("key", key).Debug("missed config cache") @@ -204,7 +206,11 @@ func (cfg *config) Get(key string, encryptionKey string) (string, error) { if err != nil { return "", err } - cfg.cache[cacheKey] = value + + if cfg.cache[key] == nil { + cfg.cache[key] = make(map[string]string) + } + cfg.cache[key][encKeyHash] = value logger.Logger.WithField("key", key).Debug("set config cache") return value, nil } @@ -245,16 +251,7 @@ func (cfg *config) set(key string, value string, clauses clause.OnConflict, encr logger.Logger.WithField("key", key).Debug("clearing config cache") cfg.cacheMutex.Lock() defer cfg.cacheMutex.Unlock() - - if encryptionKey != "" { - for cacheKey := range cfg.cache { - if cacheKey == key || strings.HasPrefix(cacheKey, key+":") { - delete(cfg.cache, cacheKey) - } - } - } else { - delete(cfg.cache, key) - } + delete(cfg.cache, key) return nil } From 6c0a0c1d56107ace4f4328bf510d965a63f15961 Mon Sep 17 00:00:00 2001 From: fmar Date: Thu, 11 Dec 2025 18:35:41 +0100 Subject: [PATCH 04/20] chore: comment --- config/config.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config/config.go b/config/config.go index 181ca6102..386fc0947 100644 --- a/config/config.go +++ b/config/config.go @@ -185,6 +185,9 @@ func (cfg *config) getEncryptionKeyHash(encryptionKey string) string { return "" } hash := sha256.Sum256([]byte(encryptionKey)) + // For cache key purposes, 8 bytes (16 hex chars) provides: + // 2^64 possible values = ~18 quintillion combinations + // More than sufficient to avoid collisions for cache keys return hex.EncodeToString(hash[:8]) } From 23bef23498ca875f2e76869da6a9daf0f7d65ca9 Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 12 Dec 2025 06:18:12 +0100 Subject: [PATCH 05/20] fix: missing import --- config/config.go | 1 + 1 file changed, 1 insertion(+) diff --git a/config/config.go b/config/config.go index 386fc0947..132e5c35b 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "crypto/rand" + "crypto/sha256" "encoding/hex" "errors" "fmt" From c43a37fa77d4ae43d78ee257b646d7dbc842ebdf Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 12 Dec 2025 09:28:09 +0100 Subject: [PATCH 06/20] fix: use ValidateXpub --- api/api.go | 36 ++++++++++++++---------------------- config/config.go | 2 +- swaps/swaps_service.go | 7 ++++--- 3 files changed, 19 insertions(+), 26 deletions(-) diff --git a/api/api.go b/api/api.go index 60c0cefd3..f65c6e8a3 100644 --- a/api/api.go +++ b/api/api.go @@ -960,17 +960,22 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E return err } - // Check if destination is an xpub - if so, it needs to be encrypted + if api.svc.GetSwapsService() == nil { + return errors.New("SwapsService not started") + } + encryptionKey := "" - if enableAutoSwapsRequest.Destination != "" && api.isXpub(enableAutoSwapsRequest.Destination) { - // Validate the unlock password - if enableAutoSwapsRequest.UnlockPassword == "" { - return errors.New("unlock password is required when using an xpub as destination") - } - if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) { - return errors.New("invalid unlock password") + if enableAutoSwapsRequest.Destination != "" { + + if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err == nil { + if enableAutoSwapsRequest.UnlockPassword == "" { + return errors.New("unlock password is required when using an xpub as destination") + } + if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) { + return errors.New("invalid unlock password") + } + encryptionKey = enableAutoSwapsRequest.UnlockPassword } - encryptionKey = enableAutoSwapsRequest.UnlockPassword } err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey) @@ -979,9 +984,6 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E return err } - if api.svc.GetSwapsService() == nil { - return errors.New("SwapsService not started") - } return api.svc.GetSwapsService().EnableAutoSwapOut(enableAutoSwapsRequest.UnlockPassword) } @@ -1801,13 +1803,3 @@ func (api *api) GetForwards() (*GetForwardsResponse, error) { NumForwards: uint64(numForwards), }, nil } - -func (api *api) isXpub(destination string) bool { - // Check if the destination starts with xpub, ypub, zpub, tpub, upub, or vpub - return strings.HasPrefix(destination, "xpub") || - strings.HasPrefix(destination, "ypub") || - strings.HasPrefix(destination, "zpub") || - strings.HasPrefix(destination, "tpub") || - strings.HasPrefix(destination, "upub") || - strings.HasPrefix(destination, "vpub") -} diff --git a/config/config.go b/config/config.go index 132e5c35b..7a3c355cb 100644 --- a/config/config.go +++ b/config/config.go @@ -276,7 +276,7 @@ func (cfg *config) SetIgnore(key string, value string, encryptionKey string) err func (cfg *config) SetUpdate(key string, value string, encryptionKey string) error { clauses := clause.OnConflict{ Columns: []clause.Column{{Name: "key"}}, - DoUpdates: clause.AssignmentColumns([]string{"value"}), + DoUpdates: clause.AssignmentColumns([]string{"value", "encrypted"}), } err := cfg.set(key, value, clauses, encryptionKey, cfg.db) if err != nil { diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index 05b75dfe5..b17c70e37 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -63,6 +63,7 @@ type SwapsService interface { GetSwap(swapId string) (*Swap, error) ListSwaps() ([]Swap, error) GetDecryptedAutoSwapXpub() string + ValidateXpub(xpub string) error } const ( @@ -182,7 +183,7 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { // Store the decrypted XPUB in memory (encryption key is discarded after this function) // This follows the same pattern as the mnemonic in the keys service - if swapDestination != "" && svc.validateXpub(swapDestination) == nil { + if swapDestination != "" && svc.ValidateXpub(swapDestination) == nil { svc.decryptedXpub = swapDestination } else { svc.decryptedXpub = "" // Not an XPUB or empty @@ -1509,7 +1510,7 @@ func (svc *swapsService) getNextUnusedAddressFromXpub() (string, error) { return "", errors.New("no XPUB configured") } - if err := svc.validateXpub(destination); err != nil { + if err := svc.ValidateXpub(destination); err != nil { return "", errors.New("destination is not a valid XPUB") } @@ -1576,7 +1577,7 @@ 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 { +func (svc *swapsService) ValidateXpub(xpub string) error { _, err := hdkeychain.NewKeyFromString(xpub) if err != nil { return fmt.Errorf("invalid xpub: %w", err) From 1b31d30600159f98eee204329a1ca60ddc33090b Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 12 Dec 2025 09:30:20 +0100 Subject: [PATCH 07/20] fix: cleanup --- api/api.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/api/api.go b/api/api.go index f65c6e8a3..c03234741 100644 --- a/api/api.go +++ b/api/api.go @@ -763,17 +763,14 @@ func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) { swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "") swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "") - // Get the destination - try from memory first (decrypted XPUB), then from config swapOutDestination := "" if api.svc.GetSwapsService() != nil { - // If we have a decrypted XPUB in memory, use it decryptedXpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub() if decryptedXpub != "" { swapOutDestination = decryptedXpub } } - // If no decrypted XPUB, try to get the destination from config (could be a regular address) if swapOutDestination == "" { swapOutDestination, _ = api.cfg.Get(config.AutoSwapDestinationKey, "") } From 93ac2926cc3a81fb54de2125b9488918b71108e0 Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 12 Dec 2025 09:31:36 +0100 Subject: [PATCH 08/20] fix: cleanup --- frontend/src/screens/wallet/swap/AutoSwap.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index 9e853722c..873c80175 100644 --- a/frontend/src/screens/wallet/swap/AutoSwap.tsx +++ b/frontend/src/screens/wallet/swap/AutoSwap.tsx @@ -79,7 +79,6 @@ function AutoSwapOutForm() { return; } - // Check if using xpub and password is required const isXpub = externalType === "xpub" && !isInternalSwap; if (isXpub && !unlockPassword) { toast.error("Password required", { From fd9882b6e802545114d02722b505df4ef6289b45 Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 12 Dec 2025 09:34:11 +0100 Subject: [PATCH 09/20] fix: better name --- swaps/swaps_service.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index b17c70e37..c4f332820 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -49,7 +49,7 @@ type swapsService struct { boltzWs *boltz.Websocket swapListeners map[string]chan boltz.SwapUpdate swapListenersLock sync.Mutex - decryptedXpub string // Decrypted XPUB kept in memory (like mnemonic in keys service) + autoSwapOutDecryptedXpub string } type SwapsService interface { @@ -184,9 +184,9 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { // Store the decrypted XPUB in memory (encryption key is discarded after this function) // This follows the same pattern as the mnemonic in the keys service if swapDestination != "" && svc.ValidateXpub(swapDestination) == nil { - svc.decryptedXpub = swapDestination + svc.autoSwapOutDecryptedXpub = swapDestination } else { - svc.decryptedXpub = "" // Not an XPUB or empty + svc.autoSwapOutDecryptedXpub = "" // Not an XPUB or empty } balanceThresholdStr, _ := svc.cfg.Get(config.AutoSwapBalanceThresholdKey, "") @@ -232,7 +232,7 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { actualDestination := swapDestination var usedXpubDerivation bool // Check if we have a decrypted XPUB in memory - if svc.decryptedXpub != "" { + if svc.autoSwapOutDecryptedXpub != "" { actualDestination, err = svc.getNextUnusedAddressFromXpub() if err != nil { logger.Logger.WithError(err).Error("Failed to get next address from xpub") @@ -1505,7 +1505,7 @@ func (svc *swapsService) checkAddressHasTransactions(address string, esploraApiR func (svc *swapsService) getNextUnusedAddressFromXpub() (string, error) { // Use the decrypted XPUB from memory (already decrypted during EnableAutoSwapOut) - destination := svc.decryptedXpub + destination := svc.autoSwapOutDecryptedXpub if destination == "" { return "", errors.New("no XPUB configured") } @@ -1586,5 +1586,5 @@ func (svc *swapsService) ValidateXpub(xpub string) error { } func (svc *swapsService) GetDecryptedAutoSwapXpub() string { - return svc.decryptedXpub + return svc.autoSwapOutDecryptedXpub } From 7bd1f4586d64b719df35d2693b264d1774a77e75 Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 12 Dec 2025 09:35:13 +0100 Subject: [PATCH 10/20] fix: cleanup --- swaps/swaps_service.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index c4f332820..0a0fcf830 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -152,8 +152,6 @@ func NewSwapsService(ctx context.Context, db *gorm.DB, cfg config.Config, keys k } }() - // Decrypt the XPUB once during initialization if it exists - // The encryption key is only used here and then discarded err := svc.EnableAutoSwapOut(encryptionKey) if err != nil { logger.Logger.WithError(err).Error("Couldn't enable auto swaps") From 098de1b57ac33d4a431902042709c54db2662854 Mon Sep 17 00:00:00 2001 From: fmar Date: Fri, 12 Dec 2025 09:35:41 +0100 Subject: [PATCH 11/20] fix: cleanup --- swaps/swaps_service.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index 0a0fcf830..7c48ca057 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -175,12 +175,8 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { ctx, cancelFn := context.WithCancel(svc.ctx) - // Try to decrypt the destination with the encryption key - // If it's an XPUB, it will be encrypted and we decrypt it once here swapDestination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, encryptionKey) - // Store the decrypted XPUB in memory (encryption key is discarded after this function) - // This follows the same pattern as the mnemonic in the keys service if swapDestination != "" && svc.ValidateXpub(swapDestination) == nil { svc.autoSwapOutDecryptedXpub = swapDestination } else { From d80692c132161fbb5e2e9de371560bd6a2d67bdf Mon Sep 17 00:00:00 2001 From: anon Date: Fri, 13 Mar 2026 10:31:26 +0100 Subject: [PATCH 12/20] fix: cleanup --- swaps/swaps_service.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index 7c48ca057..32040cf5d 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -233,9 +233,6 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { continue } usedXpubDerivation = true - } else if swapDestination != "" { - // Regular address (not XPUB) - actualDestination = swapDestination } logger.Logger.WithFields(logrus.Fields{ From 50e5c50310013238c29c4137613f01afbcf16b57 Mon Sep 17 00:00:00 2001 From: anon Date: Fri, 13 Mar 2026 11:20:31 +0100 Subject: [PATCH 13/20] fix: open dialog to ask password on autoswap xpub --- frontend/src/screens/wallet/swap/AutoSwap.tsx | 451 ++++++++++-------- 1 file changed, 254 insertions(+), 197 deletions(-) diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index 873c80175..a5382fb35 100644 --- a/frontend/src/screens/wallet/swap/AutoSwap.tsx +++ b/frontend/src/screens/wallet/swap/AutoSwap.tsx @@ -13,6 +13,15 @@ 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"; @@ -67,11 +76,13 @@ function AutoSwapOutForm() { "address" ); const [unlockPassword, setUnlockPassword] = useState(""); + const [showUnlockPasswordDialog, setShowUnlockPasswordDialog] = + useState(false); const [loading, setLoading] = useState(false); - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); + const isXpub = externalType === "xpub" && !isInternalSwap; + const submitAutoSwap = async (password?: string) => { if (swapAmount > balanceThreshold) { toast.info( "Balance threshold must be greater than or equal to swap amount" @@ -79,11 +90,8 @@ function AutoSwapOutForm() { return; } - const isXpub = externalType === "xpub" && !isInternalSwap; - if (isXpub && !unlockPassword) { - toast.error("Password required", { - description: "Please enter your unlock password to encrypt the XPUB", - }); + if (isXpub && !password) { + setShowUnlockPasswordDialog(true); return; } @@ -98,10 +106,12 @@ function AutoSwapOutForm() { swapAmount: parseInt(swapAmount), balanceThreshold: parseInt(balanceThreshold), destination, - unlockPassword: isXpub ? unlockPassword : undefined, + unlockPassword: isXpub ? password : undefined, }), }); toast("Auto swap enabled successfully"); + setUnlockPassword(""); + setShowUnlockPasswordDialog(false); await mutate(); } catch (error) { toast("Failed to save auto swap settings", { @@ -112,6 +122,12 @@ function AutoSwapOutForm() { } }; + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + await submitAutoSwap(unlockPassword); + }; + const paste = async () => { const text = await navigator.clipboard.readText(); setDestination(text.trim()); @@ -122,208 +138,249 @@ function AutoSwapOutForm() { } 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 -

-
-
-
- -
- -

- Generate new addresses from extended public key -

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

+ Minimum{" "} + +

+
+
+ + { + setDestination(""); + setInternalSwap(!isInternalSwap); + }} + className="flex gap-4 flex-row" + > +
+ - + On-chain balance +
-

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

-
- {externalType === "xpub" && ( -
- - + + +
+ +
+ {!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 +

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

- Your password is required to encrypt the XPUB for secure storage + {externalType === "address" + ? "Enter a Bitcoin address to receive swapped funds" + : "Enter an XPUB to automatically generate new addresses for each swap"}

+ {externalType === "xpub" && ( +

+ You will be asked to enter your unlock password when enabling + auto swap. +

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

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

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

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

+ + Begin Auto Swap + +

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

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

- powered by{" "} - + + { + setShowUnlockPasswordDialog(open); + if (!open) { + setUnlockPassword(""); + } + }} + > + +

{ + e.preventDefault(); + void submitAutoSwap(unlockPassword); + }} > - boltz.exchange - -

-
- + + Confirm Auto Swap Setup + +
+

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

+
+ + +
+
+
+
+ + Cancel + + + + + + ); } From 29496acf5c28857736c1072d49815ff3d0dd1b97 Mon Sep 17 00:00:00 2001 From: anon Date: Fri, 13 Mar 2026 11:23:25 +0100 Subject: [PATCH 14/20] fix: reject extended private keys --- swaps/swaps_service.go | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index 32040cf5d..084d9e8f9 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -37,19 +37,19 @@ 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 - autoSwapOutDecryptedXpub string + 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 + autoSwapOutDecryptedXpub string } type SwapsService interface { @@ -1569,10 +1569,15 @@ func (svc *swapsService) getNextUnusedAddressFromXpub() (string, error) { } func (svc *swapsService) ValidateXpub(xpub string) error { - _, err := hdkeychain.NewKeyFromString(xpub) + 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 } From 08385fff68fa6af7c795beec6eee26e6c9db1fcf Mon Sep 17 00:00:00 2001 From: anon Date: Fri, 13 Mar 2026 11:29:40 +0100 Subject: [PATCH 15/20] fix: fix amount comp --- frontend/src/screens/wallet/swap/AutoSwap.tsx | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index a5382fb35..a2e730226 100644 --- a/frontend/src/screens/wallet/swap/AutoSwap.tsx +++ b/frontend/src/screens/wallet/swap/AutoSwap.tsx @@ -83,7 +83,21 @@ function AutoSwapOutForm() { const isXpub = externalType === "xpub" && !isInternalSwap; const submitAutoSwap = async (password?: string) => { - if (swapAmount > balanceThreshold) { + const swapAmountNum = Number(swapAmount); + const balanceThresholdNum = Number(balanceThreshold); + + if ( + !Number.isFinite(swapAmountNum) || + !Number.isFinite(balanceThresholdNum) + ) { + toast.error("Invalid amount", { + description: + "Please enter valid numeric values for swap amount and balance threshold", + }); + return; + } + + if (!(swapAmountNum <= balanceThresholdNum)) { toast.info( "Balance threshold must be greater than or equal to swap amount" ); @@ -103,8 +117,8 @@ function AutoSwapOutForm() { "Content-Type": "application/json", }, body: JSON.stringify({ - swapAmount: parseInt(swapAmount), - balanceThreshold: parseInt(balanceThreshold), + swapAmount: swapAmountNum, + balanceThreshold: balanceThresholdNum, destination, unlockPassword: isXpub ? password : undefined, }), From ca5f0d81ef5afb88b2d2e8d3b4f4e09cf34b582b Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 25 Mar 2026 18:20:05 +0530 Subject: [PATCH 16/20] chore: improve autoswap destination handling and form submission flow --- api/api.go | 35 ++++---- frontend/src/screens/wallet/swap/AutoSwap.tsx | 87 ++++++------------- swaps/swaps_service.go | 30 +++---- 3 files changed, 57 insertions(+), 95 deletions(-) diff --git a/api/api.go b/api/api.go index 95c0ba5d7..5ccf10228 100644 --- a/api/api.go +++ b/api/api.go @@ -810,19 +810,16 @@ 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, "") - swapOutDestination := "" - if api.svc.GetSwapsService() != nil { - decryptedXpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub() - if decryptedXpub != "" { - swapOutDestination = decryptedXpub - } - } - - if swapOutDestination == "" { - swapOutDestination, _ = api.cfg.Get(config.AutoSwapDestinationKey, "") + if xpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub(); xpub != "" { + swapOutDestination = xpub } swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != "" @@ -995,6 +992,10 @@ 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") + } + 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") @@ -1007,17 +1008,13 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E return err } - if api.svc.GetSwapsService() == nil { - return errors.New("SwapsService not started") - } - encryptionKey := "" if enableAutoSwapsRequest.Destination != "" { - - if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err == nil { - if enableAutoSwapsRequest.UnlockPassword == "" { - return errors.New("unlock password is required when using an xpub as destination") - } + isXpub, err := api.svc.GetSwapsService().ParseSwapDestination(enableAutoSwapsRequest.Destination) + if err != nil { + return err + } + if isXpub { if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) { return errors.New("invalid unlock password") } diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index a2e730226..557d863d3 100644 --- a/frontend/src/screens/wallet/swap/AutoSwap.tsx +++ b/frontend/src/screens/wallet/swap/AutoSwap.tsx @@ -27,7 +27,6 @@ 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 { AutoSwapConfig } from "src/types"; import { request } from "src/utils/request"; @@ -64,7 +63,6 @@ export default function AutoSwap() { } function AutoSwapOutForm() { - const { data: balances } = useBalances(); const { mutate } = useAutoSwapsConfig(); const { data: swapInfo } = useSwapInfo("out"); @@ -80,35 +78,30 @@ function AutoSwapOutForm() { useState(false); const [loading, setLoading] = useState(false); - const isXpub = externalType === "xpub" && !isInternalSwap; - - const submitAutoSwap = async (password?: string) => { - const swapAmountNum = Number(swapAmount); - const balanceThresholdNum = Number(balanceThreshold); - - if ( - !Number.isFinite(swapAmountNum) || - !Number.isFinite(balanceThresholdNum) - ) { - toast.error("Invalid amount", { - description: - "Please enter valid numeric values for swap amount and balance threshold", - }); - return; - } + const onSubmit = async (e: React.FormEvent) => { + e.preventDefault(); - if (!(swapAmountNum <= balanceThresholdNum)) { + if (Number(swapAmount) > Number(balanceThreshold)) { toast.info( "Balance threshold must be greater than or equal to swap amount" ); return; } - if (isXpub && !password) { + 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", { @@ -117,15 +110,15 @@ function AutoSwapOutForm() { "Content-Type": "application/json", }, body: JSON.stringify({ - swapAmount: swapAmountNum, - balanceThreshold: balanceThresholdNum, + swapAmount: parseInt(swapAmount), + balanceThreshold: parseInt(balanceThreshold), destination, - unlockPassword: isXpub ? password : undefined, + unlockPassword: password, }), }); - toast("Auto swap enabled successfully"); setUnlockPassword(""); setShowUnlockPasswordDialog(false); + toast("Auto swap enabled successfully"); await mutate(); } catch (error) { toast("Failed to save auto swap settings", { @@ -136,18 +129,12 @@ function AutoSwapOutForm() { } }; - const onSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - - await submitAutoSwap(unlockPassword); - }; - const paste = async () => { const text = await navigator.clipboard.readText(); setDestination(text.trim()); }; - if (!balances || !swapInfo) { + if (!swapInfo) { return ; } @@ -202,7 +189,6 @@ function AutoSwapOutForm() {
{ setDestination(""); @@ -311,28 +297,18 @@ function AutoSwapOutForm() {

{externalType === "address" ? "Enter a Bitcoin address to receive swapped funds" - : "Enter an XPUB to automatically generate new addresses for each swap"} + : "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 === "xpub" && ( -

- You will be asked to enter your unlock password when enabling - auto swap. -

- )}
)}
- {swapInfo ? ( -

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

- ) : ( - - )} +

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

@@ -359,12 +335,7 @@ function AutoSwapOutForm() { }} > -
{ - e.preventDefault(); - void submitAutoSwap(unlockPassword); - }} - > + Confirm Auto Swap Setup @@ -448,9 +419,7 @@ function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) {
Destination
- {swapConfig.destination - ? swapConfig.destination - : "On-chain Balance"} + {swapConfig.destination || "On-chain Balance"}
@@ -481,11 +450,7 @@ function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) { )}
- diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index 4ae54f2e2..180113dff 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -63,7 +63,7 @@ type SwapsService interface { GetSwap(swapId string) (*Swap, error) ListSwaps() ([]Swap, error) GetDecryptedAutoSwapXpub() string - ValidateXpub(xpub string) error + ParseSwapDestination(destination string) (bool, error) } const ( @@ -176,11 +176,15 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { ctx, cancelFn := context.WithCancel(svc.ctx) swapDestination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, encryptionKey) - - if swapDestination != "" && svc.ValidateXpub(swapDestination) == nil { - svc.autoSwapOutDecryptedXpub = swapDestination - } else { - svc.autoSwapOutDecryptedXpub = "" // Not an XPUB or empty + if swapDestination != "" { + isXpub, err := svc.ParseSwapDestination(swapDestination) + if err != nil { + cancelFn() + return err + } + if isXpub { + svc.autoSwapOutDecryptedXpub = swapDestination + } } balanceThresholdStr, _ := svc.cfg.Get(config.AutoSwapBalanceThresholdKey, "") @@ -1506,10 +1510,6 @@ func (svc *swapsService) getNextUnusedAddressFromXpub() (string, error) { return "", errors.New("no XPUB configured") } - if err := svc.ValidateXpub(destination); err != nil { - return "", errors.New("destination is not a valid XPUB") - } - indexStr, err := svc.cfg.Get(config.AutoSwapXpubIndexStart, "") if err != nil { return "", err @@ -1573,17 +1573,17 @@ 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 { - extendedKey, err := hdkeychain.NewKeyFromString(xpub) +func (svc *swapsService) ParseSwapDestination(destination string) (isXpub bool, err error) { + extendedKey, err := hdkeychain.NewKeyFromString(destination) if err != nil { - return fmt.Errorf("invalid xpub: %w", err) + return false, nil } if extendedKey.IsPrivate() { - return fmt.Errorf("private extended key not allowed") + return true, fmt.Errorf("private extended key not allowed") } - return nil + return true, nil } func (svc *swapsService) GetDecryptedAutoSwapXpub() string { From a5076b7cfbccd7f6b318b3a4418e5733ccc297c8 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 25 Mar 2026 18:20:49 +0530 Subject: [PATCH 17/20] fix: clear cached xpub when enabling autoswaps --- swaps/swaps_service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index 180113dff..6519879b5 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -174,6 +174,7 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { svc.StopAutoSwapOut() ctx, cancelFn := context.WithCancel(svc.ctx) + svc.autoSwapOutDecryptedXpub = "" swapDestination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, encryptionKey) if swapDestination != "" { From 4fe3a34b3d0c8260eb5a6fe386da5c5c0825625d Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 25 Mar 2026 21:43:25 +0530 Subject: [PATCH 18/20] fix: autoswap destination validation and xpub handling --- api/api.go | 34 ++++++++------ api/models.go | 1 + frontend/src/screens/wallet/swap/AutoSwap.tsx | 1 + .../src/screens/wallet/swap/SwapOutStatus.tsx | 22 +++++---- swaps/swaps_service.go | 47 +++++++++++++------ 5 files changed, 68 insertions(+), 37 deletions(-) diff --git a/api/api.go b/api/api.go index 5ccf10228..38ec705bc 100644 --- a/api/api.go +++ b/api/api.go @@ -996,6 +996,26 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E 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") @@ -1008,20 +1028,6 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E return err } - encryptionKey := "" - if enableAutoSwapsRequest.Destination != "" { - isXpub, err := api.svc.GetSwapsService().ParseSwapDestination(enableAutoSwapsRequest.Destination) - if err != nil { - return err - } - if isXpub { - if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) { - return errors.New("invalid unlock password") - } - encryptionKey = enableAutoSwapsRequest.UnlockPassword - } - } - err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey) if err != nil { logger.Logger.WithError(err).Error("Failed to save autoswap destination to config") diff --git a/api/models.go b/api/models.go index 6191ee8b9..4d2151253 100644 --- a/api/models.go +++ b/api/models.go @@ -168,6 +168,7 @@ type EnableAutoSwapRequest struct { BalanceThreshold uint64 `json:"balanceThreshold"` SwapAmount uint64 `json:"swapAmount"` Destination string `json:"destination"` + DestinationType string `json:"destinationType"` UnlockPassword string `json:"unlockPassword"` } diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index 557d863d3..1990ecb6e 100644 --- a/frontend/src/screens/wallet/swap/AutoSwap.tsx +++ b/frontend/src/screens/wallet/swap/AutoSwap.tsx @@ -113,6 +113,7 @@ function AutoSwapOutForm() { swapAmount: parseInt(swapAmount), balanceThreshold: parseInt(balanceThreshold), destination, + destinationType: !isInternalSwap ? externalType : undefined, unlockPassword: password, }), }); diff --git a/frontend/src/screens/wallet/swap/SwapOutStatus.tsx b/frontend/src/screens/wallet/swap/SwapOutStatus.tsx index 8e6a94e08..864d531ba 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/swaps/swaps_service.go b/swaps/swaps_service.go index 6519879b5..d570d40e1 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -63,7 +63,8 @@ type SwapsService interface { GetSwap(swapId string) (*Swap, error) ListSwaps() ([]Swap, error) GetDecryptedAutoSwapXpub() string - ParseSwapDestination(destination string) (bool, error) + ValidateAddress(address string) error + ValidateXpub(xpub string) error } const ( @@ -178,12 +179,7 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { swapDestination, _ := svc.cfg.Get(config.AutoSwapDestinationKey, encryptionKey) if swapDestination != "" { - isXpub, err := svc.ParseSwapDestination(swapDestination) - if err != nil { - cancelFn() - return err - } - if isXpub { + if err := svc.ValidateXpub(swapDestination); err == nil { svc.autoSwapOutDecryptedXpub = swapDestination } } @@ -1446,7 +1442,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": @@ -1458,7 +1454,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) @@ -1574,17 +1579,31 @@ 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) ParseSwapDestination(destination string) (isXpub bool, err error) { - extendedKey, err := hdkeychain.NewKeyFromString(destination) +func (svc *swapsService) ValidateAddress(address string) error { + netParams, err := svc.getChainParams() if err != nil { - return false, 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 true, fmt.Errorf("private extended key not allowed") + return fmt.Errorf("private extended key not allowed") } - return true, nil + return nil } func (svc *swapsService) GetDecryptedAutoSwapXpub() string { From b96d23167753474ffdb7e4d0bbf16857721b3759 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 25 Mar 2026 22:14:54 +0530 Subject: [PATCH 19/20] chore: add mutex for auto swap xpub --- swaps/swaps_service.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/swaps/swaps_service.go b/swaps/swaps_service.go index d570d40e1..b13bff409 100644 --- a/swaps/swaps_service.go +++ b/swaps/swaps_service.go @@ -49,6 +49,7 @@ type swapsService struct { boltzWs *boltz.Websocket swapListeners map[string]chan boltz.SwapUpdate swapListenersLock sync.Mutex + autoSwapOutXpubLock sync.Mutex autoSwapOutDecryptedXpub string } @@ -169,18 +170,25 @@ 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(encryptionKey string) error { svc.StopAutoSwapOut() ctx, cancelFn := context.WithCancel(svc.ctx) + 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() } } @@ -227,7 +235,10 @@ func (svc *swapsService) EnableAutoSwapOut(encryptionKey string) error { actualDestination := swapDestination var usedXpubDerivation bool // Check if we have a decrypted XPUB in memory - if svc.autoSwapOutDecryptedXpub != "" { + 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") @@ -1511,7 +1522,9 @@ func (svc *swapsService) checkAddressHasTransactions(address string, esploraApiR func (svc *swapsService) getNextUnusedAddressFromXpub() (string, error) { // 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 XPUB configured") } @@ -1607,5 +1620,8 @@ func (svc *swapsService) ValidateXpub(xpub string) error { } func (svc *swapsService) GetDecryptedAutoSwapXpub() string { + svc.autoSwapOutXpubLock.Lock() + defer svc.autoSwapOutXpubLock.Unlock() + return svc.autoSwapOutDecryptedXpub } From ca1bb88668074b7ba881242d0c9d1cc126c64d0a Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 25 Mar 2026 22:27:46 +0530 Subject: [PATCH 20/20] chore: add copy icon for destination in auto swap info --- frontend/src/screens/wallet/swap/AutoSwap.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/frontend/src/screens/wallet/swap/AutoSwap.tsx b/frontend/src/screens/wallet/swap/AutoSwap.tsx index 1990ecb6e..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"; @@ -28,6 +29,7 @@ import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group"; import { useAutoSwapsConfig, useSwapInfo } from "src/hooks/useSwaps"; +import { copyToClipboard } from "src/lib/clipboard"; import { AutoSwapConfig } from "src/types"; import { request } from "src/utils/request"; @@ -419,8 +421,16 @@ function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) {
Destination
-
- {swapConfig.destination || "On-chain Balance"} +
+
+ {swapConfig.destination || "On-chain Balance"} +
+ {swapConfig.destination && ( + copyToClipboard(swapConfig.destination)} + /> + )}