Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/master' into task-macos-bkp
Browse files Browse the repository at this point in the history
  • Loading branch information
rolznz committed Aug 23, 2024
2 parents 60afb5a + 1fcd958 commit 1197b4a
Show file tree
Hide file tree
Showing 91 changed files with 2,257 additions and 780 deletions.
14 changes: 9 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ LOG_EVENTS=false
# do not link your current account when you run a dev instance (so it stays pointing at your mainnet one)
AUTO_LINK_ALBY_ACCOUNT=false

# LDK trace log level
# Optionally set LDK debug log level to get more info
#LDK_LOG_LEVEL=2
# Logrus debug log level
LOG_LEVEL=5
# Optionally set Main application debug log level to get more info
#LOG_LEVEL=5

# Base URL required for custom OAuth client
#BASE_URL=http://localhost:8080
# Development settings (yarn dev:http)
FRONTEND_URL=http://localhost:5173

#WORK_DIR=.data
#DATABASE_URI=nwc.db
Expand All @@ -16,13 +21,12 @@ LOG_LEVEL=5
#RELAY=wss://relay.getalby.com/v1
#RELAY=ws://localhost:7447/v1
#PORT=8080
#FRONTEND_URL=http://localhost:5173


# Alby OAuth configuration
#ALBY_OAUTH_CLIENT_SECRET=
#ALBY_OAUTH_CLIENT_ID=
#BASE_URL=


# Polar LND Client
#LN_BACKEND_TYPE=LND
Expand Down
63 changes: 63 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,66 @@ In this repository. Or manually download the docker-compose.yml file and then ru
### Render.com

