2024-05-30 00:06:06 +07:00
package api
import (
"context"
2026-08-11 15:05:56 +07:00
"crypto/tls"
"crypto/x509"
2026-04-21 22:33:25 +05:30
"encoding/hex"
2024-05-30 00:06:06 +07:00
"encoding/json"
2026-08-11 15:05:56 +07:00
"encoding/pem"
2024-05-30 00:06:06 +07:00
"errors"
2025-01-30 14:20:17 +03:00
"flag"
2024-05-30 00:06:06 +07:00
"fmt"
"io"
"net/http"
"net/url"
2026-04-21 22:33:25 +05:30
"os"
2026-08-11 15:05:56 +07:00
"path/filepath"
2024-07-01 20:30:40 +07:00
"slices"
2025-04-16 20:58:32 +05:30
"strconv"
2025-03-06 15:27:32 +07:00
"strings"
2024-06-19 23:25:39 +07:00
"sync"
2026-08-10 15:37:26 +07:00
"sync/atomic"
2024-05-30 00:06:06 +07:00
"time"
"github.com/sirupsen/logrus"
2026-08-11 15:05:56 +07:00
"gopkg.in/macaroon.v2"
2024-09-03 21:31:28 +07:00
"gorm.io/datatypes"
2024-05-30 00:06:06 +07:00
"gorm.io/gorm"
2024-07-05 20:32:40 +07:00
"github.com/getAlby/hub/alby"
2024-10-28 13:29:34 +07:00
"github.com/getAlby/hub/apps"
2024-07-05 20:32:40 +07:00
"github.com/getAlby/hub/config"
2024-07-19 23:30:22 +07:00
"github.com/getAlby/hub/constants"
2024-07-05 20:32:40 +07:00
"github.com/getAlby/hub/db"
2024-07-19 23:30:22 +07:00
"github.com/getAlby/hub/db/queries"
2024-07-05 20:32:40 +07:00
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/logger"
permissions "github.com/getAlby/hub/nip47/permissions"
"github.com/getAlby/hub/service"
"github.com/getAlby/hub/service/keys"
2025-07-10 18:36:12 +05:30
"github.com/getAlby/hub/swaps"
2024-07-05 20:32:40 +07:00
"github.com/getAlby/hub/utils"
"github.com/getAlby/hub/version"
2024-05-30 00:06:06 +07:00
)
type api struct {
2024-09-04 10:46:12 +05:30
db * gorm . DB
2024-10-28 13:29:34 +07:00
appsSvc apps . AppsService
2024-09-04 10:46:12 +05:30
cfg config . Config
svc service . Service
permissionsSvc permissions . PermissionsService
keys keys . Keys
albyOAuthSvc alby . AlbyOAuthService
2025-08-27 23:34:15 +07:00
albySvc alby . AlbyService
2024-09-04 10:46:12 +05:30
startupError error
startupErrorTime time . Time
2025-07-11 15:08:32 +07:00
eventPublisher events . EventPublisher
2026-08-10 15:37:26 +07:00
// set after a migration file is created; the hub is halted at that point
// and the frontend should keep showing the migration success page
nodeMigrationFileCreated atomic . Bool
2024-05-30 00:06:06 +07:00
}
2025-08-27 23:34:15 +07:00
func NewAPI ( svc service . Service , gormDB * gorm . DB , config config . Config , keys keys . Keys , albySvc alby . AlbyService , albyOAuthSvc alby . AlbyOAuthService , eventPublisher events . EventPublisher ) * api {
2024-05-30 00:06:06 +07:00
return & api {
2024-06-17 19:42:09 +07:00
db : gormDB ,
2025-02-27 19:12:55 +07:00
appsSvc : apps . NewAppsService ( gormDB , eventPublisher , keys , config ) ,
2024-06-17 19:42:09 +07:00
cfg : config ,
svc : svc ,
2024-06-29 22:29:55 +07:00
permissionsSvc : permissions . NewPermissionsService ( gormDB , eventPublisher ) ,
2024-06-17 19:42:09 +07:00
keys : keys ,
2025-08-27 23:34:15 +07:00
albySvc : albySvc ,
2024-06-17 19:42:09 +07:00
albyOAuthSvc : albyOAuthSvc ,
2025-07-11 15:08:32 +07:00
eventPublisher : eventPublisher ,
2024-05-30 00:06:06 +07:00
}
}
func ( api * api ) CreateApp ( createAppRequest * CreateAppRequest ) ( * CreateAppResponse , error ) {
2025-02-27 19:12:55 +07:00
if slices . Contains ( createAppRequest . Scopes , constants . SUPERUSER_SCOPE ) {
if ! api . cfg . CheckUnlockPassword ( createAppRequest . UnlockPassword ) {
return nil , fmt . Errorf (
"incorrect unlock password to create app with superuser permission" )
}
2024-11-26 20:30:53 -06:00
}
2026-05-01 15:41:56 +05:30
maxAmountSat := uint64 ( 0 )
resolvedMaxAmountSat := ResolveToSat ( createAppRequest . MaxAmountSat , createAppRequest . MaxAmountMsat , createAppRequest . MaxAmount , nil )
if resolvedMaxAmountSat != nil {
maxAmountSat = * resolvedMaxAmountSat
}
2025-07-14 07:45:48 +02:00
if createAppRequest . Name == alby . ALBY_ACCOUNT_APP_NAME {
return nil , fmt . Errorf ( "Reserved app name: %s" , alby . ALBY_ACCOUNT_APP_NAME )
}
2024-05-30 00:06:06 +07:00
expiresAt , err := api . parseExpiresAt ( createAppRequest . ExpiresAt )
if err != nil {
return nil , fmt . Errorf ( "invalid expiresAt: %v" , err )
}
2024-07-01 20:30:40 +07:00
for _ , scope := range createAppRequest . Scopes {
if ! slices . Contains ( permissions . AllScopes ( ) , scope ) {
return nil , fmt . Errorf ( "did not recognize requested scope: %s" , scope )
2024-06-17 19:42:09 +07:00
}
}
2024-10-28 13:29:34 +07:00
app , pairingSecretKey , err := api . appsSvc . CreateApp (
2024-07-19 23:30:22 +07:00
createAppRequest . Name ,
createAppRequest . Pubkey ,
2026-05-01 15:41:56 +05:30
maxAmountSat ,
2024-07-19 23:30:22 +07:00
createAppRequest . BudgetRenewal ,
expiresAt ,
createAppRequest . Scopes ,
2024-09-02 17:40:34 +07:00
createAppRequest . Isolated ,
2024-11-07 13:06:01 +01:00
createAppRequest . Metadata ,
)
2024-05-30 00:06:06 +07:00
if err != nil {
return nil , err
}
2025-11-06 18:01:09 +07:00
relayUrls := api . cfg . GetRelayUrls ( )
2024-05-30 00:06:06 +07:00
2025-02-06 12:30:28 +07:00
lightningAddress , err := api . albyOAuthSvc . GetLightningAddress ( )
if err != nil {
return nil , err
}
2024-05-30 00:06:06 +07:00
responseBody := & CreateAppResponse { }
2024-08-23 14:15:15 +05:30
responseBody . Id = app . ID
2025-02-27 19:12:55 +07:00
responseBody . Name = app . Name
2024-11-07 13:06:01 +01:00
responseBody . Pubkey = app . AppPubkey
2024-05-30 00:06:06 +07:00
responseBody . PairingSecret = pairingSecretKey
2025-01-31 12:08:18 +07:00
responseBody . WalletPubkey = * app . WalletPubkey
2025-11-06 18:01:09 +07:00
responseBody . RelayUrls = relayUrls
2025-02-06 12:30:28 +07:00
responseBody . Lud16 = lightningAddress
2024-09-03 07:52:54 +02:00
2026-08-11 14:57:51 +07:00
responseBody . ReturnTo = buildReturnToUrl ( createAppRequest . ReturnTo , relayUrls , * app . WalletPubkey , lightningAddress , app . Isolated )
2024-05-30 00:06:06 +07:00
var lud16 string
2024-09-04 20:57:27 +02:00
if lightningAddress != "" && ! app . Isolated {
2024-09-03 07:52:54 +02:00
lud16 = fmt . Sprintf ( "&lud16=%s" , lightningAddress )
}
2025-11-06 18:01:09 +07:00
responseBody . PairingUri = fmt . Sprintf ( "nostr+walletconnect://%s?relay=%s&secret=%s%s" , * app . WalletPubkey , strings . Join ( relayUrls , "&relay=" ) , pairingSecretKey , lud16 )
2024-11-07 13:06:01 +01:00
2024-05-30 00:06:06 +07:00
return responseBody , nil
}
2026-08-11 14:57:51 +07:00
// buildReturnToUrl adds the connection query parameters to the return_to
// URL the user will be redirected to. Only http and https URLs are accepted.
func buildReturnToUrl ( returnTo string , relayUrls [ ] string , walletPubkey string , lightningAddress string , isolated bool ) string {
if returnTo == "" {
return ""
}
returnToUrl , err := url . Parse ( returnTo )
if err != nil || ( returnToUrl . Scheme != "http" && returnToUrl . Scheme != "https" ) {
return ""
}
query := returnToUrl . Query ( )
for _ , relayUrl := range relayUrls {
query . Add ( "relay" , relayUrl )
}
query . Add ( "pubkey" , walletPubkey )
if lightningAddress != "" && ! isolated {
query . Add ( "lud16" , lightningAddress )
}
returnToUrl . RawQuery = query . Encode ( )
return returnToUrl . String ( )
}
2024-05-30 00:06:06 +07:00
func ( api * api ) UpdateApp ( userApp * db . App , updateAppRequest * UpdateAppRequest ) error {
2026-05-01 15:41:56 +05:30
resolvedMaxAmountSat := ResolveToSat ( updateAppRequest . MaxAmountSat , updateAppRequest . MaxAmountMsat , updateAppRequest . MaxAmount , nil )
2025-10-14 13:12:55 +05:30
err := api . db . Transaction ( func ( tx * gorm . DB ) error {
// Initialize name with current app name, update if provided
name := userApp . Name
2024-08-22 23:45:48 +05:30
2025-10-14 13:12:55 +05:30
// Update app name if provided and different
if updateAppRequest . Name != nil {
name = * updateAppRequest . Name
2024-05-30 00:06:06 +07:00
2025-10-14 13:12:55 +05:30
if name == "" {
return fmt . Errorf ( "won't update an app to have no name" )
}
if name != userApp . Name {
err := tx . Model ( & db . App { } ) . Where ( "id" , userApp . ID ) . Update ( "name" , name ) . Error
if err != nil {
return err
}
2024-08-22 23:45:48 +05:30
}
}
2025-10-14 13:12:55 +05:30
// Update app isolation if provided and different
if updateAppRequest . Isolated != nil {
isolated := * updateAppRequest . Isolated
if isolated != userApp . Isolated {
if ! isolated {
var existingMetadata Metadata
if userApp . Metadata != nil {
err := json . Unmarshal ( userApp . Metadata , & existingMetadata )
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : userApp . ID ,
} ) . Error ( "Failed to deserialize app metadata" )
return err
}
2026-02-26 12:25:06 +05:30
if existingMetadata [ constants . METADATA_APPSTORE_APP_ID_KEY ] == constants . SUBWALLET_APPSTORE_APP_ID {
2025-10-14 13:12:55 +05:30
return errors . New ( "Cannot update sub-wallet to be non-isolated" )
}
}
}
err := tx . Model ( & db . App { } ) . Where ( "id" , userApp . ID ) . Update ( "isolated" , isolated ) . Error
if err != nil {
return err
}
2024-11-26 20:30:53 -06:00
}
}
2025-10-14 13:12:55 +05:30
// Update the app metadata if provided
2024-09-03 21:31:28 +07:00
if updateAppRequest . Metadata != nil {
var metadataBytes [ ] byte
var err error
2025-10-14 13:12:55 +05:30
metadataBytes , err = json . Marshal ( * updateAppRequest . Metadata )
2024-09-03 21:31:28 +07:00
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to serialize metadata" )
return err
}
err = tx . Model ( & db . App { } ) . Where ( "id" , userApp . ID ) . Update ( "metadata" , datatypes . JSON ( metadataBytes ) ) . Error
if err != nil {
return err
}
}
2025-10-14 13:12:55 +05:30
// Handle permissions updates only if any permission-related field is provided
2026-05-01 15:41:56 +05:30
if updateAppRequest . Scopes != nil || resolvedMaxAmountSat != nil ||
2025-10-14 13:12:55 +05:30
updateAppRequest . BudgetRenewal != nil || updateAppRequest . ExpiresAt != nil || updateAppRequest . UpdateExpiresAt {
2024-05-30 00:06:06 +07:00
2025-10-14 13:12:55 +05:30
// Get current values or use provided ones
2026-04-17 13:53:28 +05:30
var maxAmountSat uint64
2025-10-14 13:12:55 +05:30
var budgetRenewal string
var expiresAt * time . Time
2024-05-30 00:06:06 +07:00
2025-10-14 13:12:55 +05:30
// Get existing permissions to use as defaults
var existingPermissions [ ] db . AppPermission
if err := tx . Where ( "app_id = ?" , userApp . ID ) . Find ( & existingPermissions ) . Error ; err != nil {
return err
}
2025-02-27 19:12:55 +07:00
2025-10-14 13:12:55 +05:30
// Use existing values as defaults
if len ( existingPermissions ) > 0 {
// Find pay_invoice permission for budget-related fields
for _ , perm := range existingPermissions {
if perm . Scope == constants . PAY_INVOICE_SCOPE {
2026-04-17 13:53:28 +05:30
maxAmountSat = uint64 ( perm . MaxAmountSat )
2025-10-14 13:12:55 +05:30
budgetRenewal = perm . BudgetRenewal
expiresAt = perm . ExpiresAt
break
}
2024-05-30 00:06:06 +07:00
}
2025-10-14 13:12:55 +05:30
}
// Override with provided values
2026-05-01 15:41:56 +05:30
if resolvedMaxAmountSat != nil {
maxAmountSat = * resolvedMaxAmountSat
2025-10-14 13:12:55 +05:30
}
if updateAppRequest . BudgetRenewal != nil {
budgetRenewal = * updateAppRequest . BudgetRenewal
}
if updateAppRequest . ExpiresAt != nil {
parsedExpiresAt , err := api . parseExpiresAt ( * updateAppRequest . ExpiresAt )
if err != nil {
return fmt . Errorf ( "invalid expiresAt: %v" , err )
2024-05-30 00:06:06 +07:00
}
2025-10-14 13:12:55 +05:30
expiresAt = parsedExpiresAt
}
if updateAppRequest . ExpiresAt == nil && updateAppRequest . UpdateExpiresAt {
expiresAt = nil
2024-05-30 00:06:06 +07:00
}
2025-10-14 13:12:55 +05:30
// Update existing permissions with new budget and expiry
err := tx . Model ( & db . AppPermission { } ) . Where ( "app_id" , userApp . ID ) . Updates ( map [ string ] interface { } {
"ExpiresAt" : expiresAt ,
2026-04-17 13:53:28 +05:30
"MaxAmountSat" : maxAmountSat ,
2025-10-14 13:12:55 +05:30
"BudgetRenewal" : budgetRenewal ,
} ) . Error
if err != nil {
2024-05-30 00:06:06 +07:00
return err
}
2025-10-14 13:12:55 +05:30
// Handle scope changes only if scopes were provided
if updateAppRequest . Scopes != nil {
if len ( updateAppRequest . Scopes ) == 0 {
return fmt . Errorf ( "won't update an app to have no request methods" )
}
existingScopeMap := make ( map [ string ] bool )
for _ , perm := range existingPermissions {
existingScopeMap [ perm . Scope ] = true
}
if slices . Contains ( updateAppRequest . Scopes , constants . SUPERUSER_SCOPE ) && ! existingScopeMap [ constants . SUPERUSER_SCOPE ] {
return fmt . Errorf ( "cannot update app to add superuser permission" )
}
// Add new permissions
for _ , scope := range updateAppRequest . Scopes {
if ! existingScopeMap [ scope ] {
perm := db . AppPermission {
App : * userApp ,
Scope : scope ,
ExpiresAt : expiresAt ,
2026-04-17 13:53:28 +05:30
MaxAmountSat : int ( maxAmountSat ) ,
2025-10-14 13:12:55 +05:30
BudgetRenewal : budgetRenewal ,
}
if err := tx . Create ( & perm ) . Error ; err != nil {
return err
}
}
delete ( existingScopeMap , scope )
}
// Remove old permissions
for scope := range existingScopeMap {
if err := tx . Where ( "app_id = ? AND scope = ?" , userApp . ID , scope ) . Delete ( & db . AppPermission { } ) . Error ; err != nil {
return err
}
}
}
2024-05-30 00:06:06 +07:00
}
2025-10-14 13:12:55 +05:30
// Publish update event
2024-12-20 12:54:45 +01:00
api . svc . GetEventPublisher ( ) . Publish ( & events . Event {
Event : "nwc_app_updated" ,
Properties : map [ string ] interface { } {
"name" : name ,
"id" : userApp . ID ,
} ,
} )
2024-05-30 00:06:06 +07:00
// commit transaction
return nil
} )
return err
}
func ( api * api ) DeleteApp ( userApp * db . App ) error {
2025-11-06 12:11:12 +01:00
// Delete lightning address if one exists
if api . appsSvc . HasLightningAddress ( userApp ) {
err := api . DeleteLightningAddress ( context . Background ( ) , userApp . ID )
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : userApp . ID ,
} ) . Error ( "Failed to delete lightning address during app deletion" )
}
}
2024-11-07 13:06:01 +01:00
return api . appsSvc . DeleteApp ( userApp )
2024-05-30 00:06:06 +07:00
}
2025-07-08 22:57:41 +07:00
func ( api * api ) CreateLightningAddress ( ctx context . Context , createLightningAddressRequest * CreateLightningAddressRequest ) error {
app := api . appsSvc . GetAppById ( createLightningAddressRequest . AppId )
if app == nil {
return errors . New ( "app not found" )
}
var metadata map [ string ] interface { }
err := json . Unmarshal ( app . Metadata , & metadata )
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : app . ID ,
} ) . Error ( "Failed to deserialize app metadata" )
return err
}
createLightningAddressResponse , err := api . albyOAuthSvc . CreateLightningAddress ( ctx , createLightningAddressRequest . Address , createLightningAddressRequest . AppId )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to create lightning address for app" )
return err
}
metadata [ "lud16" ] = createLightningAddressResponse . FullAddress
err = api . appsSvc . SetAppMetadata ( app . ID , metadata )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to add lightning address to app metadata" )
return err
}
return nil
}
func ( api * api ) DeleteLightningAddress ( ctx context . Context , appId uint ) error {
app := api . appsSvc . GetAppById ( appId )
if app == nil {
return errors . New ( "app not found" )
}
var metadata map [ string ] interface { }
err := json . Unmarshal ( app . Metadata , & metadata )
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : app . ID ,
} ) . Error ( "Failed to deserialize app metadata" )
return err
}
if metadata [ "lud16" ] == nil {
return errors . New ( "no lightning address set" )
}
lud16 := metadata [ "lud16" ] . ( string )
if ! strings . Contains ( lud16 , "@" ) {
return errors . New ( "invalid lightning address" )
}
address := strings . Split ( lud16 , "@" ) [ 0 ]
// Call the Alby OAuth service to delete the lightning address
err = api . albyOAuthSvc . DeleteLightningAddress ( ctx , address )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to delete lightning address for app" )
return err
}
delete ( metadata , "lud16" )
err = api . appsSvc . SetAppMetadata ( app . ID , metadata )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to remove lightning address from app metadata" )
return err
}
return nil
}
2026-02-27 15:01:25 +05:30
func ( api * api ) GetApp ( dbApp * db . App ) ( * App , error ) {
2024-05-30 00:06:06 +07:00
paySpecificPermission := db . AppPermission { }
appPermissions := [ ] db . AppPermission { }
var expiresAt * time . Time
2026-02-27 15:01:25 +05:30
if err := api . db . Where ( "app_id = ?" , dbApp . ID ) . Find ( & appPermissions ) . Error ; err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : dbApp . ID ,
} ) . Error ( "Failed to list app permissions" )
return nil , err
}
2024-05-30 00:06:06 +07:00
requestMethods := [ ] string { }
for _ , appPerm := range appPermissions {
expiresAt = appPerm . ExpiresAt
2024-07-19 23:30:22 +07:00
if appPerm . Scope == constants . PAY_INVOICE_SCOPE {
2024-12-23 12:25:59 +03:00
// find the pay_invoice-specific permissions
2024-05-30 00:06:06 +07:00
paySpecificPermission = appPerm
}
2024-07-01 20:30:40 +07:00
requestMethods = append ( requestMethods , appPerm . Scope )
2024-05-30 00:06:06 +07:00
}
2024-12-23 12:25:59 +03:00
// renewsIn := ""
2026-04-17 13:53:28 +05:30
maxAmountSat := uint64 ( paySpecificPermission . MaxAmountSat )
budgetUsageMsat , err := queries . GetBudgetUsageMsat ( api . db , & paySpecificPermission )
2026-02-27 15:01:25 +05:30
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : dbApp . ID ,
} ) . Error ( "Failed to get budget usage for app" )
return nil , err
}
2024-05-30 00:06:06 +07:00
2024-09-02 17:40:34 +07:00
var metadata Metadata
if dbApp . Metadata != nil {
jsonErr := json . Unmarshal ( dbApp . Metadata , & metadata )
if jsonErr != nil {
logger . Logger . WithError ( jsonErr ) . WithFields ( logrus . Fields {
"app_id" : dbApp . ID ,
} ) . Error ( "Failed to deserialize app metadata" )
}
}
2025-03-04 19:08:08 +07:00
walletPubkey := api . keys . GetNostrPublicKey ( )
uniqueWalletPubkey := false
if dbApp . WalletPubkey != nil {
walletPubkey = * dbApp . WalletPubkey
uniqueWalletPubkey = true
}
2024-05-30 00:06:06 +07:00
response := App {
2026-04-09 16:26:45 +05:30
ID : dbApp . ID ,
Name : dbApp . Name ,
Description : dbApp . Description ,
CreatedAt : dbApp . CreatedAt ,
UpdatedAt : dbApp . UpdatedAt ,
AppPubkey : dbApp . AppPubkey ,
ExpiresAt : expiresAt ,
2026-04-17 13:53:28 +05:30
MaxAmount : maxAmountSat ,
MaxAmountSat : maxAmountSat ,
MaxAmountMsat : maxAmountSat * 1000 ,
2026-04-09 16:26:45 +05:30
Scopes : requestMethods ,
2026-04-17 13:53:28 +05:30
BudgetUsage : budgetUsageMsat / 1000 ,
BudgetUsageSat : budgetUsageMsat / 1000 ,
BudgetUsageMsat : budgetUsageMsat ,
2026-04-09 16:26:45 +05:30
BudgetRenewal : paySpecificPermission . BudgetRenewal ,
Isolated : dbApp . Isolated ,
Metadata : metadata ,
WalletPubkey : walletPubkey ,
UniqueWalletPubkey : uniqueWalletPubkey ,
LastUsedAt : dbApp . LastUsedAt ,
LastSettledTransactionAt : dbApp . LastSettledTransactionAt ,
2024-07-19 23:30:22 +07:00
}
if dbApp . Isolated {
2026-04-17 13:53:28 +05:30
balanceMsat , err := queries . GetIsolatedBalanceMsat ( api . db , dbApp . ID )
2026-02-27 15:01:25 +05:30
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : dbApp . ID ,
} ) . Error ( "Failed to get isolated app balance" )
return nil , err
}
2026-04-17 13:53:28 +05:30
response . Balance = balanceMsat
response . BalanceSat = balanceMsat / 1000
response . BalanceMsat = balanceMsat
2024-05-30 00:06:06 +07:00
}
2026-02-27 15:01:25 +05:30
return & response , nil
2024-05-30 00:06:06 +07:00
}
2025-07-29 20:57:18 +07:00
func ( api * api ) ListApps ( limit uint64 , offset uint64 , filters ListAppsFilters , orderBy string ) ( * ListAppsResponse , error ) {
2024-05-30 00:06:06 +07:00
// TODO: join dbApps and permissions
dbApps := [ ] db . App { }
2025-07-29 20:57:18 +07:00
query := api . db
if filters . Name != "" {
2025-08-27 22:51:18 +07:00
// searching for "Damus" will return "Damus" and "Damus (1)"
2025-10-14 11:57:21 +05:30
// Use case-insensitive search for both SQLite and PostgreSQL
if api . db . Dialector . Name ( ) == "postgres" {
query = query . Where ( "name ILIKE ?" , filters . Name + "%" )
} else {
query = query . Where ( "name LIKE ?" , filters . Name + "%" )
}
2025-07-29 20:57:18 +07:00
}
if filters . AppStoreAppId != "" {
2026-02-26 12:25:06 +05:30
query = query . Where ( datatypes . JSONQuery ( "metadata" ) . Equals ( filters . AppStoreAppId , constants . METADATA_APPSTORE_APP_ID_KEY ) )
2025-07-29 20:57:18 +07:00
}
if filters . Unused {
// find unused non-subwallet apps not used in the past 60 days
query = query . Where ( "last_used_at IS NULL OR last_used_at < ?" , time . Now ( ) . Add ( - 60 * 24 * time . Hour ) )
2025-08-19 17:31:54 +07:00
}
2026-02-26 12:25:06 +05:30
if filters . SubWallets != nil {
if * filters . SubWallets {
query = query . Where ( datatypes . JSONQuery ( "metadata" ) . Equals ( constants . SUBWALLET_APPSTORE_APP_ID , constants . METADATA_APPSTORE_APP_ID_KEY ) )
2025-07-29 20:57:18 +07:00
} else {
2026-02-26 12:25:06 +05:30
// exclude subwallets :scream:
if api . db . Dialector . Name ( ) == "sqlite" {
query = query . Where ( fmt . Sprintf ( "metadata is NULL OR JSON_EXTRACT(metadata, '$.%s') IS NULL OR JSON_EXTRACT(metadata, '$.%s') != ?" , constants . METADATA_APPSTORE_APP_ID_KEY , constants . METADATA_APPSTORE_APP_ID_KEY ) , constants . SUBWALLET_APPSTORE_APP_ID )
} else {
query = query . Where ( fmt . Sprintf ( "metadata IS NULL OR metadata->>'%s' IS NULL OR metadata->>'%s' != ?" , constants . METADATA_APPSTORE_APP_ID_KEY , constants . METADATA_APPSTORE_APP_ID_KEY ) , constants . SUBWALLET_APPSTORE_APP_ID )
}
2025-07-29 20:57:18 +07:00
}
}
2026-04-09 16:26:45 +05:30
query = query . Order ( resolveAppOrderBy ( orderBy ) )
2025-07-29 20:57:18 +07:00
if limit == 0 {
limit = 100
}
var totalCount int64
result := query . Model ( & db . App { } ) . Count ( & totalCount )
if result . Error != nil {
logger . Logger . WithError ( result . Error ) . Error ( "Failed to count DB apps" )
return nil , result . Error
}
2026-02-26 12:25:06 +05:30
var totalBalance * int64
2026-04-17 13:53:28 +05:30
var totalBalanceSat * int64
2026-02-26 12:25:06 +05:30
if filters . SubWallets != nil && * filters . SubWallets {
2026-04-17 13:53:28 +05:30
totalBalanceMsat , err := queries . GetTotalSubwalletBalanceMsat ( api . db )
2026-02-26 12:25:06 +05:30
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to calculate total subwallet balance" )
return nil , err
}
totalBalance = & totalBalanceMsat
2026-04-17 13:53:28 +05:30
totalBalanceSatVal := totalBalanceMsat / 1000
totalBalanceSat = & totalBalanceSatVal
2026-02-26 12:25:06 +05:30
}
2025-07-29 20:57:18 +07:00
query = query . Offset ( int ( offset ) ) . Limit ( int ( limit ) )
err := query . Find ( & dbApps ) . Error
2024-08-10 17:08:08 +07:00
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to list apps" )
return nil , err
}
2024-05-30 00:06:06 +07:00
2025-07-29 20:57:18 +07:00
appIds := [ ] uint64 { }
for _ , app := range dbApps {
appIds = append ( appIds , uint64 ( app . ID ) )
}
2024-07-01 20:30:40 +07:00
appPermissions := [ ] db . AppPermission { }
2025-07-29 20:57:18 +07:00
err = api . db . Where ( "app_id IN ?" , appIds ) . Find ( & appPermissions ) . Error
2024-08-10 17:08:08 +07:00
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to list app permissions" )
return nil , err
}
2024-05-30 00:06:06 +07:00
permissionsMap := make ( map [ uint ] [ ] db . AppPermission )
2024-07-01 20:30:40 +07:00
for _ , perm := range appPermissions {
2024-05-30 00:06:06 +07:00
permissionsMap [ perm . AppId ] = append ( permissionsMap [ perm . AppId ] , perm )
}
apiApps := [ ] App { }
2024-07-19 23:30:22 +07:00
for _ , dbApp := range dbApps {
2025-03-04 19:08:08 +07:00
walletPubkey := api . keys . GetNostrPublicKey ( )
uniqueWalletPubkey := false
if dbApp . WalletPubkey != nil {
walletPubkey = * dbApp . WalletPubkey
uniqueWalletPubkey = true
}
2024-05-30 00:06:06 +07:00
apiApp := App {
2026-04-09 16:26:45 +05:30
ID : dbApp . ID ,
Name : dbApp . Name ,
Description : dbApp . Description ,
CreatedAt : dbApp . CreatedAt ,
UpdatedAt : dbApp . UpdatedAt ,
AppPubkey : dbApp . AppPubkey ,
Isolated : dbApp . Isolated ,
WalletPubkey : walletPubkey ,
UniqueWalletPubkey : uniqueWalletPubkey ,
LastUsedAt : dbApp . LastUsedAt ,
LastSettledTransactionAt : dbApp . LastSettledTransactionAt ,
2024-07-19 23:30:22 +07:00
}
if dbApp . Isolated {
2026-04-17 13:53:28 +05:30
balanceMsat , err := queries . GetIsolatedBalanceMsat ( api . db , dbApp . ID )
2026-02-27 15:01:25 +05:30
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : dbApp . ID ,
} ) . Error ( "Failed to get isolated app balance" )
return nil , err
}
2026-04-17 13:53:28 +05:30
apiApp . Balance = balanceMsat
apiApp . BalanceSat = balanceMsat / 1000
apiApp . BalanceMsat = balanceMsat
2024-05-30 00:06:06 +07:00
}
2024-07-19 23:30:22 +07:00
for _ , appPermission := range permissionsMap [ dbApp . ID ] {
2024-07-01 20:30:40 +07:00
apiApp . Scopes = append ( apiApp . Scopes , appPermission . Scope )
apiApp . ExpiresAt = appPermission . ExpiresAt
2024-07-19 23:30:22 +07:00
if appPermission . Scope == constants . PAY_INVOICE_SCOPE {
2024-07-01 20:30:40 +07:00
apiApp . BudgetRenewal = appPermission . BudgetRenewal
2026-04-17 13:53:28 +05:30
apiApp . MaxAmount = uint64 ( appPermission . MaxAmountSat )
2024-07-19 23:30:22 +07:00
apiApp . MaxAmountSat = uint64 ( appPermission . MaxAmountSat )
2026-04-17 13:53:28 +05:30
apiApp . MaxAmountMsat = uint64 ( appPermission . MaxAmountSat ) * 1000
budgetUsageMsat , err := queries . GetBudgetUsageMsat ( api . db , & appPermission )
2026-02-27 15:01:25 +05:30
if err != nil {
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
"app_id" : dbApp . ID ,
} ) . Error ( "Failed to get budget usage for app" )
return nil , err
}
2026-04-17 13:53:28 +05:30
apiApp . BudgetUsage = budgetUsageMsat / 1000
apiApp . BudgetUsageSat = budgetUsageMsat / 1000
apiApp . BudgetUsageMsat = budgetUsageMsat
2024-05-30 00:06:06 +07:00
}
}
2024-09-02 17:40:34 +07:00
var metadata Metadata
if dbApp . Metadata != nil {
jsonErr := json . Unmarshal ( dbApp . Metadata , & metadata )
if jsonErr != nil {
logger . Logger . WithError ( jsonErr ) . WithFields ( logrus . Fields {
"app_id" : dbApp . ID ,
} ) . Error ( "Failed to deserialize app metadata" )
}
apiApp . Metadata = metadata
}
2024-05-30 00:06:06 +07:00
apiApps = append ( apiApps , apiApp )
}
2025-07-29 20:57:18 +07:00
return & ListAppsResponse {
2026-04-17 13:53:28 +05:30
Apps : apiApps ,
TotalCount : uint64 ( totalCount ) ,
TotalBalance : totalBalance ,
TotalBalanceSat : totalBalanceSat ,
TotalBalanceMsat : totalBalance ,
2025-07-29 20:57:18 +07:00
} , nil
2024-05-30 00:06:06 +07:00
}
2026-04-09 16:26:45 +05:30
func resolveAppOrderBy ( orderBy string ) string {
switch orderBy {
case "created_at" :
return "created_at DESC"
case "last_settled_transaction" :
return "last_settled_transaction_at IS NULL, last_settled_transaction_at DESC"
default :
return "last_used_at IS NULL, last_used_at DESC"
}
}
2024-07-31 09:10:26 +02:00
func ( api * api ) ListChannels ( ctx context . Context ) ( [ ] Channel , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
channels , err := lnClient . ListChannels ( ctx )
2024-07-31 09:10:26 +02:00
if err != nil {
return nil , err
}
apiChannels := [ ] Channel { }
for _ , channel := range channels {
status := "offline"
if channel . Active {
status = "online"
} else if channel . Confirmations != nil && channel . ConfirmationsRequired != nil && * channel . ConfirmationsRequired > * channel . Confirmations {
status = "opening"
}
apiChannels = append ( apiChannels , Channel {
2026-05-01 15:41:56 +05:30
LocalBalance : channel . LocalBalanceMsat ,
LocalBalanceSat : channel . LocalBalanceMsat / 1000 ,
LocalBalanceMsat : channel . LocalBalanceMsat ,
LocalSpendableBalance : channel . LocalSpendableBalanceMsat ,
LocalSpendableBalanceSat : channel . LocalSpendableBalanceMsat / 1000 ,
LocalSpendableBalanceMsat : channel . LocalSpendableBalanceMsat ,
RemoteBalance : channel . RemoteBalanceMsat ,
RemoteBalanceSat : channel . RemoteBalanceMsat / 1000 ,
RemoteBalanceMsat : channel . RemoteBalanceMsat ,
2024-07-31 09:10:26 +02:00
Id : channel . Id ,
RemotePubkey : channel . RemotePubkey ,
FundingTxId : channel . FundingTxId ,
2025-01-06 23:33:13 +07:00
FundingTxVout : channel . FundingTxVout ,
2024-07-31 09:10:26 +02:00
Active : channel . Active ,
Public : channel . Public ,
InternalChannel : channel . InternalChannel ,
Confirmations : channel . Confirmations ,
ConfirmationsRequired : channel . ConfirmationsRequired ,
ForwardingFeeBaseMsat : channel . ForwardingFeeBaseMsat ,
2025-08-21 18:09:03 +07:00
ForwardingFeeProportionalMillionths : channel . ForwardingFeeProportionalMillionths ,
2026-05-01 15:41:56 +05:30
UnspendablePunishmentReserve : channel . UnspendablePunishmentReserveSat ,
UnspendablePunishmentReserveSat : channel . UnspendablePunishmentReserveSat ,
CounterpartyUnspendablePunishmentReserve : channel . CounterpartyUnspendablePunishmentReserveSat ,
CounterpartyUnspendablePunishmentReserveSat : channel . CounterpartyUnspendablePunishmentReserveSat ,
2026-04-17 13:53:28 +05:30
Error : channel . Error ,
IsOutbound : channel . IsOutbound ,
Status : status ,
2024-07-31 09:10:26 +02:00
} )
}
2025-03-06 15:27:32 +07:00
slices . SortFunc ( apiChannels , func ( a , b Channel ) int {
// sort by channel size first
aSize := a . LocalBalance + a . RemoteBalance
bSize := b . LocalBalance + b . RemoteBalance
if aSize != bSize {
return int ( bSize - aSize )
}
// then by local balance in the channel
if a . LocalBalance != b . LocalBalance {
return int ( b . LocalBalance - a . LocalBalance )
}
// finally sort by channel ID to prevent sort randomly changing
return strings . Compare ( b . Id , a . Id )
} )
2024-07-31 09:10:26 +02:00
return apiChannels , nil
2024-05-30 00:06:06 +07:00
}
func ( api * api ) GetChannelPeerSuggestions ( ctx context . Context ) ( [ ] alby . ChannelPeerSuggestion , error ) {
2025-08-27 23:34:15 +07:00
return api . albySvc . GetChannelPeerSuggestions ( ctx )
feat: stories (#2172)
* feat: integrate Stories widget with backend endpoint
Add stories endpoint plumbing for HTTP and Wails, wire the Home Stories card
to fetch from /api/alby/stories, and keep it first in the right column.
Made-with: Cursor
* feat(home): story modal CTAs and preview fallback
- Add contextual actions in the story dialog (update hub with version,
open Alby Go in-app, install extension) keyed by kind or title
- Use preview stories when the stories API request fails
- Pass hub version from useInfo into the update link
Made-with: Cursor
* feat(stories): polish modal, drop preview fallback
- Widen modal and put video edge-to-edge with overlay close button
- Drop verbose header and 'Watch on YouTube' button
- Remove previewStories fallback so widget hides until upstream API ships
- Tighten title line-height
* feat(stories): render cta from API instead of mapping by kind
Move CTA copy and URLs into the API response. Hub renders story.cta
directly, so adding new story types no longer requires a hub release.
* chore(csp): allow cdn.getalby-assets.com in img-src
* feat(stories): bump avatar size and add ring gap
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): post-review cleanups
- Use react-router Link for in-tab CTA instead of plain <a>.
- Drop redundant www.youtube.com from frame-src (embeds always go through nocookie).
- Tighten stories endpoint status check from >= 300 to >= 400.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): address CodeRabbit feedback
- Switch StoriesWidget to useSWR + swrFetcher (project convention).
- Guard story iframe with isYouTubeUrl so non-YouTube urls never embed.
- Wrap GetStories errors with fmt.Errorf("...: %w", err).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): drop isYouTubeUrl guard
Stories are curated and always YouTube; the runtime check was
redundant. CSP frame-src still constrains the iframe source.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): drop getYouTubeEmbedUrl, embed videoUrl as-is
The Alby API now sends canonical youtube-nocookie embed URLs with
autoplay/rel query strings (getAlby/getalby.com#2568), so the
runtime normalization is no longer needed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): use w-16 instead of arbitrary w-[73px]
Match the avatar's size token; no magic numbers.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): take videoId from API and assemble embed url locally
Pairs with getAlby/getalby.com#2568. The API now sends just the
YouTube videoId; the hub composes the canonical embed URL so the
domain/query-string format stays in one place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): treat 3xx as non-success, matching file convention
The other status checks in alby_oauth_service.go all use >= 300;
align GetStories so redirects don't slip through.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): move viewed-storage key to constants, widen story button
Address review feedback:
- Centralize the localStorage key for viewed stories in localStorageKeys
alongside the other keys.
- Widen the story button from w-16 to w-20 so "Alby Extension" fits on
one line and matches the other titles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore(stories): bump story button to w-24 so titles fit one line
w-20 still wrapped "Alby Extension"; w-24 fits all current titles
without truncation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* Revert "chore(stories): bump story button to w-24 so titles fit one line"
This reverts commit 0b47438f501aee4b2a91827bec4267ac06072747.
* chore(stories): split title words onto separate lines
Reserve two lines for every story title so avatars align regardless of
title length.
* chore(stories): align homeStoriesViewed key with sibling pattern
* chore(stories): fit titles on one line
* chore(stories): widen story button to w-21 for one-line titles
---------
Co-authored-by: René Aaron <rene@twentyuno.net>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-02 10:50:15 +02:00
}
func ( api * api ) GetStories ( ctx context . Context ) ( [ ] alby . Story , error ) {
return api . albyOAuthSvc . GetStories ( ctx )
2025-08-27 23:34:15 +07:00
}
func ( api * api ) GetLSPChannelOffer ( ctx context . Context ) ( * alby . LSPChannelOffer , error ) {
return api . albyOAuthSvc . GetLSPChannelOffer ( ctx )
2024-05-30 00:06:06 +07:00
}
func ( api * api ) ResetRouter ( key string ) error {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
err := lnClient . ResetRouter ( key )
2024-05-30 00:06:06 +07:00
if err != nil {
return err
}
// Because the above method has to stop the node to reset the router,
// We also need to stop the lnclient and ask the user to start it again
return api . Stop ( )
}
func ( api * api ) ChangeUnlockPassword ( changeUnlockPasswordRequest * ChangeUnlockPasswordRequest ) error {
if api . svc . GetLNClient ( ) == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2025-01-07 20:08:10 +07:00
autoUnlockPassword , err := api . cfg . Get ( "AutoUnlockPassword" , "" )
if err != nil {
return err
}
if autoUnlockPassword != "" {
2025-06-24 21:23:19 +05:30
return errors . New ( "please disable auto-unlock before using this feature" )
2025-01-07 20:08:10 +07:00
}
err = api . cfg . ChangeUnlockPassword ( changeUnlockPasswordRequest . CurrentUnlockPassword , changeUnlockPasswordRequest . NewUnlockPassword )
2024-05-30 00:06:06 +07:00
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . Error ( "failed to change unlock password" )
2024-05-30 00:06:06 +07:00
return err
}
// Because all the encrypted fields have changed
// we also need to stop the lnclient and ask the user to start it again
return api . Stop ( )
}
2025-01-07 20:08:10 +07:00
func ( api * api ) SetAutoUnlockPassword ( unlockPassword string ) error {
if api . svc . GetLNClient ( ) == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2025-01-07 20:08:10 +07:00
}
err := api . cfg . SetAutoUnlockPassword ( unlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "failed to set auto unlock password" )
return err
}
return nil
}
2024-05-30 00:06:06 +07:00
func ( api * api ) Stop ( ) error {
2024-09-04 14:01:16 +07:00
if ! startMutex . TryLock ( ) {
// do not allow to stop twice in case this is somehow called twice
return errors . New ( "app is busy" )
}
defer startMutex . Unlock ( )
2024-06-17 19:42:09 +07:00
logger . Logger . Info ( "Running Stop command" )
2024-05-30 00:06:06 +07:00
if api . svc . GetLNClient ( ) == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2024-06-17 19:42:09 +07:00
2024-07-13 20:01:33 +07:00
// stop the lnclient, nostr relay etc.
2024-05-30 00:06:06 +07:00
// The user will be forced to re-enter their unlock password to restart the node
2024-07-13 20:01:33 +07:00
api . svc . StopApp ( )
2024-06-17 19:42:09 +07:00
2024-07-13 20:01:33 +07:00
return nil
2024-05-30 00:06:06 +07:00
}
2026-05-25 21:56:07 +07:00
func ( api * api ) GetNodeConnectionInfo ( ctx context . Context ) ( * NodeConnectionInfo , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-05-25 21:56:07 +07:00
info , err := lnClient . GetNodeConnectionInfo ( ctx )
if err != nil {
return nil , err
}
return & NodeConnectionInfo {
Pubkey : info . Pubkey ,
Address : info . Address ,
Port : info . Port ,
} , nil
2024-05-30 00:06:06 +07:00
}
2025-07-10 18:36:12 +05:30
func ( api * api ) RefundSwap ( refundSwapRequest * RefundSwapRequest ) error {
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) == nil {
return errors . New ( "SwapsService not started" )
}
2025-11-18 09:35:20 +05:30
return api . svc . GetSwapsService ( ) . RefundSwap ( refundSwapRequest . SwapId , refundSwapRequest . Address , false )
2025-07-10 18:36:12 +05:30
}
func ( api * api ) GetAutoSwapConfig ( ) ( * GetAutoSwapConfigResponse , error ) {
2026-03-25 16:58:16 +00:00
if api . svc . GetSwapsService ( ) == nil {
return nil , errors . New ( "SwapsService not started" )
}
2025-07-10 18:36:12 +05:30
swapOutBalanceThresholdStr , _ := api . cfg . Get ( config . AutoSwapBalanceThresholdKey , "" )
swapOutAmountStr , _ := api . cfg . Get ( config . AutoSwapAmountKey , "" )
swapOutDestination , _ := api . cfg . Get ( config . AutoSwapDestinationKey , "" )
2025-04-18 16:01:38 +05:30
2026-03-25 16:58:16 +00:00
if xpub := api . svc . GetSwapsService ( ) . GetDecryptedAutoSwapXpub ( ) ; xpub != "" {
swapOutDestination = xpub
}
2025-07-10 18:36:12 +05:30
swapOutEnabled := swapOutBalanceThresholdStr != "" && swapOutAmountStr != ""
2026-04-17 13:53:28 +05:30
var swapOutBalanceThresholdSat , swapOutAmountSat uint64
2025-07-10 18:36:12 +05:30
if swapOutEnabled {
2025-04-18 16:01:38 +05:30
var err error
2026-04-17 13:53:28 +05:30
if swapOutBalanceThresholdSat , err = strconv . ParseUint ( swapOutBalanceThresholdStr , 10 , 64 ) ; err != nil {
2025-07-10 18:36:12 +05:30
return nil , fmt . Errorf ( "invalid autoswap out balance threshold: %w" , err )
2025-04-18 16:01:38 +05:30
}
2026-04-17 13:53:28 +05:30
if swapOutAmountSat , err = strconv . ParseUint ( swapOutAmountStr , 10 , 64 ) ; err != nil {
2025-07-10 18:36:12 +05:30
return nil , fmt . Errorf ( "invalid autoswap out amount: %w" , err )
2025-04-18 16:01:38 +05:30
}
}
2025-07-10 18:36:12 +05:30
return & GetAutoSwapConfigResponse {
2026-04-17 13:53:28 +05:30
Type : constants . SWAP_TYPE_OUT ,
Enabled : swapOutEnabled ,
BalanceThreshold : swapOutBalanceThresholdSat ,
BalanceThresholdSat : swapOutBalanceThresholdSat ,
SwapAmount : swapOutAmountSat ,
SwapAmountSat : swapOutAmountSat ,
Destination : swapOutDestination ,
2025-07-10 18:36:12 +05:30
} , nil
}
func ( api * api ) LookupSwap ( swapId string ) ( * LookupSwapResponse , error ) {
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) == nil {
return nil , errors . New ( "SwapsService not started" )
}
2025-07-10 18:36:12 +05:30
dbSwap , err := api . svc . GetSwapsService ( ) . GetSwap ( swapId )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "failed to fetch swap info" )
return nil , err
}
return toApiSwap ( dbSwap ) , nil
}
func ( api * api ) ListSwaps ( ) ( * ListSwapsResponse , error ) {
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) == nil {
return nil , errors . New ( "SwapsService not started" )
}
2025-07-10 18:36:12 +05:30
swaps , err := api . svc . GetSwapsService ( ) . ListSwaps ( )
if err != nil {
return nil , err
}
apiSwaps := [ ] Swap { }
for _ , swap := range swaps {
apiSwaps = append ( apiSwaps , * toApiSwap ( & swap ) )
}
return & ListSwapsResponse {
Swaps : apiSwaps ,
} , nil
}
func toApiSwap ( swap * swaps . Swap ) * Swap {
return & Swap {
Id : swap . SwapId ,
Type : swap . Type ,
State : swap . State ,
Invoice : swap . Invoice ,
2026-05-01 15:41:56 +05:30
SendAmount : swap . SendAmountSat ,
SendAmountSat : swap . SendAmountSat ,
ReceiveAmount : swap . ReceiveAmountSat ,
ReceiveAmountSat : swap . ReceiveAmountSat ,
2025-07-10 18:36:12 +05:30
PaymentHash : swap . PaymentHash ,
DestinationAddress : swap . DestinationAddress ,
RefundAddress : swap . RefundAddress ,
LockupAddress : swap . LockupAddress ,
LockupTxId : swap . LockupTxId ,
ClaimTxId : swap . ClaimTxId ,
AutoSwap : swap . AutoSwap ,
BoltzPubkey : swap . BoltzPubkey ,
CreatedAt : swap . CreatedAt . Format ( time . RFC3339 ) ,
UpdatedAt : swap . UpdatedAt . Format ( time . RFC3339 ) ,
2025-08-29 16:47:31 +07:00
UsedXpub : swap . UsedXpub ,
2025-07-10 18:36:12 +05:30
}
}
2025-08-15 16:58:00 +05:30
func ( api * api ) GetSwapInInfo ( ) ( * SwapInfoResponse , error ) {
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) == nil {
return nil , errors . New ( "SwapsService not started" )
}
2025-08-15 16:58:00 +05:30
swapInInfo , err := api . svc . GetSwapsService ( ) . GetSwapInInfo ( )
2025-07-10 18:36:12 +05:30
if err != nil {
logger . Logger . WithError ( err ) . Error ( "failed to calculate fee info" )
return nil , err
}
2025-08-15 16:58:00 +05:30
return & SwapInfoResponse {
2026-04-17 13:53:28 +05:30
AlbyServiceFee : swapInInfo . AlbyServiceFee ,
BoltzServiceFee : swapInInfo . BoltzServiceFee ,
2026-05-01 15:41:56 +05:30
BoltzNetworkFee : swapInInfo . BoltzNetworkFeeSat ,
BoltzNetworkFeeSat : swapInInfo . BoltzNetworkFeeSat ,
MinAmount : swapInInfo . MinAmountSat ,
MinAmountSat : swapInInfo . MinAmountSat ,
MaxAmount : swapInInfo . MaxAmountSat ,
MaxAmountSat : swapInInfo . MaxAmountSat ,
2025-07-10 18:36:12 +05:30
} , nil
}
2025-08-15 16:58:00 +05:30
func ( api * api ) GetSwapOutInfo ( ) ( * SwapInfoResponse , error ) {
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) == nil {
return nil , errors . New ( "SwapsService not started" )
}
2025-08-15 16:58:00 +05:30
swapOutInfo , err := api . svc . GetSwapsService ( ) . GetSwapOutInfo ( )
2025-04-29 13:32:17 +05:30
if err != nil {
logger . Logger . WithError ( err ) . Error ( "failed to calculate fee info" )
return nil , err
}
2025-08-15 16:58:00 +05:30
return & SwapInfoResponse {
2026-04-17 13:53:28 +05:30
AlbyServiceFee : swapOutInfo . AlbyServiceFee ,
BoltzServiceFee : swapOutInfo . BoltzServiceFee ,
2026-05-01 15:41:56 +05:30
BoltzNetworkFee : swapOutInfo . BoltzNetworkFeeSat ,
BoltzNetworkFeeSat : swapOutInfo . BoltzNetworkFeeSat ,
MinAmount : swapOutInfo . MinAmountSat ,
MinAmountSat : swapOutInfo . MinAmountSat ,
MaxAmount : swapOutInfo . MaxAmountSat ,
MaxAmountSat : swapOutInfo . MaxAmountSat ,
2025-04-18 16:01:38 +05:30
} , nil
}
2025-07-10 18:36:12 +05:30
func ( api * api ) InitiateSwapOut ( ctx context . Context , initiateSwapOutRequest * InitiateSwapRequest ) ( * swaps . SwapResponse , error ) {
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2025-07-10 18:36:12 +05:30
}
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) == nil {
return nil , errors . New ( "SwapsService not started" )
}
2026-05-01 15:41:56 +05:30
amountSat := uint64 ( 0 )
resolvedAmountSat := ResolveToSat ( initiateSwapOutRequest . SwapAmountSat , nil , initiateSwapOutRequest . SwapAmount , nil )
if resolvedAmountSat != nil {
amountSat = * resolvedAmountSat
}
2025-07-10 18:36:12 +05:30
destination := initiateSwapOutRequest . Destination
2026-05-01 15:41:56 +05:30
if amountSat == 0 {
2025-07-10 18:36:12 +05:30
return nil , errors . New ( "invalid swap amount" )
}
2026-05-01 15:41:56 +05:30
swapOutResponse , err := api . svc . GetSwapsService ( ) . SwapOut ( amountSat , destination , false , false )
2025-07-10 18:36:12 +05:30
if err != nil {
logger . Logger . WithFields ( logrus . Fields {
2026-05-01 15:41:56 +05:30
"amount_sat" : amountSat ,
2025-07-10 18:36:12 +05:30
"destination" : destination ,
} ) . WithError ( err ) . Error ( "Failed to initiate swap out" )
return nil , err
}
return swapOutResponse , nil
}
func ( api * api ) InitiateSwapIn ( ctx context . Context , initiateSwapInRequest * InitiateSwapRequest ) ( * swaps . SwapResponse , error ) {
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2025-07-10 18:36:12 +05:30
}
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) == nil {
return nil , errors . New ( "SwapsService not started" )
}
2026-05-01 15:41:56 +05:30
amountSat := uint64 ( 0 )
resolvedAmountSat := ResolveToSat ( initiateSwapInRequest . SwapAmountSat , nil , initiateSwapInRequest . SwapAmount , nil )
if resolvedAmountSat != nil {
amountSat = * resolvedAmountSat
}
2025-07-10 18:36:12 +05:30
2026-05-01 15:41:56 +05:30
if amountSat == 0 {
2025-07-10 18:36:12 +05:30
return nil , errors . New ( "invalid swap amount" )
}
2026-05-01 15:41:56 +05:30
swapInResponse , err := api . svc . GetSwapsService ( ) . SwapIn ( amountSat , false )
2025-07-10 18:36:12 +05:30
if err != nil {
logger . Logger . WithFields ( logrus . Fields {
2026-05-01 15:41:56 +05:30
"amount_sat" : amountSat ,
2025-07-10 18:36:12 +05:30
} ) . WithError ( err ) . Error ( "Failed to initiate swap in" )
return nil , err
}
return swapInResponse , nil
}
func ( api * api ) EnableAutoSwapOut ( ctx context . Context , enableAutoSwapsRequest * EnableAutoSwapRequest ) error {
2026-03-25 16:58:16 +00:00
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" )
}
}
2026-05-01 15:41:56 +05:30
balanceThresholdSat := uint64 ( 0 )
resolvedBalanceThresholdSat := ResolveToSat ( enableAutoSwapsRequest . BalanceThresholdSat , nil , enableAutoSwapsRequest . BalanceThreshold , nil )
if resolvedBalanceThresholdSat != nil {
balanceThresholdSat = * resolvedBalanceThresholdSat
}
err := api . cfg . SetUpdate ( config . AutoSwapBalanceThresholdKey , strconv . FormatUint ( balanceThresholdSat , 10 ) , "" )
2025-04-16 20:58:32 +05:30
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save autoswap balance threshold to config" )
2025-04-18 12:03:45 +05:30
return err
2025-04-16 20:58:32 +05:30
}
2026-05-01 15:41:56 +05:30
swapAmountSat := uint64 ( 0 )
resolvedSwapAmountSat := ResolveToSat ( enableAutoSwapsRequest . SwapAmountSat , nil , enableAutoSwapsRequest . SwapAmount , nil )
if resolvedSwapAmountSat != nil {
swapAmountSat = * resolvedSwapAmountSat
}
err = api . cfg . SetUpdate ( config . AutoSwapAmountKey , strconv . FormatUint ( swapAmountSat , 10 ) , "" )
2025-04-18 14:41:11 +05:30
if err != nil {
2025-04-28 14:20:10 +05:30
logger . Logger . WithError ( err ) . Error ( "Failed to save autoswap amount to config" )
2025-04-18 14:41:11 +05:30
return err
}
2026-03-25 16:58:16 +00:00
err = api . cfg . SetUpdate ( config . AutoSwapDestinationKey , enableAutoSwapsRequest . Destination , encryptionKey )
2025-04-16 20:58:32 +05:30
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save autoswap destination to config" )
2025-04-18 12:03:45 +05:30
return err
2025-04-16 20:58:32 +05:30
}
2026-03-25 16:58:16 +00:00
return api . svc . GetSwapsService ( ) . EnableAutoSwapOut ( enableAutoSwapsRequest . UnlockPassword )
2025-04-16 20:58:32 +05:30
}
2025-07-10 18:36:12 +05:30
func ( api * api ) DisableAutoSwap ( ) error {
keys := [ ] string { config . AutoSwapBalanceThresholdKey , config . AutoSwapAmountKey , config . AutoSwapDestinationKey }
2025-04-18 16:01:38 +05:30
2025-07-10 18:36:12 +05:30
for _ , key := range keys {
if err := api . cfg . SetUpdate ( key , "" , "" ) ; err != nil {
logger . Logger . WithError ( err ) . Errorf ( "Failed to remove autoswap config for key: %s" , key )
return err
}
}
2025-04-18 16:01:38 +05:30
2025-10-14 11:09:54 +05:30
if api . svc . GetSwapsService ( ) != nil {
api . svc . GetSwapsService ( ) . StopAutoSwapOut ( )
}
2025-04-18 16:01:38 +05:30
return nil
}
2025-07-10 18:36:12 +05:30
func ( api * api ) GetSwapMnemonic ( ) string {
return api . keys . GetSwapMnemonic ( )
}
2026-05-25 21:56:07 +07:00
func ( api * api ) GetNodeStatus ( ctx context . Context ) ( * NodeStatus , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-05-25 21:56:07 +07:00
nodeStatus , err := lnClient . GetNodeStatus ( ctx )
if err != nil {
return nil , err
}
if nodeStatus == nil {
return nil , nil
}
return toApiNodeStatus ( nodeStatus ) , nil
}
func toApiNodeStatus ( nodeStatus * lnclient . NodeStatus ) * NodeStatus {
return & NodeStatus {
IsReady : nodeStatus . IsReady ,
InternalNodeStatus : nodeStatus . InternalNodeStatus ,
}
2024-05-30 00:06:06 +07:00
}
2026-05-25 21:56:07 +07:00
func ( api * api ) ListPeers ( ctx context . Context ) ( [ ] PeerDetails , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-05-25 21:56:07 +07:00
peers , err := lnClient . ListPeers ( ctx )
if err != nil {
return nil , err
}
apiPeers := make ( [ ] PeerDetails , 0 , len ( peers ) )
for _ , peer := range peers {
apiPeers = append ( apiPeers , PeerDetails {
NodeId : peer . NodeId ,
Address : peer . Address ,
IsPersisted : peer . IsPersisted ,
IsConnected : peer . IsConnected ,
} )
}
return apiPeers , nil
2024-05-30 00:06:06 +07:00
}
func ( api * api ) ConnectPeer ( ctx context . Context , connectPeerRequest * ConnectPeerRequest ) error {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-05-25 21:56:07 +07:00
return lnClient . ConnectPeer ( ctx , & lnclient . ConnectPeerRequest {
Pubkey : connectPeerRequest . Pubkey ,
Address : connectPeerRequest . Address ,
Port : connectPeerRequest . Port ,
} )
2024-05-30 00:06:06 +07:00
}
func ( api * api ) OpenChannel ( ctx context . Context , openChannelRequest * OpenChannelRequest ) ( * OpenChannelResponse , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-05-25 21:56:07 +07:00
resp , err := lnClient . OpenChannel ( ctx , & lnclient . OpenChannelRequest {
Pubkey : openChannelRequest . Pubkey ,
AmountSats : openChannelRequest . AmountSats ,
Public : openChannelRequest . Public ,
} )
if err != nil {
return nil , err
}
return & OpenChannelResponse {
FundingTxId : resp . FundingTxId ,
} , nil
2024-05-30 00:06:06 +07:00
}
2024-05-30 23:04:32 +07:00
func ( api * api ) DisconnectPeer ( ctx context . Context , peerId string ) error {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-05-30 23:04:32 +07:00
}
2024-06-17 19:42:09 +07:00
logger . Logger . WithFields ( logrus . Fields {
2024-05-30 23:04:32 +07:00
"peer_id" : peerId ,
} ) . Info ( "Disconnecting peer" )
2026-02-27 14:15:09 +05:30
return lnClient . DisconnectPeer ( ctx , peerId )
2024-05-30 23:04:32 +07:00
}
2024-05-30 00:06:06 +07:00
func ( api * api ) CloseChannel ( ctx context . Context , peerId , channelId string , force bool ) ( * CloseChannelResponse , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2024-06-17 19:42:09 +07:00
logger . Logger . WithFields ( logrus . Fields {
2024-05-30 00:06:06 +07:00
"peer_id" : peerId ,
"channel_id" : channelId ,
"force" : force ,
} ) . Info ( "Closing channel" )
2026-05-25 21:56:07 +07:00
err := lnClient . CloseChannel ( ctx , & lnclient . CloseChannelRequest {
2024-05-30 00:06:06 +07:00
NodeId : peerId ,
ChannelId : channelId ,
Force : force ,
} )
2026-05-25 21:56:07 +07:00
if err != nil {
return nil , err
}
return & CloseChannelResponse { } , nil
2024-05-30 00:06:06 +07:00
}
2024-06-18 15:22:19 +07:00
func ( api * api ) UpdateChannel ( ctx context . Context , updateChannelRequest * UpdateChannelRequest ) error {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-06-18 15:22:19 +07:00
}
logger . Logger . WithFields ( logrus . Fields {
"request" : updateChannelRequest ,
} ) . Info ( "updating channel" )
2026-05-25 21:56:07 +07:00
return lnClient . UpdateChannel ( ctx , & lnclient . UpdateChannelRequest {
ChannelId : updateChannelRequest . ChannelId ,
NodeId : updateChannelRequest . NodeId ,
ForwardingFeeBaseMsat : updateChannelRequest . ForwardingFeeBaseMsat ,
ForwardingFeeProportionalMillionths : updateChannelRequest . ForwardingFeeProportionalMillionths ,
MaxDustHtlcExposureFromFeeRateMultiplier : updateChannelRequest . MaxDustHtlcExposureFromFeeRateMultiplier ,
} )
2024-06-18 15:22:19 +07:00
}
2025-06-04 20:58:19 +05:30
func ( api * api ) MakeOffer ( ctx context . Context , description string ) ( string , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return "" , ErrLNClientNotStarted
2025-06-04 20:58:19 +05:30
}
2026-02-27 14:15:09 +05:30
offer , err := lnClient . MakeOffer ( ctx , description )
2025-06-04 20:58:19 +05:30
if err != nil {
return "" , err
}
return offer , nil
}
2024-06-11 06:49:13 +03:00
func ( api * api ) GetNewOnchainAddress ( ctx context . Context ) ( string , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return "" , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
address , err := lnClient . GetNewOnchainAddress ( ctx )
2024-05-30 00:06:06 +07:00
if err != nil {
2024-06-11 06:49:13 +03:00
return "" , err
2024-05-30 00:06:06 +07:00
}
2024-06-11 06:49:13 +03:00
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( config . OnchainAddressKey , address , "" )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save new onchain address to config" )
}
2024-06-11 06:49:13 +03:00
return address , nil
}
func ( api * api ) GetUnusedOnchainAddress ( ctx context . Context ) ( string , error ) {
if api . svc . GetLNClient ( ) == nil {
2026-04-07 17:38:47 +05:30
return "" , ErrLNClientNotStarted
2024-06-11 06:49:13 +03:00
}
2024-06-17 19:42:09 +07:00
currentAddress , err := api . cfg . Get ( config . OnchainAddressKey , "" )
2024-06-11 06:49:13 +03:00
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . Error ( "Failed to get current address from config" )
2024-06-11 06:49:13 +03:00
return "" , err
}
if currentAddress != "" {
// check if address has any transactions
2025-09-02 18:22:30 +05:30
response , err := api . RequestEsploraApi ( ctx , "/address/" + currentAddress + "/txs" )
2024-06-11 06:49:13 +03:00
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . Error ( "Failed to get current address transactions" )
2024-06-11 06:49:13 +03:00
return currentAddress , nil
}
transactions , ok := response . ( [ ] interface { } )
if ! ok {
2024-06-17 19:42:09 +07:00
logger . Logger . WithField ( "response" , response ) . Error ( "Failed to cast esplora address txs response" , response )
2024-06-11 06:49:13 +03:00
return currentAddress , nil
}
if len ( transactions ) == 0 {
// address has not been used yet
return currentAddress , nil
}
}
newAddress , err := api . GetNewOnchainAddress ( ctx )
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . Error ( "Failed to retrieve new onchain address" )
2024-06-11 06:49:13 +03:00
return "" , err
}
return newAddress , nil
2024-05-30 00:06:06 +07:00
}
func ( api * api ) SignMessage ( ctx context . Context , message string ) ( * SignMessageResponse , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
signature , err := lnClient . SignMessage ( ctx , message )
2024-05-30 00:06:06 +07:00
if err != nil {
return nil , err
}
return & SignMessageResponse {
Message : message ,
Signature : signature ,
} , nil
}
2026-04-17 13:53:28 +05:30
func ( api * api ) RedeemOnchainFunds ( ctx context . Context , toAddress string , amountSat uint64 , feeRate * uint64 , sendAll bool ) ( * RedeemOnchainFundsResponse , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-04-17 13:53:28 +05:30
txId , err := lnClient . RedeemOnchainFunds ( ctx , toAddress , amountSat , feeRate , sendAll )
2024-05-30 00:06:06 +07:00
if err != nil {
return nil , err
}
return & RedeemOnchainFundsResponse {
TxId : txId ,
} , nil
}
func ( api * api ) GetBalances ( ctx context . Context ) ( * BalancesResponse , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
balances , err := lnClient . GetBalances ( ctx , false )
2024-05-30 00:06:06 +07:00
if err != nil {
return nil , err
}
2026-05-25 21:56:07 +07:00
return toApiBalances ( balances ) , nil
}
func toApiBalances ( balances * lnclient . BalancesResponse ) * BalancesResponse {
totalSpendableMsat := balances . Lightning . TotalSpendableMsat
totalReceivableMsat := balances . Lightning . TotalReceivableMsat
nextMaxSpendableMsat := balances . Lightning . NextMaxSpendableMsat
nextMaxReceivableMsat := balances . Lightning . NextMaxReceivableMsat
nextMaxSpendableMPPMsat := balances . Lightning . NextMaxSpendableMPPMsat
nextMaxReceivableMPPMsat := balances . Lightning . NextMaxReceivableMPPMsat
return & BalancesResponse {
Onchain : OnchainBalanceResponse {
Spendable : balances . Onchain . SpendableSat ,
SpendableSat : balances . Onchain . SpendableSat ,
Total : balances . Onchain . TotalSat ,
TotalSat : balances . Onchain . TotalSat ,
Reserved : balances . Onchain . ReservedSat ,
ReservedSat : balances . Onchain . ReservedSat ,
PendingBalancesFromChannelClosures : balances . Onchain . PendingBalancesFromChannelClosuresSat ,
PendingBalancesFromChannelClosuresSat : balances . Onchain . PendingBalancesFromChannelClosuresSat ,
PendingBalancesDetails : toApiPendingBalanceDetails ( balances . Onchain . PendingBalancesDetails ) ,
PendingSweepBalancesDetails : toApiPendingBalanceDetails ( balances . Onchain . PendingSweepBalancesDetails ) ,
InternalBalances : balances . Onchain . InternalBalances ,
} ,
Lightning : LightningBalanceResponse {
TotalSpendable : totalSpendableMsat ,
TotalSpendableSat : totalSpendableMsat / 1000 ,
TotalSpendableMsat : totalSpendableMsat ,
TotalReceivable : totalReceivableMsat ,
TotalReceivableSat : totalReceivableMsat / 1000 ,
TotalReceivableMsat : totalReceivableMsat ,
NextMaxSpendable : nextMaxSpendableMsat ,
NextMaxSpendableSat : nextMaxSpendableMsat / 1000 ,
NextMaxSpendableMsat : nextMaxSpendableMsat ,
NextMaxReceivable : nextMaxReceivableMsat ,
NextMaxReceivableSat : nextMaxReceivableMsat / 1000 ,
NextMaxReceivableMsat : nextMaxReceivableMsat ,
NextMaxSpendableMPP : nextMaxSpendableMPPMsat ,
NextMaxSpendableMPPSat : nextMaxSpendableMPPMsat / 1000 ,
NextMaxSpendableMPPMsat : nextMaxSpendableMPPMsat ,
NextMaxReceivableMPP : nextMaxReceivableMPPMsat ,
NextMaxReceivableMPPSat : nextMaxReceivableMPPMsat / 1000 ,
NextMaxReceivableMPPMsat : nextMaxReceivableMPPMsat ,
} ,
}
}
func toApiPendingBalanceDetails ( details [ ] lnclient . PendingBalanceDetails ) [ ] PendingBalanceDetails {
if details == nil {
return nil
}
apiDetails := make ( [ ] PendingBalanceDetails , 0 , len ( details ) )
for _ , d := range details {
apiDetails = append ( apiDetails , PendingBalanceDetails {
ChannelId : d . ChannelId ,
NodeId : d . NodeId ,
Amount : d . AmountSat ,
AmountSat : d . AmountSat ,
FundingTxId : d . FundingTxId ,
FundingTxVout : d . FundingTxVout ,
} )
}
return apiDetails
2024-05-30 00:06:06 +07:00
}
2024-06-11 06:49:13 +03:00
// TODO: remove dependency on this endpoint
2025-09-02 18:22:30 +05:30
func ( api * api ) RequestMempoolApi ( ctx context . Context , endpoint string ) ( interface { } , error ) {
2024-06-17 19:42:09 +07:00
url := api . cfg . GetEnv ( ) . MempoolApi + endpoint
2024-05-30 00:06:06 +07:00
client := http . Client {
Timeout : time . Second * 10 ,
}
2025-09-02 18:22:30 +05:30
req , err := http . NewRequestWithContext ( ctx , http . MethodGet , url , nil )
2024-05-30 00:06:06 +07:00
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
2024-05-30 00:06:06 +07:00
"url" : url ,
} ) . Error ( "Failed to create http request" )
return nil , err
}
res , err := client . Do ( req )
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
2024-05-30 00:06:06 +07:00
"url" : url ,
} ) . Error ( "Failed to send request" )
return nil , err
}
defer res . Body . Close ( )
body , readErr := io . ReadAll ( res . Body )
if readErr != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . WithFields ( logrus . Fields {
2024-05-30 00:06:06 +07:00
"url" : url ,
} ) . Error ( "Failed to read response body" )
return nil , errors . New ( "failed to read response body" )
}
2026-03-31 20:51:26 +03:00
if res . StatusCode != http . StatusOK {
logger . Logger . WithFields ( logrus . Fields {
"endpoint" : endpoint ,
"status_code" : res . StatusCode ,
"body" : string ( body ) ,
} ) . Error ( "Mempool endpoint returned non-success code" )
return nil , fmt . Errorf ( "mempool endpoint returned non-success code: %s" , string ( body ) )
}
2024-05-30 00:06:06 +07:00
var jsonContent interface { }
jsonErr := json . Unmarshal ( body , & jsonContent )
if jsonErr != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( jsonErr ) . WithFields ( logrus . Fields {
2024-05-30 00:06:06 +07:00
"url" : url ,
} ) . Error ( "Failed to deserialize json" )
return nil , fmt . Errorf ( "failed to deserialize json %s %s" , url , string ( body ) )
}
return jsonContent , nil
}
func ( api * api ) GetInfo ( ctx context . Context ) ( * InfoResponse , error ) {
info := InfoResponse { }
2026-08-10 15:37:26 +07:00
if api . nodeMigrationFileCreated . Load ( ) {
// the hub is halted and the database is closed after a migration file
// is created, so return a minimal response without reading any config
// or node state; the frontend only needs the flag to keep showing the
// migration success page
info . NodeMigrationFileCreated = true
info . SetupCompleted = true
info . Version = version . Tag
info . Relays = [ ] InfoResponseRelay { }
return & info , nil
}
2024-06-17 19:42:09 +07:00
backendType , _ := api . cfg . Get ( "LNBackendType" , "" )
2024-11-29 15:16:20 -06:00
ldkVssEnabled , _ := api . cfg . Get ( "LdkVssEnabled" , "" )
2026-06-10 09:47:07 +02:00
jitChannelsEnabled , _ := api . cfg . Get ( "JitChannelsEnabled" , "" )
2025-01-07 20:08:10 +07:00
autoUnlockPassword , _ := api . cfg . Get ( "AutoUnlockPassword" , "" )
2026-02-09 17:39:25 +03:00
setupCompleted , err := api . cfg . SetupCompleted ( )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to check if setup is completed" )
return nil , err
}
info . SetupCompleted = setupCompleted
2025-01-31 17:16:50 +05:30
info . Currency = api . cfg . GetCurrency ( )
2025-11-10 09:48:27 +01:00
info . BitcoinDisplayFormat = api . cfg . GetBitcoinDisplayFormat ( )
2025-02-14 16:03:50 +07:00
info . StartupState = api . svc . GetStartupState ( )
2024-09-04 10:46:12 +05:30
if api . startupError != nil {
info . StartupError = api . startupError . Error ( )
info . StartupErrorTime = api . startupErrorTime
}
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
info . Running = lnClient != nil
2026-08-10 15:37:26 +07:00
info . NodeMigrationFileCreated = api . nodeMigrationFileCreated . Load ( )
2024-05-30 00:06:06 +07:00
info . BackendType = backendType
2024-06-17 19:42:09 +07:00
info . AlbyAuthUrl = api . albyOAuthSvc . GetAuthUrl ( )
info . OAuthRedirect = ! api . cfg . GetEnv ( ) . IsDefaultClientId ( )
2024-06-22 12:22:01 +07:00
info . Version = version . Tag
2024-08-08 16:36:46 +07:00
info . EnableAdvancedSetup = api . cfg . GetEnv ( ) . EnableAdvancedSetup
2026-02-10 09:00:08 -08:00
info . HideUpdateBanner = api . cfg . GetEnv ( ) . HideUpdateBanner
2024-11-29 15:16:20 -06:00
info . LdkVssEnabled = ldkVssEnabled == "true"
2026-06-10 09:47:07 +02:00
info . JitChannelsEnabled = jitChannelsEnabled != "false"
2024-12-17 13:53:58 +07:00
info . VssSupported = backendType == config . LDKBackendType && api . cfg . GetEnv ( ) . LDKVssUrl != ""
2026-08-10 14:20:41 +07:00
info . LdkVssUrl = api . cfg . GetEnv ( ) . LDKVssUrl
info . DatabaseType = api . db . Dialector . Name ( )
2026-05-23 15:10:02 +02:00
info . SupportsBolt12 = backendType == config . LDKBackendType || backendType == config . CLNBackendType
2025-01-07 20:08:10 +07:00
info . AutoUnlockPasswordEnabled = autoUnlockPassword != ""
info . AutoUnlockPasswordSupported = api . cfg . GetEnv ( ) . IsDefaultClientId ( )
2025-11-06 18:01:09 +07:00
info . Relays = [ ] InfoResponseRelay { }
for _ , relayStatus := range api . svc . GetRelayStatuses ( ) {
info . Relays = append ( info . Relays , InfoResponseRelay {
Url : relayStatus . Url ,
Online : relayStatus . Online ,
} )
}
2025-06-24 21:23:19 +05:30
info . MempoolUrl = api . cfg . GetMempoolUrl ( )
2025-11-06 18:01:09 +07:00
info . AlbyAccountConnected = api . albyOAuthSvc . IsConnected ( ctx )
albyUserIdentifier , err := api . albyOAuthSvc . GetUserIdentifier ( )
2024-05-30 00:06:06 +07:00
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . Error ( "Failed to get alby user identifier" )
2024-05-30 00:06:06 +07:00
return nil , err
}
info . AlbyUserIdentifier = albyUserIdentifier
2025-11-06 18:01:09 +07:00
2026-02-27 14:15:09 +05:30
if lnClient != nil {
nodeInfo , err := lnClient . GetInfo ( ctx )
2024-05-30 00:06:06 +07:00
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . Error ( "Failed to get nodeInfo" )
2024-05-30 00:06:06 +07:00
return nil , err
}
info . Network = nodeInfo . Network
2026-04-06 22:55:13 +01:00
if backendType == config . LDKBackendType {
// Only LDK supports this right now. Using a local interface here
// so we don't have to bloat the main LNClient interface for everyone else.
type chainSourceProvider interface {
GetChainDataSource ( ) ( string , string )
}
2026-06-10 09:47:07 +02:00
type lsps2SourceProvider interface {
GetLiquiditySourceLsps2 ( ) string
}
type lsps2MinPaymentSizeProvider interface {
GetLiquiditySourceLsps2MinPaymentSizeMsat ( ) * uint64
}
type lsps2MaxPaymentSizeProvider interface {
GetLiquiditySourceLsps2MaxPaymentSizeMsat ( ) * uint64
}
2026-04-06 22:55:13 +01:00
if ldkService , ok := api . svc . GetLNClient ( ) . ( chainSourceProvider ) ; ok {
info . ChainDataSourceType , info . ChainDataSourceAddress = ldkService . GetChainDataSource ( )
}
2026-06-10 09:47:07 +02:00
if ldkService , ok := api . svc . GetLNClient ( ) . ( lsps2SourceProvider ) ; ok {
info . JitChannelsLiquiditySource = ldkService . GetLiquiditySourceLsps2 ( )
}
if ldkService , ok := api . svc . GetLNClient ( ) . ( lsps2MinPaymentSizeProvider ) ; ok {
info . JitChannelsMinPaymentSizeMsat = ldkService . GetLiquiditySourceLsps2MinPaymentSizeMsat ( )
}
if ldkService , ok := api . svc . GetLNClient ( ) . ( lsps2MaxPaymentSizeProvider ) ; ok {
info . JitChannelsMaxPaymentSizeMsat = ldkService . GetLiquiditySourceLsps2MaxPaymentSizeMsat ( )
}
2026-04-06 22:55:13 +01:00
}
2024-05-30 00:06:06 +07:00
}
2024-06-17 19:42:09 +07:00
info . NextBackupReminder , _ = api . cfg . Get ( "NextBackupReminder" , "" )
2024-05-30 00:06:06 +07:00
2025-06-18 13:33:14 +07:00
info . NodeAlias , _ = api . cfg . Get ( "NodeAlias" , "" )
2024-05-30 00:06:06 +07:00
return & info , nil
}
2026-06-10 09:47:07 +02:00
func ( api * api ) setCurrency ( currency string ) error {
2025-01-31 17:16:50 +05:30
if currency == "" {
return fmt . Errorf ( "currency value cannot be empty" )
}
err := api . cfg . SetCurrency ( currency )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to update currency" )
return err
}
return nil
}
2026-06-10 09:47:07 +02:00
func ( api * api ) setBitcoinDisplayFormat ( format string ) error {
2025-11-10 09:48:27 +01:00
if format != constants . BITCOIN_DISPLAY_FORMAT_SATS && format != constants . BITCOIN_DISPLAY_FORMAT_BIP177 {
return fmt . Errorf ( "bitcoin display format must be '%s' or '%s'" , constants . BITCOIN_DISPLAY_FORMAT_SATS , constants . BITCOIN_DISPLAY_FORMAT_BIP177 )
}
err := api . cfg . SetBitcoinDisplayFormat ( format )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to update bitcoin display format" )
return err
}
return nil
}
2026-06-10 09:47:07 +02:00
func ( api * api ) setJitChannelsEnabled ( enabled bool ) error {
value := "true"
if ! enabled {
value = "false"
}
err := api . cfg . SetUpdate ( "JitChannelsEnabled" , value , "" )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to update JIT channels setting" )
return err
}
return nil
}
2025-11-10 09:48:27 +01:00
func ( api * api ) UpdateSettings ( updateSettingsRequest * UpdateSettingsRequest ) error {
if updateSettingsRequest . Currency != "" {
2026-06-10 09:47:07 +02:00
err := api . setCurrency ( updateSettingsRequest . Currency )
2025-11-10 09:48:27 +01:00
if err != nil {
return fmt . Errorf ( "failed to set currency: %w" , err )
}
}
if updateSettingsRequest . BitcoinDisplayFormat != "" {
2026-06-10 09:47:07 +02:00
err := api . setBitcoinDisplayFormat ( updateSettingsRequest . BitcoinDisplayFormat )
2025-11-10 09:48:27 +01:00
if err != nil {
return fmt . Errorf ( "failed to set bitcoin display format: %w" , err )
}
}
2026-06-10 09:47:07 +02:00
if updateSettingsRequest . JitChannelsEnabled != nil {
err := api . setJitChannelsEnabled ( * updateSettingsRequest . JitChannelsEnabled )
if err != nil {
return fmt . Errorf ( "failed to set JIT channels setting: %w" , err )
}
}
2025-11-10 09:48:27 +01:00
return nil
}
2025-06-18 13:33:14 +07:00
func ( api * api ) SetNodeAlias ( nodeAlias string ) error {
err := api . cfg . SetUpdate ( "NodeAlias" , nodeAlias , "" )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save node alias to config" )
return err
}
return nil
}
2024-08-08 11:08:14 +02:00
func ( api * api ) GetMnemonic ( unlockPassword string ) ( * MnemonicResponse , error ) {
if ! api . cfg . CheckUnlockPassword ( unlockPassword ) {
return nil , fmt . Errorf ( "wrong password" )
}
mnemonic , err := api . cfg . Get ( "Mnemonic" , unlockPassword )
if err != nil {
return nil , fmt . Errorf ( "failed to fetch encryption key: %w" , err )
}
resp := MnemonicResponse {
Mnemonic : mnemonic ,
}
2025-02-24 16:12:38 +01:00
return & resp , nil
2024-05-30 00:06:06 +07:00
}
func ( api * api ) SetNextBackupReminder ( backupReminderRequest * BackupReminderRequest ) error {
2024-09-25 17:31:05 +07:00
err := api . cfg . SetUpdate ( "NextBackupReminder" , backupReminderRequest . NextBackupReminder , "" )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save next backup reminder to config" )
}
2024-05-30 00:06:06 +07:00
return nil
}
2024-06-19 23:25:39 +07:00
var startMutex sync . Mutex
2024-09-04 10:46:12 +05:30
func ( api * api ) Start ( startRequest * StartRequest ) {
api . startupError = nil
2025-12-17 00:25:33 +07:00
err := api . startInternal ( startRequest )
2024-09-04 10:46:12 +05:30
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to start node" )
api . startupError = err
api . startupErrorTime = time . Now ( )
}
}
2025-12-17 00:25:33 +07:00
func ( api * api ) startInternal ( startRequest * StartRequest ) ( err error ) {
2024-06-19 23:25:39 +07:00
if ! startMutex . TryLock ( ) {
// do not allow to start twice in case this is somehow called twice
2024-09-04 14:01:16 +07:00
return errors . New ( "app is busy" )
2024-06-19 23:25:39 +07:00
}
defer startMutex . Unlock ( )
2024-05-30 00:06:06 +07:00
return api . svc . StartApp ( startRequest . UnlockPassword )
}
func ( api * api ) Setup ( ctx context . Context , setupRequest * SetupRequest ) error {
2024-08-24 20:25:05 +07:00
if ! startMutex . TryLock ( ) {
// do not allow to start twice in case this is somehow called twice
2024-09-04 14:01:16 +07:00
return errors . New ( "app is busy" )
2024-08-24 20:25:05 +07:00
}
defer startMutex . Unlock ( )
2024-05-30 00:06:06 +07:00
info , err := api . GetInfo ( ctx )
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithError ( err ) . Error ( "Failed to get info" )
2024-05-30 00:06:06 +07:00
return err
}
if info . SetupCompleted {
2024-06-17 19:42:09 +07:00
logger . Logger . Error ( "Cannot re-setup node" )
2024-05-30 00:06:06 +07:00
return errors . New ( "setup already completed" )
}
2024-08-24 20:25:05 +07:00
if setupRequest . UnlockPassword == "" {
return errors . New ( "no unlock password provided" )
}
fix: bark onboarding, migration messaging and receive settlement for bark 0.6.0 (#2523)
* fix: update bark onboarding and backup messaging for seed-based recovery
Since bark 0.6.0, offchain funds are recoverable from the mnemonic
alone via the seed-derived recovery mailbox. Remove the outdated
warnings that the recovery phrase is not sufficient, show the standard
recovery guidance for bark during onboarding, and expose the
seed-recovery scan result as a 'recoveryreport' custom node command so
users migrating to a new device can verify their funds were restored.
Closes #2512
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: settle bark lightning receives in the new delivering state
bark 0.6.0 added a 'delivering' receive state between preimage reveal
and settlement. The receive claim handler only treated
'preimage-revealed' and 'settled' as paid, so claimed receives were
published without a preimage and the transactions service rejected the
settlement ('no preimage in payment'), leaving paid invoices pending
forever.
Recognize all states at or past preimage reveal via a receiveIsPaid
helper (a positive allowlist, so an unknown future state degrades to
pending rather than falsely settled), only mark the transaction settled
when the preimage is present, and prefer bark's own settled_at
timestamp when available.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: replace import channels checkbox with LDK-specific warning
The 'I don't have another Alby Hub to migrate or open channels'
checkbox on the import recovery phrase screen only applied to LDK but
was required for every backend, and its claim that channel funds are
always lost is wrong when dynamic channel backups (VSS) are enabled.
Remove the checkbox and the channels bullet from the import screen and
show the caveat on the Security & Recovery page instead, only when a
mnemonic was imported and the LDK backend was chosen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 16:39:02 +07:00
// Bark and Cashu both store wallet state on local disk, so they cannot
// run in environments without persistent volumes (e.g. Alby Cloud). Bark
// can recover spendable VTXOs from the mnemonic alone, but in-flight
// payment checkpoints and wallet metadata are local-only, so persistent
// storage is still required. The default OAuth client ID identifies a
// local / self-hosted deployment.
2026-06-04 11:57:29 +07:00
if ! api . cfg . GetEnv ( ) . IsDefaultClientId ( ) {
switch setupRequest . LNBackendType {
case config . BarkBackendType , config . CashuBackendType :
return fmt . Errorf ( "%s backend is not supported in this environment (no persistent storage)" , setupRequest . LNBackendType )
}
}
2024-09-25 17:31:05 +07:00
err = api . cfg . SaveUnlockPasswordCheck ( setupRequest . UnlockPassword )
if err != nil {
return err
}
2024-05-30 00:06:06 +07:00
// update next backup reminder
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( "NextBackupReminder" , setupRequest . NextBackupReminder , "" )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save next backup reminder" )
}
2024-05-30 00:06:06 +07:00
// only update non-empty values
if setupRequest . LNBackendType != "" {
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( "LNBackendType" , setupRequest . LNBackendType , "" )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save backend type" )
return err
}
2024-05-30 00:06:06 +07:00
}
if setupRequest . Mnemonic != "" {
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( "Mnemonic" , setupRequest . Mnemonic , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save encrypted mnemonic" )
return err
}
2024-05-30 00:06:06 +07:00
}
if setupRequest . LNDAddress != "" {
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( "LNDAddress" , setupRequest . LNDAddress , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save lnd address" )
return err
}
2024-05-30 00:06:06 +07:00
}
2026-04-21 22:33:25 +05:30
if setupRequest . LNDCertFile != "" {
2026-08-11 15:05:56 +07:00
// The file path is provided by the (unauthenticated) setup request, so
// only persist the content if it parses as a certificate. Storing the
// re-encoded certificate(s) guarantees nothing but the parsed structure
// reaches the database - e.g. a private key bundled in the same PEM file
// is dropped rather than persisted.
certHex , err := readAndCanonicalizeLNDCert ( setupRequest . LNDCertFile )
2026-04-21 22:33:25 +05:30
if err != nil {
2026-08-11 15:05:56 +07:00
// Return a generic error and log the detail server-side so the
// response is not a file existence/readability oracle.
logger . Logger . WithError ( err ) . Error ( "Failed to process lnd cert file" )
return errors . New ( "invalid LND certificate file" )
2026-04-21 22:33:25 +05:30
}
err = api . cfg . SetUpdate ( "LNDCertHex" , certHex , setupRequest . UnlockPassword )
2024-09-25 17:31:05 +07:00
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save lnd cert hex" )
return err
}
2024-05-30 00:06:06 +07:00
}
2026-04-21 22:33:25 +05:30
if setupRequest . LNDMacaroonFile != "" {
2026-08-11 15:05:56 +07:00
// The file path is provided by the (unauthenticated) setup request, so
// only persist the content if it parses as a macaroon. Storing the
// re-marshalled macaroon guarantees only the parsed structure reaches
// the database.
macaroonHex , err := readAndCanonicalizeLNDMacaroon ( setupRequest . LNDMacaroonFile )
2026-04-21 22:33:25 +05:30
if err != nil {
2026-08-11 15:05:56 +07:00
// Return a generic error and log the detail server-side so the
// response is not a file existence/readability oracle.
logger . Logger . WithError ( err ) . Error ( "Failed to process lnd macaroon file" )
return errors . New ( "invalid LND macaroon file" )
2026-04-21 22:33:25 +05:30
}
err = api . cfg . SetUpdate ( "LNDMacaroonHex" , macaroonHex , setupRequest . UnlockPassword )
2024-09-25 17:31:05 +07:00
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save lnd macaroon hex" )
return err
}
2024-05-30 00:06:06 +07:00
}
2024-06-04 10:47:51 +03:00
if setupRequest . PhoenixdAddress != "" {
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( "PhoenixdAddress" , setupRequest . PhoenixdAddress , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save phoenix address" )
return err
}
2024-06-04 10:47:51 +03:00
}
if setupRequest . PhoenixdAuthorization != "" {
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( "PhoenixdAuthorization" , setupRequest . PhoenixdAuthorization , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save phoenix auth" )
return err
}
2024-06-04 10:47:51 +03:00
}
2024-06-14 19:12:22 +03:00
if setupRequest . CashuMintUrl != "" {
2024-09-25 17:31:05 +07:00
err = api . cfg . SetUpdate ( "CashuMintUrl" , setupRequest . CashuMintUrl , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save cashu mint url" )
return err
}
2024-06-14 19:12:22 +03:00
}
2026-05-03 21:36:13 +02:00
if setupRequest . CLNAddress != "" {
err = api . cfg . SetUpdate ( "CLNAddress" , setupRequest . CLNAddress , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save CLN address" )
return err
}
}
if setupRequest . CLNLightningDir != "" {
2026-08-11 15:05:56 +07:00
// The directory path is provided by the (unauthenticated) setup request.
// Validate that it holds the expected CLN TLS credentials before saving,
// so the path cannot be used as an existence/readability oracle for
// arbitrary directories (the failure otherwise surfaces via startupError
// on the anonymous /api/info response).
if err := validateCLNLightningDir ( setupRequest . CLNLightningDir , setupRequest . CLNAddressHold != "" ) ; err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to validate CLN lightning directory" )
return errors . New ( "invalid CLN lightning directory" )
}
2026-05-03 21:36:13 +02:00
err = api . cfg . SetUpdate ( "CLNLightningDir" , setupRequest . CLNLightningDir , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save CLN Lightning directory path" )
return err
}
}
if setupRequest . CLNAddressHold != "" {
err = api . cfg . SetUpdate ( "CLNAddressHold" , setupRequest . CLNAddressHold , setupRequest . UnlockPassword )
if err != nil {
logger . Logger . WithError ( err ) . Error ( "Failed to save cln hold plugin address" )
return err
}
}
2024-05-30 00:06:06 +07:00
return nil
}
2026-08-11 15:05:56 +07:00
// readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
// validates that it contains at least one parseable certificate, and returns
// the hex-encoded re-encoding of only the parsed certificate(s). Any non
// CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
// never persisted. Callers must not reflect the returned error to the client.
func readAndCanonicalizeLNDCert ( path string ) ( string , error ) {
raw , err := os . ReadFile ( path )
if err != nil {
return "" , fmt . Errorf ( "failed to read LND cert file: %w" , err )
}
var canonical [ ] byte
rest := raw
for {
var block * pem . Block
block , rest = pem . Decode ( rest )
if block == nil {
break
}
if block . Type != "CERTIFICATE" {
continue
}
cert , err := x509 . ParseCertificate ( block . Bytes )
if err != nil {
return "" , fmt . Errorf ( "failed to parse LND certificate: %w" , err )
}
canonical = append ( canonical , pem . EncodeToMemory ( & pem . Block {
Type : "CERTIFICATE" ,
Bytes : cert . Raw ,
} ) ... )
}
if len ( canonical ) == 0 {
return "" , errors . New ( "no valid certificate found in LND cert file" )
}
return hex . EncodeToString ( canonical ) , nil
}
// readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
// validates that it is a well-formed macaroon, and returns the hex-encoded
// re-marshalling so that only the parsed structure is persisted. Callers must
// not reflect the returned error to the client.
func readAndCanonicalizeLNDMacaroon ( path string ) ( string , error ) {
raw , err := os . ReadFile ( path )
if err != nil {
return "" , fmt . Errorf ( "failed to read LND macaroon file: %w" , err )
}
mac := & macaroon . Macaroon { }
if err := mac . UnmarshalBinary ( raw ) ; err != nil {
return "" , fmt . Errorf ( "failed to parse LND macaroon: %w" , err )
}
canonical , err := mac . MarshalBinary ( )
if err != nil {
return "" , fmt . Errorf ( "failed to marshal LND macaroon: %w" , err )
}
return hex . EncodeToString ( canonical ) , nil
}
// validateCLNLightningDir checks that the given directory holds the CLN TLS
// credentials that will later be loaded at connect time (ca.pem, client.pem,
// client-key.pem), for each gRPC server name the config will use. This mirrors
// the parses performed by the CLN client's loadTLSCredentials so a directory
// that passes here is one CLN can actually use. Callers must not reflect the
// returned error to the client.
func validateCLNLightningDir ( lightningDir string , hold bool ) error {
// "cln" reads the directory directly; other server names are joined as a
// subdirectory, matching loadTLSCredentials in lnclient/cln.
serverNames := [ ] string { "cln" }
if hold {
serverNames = append ( serverNames , "hold" )
}
for _ , serverName := range serverNames {
dir := lightningDir
if serverName != "cln" {
dir = filepath . Join ( dir , serverName )
}
caPEM , err := os . ReadFile ( filepath . Join ( dir , "ca.pem" ) )
if err != nil {
return fmt . Errorf ( "failed to read CLN CA cert (%s): %w" , serverName , err )
}
if ! x509 . NewCertPool ( ) . AppendCertsFromPEM ( caPEM ) {
return fmt . Errorf ( "failed to parse CLN CA cert (%s)" , serverName )
}
if _ , err := tls . LoadX509KeyPair ( filepath . Join ( dir , "client.pem" ) , filepath . Join ( dir , "client-key.pem" ) ) ; err != nil {
return fmt . Errorf ( "failed to load CLN client cert/key (%s): %w" , serverName , err )
}
}
return nil
}
2024-07-01 20:30:40 +07:00
func ( api * api ) GetWalletCapabilities ( ctx context . Context ) ( * WalletCapabilitiesResponse , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-07-01 20:30:40 +07:00
}
2026-02-27 14:15:09 +05:30
methods := lnClient . GetSupportedNIP47Methods ( )
notificationTypes := lnClient . GetSupportedNIP47NotificationTypes ( )
2024-07-01 20:30:40 +07:00
scopes , err := permissions . RequestMethodsToScopes ( methods )
if err != nil {
return nil , err
}
if len ( notificationTypes ) > 0 {
2024-07-19 23:30:22 +07:00
scopes = append ( scopes , constants . NOTIFICATIONS_SCOPE )
2024-07-01 20:30:40 +07:00
}
return & WalletCapabilitiesResponse {
Methods : methods ,
NotificationTypes : notificationTypes ,
Scopes : scopes ,
} , nil
}
2024-12-16 12:32:35 +07:00
func ( api * api ) MigrateNodeStorage ( ctx context . Context , to string ) error {
if api . svc . GetLNClient ( ) == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-12-16 12:32:35 +07:00
}
if to != "VSS" {
2025-06-24 21:23:19 +05:30
return fmt . Errorf ( "migration type not supported: %s" , to )
2024-12-16 12:32:35 +07:00
}
ldkVssEnabled , err := api . cfg . Get ( "LdkVssEnabled" , "" )
if err != nil {
return err
}
if ldkVssEnabled == "true" {
return errors . New ( "VSS already enabled" )
}
2024-12-17 13:53:58 +07:00
if api . cfg . GetEnv ( ) . LDKVssUrl == "" {
2025-06-24 21:23:19 +05:30
return errors . New ( "no VSS URL set" )
2024-12-17 13:53:58 +07:00
}
2024-12-16 12:32:35 +07:00
api . cfg . SetUpdate ( "LdkVssEnabled" , "true" , "" )
api . cfg . SetUpdate ( "LdkMigrateStorage" , "VSS" , "" )
return api . Stop ( )
}
2024-08-22 18:29:51 +05:30
func ( api * api ) GetNetworkGraph ( ctx context . Context , nodeIds [ ] string ) ( NetworkGraphResponse , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
return lnClient . GetNetworkGraph ( ctx , nodeIds )
2024-05-30 00:06:06 +07:00
}
func ( api * api ) SyncWallet ( ) error {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
lnClient . UpdateLastWalletSyncRequest ( )
2024-05-30 00:06:06 +07:00
return nil
}
2026-05-25 21:56:07 +07:00
func ( api * api ) ListOnchainTransactions ( ctx context . Context ) ( [ ] OnchainTransaction , error ) {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2025-05-02 13:51:37 +07:00
}
2026-05-25 21:56:07 +07:00
transactions , err := lnClient . ListOnchainTransactions ( ctx )
if err != nil {
return nil , err
}
apiTransactions := make ( [ ] OnchainTransaction , 0 , len ( transactions ) )
for _ , t := range transactions {
apiTransactions = append ( apiTransactions , OnchainTransaction {
AmountSat : t . AmountSat ,
CreatedAt : t . CreatedAt ,
State : t . State ,
Type : t . Type ,
NumConfirmations : t . NumConfirmations ,
TxId : t . TxId ,
} )
}
return apiTransactions , nil
2025-05-02 13:51:37 +07:00
}
2024-05-30 00:06:06 +07:00
func ( api * api ) GetLogOutput ( ctx context . Context , logType string , getLogRequest * GetLogOutputRequest ) ( * GetLogOutputResponse , error ) {
var err error
var logData [ ] byte
if logType == LogTypeNode {
2026-02-27 14:15:09 +05:30
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2024-05-30 00:06:06 +07:00
}
2026-02-27 14:15:09 +05:30
logData , err = lnClient . GetLogOutput ( ctx , getLogRequest . MaxLen )
2024-05-30 00:06:06 +07:00
if err != nil {
return nil , err
}
} else if logType == LogTypeApp {
2024-06-17 19:42:09 +07:00
logFileName := logger . GetLogFilePath ( )
2024-12-23 12:25:59 +03:00
if logFileName == "" {
logData = [ ] byte ( "file log is disabled" )
} else {
logData , err = utils . ReadFileTail ( logFileName , getLogRequest . MaxLen )
if err != nil {
return nil , err
}
2024-05-30 00:06:06 +07:00
}
} else {
return nil , fmt . Errorf ( "invalid log type: '%s'" , logType )
}
return & GetLogOutputResponse { Log : string ( logData ) } , nil
}
2025-01-17 11:29:20 +03:00
func ( api * api ) Health ( ctx context . Context ) ( * HealthResponse , error ) {
var alarms [ ] HealthAlarm
2025-08-27 23:34:15 +07:00
albyInfo , err := api . albySvc . GetInfo ( ctx )
2025-01-17 11:29:20 +03:00
if err != nil {
return nil , err
}
if ! albyInfo . Healthy {
alarms = append ( alarms , NewHealthAlarm ( HealthAlarmKindAlbyService , albyInfo . Incidents ) )
}
2025-11-18 11:03:51 +07:00
relayStatuses := api . svc . GetRelayStatuses ( )
if len ( relayStatuses ) > 0 {
isAnyNostrRelayOffline := false
offlineRelayUrls := [ ] string { }
for _ , relayStatus := range relayStatuses {
if ! relayStatus . Online {
isAnyNostrRelayOffline = true
offlineRelayUrls = append ( offlineRelayUrls , relayStatus . Url )
}
}
if isAnyNostrRelayOffline {
alarms = append ( alarms , NewHealthAlarm ( HealthAlarmKindNostrRelayOffline , offlineRelayUrls ) )
2025-11-06 18:01:09 +07:00
}
2025-01-17 11:29:20 +03:00
}
2025-03-31 21:55:33 +07:00
ldkVssEnabled , _ := api . cfg . Get ( "LdkVssEnabled" , "" )
if ldkVssEnabled == "true" {
albyMe , err := api . albyOAuthSvc . GetMe ( ctx )
if err != nil {
return nil , err
}
if albyMe . Subscription . PlanCode == "" {
alarms = append ( alarms , NewHealthAlarm ( HealthAlarmKindVssNoSubscription , nil ) )
}
}
2025-01-17 11:29:20 +03:00
lnClient := api . svc . GetLNClient ( )
if lnClient != nil {
2025-03-24 16:52:19 +07:00
nodeStatus , _ := lnClient . GetNodeStatus ( ctx )
2025-01-17 11:29:20 +03:00
if nodeStatus == nil || ! nodeStatus . IsReady {
2026-05-25 21:56:07 +07:00
var apiNodeStatus * NodeStatus
if nodeStatus != nil {
apiNodeStatus = toApiNodeStatus ( nodeStatus )
}
alarms = append ( alarms , NewHealthAlarm ( HealthAlarmKindNodeNotReady , apiNodeStatus ) )
2025-01-17 11:29:20 +03:00
}
channels , err := lnClient . ListChannels ( ctx )
if err != nil {
return nil , err
}
offlineChannels := slices . DeleteFunc ( channels , func ( channel lnclient . Channel ) bool {
2025-02-12 20:03:45 +05:30
if channel . Active {
return true
}
if channel . Confirmations == nil || channel . ConfirmationsRequired == nil {
return false
}
return * channel . Confirmations < * channel . ConfirmationsRequired
2025-01-17 11:29:20 +03:00
} )
if len ( offlineChannels ) > 0 {
alarms = append ( alarms , NewHealthAlarm ( HealthAlarmKindChannelsOffline , nil ) )
}
}
return & HealthResponse { Alarms : alarms } , nil
}
2025-01-30 14:20:17 +03:00
func ( api * api ) GetCustomNodeCommands ( ) ( * CustomNodeCommandsResponse , error ) {
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2025-01-30 14:20:17 +03:00
}
allCommandDefs := lnClient . GetCustomNodeCommandDefinitions ( )
commandDefs := make ( [ ] CustomNodeCommandDef , 0 , len ( allCommandDefs ) )
for _ , commandDef := range allCommandDefs {
argDefs := make ( [ ] CustomNodeCommandArgDef , 0 , len ( commandDef . Args ) )
for _ , argDef := range commandDef . Args {
argDefs = append ( argDefs , CustomNodeCommandArgDef {
Name : argDef . Name ,
Description : argDef . Description ,
} )
}
commandDefs = append ( commandDefs , CustomNodeCommandDef {
Name : commandDef . Name ,
Description : commandDef . Description ,
Args : argDefs ,
} )
}
return & CustomNodeCommandsResponse { Commands : commandDefs } , nil
}
func ( api * api ) ExecuteCustomNodeCommand ( ctx context . Context , command string ) ( interface { } , error ) {
lnClient := api . svc . GetLNClient ( )
if lnClient == nil {
2026-04-07 17:38:47 +05:30
return nil , ErrLNClientNotStarted
2025-01-30 14:20:17 +03:00
}
// Split command line into arguments. Command name must be the first argument.
parsedArgs , err := utils . ParseCommandLine ( command )
if err != nil {
return nil , fmt . Errorf ( "failed to parse node command: %w" , err )
} else if len ( parsedArgs ) == 0 {
return nil , errors . New ( "no command provided" )
}
// Look up the requested command definition.
allCommandDefs := lnClient . GetCustomNodeCommandDefinitions ( )
commandDefIdx := slices . IndexFunc ( allCommandDefs , func ( def lnclient . CustomNodeCommandDef ) bool {
return def . Name == parsedArgs [ 0 ]
} )
if commandDefIdx < 0 {
return nil , fmt . Errorf ( "unknown command: %q" , parsedArgs [ 0 ] )
}
// Build flag set.
commandDef := allCommandDefs [ commandDefIdx ]
flagSet := flag . NewFlagSet ( commandDef . Name , flag . ContinueOnError )
for _ , argDef := range commandDef . Args {
flagSet . String ( argDef . Name , "" , argDef . Description )
}
if err = flagSet . Parse ( parsedArgs [ 1 : ] ) ; err != nil {
return nil , fmt . Errorf ( "failed to parse command arguments: %w" , err )
}
// Collect flags that have been set.
argValues := make ( map [ string ] string )
flagSet . Visit ( func ( f * flag . Flag ) {
argValues [ f . Name ] = f . Value . String ( )
} )
reqArgs := make ( [ ] lnclient . CustomNodeCommandArg , 0 , len ( argValues ) )
for _ , argDef := range commandDef . Args {
if argValue , ok := argValues [ argDef . Name ] ; ok {
reqArgs = append ( reqArgs , lnclient . CustomNodeCommandArg {
Name : argDef . Name ,
Value : argValue ,
} )
}
}
nodeResp , err := lnClient . ExecuteCustomNodeCommand ( ctx , & lnclient . CustomNodeCommandRequest {
Name : commandDef . Name ,
Args : reqArgs ,
} )
if err != nil {
return nil , fmt . Errorf ( "node failed to execute custom command: %w" , err )
}
return nodeResp . Response , nil
}
2025-08-21 18:39:39 +07:00
func ( api * api ) SendEvent ( event string , properties interface { } ) {
2025-06-04 15:11:33 +02:00
api . svc . GetEventPublisher ( ) . Publish ( & events . Event {
2025-08-21 18:39:39 +07:00
Event : event ,
Properties : properties ,
2025-06-04 15:11:33 +02:00
} )
}
2024-05-30 00:06:06 +07:00
func ( api * api ) parseExpiresAt ( expiresAtString string ) ( * time . Time , error ) {
var expiresAt * time . Time
if expiresAtString != "" {
var err error
expiresAtValue , err := time . Parse ( time . RFC3339 , expiresAtString )
if err != nil {
2024-06-17 19:42:09 +07:00
logger . Logger . WithField ( "expiresAt" , expiresAtString ) . Error ( "Invalid expiresAt" )
2024-05-30 00:06:06 +07:00
return nil , fmt . Errorf ( "invalid expiresAt: %v" , err )
}
expiresAt = & expiresAtValue
}
return expiresAt , nil
}
2025-08-21 18:09:03 +07:00
func ( api * api ) GetForwards ( ) ( * GetForwardsResponse , error ) {
var forwards [ ] db . Forward
err := api . db . Find ( & forwards ) . Error
if err != nil {
return nil , err
}
2026-04-17 13:53:28 +05:30
var totalOutboundAmountMsat uint64
var totalFeeEarnedMsat uint64
2025-08-21 18:09:03 +07:00
for _ , forward := range forwards {
2026-04-17 13:53:28 +05:30
totalOutboundAmountMsat += forward . OutboundAmountForwardedMsat
totalFeeEarnedMsat += forward . TotalFeeEarnedMsat
2025-08-21 18:09:03 +07:00
}
numForwards := len ( forwards )
return & GetForwardsResponse {
2026-04-17 13:53:28 +05:30
OutboundAmountForwardedSat : totalOutboundAmountMsat / 1000 ,
OutboundAmountForwardedMsat : totalOutboundAmountMsat ,
TotalFeeEarnedSat : totalFeeEarnedMsat / 1000 ,
TotalFeeEarnedMsat : totalFeeEarnedMsat ,
2025-08-21 18:09:03 +07:00
NumForwards : uint64 ( numForwards ) ,
} , nil
}