Skip to content

Commit f41f963

Browse files
authored
feat: auto-swaps (#1266)
Also fixes #1303 ## TODOs - [x] Do final testing with LDK/LND on mainnet - [x] Improve fee display on the frontend, currently looks scary - [x] ~Shift Swaps from settings~ (Agreeed to do in follow-up) - [x] ~Add option to do one-time swaps~ (Agreeed to do in follow-up) ### Minor - [x] `LDK_NETWORK` config var should now be `NETWORK` - [x] ~Use LNClient method to publish transactions~ (Agreeed to do in follow-up) ### Testing - [ ] Phoenix - [ ] Cashu - [x] Wails
2 parents f4bfa42 + 5eafb75 commit f41f963

38 files changed

+1257
-137
lines changed

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,8 @@ FRONTEND_URL=http://localhost:5173
3535
#LND_ADDRESS=127.0.0.1:10001
3636
#LND_MACAROON_FILE=/home/YOUR_USERNAME/.polar/networks/1/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon
3737

38-
#LDK_VSS_URL="http://localhost:8090/vss"
38+
#LDK_VSS_URL="http://localhost:8090/vss"
39+
40+
# Boltz API
41+
#BOLTZ_API=https://api.testnet.boltz.exchange
42+
#NETWORK=testnet

README.md

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -159,12 +159,14 @@ For more information refer to:
159159
The following configuration options can be set as environment variables or in a .env file
160160

161161
- `RELAY`: default: "wss://relay.getalby.com/v1"
162-
- `JWT_SECRET`: a randomly generated secret string. (only needed in http mode)
163-
- `DATABASE_URI`: a sqlite filename or postgres URL. Default is SQLite DB `nwc.db` without a path, which will be put in the user home directory: $XDG_DATA_HOME/albyhub/nwc.db
164-
- `PORT`: the port on which the app should listen on (default: 8080)
165-
- `WORK_DIR`: directory to store NWC data files. Default: $XDG_DATA_HOME/albyhub
166-
- `LOG_LEVEL`: log level for the application. Higher is more verbose. Default: 4 (info)
167-
- `AUTO_UNLOCK_PASSWORD`: provide unlock password to auto-unlock Alby Hub on startup (e.g. after a machine restart). Unlock password still be required to access the interface.
162+
- `JWT_SECRET`: A randomly generated secret string. (only needed in http mode)
163+
- `DATABASE_URI`: A sqlite filename or postgres URL. Default is SQLite DB `nwc.db` without a path, which will be put in the user home directory: $XDG_DATA_HOME/albyhub/nwc.db
164+
- `PORT`: The port on which the app should listen on (default: 8080)
165+
- `WORK_DIR`: Directory to store NWC data files. Default: $XDG_DATA_HOME/albyhub
166+
- `LOG_LEVEL`: Log level for the application. Higher is more verbose. Default: 4 (info)
167+
- `AUTO_UNLOCK_PASSWORD`: Provide unlock password to auto-unlock Alby Hub on startup (e.g. after a machine restart). Unlock password still be required to access the interface.
168+
- `BOLTZ_API`: The api which provides auto swaps functionality. Default: "https://api.boltz.exchange"
169+
- `NETWORK`: On-chain network used for auto swaps. Should match the backend network. Default: "bitcoin"
168170

169171
### Migrating the database (Sqlite <-> Postgres)
170172

@@ -204,14 +206,14 @@ _To configure via env, the following parameters must be provided:_
204206
##### Mutinynet
205207

206208
- `MEMPOOL_API=https://mutinynet.com/api`
207-
- `LDK_NETWORK=signet`
209+
- `NETWORK=signet`
208210
- `LDK_ESPLORA_SERVER=https://mutinynet.com/api`
209211
- `LDK_GOSSIP_SOURCE=https://rgs.mutinynet.com/snapshot`
210212

211213
##### Testnet (Not recommended - try Mutinynet)
212214

213215
- `MEMPOOL_API=https://mempool.space/testnet/api`
214-
- `LDK_NETWORK=testnet`
216+
- `NETWORK=testnet`
215217
- `LDK_ESPLORA_SERVER=https://mempool.space/testnet/api`
216218
- `LDK_GOSSIP_SOURCE=https://rapidsync.lightningdevkit.org/testnet/snapshot`
217219

api/api.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"net/http"
1111
"net/url"
1212
"slices"
13+
"strconv"
1314
"strings"
1415
"sync"
1516
"time"
@@ -543,6 +544,83 @@ func (api *api) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnec
543544
return api.svc.GetLNClient().GetNodeConnectionInfo(ctx)
544545
}
545546