[![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](https://render.com/deploy?repo=https://github.com/getAlby/hub)

## Alby Hub Architecture

### NWC Wallet Service

At a high level Alby Hub is an [NWC](https://nwc.dev) wallet service which allows users to use their single wallet seamlessly within a multitude of apps(clients). Any client that supports NWC and has a valid connection secret can communicate with the wallet service to execute commands on the underlying wallet (internally called LNClient).

### LNClient

The LNClient interface abstracts the differences between wallet implementations and allows users to run Alby Hub with their preferred wallet, such as LDK, LND, Phoenixd, Cashu, Breez, Greenlight.

### Transactions Service

Alby Hub maintains its own database of transactions to enable features like self-payments for isolated app connections (subaccounts), additional metadata (that apps can provide when creating invoices or making keysend payments), and to associate transactions with apps, providing additional context to users about how their wallet is being used across apps.

The transactions service sits between the LNClient and two possible entry points: the NIP-47 handlers, and our internal API which is used by the Alby Hub frontend.

### Event Publisher

Internally Alby Hub uses a basic implementation of the pubsub messaging pattern which allows different parts of the system to fire or consume events. For example, the LNClients can fire events when they asynchronously receive or send a payment, which is consumed by the transaction service to update our internal transaction database, and then fire its own events which can be consumed by the NIP-47 notifier to publish notification events to subscribing apps, and also by the Alby OAuth service to send events to the Alby Account (to enable features such as encrypted static channel backups, email notifications of payments, and more).

#### Published Events

- `nwc_started` - when Alby Hub process starts
- `nwc_stopped` - when Alby Hub process gracefully exits
- `nwc_node_started` - when Alby Hub successfully starts or connects to the configured LNClient.
- `nwc_node_start_failed` - The LNClient failed to sync or could not be connected to (e.g. network error, or incorrect configuration for an external node)
- `nwc_node_stopped` the LNClient was gracefully stopped
- `nwc_node_stop_failed` - failed to request the node to stop. Ideally this never happens.
- `nwc_node_sync_failed` - the node failed to sync onchain, wallet or fee estimates.
- `nwc_unlocked` - when user enters correct password (HTTP only)
- `nwc_channel_ready` - a new channel is opened, active and ready to use
- `nwc_channel_closed` - a channel was closed (could be co-operatively or a force closure)
- `nwc_backup_channels` - send a list of channels that can be used as a SCB.
- `nwc_outgoing_liquidity_required` - when user tries to pay an invoice more than their current outgoing liquidity across active channels
- `nwc_incoming_liquidity_required` - when user tries to creates an invoice more than their current incoming liquidity across active channels
- `nwc_permission_denied` - a NIP-47 request was denied - either due to the app connection not having permission for a certain command, or the app does not have insufficient balance or budget to make the payment.
- `nwc_payment_failed` - failed to make a lightning payment
- `nwc_payment_sent` - successfully made a lightning payment
- `nwc_payment_received` - received a lightning payment
- `nwc_lnclient_*` - underlying LNClient events, consumed only by the transactions service.

### NIP-47 Handlers

Alby Hub subscribes to a standard Nostr relay and listens for whitelisted events from known pubkeys and handles these requests in a similar way as a standard HTTP API controller, and either doing requests to the underling LNClient, or to the transactions service in the case of payments and invoices.

### Frontend

The Alby Hub frontend is a standard React app that can run in one of two modes: as an HTTP server, or desktop app, built by Wails. To abstract away, both the HTTP service and Wails handlers pass requests through to the API, where the business logic is located, for direct requests from user interactions.

#### Authentication

Alby Hub uses simple JWT auth in HTTP mode, which also allows the HTTP API to be exposed to external apps, which can use Alby Hub's API to have access to extra functionality currently not covered by the NIP-47 spec, however there are downsides - this API is not a public spec, and only works over HTTP. Therefore, apps are recommended to use NIP-47 where possible.

### Encryption

Sensitive data such as the seed phrase are saved AES-encrypted by the user's unlock password, and only decrypted in-memory in order to run the lightning node. This data is not logged and is only transferred over encrypted channels, and always requires the user's unlock password to access.

All requests to the wallet service are made with one of the following ways:

- NIP-47 - requests encrypted by NIP-04 using randomly-generated keypairs (one per app connection) and sent via websocket through the configured relay.
- HTTP - requests encrypted by JWT and ideally HTTPS (except self-hosted, which can be protected by firewall)
- Desktop mode - requests are made internally through the Wails router, without any kind of network traffic.
37 changes: 23 additions & 14 deletions alby/alby_oauth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ func (svc *albyOAuthService) DrainSharedWallet(ctx context.Context, lnClient lnc

logger.Logger.WithField("amount", amount).WithError(err).Error("Draining Alby shared wallet funds")

transaction, err := transactions.NewTransactionsService(svc.db).MakeInvoice(ctx, amount, "Send shared wallet funds to Alby Hub", "", 120, nil, lnClient, nil, nil)
transaction, err := transactions.NewTransactionsService(svc.db, svc.eventPublisher).MakeInvoice(ctx, amount, "Send shared wallet funds to Alby Hub", "", 120, nil, lnClient, nil, nil)
if err != nil {
logger.Logger.WithField("amount", amount).WithError(err).Error("Failed to make invoice")
return err
Expand Down Expand Up @@ -460,11 +460,15 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
}

func (svc *albyOAuthService) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
// run non-blocking
go svc.consumeEvent(ctx, event, globalProperties)
}
defer func() {
// ensure the app cannot panic if firing events to Alby API fails
if r := recover(); r != nil {
logger.Logger.WithField("event", event).WithField("r", r).Error("Failed to consume event in alby oauth service")
}
}()

// TODO: we should have a whitelist rather than a blacklist, so new events are not automatically sent

func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
// TODO: rename this config option to be specific to the alby API
if !svc.cfg.GetEnv().LogEvents {
logger.Logger.WithField("event", event).Debug("Skipped sending to alby events API")
Expand All @@ -478,6 +482,11 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
return
}

if strings.HasPrefix(event.Event, "nwc_lnclient_") {
// don't consume internal LNClient events
return
}

if event.Event == "nwc_payment_received" {
type paymentReceivedEventProperties struct {
PaymentHash string `json:"payment_hash"`
Expand All @@ -486,7 +495,7 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
event = &events.Event{
Event: event.Event,
Properties: &paymentReceivedEventProperties{
PaymentHash: event.Properties.(*lnclient.Transaction).PaymentHash,
PaymentHash: event.Properties.(*db.Transaction).PaymentHash,
},
}
}
Expand All @@ -501,30 +510,30 @@ func (svc *albyOAuthService) consumeEvent(ctx context.Context, event *events.Eve
event = &events.Event{
Event: event.Event,
Properties: &paymentSentEventProperties{
PaymentHash: event.Properties.(*lnclient.Transaction).PaymentHash,
Duration: uint64(*event.Properties.(*lnclient.Transaction).SettledAt - event.Properties.(*lnclient.Transaction).CreatedAt),
PaymentHash: event.Properties.(*db.Transaction).PaymentHash,
Duration: uint64(event.Properties.(*db.Transaction).SettledAt.Unix() - event.Properties.(*db.Transaction).CreatedAt.Unix()),
},
}
}

if event.Event == "nwc_payment_failed_async" {
paymentFailedAsyncProperties, ok := event.Properties.(*events.PaymentFailedAsyncProperties)
if event.Event == "nwc_payment_failed" {
transaction, ok := event.Properties.(*db.Transaction)
if !ok {
logger.Logger.WithField("event", event).Error("Failed to cast event")
return
}

type paymentSentEventProperties struct {
type paymentFailedEventProperties struct {
PaymentHash string `json:"payment_hash"`
Reason string `json:"reason"`
}

// pass a new custom event with less detail
event = &events.Event{
Event: event.Event,
Properties: &paymentSentEventProperties{
PaymentHash: paymentFailedAsyncProperties.Transaction.PaymentHash,
Reason: paymentFailedAsyncProperties.Reason,
Properties: &paymentFailedEventProperties{
PaymentHash: transaction.PaymentHash,
Reason: transaction.FailureReason,
},
}
}
Expand Down
19 changes: 17 additions & 2 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
relayUrl := api.cfg.GetRelayUrl()

responseBody := &CreateAppResponse{}
responseBody.Id = app.ID
responseBody.Name = createAppRequest.Name
responseBody.Pubkey = app.NostrPubkey
responseBody.PairingSecret = pairingSecretKey
Expand Down Expand Up @@ -111,6 +112,12 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
}

func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error {
name := updateAppRequest.Name

if name == "" {
return fmt.Errorf("won't update an app to have no name")
}

maxAmount := updateAppRequest.MaxAmountSat
budgetRenewal := updateAppRequest.BudgetRenewal

Expand All @@ -125,6 +132,14 @@ func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) e
}

err = api.db.Transaction(func(tx *gorm.DB) error {
// Update app name if it is not the same
if name != userApp.Name {
err := tx.Model(&db.App{}).Where("id", userApp.ID).Update("name", name).Error
if err != nil {
return err
}
}

// Update existing permissions with new budget and expiry
err := tx.Model(&db.AppPermission{}).Where("app_id", userApp.ID).Updates(map[string]interface{}{
"ExpiresAt": expiresAt,
Expand Down Expand Up @@ -757,11 +772,11 @@ func (api *api) SendSpontaneousPaymentProbes(ctx context.Context, sendSpontaneou
return &SendSpontaneousPaymentProbesResponse{Error: errMessage}, nil
}

func (api *api) GetNetworkGraph(nodeIds []string) (NetworkGraphResponse, error) {
func (api *api) GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
return api.svc.GetLNClient().GetNetworkGraph(nodeIds)
return api.svc.GetLNClient().GetNetworkGraph(ctx, nodeIds)
}

func (api *api) SyncWallet() error {
Expand Down
4 changes: 3 additions & 1 deletion api/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ type API interface {
Setup(ctx context.Context, setupRequest *SetupRequest) error
SendPaymentProbes(ctx context.Context, sendPaymentProbesRequest *SendPaymentProbesRequest) (*SendPaymentProbesResponse, error)
SendSpontaneousPaymentProbes(ctx context.Context, sendSpontaneousPaymentProbesRequest *SendSpontaneousPaymentProbesRequest) (*SendSpontaneousPaymentProbesResponse, error)
GetNetworkGraph(nodeIds []string) (NetworkGraphResponse, error)
GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error)
SyncWallet() error
GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error)
RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (*LSPOrderResponse, error)
Expand Down Expand Up @@ -77,6 +77,7 @@ type ListAppsResponse struct {
}

type UpdateAppRequest struct {
Name string `json:"name"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetRenewal string `json:"budgetRenewal"`
ExpiresAt string `json:"expiresAt"`
Expand Down Expand Up @@ -138,6 +139,7 @@ type CreateAppResponse struct {
PairingUri string `json:"pairingUri"`
PairingSecret string `json:"pairingSecretKey"`
Pubkey string `json:"pairingPublicKey"`
Id uint `json:"id"`
Name string `json:"name"`
ReturnTo string `json:"returnTo"`
}
Expand Down
6 changes: 3 additions & 3 deletions config/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,17 @@ type AppConfig struct {
Port string `envconfig:"PORT" default:"8080"`
DatabaseUri string `envconfig:"DATABASE_URI" default:"nwc.db"`
JWTSecret string `envconfig:"JWT_SECRET"`
LogLevel string `envconfig:"LOG_LEVEL"`
LogLevel string `envconfig:"LOG_LEVEL" default:"4"`
LDKNetwork string `envconfig:"LDK_NETWORK" default:"bitcoin"`
LDKEsploraServer string `envconfig:"LDK_ESPLORA_SERVER" default:"https://electrs.getalbypro.com"` // TODO: remove LDK prefix
LDKGossipSource string `envconfig:"LDK_GOSSIP_SOURCE"`
LDKLogLevel string `envconfig:"LDK_LOG_LEVEL"`
LDKLogLevel string `envconfig:"LDK_LOG_LEVEL" default:"3"`
MempoolApi string `envconfig:"MEMPOOL_API" default:"https://mempool.space/api"`
AlbyAPIURL string `envconfig:"ALBY_API_URL" default:"https://api.getalby.com"`
AlbyClientId string `envconfig:"ALBY_OAUTH_CLIENT_ID" default:"J2PbXS1yOf"`
AlbyClientSecret string `envconfig:"ALBY_OAUTH_CLIENT_SECRET" default:"rABK2n16IWjLTZ9M1uKU"`
AlbyOAuthAuthUrl string `envconfig:"ALBY_OAUTH_AUTH_URL" default:"https://getalby.com/oauth"`
BaseUrl string `envconfig:"BASE_URL" default:"http://localhost:8080"`
BaseUrl string `envconfig:"BASE_URL"`
FrontendUrl string `envconfig:"FRONTEND_URL"`
LogEvents bool `envconfig:"LOG_EVENTS" default:"true"`
AutoLinkAlbyAccount bool `envconfig:"AUTO_LINK_ALBY_ACCOUNT" default:"true"`
Expand Down
14 changes: 14 additions & 0 deletions constants/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,17 @@ const (
// each transaction would have to have a maximum size of 10240
// accounting for encryption and other metadata in the response, this is set to 2048 characters
const INVOICE_METADATA_MAX_LENGTH = 2048

// errors used by NIP-47 and the transaction service
const (
ERROR_INTERNAL = "INTERNAL"
ERROR_NOT_IMPLEMENTED = "NOT_IMPLEMENTED"
ERROR_QUOTA_EXCEEDED = "QUOTA_EXCEEDED"
ERROR_INSUFFICIENT_BALANCE = "INSUFFICIENT_BALANCE"
ERROR_UNAUTHORIZED = "UNAUTHORIZED"
ERROR_EXPIRED = "EXPIRED"
ERROR_RESTRICTED = "RESTRICTED"
ERROR_BAD_REQUEST = "BAD_REQUEST"
ERROR_NOT_FOUND = "NOT_FOUND"
ERROR_OTHER = "OTHER"
)
26 changes: 26 additions & 0 deletions db/migrations/202408191242_transaction_failure_reason.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package migrations

import (
_ "embed"

"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)

// This migration removes old app permissions for request methods (now we use scopes)
var _202408191242_transaction_failure_reason = &gormigrate.Migration{
ID: "202408191242_transaction_failure_reason",
Migrate: func(tx *gorm.DB) error {

if err := tx.Exec(`
ALTER TABLE transactions ADD failure_reason string;
`).Error; err != nil {
return err
}

return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}
1 change: 1 addition & 0 deletions db/migrations/migrate.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func Migrate(gormDB *gorm.DB) error {
_202407201604_transactions_indexes,
_202407262257_remove_invalid_scopes,
_202408061737_add_boostagrams_and_use_json,
_202408191242_transaction_failure_reason,
})

return m.Migrate()
Expand Down
1 change: 1 addition & 0 deletions db/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type Transaction struct {
Metadata datatypes.JSON
SelfPayment bool
Boostagram datatypes.JSON
FailureReason string
}

type DBService interface {
Expand Down
5 changes: 2 additions & 3 deletions events/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,8 @@ func (ep *eventPublisher) Publish(event *Event) {
defer ep.subscriberMtx.Unlock()
logger.Logger.WithFields(logrus.Fields{"event": event, "global": ep.globalProperties}).Debug("Publishing event")
for _, listener := range ep.listeners {
// events are consumed in sequence as some listeners depend on earlier consumers
// (e.g. NIP-47 notifier depends on transactions service updating transactions)
listener.ConsumeEvent(context.Background(), event, ep.globalProperties)
// consume event without blocking thread
go listener.ConsumeEvent(context.Background(), event, ep.globalProperties)
}
}

Expand Down
7 changes: 0 additions & 7 deletions events/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ package events

import (
"context"

"github.com/getAlby/hub/lnclient"
)

type EventSubscriber interface {
Expand Down Expand Up @@ -34,8 +32,3 @@ type ChannelBackupInfo struct {
FundingTxID string `json:"funding_tx_id"`
FundingTxVout uint32 `json:"funding_tx_vout"`
}

type PaymentFailedAsyncProperties struct {
Transaction *lnclient.Transaction
Reason string
}
4 changes: 2 additions & 2 deletions fly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ swap_size_mb = 2048

[env]
DATABASE_URI = '/data/nwc.db'
LDK_LOG_LEVEL = '2'
LOG_LEVEL = '5'
LDK_LOG_LEVEL = '3'
LOG_LEVEL = '4'
WORK_DIR = '/data'

[[mounts]]
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit 1197b4a

Please sign in to comment.