feat: encrypt xpub for auto swap outs (#1973)

* feat: encrypt xpub for auto swap out

* fix: include encryptionKey in the cache key (sha256)

* fix: make cache 2 level nested map

* chore: comment

* fix: missing import

* fix: use ValidateXpub

* fix: cleanup

* fix: cleanup

* fix: better name

* fix: cleanup

* fix: cleanup

* fix: cleanup

* fix: open dialog to ask password on autoswap xpub

* fix: reject extended private keys

* fix: fix amount comp

* chore: improve autoswap destination handling and form submission flow

* fix: clear cached xpub when enabling autoswaps

* fix: autoswap destination validation and xpub handling

* chore: add mutex for auto swap xpub

* chore: add copy icon for destination in auto swap info

---------

Co-authored-by: fmar <fmar@fmar.dev>
Co-authored-by: anon <anon@anon.com>
Co-authored-by: im-adithya <imadithyavardhan@gmail.com>
This commit is contained in:
frnandu 2026-03-25 16:58:16 +00:00 committed by GitHub
parent 06b21139bc
commit 354099cbff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 408 additions and 244 deletions

View file

@ -810,10 +810,18 @@ func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error {
}
func (api *api) GetAutoSwapConfig() (*GetAutoSwapConfigResponse, error) {
if api.svc.GetSwapsService() == nil {
return nil, errors.New("SwapsService not started")
}
swapOutBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "")
swapOutAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "")
swapOutDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "")
if xpub := api.svc.GetSwapsService().GetDecryptedAutoSwapXpub(); xpub != "" {
swapOutDestination = xpub
}
swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != ""
var swapOutBalanceThreshold, swapOutAmount uint64
if swapOutEnabled {
@ -984,6 +992,30 @@ func (api *api) InitiateSwapIn(ctx context.Context, initiateSwapInRequest *Initi
}
func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *EnableAutoSwapRequest) error {
if api.svc.GetSwapsService() == nil {
return errors.New("SwapsService not started")
}
encryptionKey := ""
if enableAutoSwapsRequest.Destination != "" {
switch enableAutoSwapsRequest.DestinationType {
case "address":
if err := api.svc.GetSwapsService().ValidateAddress(enableAutoSwapsRequest.Destination); err != nil {
return err
}
case "xpub":
if !api.cfg.CheckUnlockPassword(enableAutoSwapsRequest.UnlockPassword) {
return errors.New("invalid unlock password")
}
if err := api.svc.GetSwapsService().ValidateXpub(enableAutoSwapsRequest.Destination); err != nil {
return err
}
encryptionKey = enableAutoSwapsRequest.UnlockPassword
default:
return errors.New("destination type must be address or xpub")
}
}
err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(enableAutoSwapsRequest.BalanceThreshold, 10), "")
if err != nil {
logger.Logger.WithError(err).Error("Failed to save autoswap balance threshold to config")
@ -996,16 +1028,13 @@ func (api *api) EnableAutoSwapOut(ctx context.Context, enableAutoSwapsRequest *E
return err
}
err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, "")
err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, encryptionKey)
if err != nil {
logger.Logger.WithError(err).Error("Failed to save autoswap destination to config")
return err
}
if api.svc.GetSwapsService() == nil {
return errors.New("SwapsService not started")
}
return api.svc.GetSwapsService().EnableAutoSwapOut()
return api.svc.GetSwapsService().EnableAutoSwapOut(enableAutoSwapsRequest.UnlockPassword)
}
func (api *api) DisableAutoSwap() error {

View file

@ -168,6 +168,8 @@ type EnableAutoSwapRequest struct {
BalanceThreshold uint64 `json:"balanceThreshold"`
SwapAmount uint64 `json:"swapAmount"`
Destination string `json:"destination"`
DestinationType string `json:"destinationType"`
UnlockPassword string `json:"unlockPassword"`
}
type GetAutoSwapConfigResponse struct {

View file

@ -2,6 +2,7 @@ import {
ArrowDownUpIcon,
ClipboardPasteIcon,
ClockIcon,
CopyIcon,
MoveRightIcon,
XCircleIcon,
} from "lucide-react";
@ -11,14 +12,24 @@ import AppHeader from "src/components/AppHeader";
import ExternalLink from "src/components/ExternalLink";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import Loading from "src/components/Loading";
import PasswordInput from "src/components/password/PasswordInput";
import ResponsiveLinkButton from "src/components/ResponsiveLinkButton";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "src/components/ui/alert-dialog";
import { Button } from "src/components/ui/button";
import { LoadingButton } from "src/components/ui/custom/loading-button";
import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
import { RadioGroup, RadioGroupItem } from "src/components/ui/radio-group";
import { useBalances } from "src/hooks/useBalances";
import { useAutoSwapsConfig, useSwapInfo } from "src/hooks/useSwaps";
import { copyToClipboard } from "src/lib/clipboard";
import { AutoSwapConfig } from "src/types";
import { request } from "src/utils/request";
@ -54,7 +65,6 @@ export default function AutoSwap() {
}
function AutoSwapOutForm() {
const { data: balances } = useBalances();
const { mutate } = useAutoSwapsConfig();
const { data: swapInfo } = useSwapInfo("out");
@ -65,18 +75,35 @@ function AutoSwapOutForm() {
const [externalType, setExternalType] = useState<"address" | "xpub">(
"address"
);
const [unlockPassword, setUnlockPassword] = useState("");
const [showUnlockPasswordDialog, setShowUnlockPasswordDialog] =
useState(false);
const [loading, setLoading] = useState(false);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (swapAmount > balanceThreshold) {
if (Number(swapAmount) > Number(balanceThreshold)) {
toast.info(
"Balance threshold must be greater than or equal to swap amount"
);
return;
}
if (externalType === "xpub" && !isInternalSwap) {
setShowUnlockPasswordDialog(true);
return;
}
await submitAutoSwap();
};
const onConfirmUnlockPassword = async (e: React.FormEvent) => {
e.preventDefault();
await submitAutoSwap(unlockPassword);
};
const submitAutoSwap = async (password?: string) => {
try {
setLoading(true);
await request("/api/autoswap", {
@ -88,8 +115,12 @@ function AutoSwapOutForm() {
swapAmount: parseInt(swapAmount),
balanceThreshold: parseInt(balanceThreshold),
destination,
destinationType: !isInternalSwap ? externalType : undefined,
unlockPassword: password,
}),
});
setUnlockPassword("");
setShowUnlockPasswordDialog(false);
toast("Auto swap enabled successfully");
await mutate();
} catch (error) {
@ -106,198 +137,238 @@ function AutoSwapOutForm() {
setDestination(text.trim());
};
if (!balances || !swapInfo) {
if (!swapInfo) {
return <Loading />;
}
return (
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<div>
<h2 className="font-medium text-foreground flex items-center gap-1">
Lightning <MoveRightIcon /> On-chain
</h2>
<p className="mt-1 text-muted-foreground">
Setup automatic swap of lightning funds into your on-chain balance
every time a set threshold is reached.
</p>
<p className="mt-2 text-muted-foreground flex gap-2 items-center text-sm">
<ClockIcon className="w-4 h-4" />
Swaps will be made once per hour
</p>
</div>
<div className="grid gap-1.5">
<Label>Spending balance threshold</Label>
<Input
type="number"
placeholder="Amount in satoshis"
value={balanceThreshold}
min={swapAmount}
onChange={(e) => setBalanceThreshold(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Swap out as soon as this amount is reached
</p>
</div>
<div className="grid gap-1.5">
<Label>Swap amount</Label>
<Input
type="number"
placeholder="Amount in satoshis"
value={swapAmount}
min={swapInfo.minAmount}
max={swapInfo.maxAmount}
onChange={(e) => setSwapAmount(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Minimum <FormattedBitcoinAmount amount={swapInfo.minAmount * 1000} />
</p>
</div>
<div className="flex flex-col gap-4">
<Label>Swap to</Label>
<RadioGroup
defaultValue="normal"
value={isInternalSwap ? "internal" : "external"}
onValueChange={() => {
setDestination("");
setInternalSwap(!isInternalSwap);
}}
className="flex gap-4 flex-row"
>
<div className="flex items-start space-x-2 mb-2">
<RadioGroupItem
value="internal"
id="internal"
className="shrink-0"
/>
<Label
htmlFor="internal"
className="text-primary font-medium cursor-pointer"
>
On-chain balance
</Label>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem
value="external"
id="external"
className="shrink-0"
/>
<Label
htmlFor="external"
className="text-primary font-medium cursor-pointer"
>
External on-chain wallet
</Label>
</div>
</RadioGroup>
</div>
{!isInternalSwap && (
<div className="grid gap-4">
<div className="flex flex-col gap-3">
<Label>Destination Type</Label>
<RadioGroup
value={externalType}
onValueChange={(value) => {
setExternalType(value as "address" | "xpub");
setDestination("");
}}
className="flex gap-4 flex-row"
>
<div className="flex items-start space-x-2">
<RadioGroupItem
value="address"
id="address"
className="shrink-0"
/>
<div className="grid gap-1.5">
<Label
htmlFor="address"
className="text-sm font-medium cursor-pointer"
>
Single Address
</Label>
<p className="text-xs text-muted-foreground">
Send to the same address each time
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem value="xpub" id="xpub" className="shrink-0" />
<div className="grid gap-1.5">
<Label
htmlFor="xpub"
className="text-sm font-medium cursor-pointer"
>
XPUB
</Label>
<p className="text-xs text-muted-foreground">
Generate new addresses from extended public key
</p>
</div>
</div>
</RadioGroup>
</div>
<div className="grid gap-1.5">
<Label>
{externalType === "address"
? "Receiving on-chain address"
: "Extended Public Key (XPUB)"}
</Label>
<div className="flex gap-2">
<Input
placeholder={externalType === "address" ? "bc1..." : "xpub..."}
value={destination}
onChange={(e) => setDestination(e.target.value)}
required
/>
<Button
type="button"
variant="outline"
className="px-2"
onClick={paste}
>
<ClipboardPasteIcon className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{externalType === "address"
? "Enter a Bitcoin address to receive swapped funds"
: "Enter an XPUB to automatically generate new addresses for each swap"}
</p>
</div>
<>
<form onSubmit={onSubmit} className="flex flex-col gap-6">
<div>
<h2 className="font-medium text-foreground flex items-center gap-1">
Lightning <MoveRightIcon /> On-chain
</h2>
<p className="mt-1 text-muted-foreground">
Setup automatic swap of lightning funds into your on-chain balance
every time a set threshold is reached.
</p>
<p className="mt-2 text-muted-foreground flex gap-2 items-center text-sm">
<ClockIcon className="w-4 h-4" />
Swaps will be made once per hour
</p>
</div>
)}
<div className="flex items-center justify-between border-t pt-4">
<Label>Fee</Label>
{swapInfo ? (
<div className="grid gap-1.5">
<Label>Spending balance threshold</Label>
<Input
type="number"
placeholder="Amount in satoshis"
value={balanceThreshold}
min={swapAmount}
onChange={(e) => setBalanceThreshold(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Swap out as soon as this amount is reached
</p>
</div>
<div className="grid gap-1.5">
<Label>Swap amount</Label>
<Input
type="number"
placeholder="Amount in satoshis"
value={swapAmount}
min={swapInfo.minAmount}
max={swapInfo.maxAmount}
onChange={(e) => setSwapAmount(e.target.value)}
required
/>
<p className="text-xs text-muted-foreground">
Minimum{" "}
<FormattedBitcoinAmount amount={swapInfo.minAmount * 1000} />
</p>
</div>
<div className="flex flex-col gap-4">
<Label>Swap to</Label>
<RadioGroup
value={isInternalSwap ? "internal" : "external"}
onValueChange={() => {
setDestination("");
setInternalSwap(!isInternalSwap);
}}
className="flex gap-4 flex-row"
>
<div className="flex items-start space-x-2 mb-2">
<RadioGroupItem
value="internal"
id="internal"
className="shrink-0"
/>
<Label
htmlFor="internal"
className="text-primary font-medium cursor-pointer"
>
On-chain balance
</Label>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem
value="external"
id="external"
className="shrink-0"
/>
<Label
htmlFor="external"
className="text-primary font-medium cursor-pointer"
>
External on-chain wallet
</Label>
</div>
</RadioGroup>
</div>
{!isInternalSwap && (
<div className="grid gap-4">
<div className="flex flex-col gap-3">
<Label>Destination Type</Label>
<RadioGroup
value={externalType}
onValueChange={(value) => {
setExternalType(value as "address" | "xpub");
setDestination("");
}}
className="flex gap-4 flex-row"
>
<div className="flex items-start space-x-2">
<RadioGroupItem
value="address"
id="address"
className="shrink-0"
/>
<div className="grid gap-1.5">
<Label
htmlFor="address"
className="text-sm font-medium cursor-pointer"
>
Single Address
</Label>
<p className="text-xs text-muted-foreground">
Send to the same address each time
</p>
</div>
</div>
<div className="flex items-start space-x-2">
<RadioGroupItem value="xpub" id="xpub" className="shrink-0" />
<div className="grid gap-1.5">
<Label
htmlFor="xpub"
className="text-sm font-medium cursor-pointer"
>
XPUB
</Label>
<p className="text-xs text-muted-foreground">
Generate new addresses from extended public key
</p>
</div>
</div>
</RadioGroup>
</div>
<div className="grid gap-1.5">
<Label>
{externalType === "address"
? "Receiving on-chain address"
: "Extended Public Key (XPUB)"}
</Label>
<div className="flex gap-2">
<Input
placeholder={
externalType === "address" ? "bc1..." : "xpub..."
}
value={destination}
onChange={(e) => setDestination(e.target.value)}
required
/>
<Button
type="button"
variant="outline"
className="px-2"
onClick={paste}
>
<ClipboardPasteIcon className="w-4 h-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
{externalType === "address"
? "Enter a Bitcoin address to receive swapped funds"
: "Enter an XPUB to automatically generate new addresses for each swap. You will be asked to enter your unlock password to encrypt it safely"}
</p>
</div>
</div>
)}
<div className="flex items-center justify-between border-t pt-4">
<Label>Fee</Label>
<p className="text-muted-foreground text-sm">
{swapInfo.albyServiceFee + swapInfo.boltzServiceFee}% + on-chain
fees
</p>
) : (
<Loading />
)}
</div>
<div className="grid gap-1">
<LoadingButton className="w-full" loading={loading}>
Begin Auto Swap
</LoadingButton>
<p className="text-xs text-muted-foreground text-right">
powered by{" "}
<ExternalLink
to="https://boltz.exchange"
className="font-medium text-foreground"
>
boltz.exchange
</ExternalLink>
</p>
</div>
</form>
</div>
<div className="grid gap-1">
<LoadingButton className="w-full" loading={loading}>
Begin Auto Swap
</LoadingButton>
<p className="text-xs text-muted-foreground text-right">
powered by{" "}
<ExternalLink
to="https://boltz.exchange"
className="font-medium text-foreground"
>
boltz.exchange
</ExternalLink>
</p>
</div>
</form>
<AlertDialog
open={showUnlockPasswordDialog}
onOpenChange={(open) => {
setShowUnlockPasswordDialog(open);
if (!open) {
setUnlockPassword("");
}
}}
>
<AlertDialogContent>
<form onSubmit={onConfirmUnlockPassword}>
<AlertDialogHeader>
<AlertDialogTitle>Confirm Auto Swap Setup</AlertDialogTitle>
<AlertDialogDescription>
<div className="flex flex-col gap-4">
<p>
Please enter your unlock password to encrypt and securely
store the XPUB.
</p>
<div className="grid gap-1.5">
<Label htmlFor="unlockPassword">Unlock Password</Label>
<PasswordInput
id="unlockPassword"
onChange={setUnlockPassword}
autoFocus
value={unlockPassword}
/>
</div>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-3">
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button type="submit" disabled={!unlockPassword || loading}>
Confirm
</Button>
</AlertDialogFooter>
</form>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@ -350,10 +421,16 @@ function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) {
</div>
<div className="flex justify-between items-center gap-2">
<div className="font-medium">Destination</div>
<div className="truncate text-muted-foreground text-right">
{swapConfig.destination
? swapConfig.destination
: "On-chain Balance"}
<div className="flex min-w-0 items-center justify-end gap-2 text-muted-foreground">
<div className="truncate text-right">
{swapConfig.destination || "On-chain Balance"}
</div>
{swapConfig.destination && (
<CopyIcon
className="cursor-pointer size-4 shrink-0"
onClick={() => copyToClipboard(swapConfig.destination)}
/>
)}
</div>
</div>
<div className="flex justify-between items-center gap-2">
@ -384,11 +461,7 @@ function ActiveSwapOutConfig({ swapConfig }: { swapConfig: AutoSwapConfig }) {
)}
</div>
</div>
<Button
onClick={() => onDeactivate()}
disabled={loading}
variant="outline"
>
<Button onClick={onDeactivate} disabled={loading} variant="outline">
<XCircleIcon />
Deactivate Auto Swap
</Button>

View file

@ -56,17 +56,21 @@ export default function SwapOutStatus() {
{swapStatus === "PENDING" && <Loading className="w-4 h-4 mr-2" />}
{statusText[swapStatus]}
</CardTitle>
<CardDescription className="flex items-center justify-center gap-2 text-muted-foreground text-sm">
<CardDescription className="text-muted-foreground text-sm">
{swap.autoSwap && (
<span>Auto swap{swap.usedXpub && <> to xpub</>} </span>
<p className="text-center mb-1">
Auto swap{swap.usedXpub && <> to xpub</>}
</p>
)}
Swap ID: {swap.id}{" "}
<CopyIcon
className="cursor-pointer text-muted-foreground size-4"
onClick={() => {
copyToClipboard(swap.id);
}}
/>
<div className="flex items-center justify-center gap-2">
Swap ID: {swap.id}{" "}
<CopyIcon
className="cursor-pointer text-muted-foreground size-4"
onClick={() => {
copyToClipboard(swap.id);
}}
/>
</div>
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center gap-4">

View file

@ -301,7 +301,7 @@ func (svc *service) StartApp(encryptionKey string) error {
return err
}
svc.swapsService = swaps.NewSwapsService(ctx, svc.db, svc.cfg, svc.keys, svc.eventPublisher, svc.GetLNClient(), svc.transactionsService)
svc.swapsService = swaps.NewSwapsService(ctx, svc.db, svc.cfg, svc.keys, svc.eventPublisher, svc.GetLNClient(), svc.transactionsService, encryptionKey)
svc.publishAllAppInfoEvents()

View file

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