547+
func (api *api) GetAutoSwapsConfig() (*GetAutoSwapsConfigResponse, error) {
548+
swapBalanceThresholdStr, _ := api.cfg.Get(config.AutoSwapBalanceThresholdKey, "")
549+
swapAmountStr, _ := api.cfg.Get(config.AutoSwapAmountKey, "")
550+
swapDestination, _ := api.cfg.Get(config.AutoSwapDestinationKey, "")
551+
552+
enabled := swapBalanceThresholdStr != "" &&
553+
swapAmountStr != "" &&
554+
swapDestination != ""
555+
var swapBalanceThreshold, swapAmount uint64
556+
if enabled {
557+
var err error
558+
if swapBalanceThreshold, err = strconv.ParseUint(swapBalanceThresholdStr, 10, 64); err != nil {
559+
return nil, fmt.Errorf("invalid autoswap balance threshold: %w", err)
560+
}
561+
if swapAmount, err = strconv.ParseUint(swapAmountStr, 10, 64); err != nil {
562+
return nil, fmt.Errorf("invalid autoswap amount: %w", err)
563+
}
564+
}
565+
566+
swapFees, err := api.svc.GetSwapsService().CalculateFee()
567+
if err != nil {
568+
logger.Logger.WithError(err).Error("failed to calculate fee info")
569+
return nil, err
570+
}
571+
572+
return &GetAutoSwapsConfigResponse{
573+
Enabled: enabled,
574+
BalanceThreshold: swapBalanceThreshold,
575+
SwapAmount: swapAmount,
576+
Destination: swapDestination,
577+
AlbyServiceFee: swapFees.AlbyServiceFee,
578+
BoltzServiceFee: swapFees.BoltzServiceFee,
579+
BoltzNetworkFee: swapFees.BoltzNetworkFee,
580+
}, nil
581+
}
582+
583+
func (api *api) EnableAutoSwaps(ctx context.Context, enableAutoSwapsRequest *EnableAutoSwapsRequest) error {
584+
err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, strconv.FormatUint(enableAutoSwapsRequest.BalanceThreshold, 10), "")
585+
if err != nil {
586+
logger.Logger.WithError(err).Error("Failed to save autoswap balance threshold to config")
587+
return err
588+
}
589+
590+
err = api.cfg.SetUpdate(config.AutoSwapAmountKey, strconv.FormatUint(enableAutoSwapsRequest.SwapAmount, 10), "")
591+
if err != nil {
592+
logger.Logger.WithError(err).Error("Failed to save autoswap amount to config")
593+
return err
594+
}
595+
596+
err = api.cfg.SetUpdate(config.AutoSwapDestinationKey, enableAutoSwapsRequest.Destination, "")
597+
if err != nil {
598+
logger.Logger.WithError(err).Error("Failed to save autoswap destination to config")
599+
return err
600+
}
601+
602+
return api.svc.StartAutoSwaps()
603+
}
604+
605+
func (api *api) DisableAutoSwaps() error {
606+
if err := api.cfg.SetUpdate(config.AutoSwapBalanceThresholdKey, "", ""); err != nil {
607+
logger.Logger.WithError(err).Error("Failed to remove autoswap balance threshold")
608+
return err
609+
}
610+
if err := api.cfg.SetUpdate(config.AutoSwapAmountKey, "", ""); err != nil {
611+
logger.Logger.WithError(err).Error("Failed to remove autoswap amount")
612+
return err
613+
}
614+
if err := api.cfg.SetUpdate(config.AutoSwapDestinationKey, "", ""); err != nil {
615+
logger.Logger.WithError(err).Error("Failed to remove autoswap destination")
616+
return err
617+
}
618+
619+
api.svc.GetSwapsService().StopAutoSwaps()
620+
621+
return nil
622+
}
623+
546624
func (api *api) GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error) {
547625
if api.svc.GetLNClient() == nil {
548626
return nil, errors.New("LNClient not started")

api/models.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ type API interface {
5959
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
6060
Health(ctx context.Context) (*HealthResponse, error)
6161
SetCurrency(currency string) error
62+
GetAutoSwapsConfig() (*GetAutoSwapsConfigResponse, error)
63+
DisableAutoSwaps() error
64+
EnableAutoSwaps(ctx context.Context, autoSwapsRequest *EnableAutoSwapsRequest) error
6265
GetCustomNodeCommands() (*CustomNodeCommandsResponse, error)
6366
ExecuteCustomNodeCommand(ctx context.Context, command string) (interface{}, error)
6467
}
@@ -114,6 +117,22 @@ type CreateAppRequest struct {
114117
UnlockPassword string `json:"unlockPassword"`
115118
}
116119

120+
type EnableAutoSwapsRequest struct {
121+
BalanceThreshold uint64 `json:"balanceThreshold"`
122+
SwapAmount uint64 `json:"swapAmount"`
123+
Destination string `json:"destination"`
124+
}
125+
126+
type GetAutoSwapsConfigResponse struct {
127+
Enabled bool `json:"enabled"`
128+
BalanceThreshold uint64 `json:"balanceThreshold"`
129+
SwapAmount uint64 `json:"swapAmount"`
130+
Destination string `json:"destination"`
131+
AlbyServiceFee float64 `json:"albyServiceFee"`
132+
BoltzServiceFee float64 `json:"boltzServiceFee"`
133+
BoltzNetworkFee uint64 `json:"boltzNetworkFee"`
134+
}
135+
117136
type StartRequest struct {
118137
UnlockPassword string `json:"unlockPassword"`
119138
}

api/transactions.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ func (api *api) SendPayment(ctx context.Context, invoice string, amountMsat *uin
6565
if api.svc.GetLNClient() == nil {
6666
return nil, errors.New("LNClient not started")
6767
}
68-
transaction, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, invoice, amountMsat, nil, api.svc.GetLNClient(), nil, nil)
68+
transaction, err := api.svc.GetTransactionsService().SendPaymentSync(ctx, invoice, amountMsat, nil, api.svc.GetLNClient(), nil, nil, nil)
6969
if err != nil {
7070
return nil, err
7171
}
@@ -142,7 +142,7 @@ func (api *api) TopupIsolatedApp(ctx context.Context, userApp *db.App, amountMsa
142142
return err
143143
}
144144

145-
_, err = api.svc.GetTransactionsService().SendPaymentSync(ctx, transaction.PaymentRequest, nil, nil, api.svc.GetLNClient(), nil, nil)
145+
_, err = api.svc.GetTransactionsService().SendPaymentSync(ctx, transaction.PaymentRequest, nil, nil, api.svc.GetLNClient(), nil, nil, nil)
146146
return err
147147
}
148148

config/config.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,20 @@ func (cfg *config) GetRelayUrl() string {
146146
return relayUrl
147147
}
148148

149+
func (cfg *config) GetNetwork() string {
150+
env := cfg.GetEnv()
151+
152+
if env.Network != "" {
153+
return env.Network
154+
}
155+
156+
if env.LDKNetwork != "" {
157+
return env.LDKNetwork
158+
}
159+
160+
return "bitcoin"
161+
}
162+
149163
func (cfg *config) Get(key string, encryptionKey string) (string, error) {
150164
return cfg.get(key, encryptionKey, cfg.db)
151165
}

config/models.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@ const (
88
)
99

1010
const (
11-
OnchainAddressKey = "OnchainAddress"
11+
OnchainAddressKey = "OnchainAddress"
12+
AutoSwapBalanceThresholdKey = "AutoSwapBalanceThreshold"
13+
AutoSwapAmountKey = "AutoSwapAmount"
14+
AutoSwapDestinationKey = "AutoSwapDestination"
1215
)
1316

1417
type AppConfig struct {
@@ -23,7 +26,8 @@ type AppConfig struct {
2326
JWTSecret string `envconfig:"JWT_SECRET"`
2427
LogLevel string `envconfig:"LOG_LEVEL" default:"4"`
2528
LogToFile bool `envconfig:"LOG_TO_FILE" default:"true"`
26-
LDKNetwork string `envconfig:"LDK_NETWORK" default:"bitcoin"`
29+
Network string `envconfig:"NETWORK"`
30+
LDKNetwork string `envconfig:"LDK_NETWORK"`
2731
LDKEsploraServer string `envconfig:"LDK_ESPLORA_SERVER" default:"https://electrs.getalbypro.com"` // TODO: remove LDK prefix
2832
LDKGossipSource string `envconfig:"LDK_GOSSIP_SOURCE"`
2933
LDKLogLevel string `envconfig:"LDK_LOG_LEVEL" default:"3"`
@@ -44,6 +48,7 @@ type AppConfig struct {
4448
EnableAdvancedSetup bool `envconfig:"ENABLE_ADVANCED_SETUP" default:"true"`
4549
AutoUnlockPassword string `envconfig:"AUTO_UNLOCK_PASSWORD"`
4650
LogDBQueries bool `envconfig:"LOG_DB_QUERIES" default:"false"`
51+
BoltzApi string `envconfig:"BOLTZ_API" default:"https://api.boltz.exchange"`
4752
}
4853

4954
func (c *AppConfig) IsDefaultClientId() bool {
@@ -56,6 +61,7 @@ type Config interface {
5661
SetUpdate(key string, value string, encryptionKey string) error
5762
GetJWTSecret() string
5863
GetRelayUrl() string
64+
GetNetwork() string
5965
GetEnv() *AppConfig
6066
CheckUnlockPassword(password string) bool
6167
ChangeUnlockPassword(currentUnlockPassword string, newUnlockPassword string) error

constants/constants.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ const (
4747
// accounting for encryption and other metadata in the response, this is set to 4096 characters
4848
const INVOICE_METADATA_MAX_LENGTH = 4096
4949

50+
const SEND_PAYMENT_TIMEOUT = 50
51+
5052
// errors used by NIP-47 and the transaction service
5153
const (
5254
ERROR_INTERNAL = "INTERNAL"

frontend/src/components/layouts/SettingsLayout.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ export default function SettingsLayout() {
104104
<aside className="flex flex-col justify-between lg:w-1/5">
105105
<nav className="flex flex-wrap lg:flex-col lg:space-y-1">
106106
<MenuItem to="/settings">General</MenuItem>
107+
<MenuItem to="/settings/swaps">Swaps</MenuItem>
107108
{info?.autoUnlockPasswordSupported && (
108109
<MenuItem to="/settings/auto-unlock">Auto Unlock</MenuItem>
109110
)}

frontend/src/components/ui/radio-group.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ const RadioGroupItem = React.forwardRef<
3232
{...props}
3333
>
3434
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
35-
<CheckIcon className="h-3.5 w-3.5 fill-primary" />
35+
<CheckIcon className="h-3.5 w-3.5" />
3636
</RadioGroupPrimitive.Indicator>
3737
</RadioGroupPrimitive.Item>
3838
);

0 commit comments

Comments
 (0)