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 (
-