Compare commits

..

No commits in common. "master" and "v1.21.1" have entirely different histories.

490 changed files with 12972 additions and 99935 deletions

View file

@ -6,8 +6,6 @@ AUTO_LINK_ALBY_ACCOUNT=false
# Optionally set LDK debug log level to get more info
#LDK_LOG_LEVEL=5
# Optionally set Bark debug log level to get more info
#BARK_LOG_LEVEL=5
# Optionally set Main application debug log level to get more info
#LOG_LEVEL=5
@ -42,13 +40,4 @@ FRONTEND_URL=http://localhost:5173
# Boltz API
#BOLTZ_API=https://api.testnet.boltz.exchange
#NETWORK=testnet
# CLN Backend
#LN_BACKEND_TYPE=CLN
# CLN's grpc-host:grpc-port
#CLN_ADDRESS=127.0.0.1:9737
# CLN's lightning directory containing the grpc certificates, usually ~/.lightning/<network>/
#CLN_LIGHTNING_DIR=/path/to/.lightning/bitcoin
# CLN's hold plugin https://github.com/BoltzExchange/hold gRPC address
#CLN_ADDRESS_HOLD=127.0.0.1:9738
#NETWORK=testnet

View file

@ -4,8 +4,6 @@ updates:
directory: /
schedule:
interval: weekly
ignore:
- dependency-name: google.golang.org/grpc
- package-ecosystem: npm
directory: /frontend
schedule:

View file

@ -9,9 +9,6 @@ jobs:
TAG: ${{ github.ref_name }}
runs-on: ubuntu-22.04
steps:
# see https://github.com/orgs/community/discussions/25678#discussioncomment-5242449
- name: Delete huge unnecessary tools folder
run: rm -rf /opt/hostedtoolcache
- uses: actions/checkout@v4
name: Check out code
- name: Install Go

View file

@ -25,7 +25,6 @@ on:
jobs:
build:
strategy:
fail-fast: false
matrix:
build:
[
@ -86,7 +85,7 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "20.x"
- name: Run tests
run: mkdir frontend/dist && touch frontend/dist/tmp && go test ./...
@ -129,15 +128,7 @@ jobs:
run: go build ${{ env.GOTAGS }} -o build/bin/${{ env.PACKAGE_NAME }}/bin/${{ env.EXEC_NAME }} -ldflags "-X 'github.com/getAlby/hub/version.Tag=${{ env.TAG }}'" cmd/http/main.go
- name: Import Code-Signing Certificates for macOS
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
if: runner.os == 'macOS'
uses: Apple-Actions/import-codesign-certs@v3
with:
# The certificates in a PKCS12 file encoded as a base64 string
@ -174,15 +165,7 @@ jobs:
shell: bash
- name: Sign the MacOS binary and libraries
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
if: runner.os == 'macOS'
run: |
/usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime ./build/bin/${{ env.PACKAGE_NAME }}/bin/${{ env.EXEC_NAME }}
/usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime ./build/bin/${{ env.PACKAGE_NAME }}/lib/*.dylib
@ -206,16 +189,7 @@ jobs:
cd ../../..
- name: Notarize the zip file
if: >-
runner.os == 'macOS' &&
inputs.build-release &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
if: runner.os == 'macOS' && inputs.build-release
run: |
echo "Notarizing Zip Files"
gon -log-level=info -log-json ./build/darwin/http/gon-notarize.json

View file

@ -18,7 +18,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 22.x
node-version: 20.x
cache: "yarn"
cache-dependency-path: frontend/yarn.lock

View file

@ -73,10 +73,10 @@ jobs:
- name: Setup NodeJS
uses: actions/setup-node@v4
with:
node-version: "22.x"
node-version: "20.x"
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.14.0
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.10.2
shell: bash
- name: Install Linux Wails deps
@ -129,15 +129,7 @@ jobs:
shell: bash
- name: Import Code-Signing Certificates for macOS
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
if: runner.os == 'macOS'
uses: Apple-Actions/import-codesign-certs@v3
with:
# The certificates in a PKCS12 file encoded as a base64 string
@ -198,15 +190,7 @@ jobs:
mv ./build/out/${{ env.PACKAGE_NAME }}.tar.bz2 ./build/bin/
- name: Sign the macOS binary
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
if: runner.os == 'macOS'
run: |
echo "Signing Package"
/usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime --entitlements ./build/darwin/entitlements.plist "./build/bin/${{ env.EXEC_NAME }}.app"
@ -238,15 +222,7 @@ jobs:
EOF
- name: Notarize the DMG image
if: >-
runner.os == 'macOS' &&
(
!github.event.pull_request ||
(
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
)
)
if: runner.os == 'macOS'
run: |
echo "Notarizing Zip Files"
gon -log-level=info -log-json ./build/darwin/gon-notarize.json

3
.gitignore vendored
View file

@ -30,5 +30,4 @@ glalby
*.db-shm
*.db-wal
*.db-journal
albyhub-data
.claude/worktrees
albyhub-data

232
AGENTS.md
View file

@ -1,232 +0,0 @@
# AGENTS.md
This file provides guidance for AI coding agents working on the Alby Hub repository.
## Project Overview
Alby Hub is a self-custodial **Nostr Wallet Connect (NWC)** service that bridges Lightning Network wallets with applications supporting the NIP-47 protocol. It supports multiple Lightning backends (LDK, LND, Phoenixd, Cashu) and runs as either a web server or a desktop app (via Wails).
## Tech Stack
- **Backend:** Go 1.25, Echo v4, GORM, SQLite (default) / PostgreSQL
- **Frontend:** React 18, TypeScript, Vite, Tailwind CSS 4, shadcn/ui, Radix UI, Zustand, SWR
- **Desktop:** Wails v2 (produces native desktop app using Go + web frontend)
- **Lightning:** LDK (embedded), LND (gRPC), Phoenixd, Cashu
- **Protocol:** Nostr NIP-47 (Nostr Wallet Connect)
## Project Structure
```text
hub/
├── api/ # HTTP API handlers and request/response models
├── alby/ # Alby account integration (OAuth, backups)
├── apps/ # App connection management
├── cmd/http/main.go # HTTP server entry point
├── config/ # Configuration management
├── db/ # Database layer, migrations, queries
├── events/ # Event pub/sub system
├── frontend/ # React frontend (see below)
├── http/ # HTTP service router
├── lnclient/ # LN abstraction interface + implementations
│ ├── ldk/ # Embedded LDK node
│ ├── lnd/ # LND gRPC client
│ ├── phoenixd/ # Phoenixd client
│ └── cashu/ # Cashu client
├── nip47/ # NIP-47 protocol implementation
│ ├── controllers/ # Per-method request handlers
│ ├── permissions/ # Permission validation
│ └── cipher/ # NIP-04 encryption
├── service/ # Core service orchestration
├── swaps/ # Boltz atomic swap integration
├── transactions/ # Transaction tracking and metadata
├── tests/ # Test helpers and utilities
└── wails/ # Wails desktop-specific handlers
```
### Frontend Structure
```text
frontend/src/
├── components/ # Reusable UI components
├── screens/ # Page-level route components
├── contexts/ # React context providers
├── hooks/ # Custom React hooks
├── state/ # Zustand client state stores
├── lib/ # Auth, backend type helpers
├── utils/ # Shared utilities (request.ts, swr.ts, formatting, etc.)
├── types.ts # Shared TypeScript types
└── routes.tsx # Route definitions
frontend/platform_specific/
├── http/ # Web-specific utilities (copied at build time)
└── wails/ # Desktop-specific utilities (copied at build time)
```
## Development Setup
### Prerequisites
- Node.js 20+
- Yarn
### Running in HTTP Mode (Primary)
```bash
# Terminal 1 Frontend (port 5173)
cd frontend
yarn install
yarn dev:http
# Terminal 2 Backend (port 8080)
cp .env.example .env # configure as needed
go run cmd/http/main.go
```
### Running in Desktop Mode (Wails)
```bash
wails dev -tags "wails"
```
**Wails versions must stay in sync:** the `github.com/wailsapp/wails/v2` version in `go.mod` and the Wails CLI version installed in `.github/workflows/wails.yml` (`go install ...cmd/wails@vX.Y.Z`) must match. When bumping one, always update the other — this is a common source of drift (e.g. via Dependabot updates to `go.mod` only).
## Testing
### Go Backend
```bash
# Run all tests
go test ./...
# Run specific test by name
go test ./... -run TestHandleGetInfoEvent
# Run with PostgreSQL (optional)
export TEST_DATABASE_URI="postgresql://user:password@localhost:5432/postgres"
go test ./...
```
Mocks are generated with `mockery` (config in `.mockery.yaml`); run it after changing any interface.
### Frontend
```bash
cd frontend
yarn lint # ESLint + TypeScript type check + Prettier
yarn tsc:compile # TypeScript only
yarn format # Prettier only
```
No Jest/Vitest tests exist; frontend quality is enforced via linting.
## Building
```bash
# HTTP production build
cd frontend && yarn build:http
go build -o main cmd/http/main.go
# Docker
docker build . -t albyhub:latest
```
## Key Architecture Patterns
### Request Flow
```text
HTTP Request / NIP-47 Nostr Event
→ HTTP Handler / NIP-47 Event Handler
→ api/ package (business logic)
→ LNClient interface
→ Backend implementation (LDK/LND/Phoenixd/Cashu)
```
### Event System
Services communicate via `events/` pub/sub. Prefer publishing events over direct inter-service calls. Key events use the `nwc_*` prefix (e.g., `nwc_payment_sent`, `nwc_payment_received`).
### Platform-Specific Frontend Code
Code under `frontend/platform_specific/http/` and `frontend/platform_specific/wails/` is swapped at build time. Any platform-specific frontend logic must have both variants.
### LNClient Interface
`lnclient/models.go` defines the interface all backends must implement. Changes to this interface require updates to all four implementations (LDK, LND, Phoenixd, Cashu) and their mocks.
## Database
- **Migrations:** `db/migrations/` — always add new migrations here; never modify existing ones.
- **SQLite:** WAL mode, 5s busy timeout, 20MB cache. Default for development and most deployments.
- **PostgreSQL:** Supported for production. If touching DB code, test with both.
- **ORM:** GORM with `go-gormigrate`. Use GORM conventions for new models.
## Coding Conventions
### Go
- Idiomatic Go; `gofmt` formatting expected.
- Structured logging via `logrus` with contextual fields — no `fmt.Print`.
- Error wrapping with `fmt.Errorf("context: %w", err)` for debugging.
- Use the event publisher for cross-service communication.
- New API endpoints belong in `api/api.go` with corresponding HTTP routes in `http/http_service.go`.
### TypeScript / React
- **Avoid using useNavigate** — Use <Link/> component where possible to ensure good browser UX.
- **Use shadcn/ui components** for all UI — do not create custom components unless no shadcn equivalent exists.
- **Do not modify core shadcn/ui components** — customize behavior by composing or wrapping them, not by editing the source files directly.
- **Prefer Tailwind utility classes** over custom `px` definitions or inline styles. Use Tailwind's spacing, sizing, and layout utilities instead of hardcoded pixel values.
- **Never use `!important` Tailwind modifiers** (e.g., `!px-12`, `!text-sm`). If a component's default styles need overriding, use a proper variant, compose with a wrapper, or extend the component — don't force specificity with `!`.
- **Use the theme system** for colors, border-radius, shadows, and other design tokens. Reference CSS variables / Tailwind theme tokens (e.g., `bg-primary`, `rounded-lg`, `shadow-sm`) rather than hardcoding hex values or arbitrary values. See `frontend/src/index.css` for available theme variables.
- **Keep layouts flat** — avoid nesting cards inside cards or wrapping elements in unnecessary bordered containers. Prefer clear, flat visual hierarchy.
- **Match existing spacing patterns** — before adding new components, check sibling components for consistent padding, margins, and gaps. Ensure sibling elements have equal dimensions where appropriate.
- **Write copy from the user's perspective** — Alby Hub IS the wallet; don't explain what a lightning wallet is or tell the user to "connect to a wallet" when they're already inside one. Keep UI copy concise and use the product's own vocabulary (sats, connections, apps).
- **Use the `*Icon` suffix when importing lucide-react icons** (e.g., `ZapIcon`, `BitcoinIcon`, `ArrowDownIcon`) — both forms are valid lucide exports, but this codebase consistently uses the suffixed alias. Don't mix styles.
- Strict TypeScript — no `any` types.
- Functional components with hooks only.
- SWR for server state; Zustand for client state (stores in `frontend/src/state/`).
- HTTP requests use the typed `request()` helper in `frontend/src/utils/request.ts`.
- New screens added to `frontend/src/routes.tsx`.
- ESLint + Prettier enforced via pre-commit hooks (husky).
### Branches
Use a type prefix followed by a short, dash-separated summary: `feat/`, `chore/`, or `fix/`. For example:
```text
feat/add-cashu-backend
fix/payment-timeout-crash
chore/bump-go-1.25
```
### Commits
Follow **Conventional Commits** format (`feat:`, `fix:`, `chore:`, etc.) — enforced by commitlint.
## Critical Files
| File | Purpose |
|------|---------|
| `cmd/http/main.go` | HTTP server entry point |
| `main_wails.go` | Desktop entry point |
| `api/api.go` | Primary API endpoint handlers |
| `service/service.go` | Core service initialization |
| `service/start.go` | Service startup sequence |
| `lnclient/models.go` | LNClient interface definition |
| `nip47/event_handler.go` | NIP-47 request dispatch |
| `db/migrations/` | Database schema history |
| `frontend/src/types.ts` | Shared TypeScript types |
| `frontend/src/routes.tsx` | Frontend routing |
## Security Considerations
- **Seed phrases** are AES-encrypted at rest; decrypted in-memory only when the LN node is running.
- **NIP-47 messages** use NIP-04 or NIP-44 v2 encryption per app keypair (NIP-44 v2 preferred; NIP-04 is the fallback default).
- **API authentication** uses JWT (golang-jwt v5).
- Never log sensitive data (seeds, macaroons, tokens).
- Validate all user input at system boundaries; trust internal service calls.
## CI/CD
CI runs Go tests (including PostgreSQL), frontend lint/type checks, and binary builds for Linux and macOS. All checks must pass before merging to `master`.

View file

@ -1 +0,0 @@
AGENTS.md

View file

@ -1,4 +1,4 @@
FROM node:22-alpine AS frontend
FROM node:20-alpine AS frontend
# Set the base path for the frontend build
# This can be overridden at build time with --build-arg BASE_PATH=<url> e.g. --build-arg BASE_PATH=/hub
@ -9,7 +9,7 @@ COPY frontend ./frontend
RUN echo "Building frontend with base path $BASE_PATH"
RUN cd frontend && yarn install --network-timeout 3000000 && yarn build:http
FROM golang:1.26.2 AS builder
FROM golang:1.24 AS builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM

View file

@ -14,7 +14,7 @@ The application can run in two modes:
Ideally the app runs 24/7 (on a node, VPS or always-online desktop/laptop machine) so it can be connected to a lightning address and receive online payments.
## Learn more about Alby Hub
## Run on Alby Cloud
Visit [albyhub.com](https://albyhub.com) to learn more and get started and get Alby Hub running in minutes.
@ -29,7 +29,6 @@ By default Alby Hub uses the embedded LDK based lightning node. Optionally it ca
- LND
- Phoenixd
- Cashu
- CLN
- want more? please open an issue.
## Development
@ -60,7 +59,7 @@ By default Alby Hub uses the embedded LDK based lightning node. Optionally it ca
Go to `/frontend`
1. `yarn install`
2. `yarn dev:http`
2. `yarn dev`
### HTTP Production build
@ -158,7 +157,7 @@ For more information on the Go pprof library, see the [official documentation](h
The following configuration options can be set as environment variables or in a .env file
- `RELAY`: default: "wss://relay.getalby.com,wss://relay2.getalby.com" (supports multiple separated by commas)
- `RELAY`: default: "wss://relay.getalby.com/v1" (can support multiple separated by commas)
- `DATABASE_URI`: A sqlite filename or postgres URL. Default is SQLite DB `nwc.db` without a path, which will be put in the user home directory: $XDG_DATA_HOME/albyhub/nwc.db
- `PORT`: The port on which the app should listen on (default: 8080)
- `WORK_DIR`: Directory to store NWC data files. Default: $XDG_DATA_HOME/albyhub
@ -207,33 +206,13 @@ Migration of the database is currently experimental. Please make a backup before
go run cmd/db_migrate/main.go -from .data/nwc.db -to postgresql://myuser:mypass@localhost:5432/nwc
#### Migration from Postgres to Sqlite
No manual steps are needed: create a migration file from Settings -> Migrate Alby Hub. The contents of the Postgres database will automatically be copied into a Sqlite database which is included in the migration file. Alternatively, run the migration tool manually:
go run cmd/db_migrate/main.go -from postgresql://myuser:mypass@localhost:5432/nwc -to .data/nwc.db
## Node-specific backend parameters
- `ENABLE_ADVANCED_SETUP`: set to `false` to force a specific backend type (combined with backend parameters below)
### CLN Backend parameters
Can be configured via env or the UI
- `LN_BACKEND_TYPE`: CLN
- `CLN_ADDRESS`: the CLN grpc address (grpc-host and grpc-port), e.g. `127.0.0.1:9737`
- `CLN_LIGHTNING_DIR`: CLN's lightning directory containing the grpc certificates, usually `~/.lightning/<network>`
Optional for hold invoice methods support:
- `CLN_ADDRESS_HOLD`: the CLN hold plugin grpc address (grpc-host and grpc-port), e.g. `127.0.0.1:9738`
If you are copying the certificates to another machine make sure you get the `ca.pem`, `client.pem` and `client-key.pem` from the lightning directory and optionally from the `hold` directory inside the lightning directory and keep the sub-directory structure of the hold directory.
### LND Backend parameters
LND can be configured via env. Other node types may need to be configured via the UI.
Currently only LND can be configured via env. Other node types must be configured via the UI.
_To configure via env, the following parameters must be provided:_
@ -251,8 +230,6 @@ _To configure via env, the following parameters must be provided:_
- `LDK_MAX_CHANNEL_SATURATION`: Sets the maximum portion of a channel's total capacity that may be used for sending a payment, expressed as a power of 1/2. See `max_channel_saturation_power_of_half` in [LDK docs](https://docs.rs/lightning/latest/lightning/routing/router/struct.PaymentParameters.html#structfield.max_channel_saturation_power_of_half).
- `LDK_MAX_PATH_COUNT`: Maximum number of paths that may be used by MPP payments.
- `LDK_LOG_LEVEL`: Log level for the LDK node. Higher is more verbose. Default: 3. This is separate from the main application log level, allowing you to enable more verbose LDK logging (e.g., level 4, 5 or 6) without enabling verbose logging for the entire application.
- `LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES`: If a channel monitor is larger than this value, a performance warning will be shown on the node page.
- `LDK_LSPS2_ADDRESSES`: Override the LSPS2 just-in-time (JIT) LSP provider for receiving. When set, Alby Hub can receive payments even without inbound liquidity: the configured LSP opens a channel on the fly and the fee is deducted from the incoming payment. Expected format is a single `<pubkey>@<host>:<port>`. When set, the "Open Your First Channel" prompts are hidden since the first channel is created automatically on the first receive.
#### LDK Network Configuration
@ -267,12 +244,6 @@ _To configure via env, the following parameters must be provided:_
- `LDK_ELECTRUM_SERVER=electrum.mutinynet.com:50001`
##### Signet
- `MEMPOOL_API=https://mempool.space/signet/api`
- `LDK_NETWORK=signet`
- `LDK_ESPLORA_SERVER=https://mempool.space/signet/api`
##### Testnet (Not recommended - try Mutinynet)
- `MEMPOOL_API=https://mempool.space/testnet/api`
@ -291,16 +262,6 @@ _To configure via env, the following parameters must be provided:_
See [Phoenixd](scripts/linux-x86_64/phoenixd/README.md)
### Bark
Bark connects to an [Ark](https://second.tech/) server. It can be configured via env.
- `LN_BACKEND_TYPE`: BARK
- `BARK_SERVER`: the Ark server URL. For signet use `https://ark.signet.2nd.dev`
- `BARK_ESPLORA_SERVER`: the Esplora server URL used for chain data. For signet use `https://esplora.signet.2nd.dev`.
- `BARK_SERVER_ACCESS_TOKEN`: an optional access token, only required if using a private Ark server.
- `BARK_LOG_LEVEL`: Log level for Bark. Higher is more verbose. Default: 3. This is separate from the main application log level, allowing you to enable more verbose Bark logging (e.g., level 4 or 5) without enabling verbose logging for the entire application.
### Alby OAuth
Create an OAuth client at the [Alby Developer Portal](https://getalby.com/developer) and set your `ALBY_OAUTH_CLIENT_ID` and `ALBY_OAUTH_CLIENT_SECRET` in your .env. If not running locally, you'll also need to change your `BASE_URL`.
@ -402,8 +363,6 @@ Once the user has authorized the app connection a `nwc:success` message is sent
If you need help contact support@getalby.com or reach out on Nostr: npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm
You can also visit the chat of our Community on [Telegram](https://t.me/getalby).
For security vulnerabilities, please follow our [security policy](SECURITY.md).
## ⚡Donations
Want to support the work on Alby?

View file

@ -1,7 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Please report suspected security vulnerabilities privately by emailing [security@getalby.com](mailto:security@getalby.com). Do not open a public issue or disclose the vulnerability publicly until we have coordinated a fix.
Please include the affected version or component, the potential impact, and clear steps to reproduce the issue. We will acknowledge your report and keep you informed as we investigate and address it.

View file

@ -61,7 +61,7 @@ func NewAlbyOAuthService(db *gorm.DB, cfg config.Config, keys keys.Keys, eventPu
conf := &oauth2.Config{
ClientID: cfg.GetEnv().AlbyClientId,
ClientSecret: cfg.GetEnv().AlbyClientSecret,
Scopes: []string{"account:read"},
Scopes: []string{"account:read", "balance:read", "payments:send"},
Endpoint: oauth2.Endpoint{
TokenURL: albyOAuthAPIURL + "/oauth/token",
AuthURL: albyOAuthAuthUrl,
@ -93,7 +93,7 @@ func (svc *albyOAuthService) RemoveOAuthAccessToken() error {
return err
}
func (svc *albyOAuthService) CallbackHandler(ctx context.Context, code string) error {
func (svc *albyOAuthService) CallbackHandler(ctx context.Context, code string, lnClient lnclient.LNClient) error {
token, err := svc.oauthConf.Exchange(ctx, code)
if err != nil {
logger.Logger.WithError(err).Error("Failed to exchange token")
@ -408,7 +408,7 @@ func (svc *albyOAuthService) DeleteLightningAddress(ctx context.Context, address
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).Error("Failed to request delete lightning address endpoint")
logger.Logger.WithError(err).Error("Failed to delete lightning address endpoint")
return err
}
@ -418,13 +418,6 @@ func (svc *albyOAuthService) DeleteLightningAddress(ctx context.Context, address
return errors.New("failed to read response body")
}
if res.StatusCode == http.StatusNotFound {
// The lightning address was already deleted on the Alby account
// (e.g. removed previously). Treat as success so deletion is idempotent.
logger.Logger.WithField("address", address).Info("Lightning address already deleted on Alby account, ignoring 404")
return nil
}
if res.StatusCode >= 300 {
return fmt.Errorf("DELETE request to /internal/lightning_addresses/%s returned non-success status: %d %s", address, res.StatusCode, string(responseBody))
}
@ -532,11 +525,7 @@ func (svc *albyOAuthService) UnlinkAccount(ctx context.Context) error {
return nil
}
func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budgetSat uint64, renewal string) error {
if lnClient == nil {
return errors.New("LNClient not available")
}
func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budget uint64, renewal string) error {
svc.deleteAlbyAccountApps()
connectionPubkey, err := svc.createAlbyAccountNWCNode(ctx)
@ -558,7 +547,7 @@ func (svc *albyOAuthService) LinkAccount(ctx context.Context, lnClient lnclient.
app, _, err := apps.NewAppsService(svc.db, svc.eventPublisher, svc.keys, svc.cfg).CreateApp(
ALBY_ACCOUNT_APP_NAME,
connectionPubkey,
budgetSat,
budget,
renewal,
nil,
scopes,
@ -1194,10 +1183,6 @@ func (svc *albyOAuthService) CreateLSPOrder(ctx context.Context, lsp, network st
}
func (svc *albyOAuthService) RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error) {
if lnClient == nil {
return nil, errors.New("LNClient not available")
}
nodeInfo, err := lnClient.GetInfo(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to request own node info", err)
@ -1332,11 +1317,11 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string,
}
var invoice string
var feeSat uint64
var fee uint64
if newAutoChannelResponse.Payment != nil {
invoice = newAutoChannelResponse.Payment.Bolt11.Invoice
feeSat, err = strconv.ParseUint(newAutoChannelResponse.Payment.Bolt11.FeeTotalSat, 10, 64)
fee, err = strconv.ParseUint(newAutoChannelResponse.Payment.Bolt11.FeeTotalSat, 10, 64)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
@ -1350,16 +1335,16 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string,
return nil, err
}
if feeSat != uint64(paymentRequest.MSatoshi/1000) {
if fee != uint64(paymentRequest.MSatoshi/1000) {
logger.Logger.WithFields(logrus.Fields{
"invoice_amount": paymentRequest.MSatoshi / 1000,
"fee": feeSat,
"fee": fee,
}).WithError(err).Error("Invoice amount does not match LSP fee")
return nil, errors.New("invoice amount does not match LSP fee")
}
}
channelSizeSat, err := strconv.ParseUint(newAutoChannelResponse.LspBalanceSat, 10, 64)
channelSize, err := strconv.ParseUint(newAutoChannelResponse.LspBalanceSat, 10, 64)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
@ -1368,60 +1353,12 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string,
}
return &AutoChannelResponse{
Invoice: invoice,
Fee: feeSat,
FeeSat: feeSat,
ChannelSize: channelSizeSat,
ChannelSizeSat: channelSizeSat,
Invoice: invoice,
Fee: fee,
ChannelSize: channelSize,
}, nil
}
func (svc *albyOAuthService) GetStories(ctx context.Context) ([]Story, error) {
client := &http.Client{Timeout: 10 * time.Second}
url := fmt.Sprintf("%s/stories", albyInternalAPIURL)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
logger.Logger.WithError(err).Error("Error creating request to stories endpoint")
return nil, fmt.Errorf("create stories request: %w", err)
}
setDefaultRequestHeaders(req)
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch stories from API")
return nil, fmt.Errorf("fetch stories: %w", err)
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to read response body")
return nil, fmt.Errorf("read stories response body: %w", err)
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"status_code": res.StatusCode,
}).Error("stories endpoint returned non-success code")
return nil, fmt.Errorf("stories endpoint returned %d: %s", res.StatusCode, string(body))
}
var stories []Story
if err := json.Unmarshal(body, &stories); err != nil {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"error": err,
}).Error("Failed to decode stories API response")
return nil, fmt.Errorf("decode stories response: %w", err)
}
return stories, nil
}
func setDefaultRequestHeaders(req *http.Request) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "AlbyHub/"+version.Tag)
@ -1466,7 +1403,5 @@ func getEventWhitelist() []string {
// client-side events
"payment_failed_details",
"debit_card_url_clicked",
"debit_card_connect",
}
}

View file

@ -9,6 +9,7 @@ import (
"net/http"
"time"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/logger"
"github.com/sirupsen/logrus"
)
@ -16,67 +17,19 @@ import (
const albyInternalAPIURL = "https://getalby.com/api"
type albyService struct {
cfg config.Config
}
func NewAlbyService() *albyService {
return &albyService{}
func NewAlbyService(cfg config.Config) *albyService {
albySvc := &albyService{
cfg: cfg,
}
return albySvc
}
func (svc *albyService) GetCurrencies(ctx context.Context) ([]Currency, error) {
client := &http.Client{Timeout: 10 * time.Second}
url := fmt.Sprintf("%s/rates", albyInternalAPIURL)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
logger.Logger.WithError(err).Error("Error creating request to currencies endpoint")
return nil, err
}
setDefaultRequestHeaders(req)
res, err := client.Do(req)
if err != nil {
logger.Logger.WithError(err).Error("Failed to fetch currencies from API")
return nil, err
}
defer res.Body.Close()
body, err := io.ReadAll(res.Body)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"url": url,
}).Error("Failed to read response body")
return nil, errors.New("failed to read response body")
}
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"status_code": res.StatusCode,
}).Error("Currencies endpoint returned non-success code")
return nil, fmt.Errorf("currencies endpoint returned non-success code: %s", string(body))
}
rawCurrencies := map[string]Currency{}
err = json.Unmarshal(body, &rawCurrencies)
if err != nil {
logger.Logger.WithFields(logrus.Fields{
"body": string(body),
"error": err,
}).Error("Failed to decode currencies API response")
return nil, err
}
currencies := []Currency{}
for _, currency := range rawCurrencies {
currencies = append(currencies, currency)
}
return currencies, nil
}
func (svc *albyService) GetBitcoinRate(ctx context.Context, currency string) (*BitcoinRate, error) {
func (svc *albyService) GetBitcoinRate(ctx context.Context) (*BitcoinRate, error) {
client := &http.Client{Timeout: 10 * time.Second}
currency := svc.cfg.GetCurrency()
url := fmt.Sprintf("%s/rates/%s", albyInternalAPIURL, currency)
@ -170,11 +123,6 @@ func (svc *albyService) GetChannelPeerSuggestions(ctx context.Context) ([]Channe
return nil, err
}
for i := range suggestions {
suggestions[i].MinimumChannelSizeSat = suggestions[i].MinimumChannelSize
suggestions[i].MaximumChannelSizeSat = suggestions[i].MaximumChannelSize
}
logger.Logger.WithFields(logrus.Fields{"channel_suggestions": suggestions}).Debug("Alby channel peer suggestions response")
return suggestions, nil
}

View file

@ -9,8 +9,7 @@ import (
type AlbyService interface {
GetInfo(ctx context.Context) (*AlbyInfo, error)
GetBitcoinRate(ctx context.Context, currency string) (*BitcoinRate, error)
GetCurrencies(ctx context.Context) ([]Currency, error)
GetBitcoinRate(ctx context.Context) (*BitcoinRate, error)
GetChannelPeerSuggestions(ctx context.Context) ([]ChannelPeerSuggestion, error)
}
@ -23,8 +22,8 @@ type AlbyOAuthService interface {
GetUserIdentifier() (string, error)
GetLightningAddress() (string, error)
IsConnected(ctx context.Context) bool
LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budgetSat uint64, renewal string) error
CallbackHandler(ctx context.Context, code string) error
LinkAccount(ctx context.Context, lnClient lnclient.LNClient, budget uint64, renewal string) error
CallbackHandler(ctx context.Context, code string, lnClient lnclient.LNClient) error
GetMe(ctx context.Context) (*AlbyMe, error)
UnlinkAccount(ctx context.Context) error
RequestAutoChannel(ctx context.Context, lnClient lnclient.LNClient, isPublic bool) (*AutoChannelResponse, error)
@ -32,7 +31,6 @@ type AlbyOAuthService interface {
RemoveOAuthAccessToken() error
CreateLightningAddress(ctx context.Context, address string, appId uint) (*CreateLightningAddressResponse, error)
DeleteLightningAddress(ctx context.Context, address string) error
GetStories(ctx context.Context) ([]Story, error)
}
type CreateLightningAddressResponse struct {
@ -50,11 +48,9 @@ type AutoChannelRequest struct {
}
type AutoChannelResponse struct {
Invoice string `json:"invoice"`
ChannelSize uint64 `json:"channelSize"` // deprecated
ChannelSizeSat uint64 `json:"channelSizeSat"`
Fee uint64 `json:"fee"` // deprecated
FeeSat uint64 `json:"feeSat"`
Invoice string `json:"invoice"`
ChannelSize uint64 `json:"channelSize"`
Fee uint64 `json:"fee"`
}
type AlbyInfoHub struct {
@ -105,10 +101,8 @@ type ChannelPeerSuggestion struct {
PaymentMethod string `json:"paymentMethod"`
Pubkey string `json:"pubkey"`
Host string `json:"host"`
MinimumChannelSize uint64 `json:"minimumChannelSize"` // deprecated
MinimumChannelSizeSat uint64 `json:"minimumChannelSizeSat"`
MaximumChannelSize uint64 `json:"maximumChannelSize"` // deprecated
MaximumChannelSizeSat uint64 `json:"maximumChannelSizeSat"`
MinimumChannelSize uint64 `json:"minimumChannelSize"`
MaximumChannelSize uint64 `json:"maximumChannelSize"`
MaximumChannelExpiryBlocks *uint32 `json:"maximumChannelExpiryBlocks"`
Name string `json:"name"`
Image string `json:"image"`
@ -119,7 +113,6 @@ type ChannelPeerSuggestion struct {
Description string `json:"description"`
Note string `json:"note"`
PublicChannelsAllowed bool `json:"publicChannelsAllowed"`
NodeAddress string `json:"nodeAddress"`
FeeTotalSat1m *uint32 `json:"feeTotalSat1m"`
FeeTotalSat2m *uint32 `json:"feeTotalSat2m"`
FeeTotalSat3m *uint32 `json:"feeTotalSat3m"`
@ -136,13 +129,6 @@ type LSPChannelOffer struct {
LspDescription string `json:"lspDescription"`
}
type Currency struct {
IsoCode string `json:"iso_code"`
Symbol string `json:"symbol"`
Name string `json:"name"`
Priority int `json:"priority"`
}
type BitcoinRate struct {
Code string `json:"code"`
Symbol string `json:"symbol"`
@ -155,20 +141,6 @@ type ErrorResponse struct {
Message string `json:"message"`
}
type StoryCta struct {
Label string `json:"label"`
URL string `json:"url"`
OpenInNewTab bool `json:"openInNewTab"`
}
type Story struct {
ID int `json:"id"`
Title string `json:"title"`
Avatar string `json:"avatar"`
VideoID string `json:"videoId,omitempty"`
Cta *StoryCta `json:"cta,omitempty"`
}
type LSPChannelPaymentBolt11 struct {
Invoice string `json:"invoice"`
FeeTotalSat string `json:"fee_total_sat"`

1038
api/api.go

File diff suppressed because it is too large Load diff

View file

@ -9,32 +9,6 @@ import (
"github.com/stretchr/testify/require"
)
func TestBuildReturnToUrl(t *testing.T) {
relayUrls := []string{"wss://relay.getalby.com/v1"}
walletPubkey := "6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7"
assert.Equal(t,
"https://example.com?pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1",
buildReturnToUrl("https://example.com", relayUrls, walletPubkey, "", false))
// existing query parameters are preserved and lud16 is added
assert.Equal(t,
"https://example.com/path?foo=bar&lud16=user%40getalby.com&pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1",
buildReturnToUrl("https://example.com/path?foo=bar", relayUrls, walletPubkey, "user@getalby.com", false))
// isolated apps do not receive a lightning address
assert.Equal(t,
"http://example.com?pubkey=6f8bf1b7d58ac41b2c793837ba528c1d0a4a1cd2e3f5b7c9d0e1f2a3b4c5d6e7&relay=wss%3A%2F%2Frelay.getalby.com%2Fv1",
buildReturnToUrl("http://example.com", relayUrls, walletPubkey, "user@getalby.com", true))
// only http and https URLs are accepted
assert.Equal(t, "", buildReturnToUrl("", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("example.com/path", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("example://app", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("javascript:void(0)", relayUrls, walletPubkey, "", false))
assert.Equal(t, "", buildReturnToUrl("::invalid::", relayUrls, walletPubkey, "", false))
}
func TestCreateApp_SuperuserScopeIncorrectPassword(t *testing.T) {
cfg := mocks.NewMockConfig(t)
cfg.On("CheckUnlockPassword", "").Return(false)

View file

@ -1,11 +1,9 @@
package api
import (
"bytes"
"errors"
"fmt"
"io"
"math"
"strings"
"time"
@ -18,48 +16,12 @@ import (
"crypto/rand"
"crypto/sha256"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/utils"
"golang.org/x/crypto/pbkdf2"
)
// zipMagic is the ZIP local file header signature "PK\x03\x04" — the first
// four bytes of every ZIP file, and therefore of every archive produced by
// CreateBackup. decryptingReader uses it to detect which cipher scheme the
// backup file was created with.
var zipMagic = []byte{'P', 'K', 0x03, 0x04}
// backupCipher describes one of the cipher schemes used for backup files,
// which are laid out as salt || iv || encrypted zip archive.
type backupCipher struct {
saltSize int
deriveKey func(password string, salt []byte) ([]byte, error)
newStream func(block cipher.Block, iv []byte) cipher.Stream
}
var backupCiphers = []backupCipher{
// current scheme, used for all new backup files
{
saltSize: 32,
deriveKey: func(password string, salt []byte) ([]byte, error) {
key, _, err := config.DeriveKey(password, salt)
return key, err
},
newStream: cipher.NewCTR,
},
// legacy scheme, kept to restore backup files created by older versions
{
saltSize: 8,
deriveKey: func(password string, salt []byte) ([]byte, error) {
return pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New), nil
},
//nolint:staticcheck // OFB is required to read files created by older versions
newStream: cipher.NewOFB,
},
}
func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
logger.Logger.Info("Creating backup to migrate Alby Hub to another device")
var err error
@ -76,9 +38,8 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
return errors.New("Please disable auto-unlock before using this feature")
}
dbBackend := api.db.Dialector.Name()
if dbBackend != "sqlite" && dbBackend != "postgres" {
return fmt.Errorf("migration with %s backend is currently not supported", dbBackend)
if api.db.Dialector.Name() != "sqlite" {
return errors.New("Migration with non-sqlite backend is currently not supported")
}
workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir)
@ -88,18 +49,17 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
lnStorageDir := ""
lnClient := api.svc.GetLNClient()
if lnClient == nil {
if api.svc.GetLNClient() == nil {
return fmt.Errorf("node not running")
}
lnStorageDir, err = lnClient.GetStorageDir()
lnStorageDir, err = api.svc.GetLNClient().GetStorageDir()
if err != nil {
return fmt.Errorf("failed to get storage dir: %w", err)
}
logger.Logger.WithField("path", lnStorageDir).Info("Found node storage dir")
// Reset the routing data to decrease the LDK DB size
err = lnClient.ResetRouter("ALL")
err = api.svc.GetLNClient().ResetRouter("ALL")
if err != nil {
logger.Logger.WithError(err).Error("Failed to reset router")
return fmt.Errorf("failed to reset router: %w", err)
@ -115,50 +75,6 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
return errors.New("failed to remove oauth access token")
}
// Locate the main database file.
dbFilePath := api.cfg.GetEnv().DatabaseUri
if dbBackend == "postgres" {
// The migration file must contain a sqlite database, so copy the
// contents of the postgres database into a temporary sqlite database
// and add that to the archive instead.
dbFilePath = filepath.Join(workDir, "migration.db")
removeConvertedDb := func() {
for _, path := range []string{dbFilePath, dbFilePath + "-wal", dbFilePath + "-shm"} {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
logger.Logger.WithError(err).WithField("path", path).Error("Failed to remove converted database file")
}
}
}
// Remove stale files from a previously failed migration attempt.
removeConvertedDb()
defer removeConvertedDb()
logger.Logger.WithField("path", dbFilePath).Info("Copying postgres database to sqlite")
sqliteDb, err := db.NewDB(dbFilePath, api.cfg.GetEnv().LogDBQueries)
if err != nil {
logger.Logger.WithError(err).Error("Failed to create sqlite database for migration")
return fmt.Errorf("failed to create sqlite database for migration: %w", err)
}
err = db.MigrateDB(api.db, sqliteDb)
if err != nil {
logger.Logger.WithError(err).Error("Failed to copy database contents to sqlite")
if stopErr := db.Stop(sqliteDb); stopErr != nil {
logger.Logger.WithError(stopErr).Error("Failed to stop sqlite database")
}
return fmt.Errorf("failed to copy database contents to sqlite: %w", err)
}
// Close the sqlite database to checkpoint the WAL before archiving it.
err = db.Stop(sqliteDb)
if err != nil {
logger.Logger.WithError(err).Error("Failed to stop sqlite database")
return fmt.Errorf("failed to close sqlite database: %w", err)
}
}
// Closing the database leaves the service in an inconsistent state,
// but that should not be a problem since the app is not expected
// to be used after its data is exported.
@ -209,6 +125,8 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
return err
}
// Locate the main database file.
dbFilePath := api.cfg.GetEnv().DatabaseUri
// Add the database file to the archive.
logger.Logger.WithField("nwc.db", dbFilePath).Info("adding nwc db to zip")
err = addFileToZip(dbFilePath, "nwc.db")
@ -233,18 +151,8 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
}
}
// Finalize the archive before reporting success; the deferred close
// only covers early returns.
err = zw.Close()
if err != nil {
logger.Logger.WithError(err).Error("Failed to finalize migration archive")
return fmt.Errorf("failed to finalize migration archive: %w", err)
}
logger.Logger.Info("Successfully created backup to migrate Alby Hub to another device")
api.nodeMigrationFileCreated.Store(true)
return nil
}
@ -295,38 +203,8 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
return fmt.Errorf("failed to create zip reader: %w", err)
}
if len(zr.File) == 0 {
return errors.New("backup file contains no files")
}
restoreDir := filepath.Join(workDir, "restore")
// Extract into a staging directory and only move it to the restore
// directory once every entry has been extracted, so that a failed
// extraction cannot leave a partial restore directory behind, which
// would be applied on the next startup.
stagingDir, err := os.MkdirTemp(workDir, "albyhub-restore-")
if err != nil {
return fmt.Errorf("failed to create staging directory: %w", err)
}
defer os.RemoveAll(stagingDir)
extractZipEntry := func(zipFile *zip.File) error {
// Entry names come from the archive and must not be trusted. Reject any
// name that is absolute or points outside the restore directory via
// ".." segments before joining it to a path.
entryName := filepath.FromSlash(zipFile.Name)
if !filepath.IsLocal(entryName) {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
}
fsFilePath := filepath.Join(stagingDir, entryName)
// Confirm the cleaned path is still contained within the staging
// directory.
if fsFilePath != stagingDir && !strings.HasPrefix(fsFilePath, stagingDir+string(os.PathSeparator)) {
return fmt.Errorf("refusing to extract zip entry outside restore directory: %q", zipFile.Name)
}
fsFilePath := filepath.Join(workDir, "restore", filepath.FromSlash(zipFile.Name))
if err = os.MkdirAll(filepath.Dir(fsFilePath), 0700); err != nil {
return fmt.Errorf("failed to create directory for zip entry: %w", err)
@ -360,20 +238,13 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
}
logger.Logger.WithField("count", len(zr.File)).Info("Extracted files")
if err = os.RemoveAll(restoreDir); err != nil {
return fmt.Errorf("failed to remove existing restore directory: %w", err)
}
if err = os.Rename(stagingDir, restoreDir); err != nil {
return fmt.Errorf("failed to move extracted files to restore directory: %w", err)
}
go func() {
logger.Logger.Info("Backup restored. Shutting down Alby Hub...")
api.svc.Shutdown()
// ensure no -shm or -wal files exist as they will stop the restore
for _, filename := range []string{"nwc.db", "nwc.db-shm", "nwc.db-wal"} {
err = os.Remove(filepath.Join(workDir, filename))
if err != nil && !errors.Is(err, os.ErrNotExist) {
if err != nil {
logger.Logger.WithError(err).WithField("filename", filename).Error("failed to remove old nwc db file before restore")
}
}
@ -387,17 +258,12 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
}
func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
scheme := backupCiphers[0]
salt := make([]byte, scheme.saltSize)
salt := make([]byte, 8)
if _, err := rand.Read(salt); err != nil {
return nil, fmt.Errorf("failed to generate salt: %w", err)
}
encKey, err := scheme.deriveKey(password, salt)
if err != nil {
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New)
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
@ -418,8 +284,9 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
return nil, fmt.Errorf("failed to write IV: %w", err)
}
stream := cipher.NewOFB(block, iv)
cw := &cipher.StreamWriter{
S: scheme.newStream(block, iv),
S: stream,
W: w,
}
@ -427,61 +294,27 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
}
func decryptingReader(r io.Reader, password string) (io.Reader, error) {
// Read the largest possible header (salt, IV and the first bytes of the
// archive) upfront, then trial-decrypt with each supported cipher scheme
// and pick the one that produces the ZIP signature.
maxHeaderSize := 0
minHeaderSize := math.MaxInt
for _, scheme := range backupCiphers {
headerSize := scheme.saltSize + aes.BlockSize + len(zipMagic)
maxHeaderSize = max(maxHeaderSize, headerSize)
minHeaderSize = min(minHeaderSize, headerSize)
salt := make([]byte, 8)
if _, err := io.ReadFull(r, salt); err != nil {
return nil, fmt.Errorf("failed to read salt: %w", err)
}
// Read the full header with io.ReadFull rather than io.ReadAtLeast: the
// reader may deliver short reads (e.g. a network request body), and
// stopping early could truncate the header of a scheme with a larger
// salt. A short file is only acceptable if it still covers the smallest
// scheme header.
header := make([]byte, maxHeaderSize)
n, err := io.ReadFull(r, header)
if err != nil && !(errors.Is(err, io.ErrUnexpectedEOF) && n >= minHeaderSize) {
return nil, fmt.Errorf("failed to read backup header: %w", err)
}
header = header[:n]
for _, scheme := range backupCiphers {
if len(header) < scheme.saltSize+aes.BlockSize+len(zipMagic) {
continue
}
salt := header[:scheme.saltSize]
iv := header[scheme.saltSize : scheme.saltSize+aes.BlockSize]
encrypted := header[scheme.saltSize+aes.BlockSize:]
encKey, err := scheme.deriveKey(password, salt)
if err != nil {
return nil, fmt.Errorf("failed to derive encryption key: %w", err)
}
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
stream := scheme.newStream(block, iv)
decrypted := make([]byte, len(encrypted))
stream.XORKeyStream(decrypted, encrypted)
if !bytes.Equal(decrypted[:len(zipMagic)], zipMagic) {
continue
}
cr := &cipher.StreamReader{
S: stream,
R: r,
}
return io.MultiReader(bytes.NewReader(decrypted), cr), nil
iv := make([]byte, aes.BlockSize)
if _, err := io.ReadFull(r, iv); err != nil {
return nil, fmt.Errorf("failed to read IV: %w", err)
}
return nil, errors.New("invalid unlock password or backup file")
encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New)
block, err := aes.NewCipher(encKey)
if err != nil {
return nil, fmt.Errorf("failed to create AES cipher: %w", err)
}
stream := cipher.NewOFB(block, iv)
cr := &cipher.StreamReader{
S: stream,
R: r,
}
return cr, nil
}

View file

@ -1,261 +0,0 @@
package api
import (
"archive/zip"
"bytes"
"encoding/hex"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"testing/iotest"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/logger"
test_db "github.com/getAlby/hub/tests/db"
"github.com/getAlby/hub/tests/mocks"
)
// TestCreateBackup creates a backup from the test database (sqlite by
// default, postgres when TEST_DATABASE_URI is set) and verifies that the
// archive contains a valid sqlite database with the expected data.
func TestCreateBackup(t *testing.T) {
logger.Init(strconv.Itoa(int(logrus.DebugLevel)))
workDir := t.TempDir()
gormDB, err := test_db.NewDB(t)
require.NoError(t, err)
defer test_db.CloseDB(gormDB)
appConfig := &config.AppConfig{
Workdir: workDir,
DatabaseUri: test_db.GetTestDatabaseURI(),
}
cfg, err := config.NewConfig(appConfig, gormDB)
require.NoError(t, err)
unlockPassword := ""
// Represent a fully set-up hub: the unlock-password canary is written during
// setup and is required for the password check to pass.
require.NoError(t, cfg.SaveUnlockPasswordCheck(unlockPassword))
app := &db.App{
Name: "test",
AppPubkey: "2b7dea2866958f17c568cf024e113db7a3baa9c253a9016889196b8d0b11c7ae",
Metadata: datatypes.JSON("{}"),
}
require.NoError(t, gormDB.Create(app).Error)
lnClient := mocks.NewMockLNClient(t)
lnClient.On("GetStorageDir").Return("", nil)
lnClient.On("ResetRouter", "ALL").Return(nil)
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(lnClient)
svc.On("StopApp").Return()
albyOAuthSvc := mocks.NewMockAlbyOAuthService(t)
albyOAuthSvc.On("RemoveOAuthAccessToken").Return(nil)
theAPI := &api{
db: gormDB,
cfg: cfg,
svc: svc,
albyOAuthSvc: albyOAuthSvc,
}
var buf bytes.Buffer
err = theAPI.CreateBackup(unlockPassword, &buf)
require.NoError(t, err)
// The temporary database created when converting from postgres must
// not be left behind in the working directory.
entries, err := os.ReadDir(workDir)
require.NoError(t, err)
require.Empty(t, entries)
cr, err := decryptingReader(&buf, unlockPassword)
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
restoredPath := filepath.Join(workDir, "restored.db")
require.NoError(t, os.WriteFile(restoredPath, dbContents, 0600))
restoredDB, err := db.NewDB(restoredPath, false)
require.NoError(t, err)
defer func() {
require.NoError(t, db.Stop(restoredDB))
}()
var restoredApp db.App
require.NoError(t, restoredDB.First(&restoredApp).Error)
require.Equal(t, app.Name, restoredApp.Name)
require.Equal(t, app.AppPubkey, restoredApp.AppPubkey)
}
// TestRestoreBackupRejectsPathTraversal verifies that a backup archive
// containing an entry whose name points outside the restore directory is
// rejected and that no file is written outside it.
func TestRestoreBackupRejectsPathTraversal(t *testing.T) {
logger.Init(strconv.Itoa(int(logrus.DebugLevel)))
gormDB, err := test_db.NewDB(t)
require.NoError(t, err)
defer test_db.CloseDB(gormDB)
if gormDB.Dialector.Name() != "sqlite" {
t.Skip("restore is only supported on sqlite")
}
workDir := t.TempDir()
appConfig := &config.AppConfig{
Workdir: workDir,
DatabaseUri: test_db.GetTestDatabaseURI(),
}
cfg, err := config.NewConfig(appConfig, gormDB)
require.NoError(t, err)
theAPI := &api{
db: gormDB,
cfg: cfg,
}
unlockPassword := ""
// The restore directory is <workDir>/restore, so a "../" entry targets a
// file directly in the working directory, one level above it.
const escapeEntryName = "../pwned.txt"
escapeTarget := filepath.Join(workDir, "pwned.txt")
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, unlockPassword)
require.NoError(t, err)
zw := zip.NewWriter(cw)
// A valid entry before the malicious one, to verify that a partially
// extracted archive is not left behind when a later entry fails.
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
entryWriter, err = zw.Create(escapeEntryName)
require.NoError(t, err)
_, err = entryWriter.Write([]byte("pwned"))
require.NoError(t, err)
require.NoError(t, zw.Close())
err = theAPI.RestoreBackup(unlockPassword, &buf)
require.ErrorContains(t, err, "refusing to extract zip entry outside restore directory")
_, statErr := os.Stat(escapeTarget)
require.True(t, os.IsNotExist(statErr), "traversal entry must not be written outside the restore directory")
// The failed restore must not leave a restore directory (which would be
// applied on the next startup) or any staging leftovers.
_, statErr = os.Stat(filepath.Join(workDir, "restore"))
require.True(t, os.IsNotExist(statErr), "failed restore must not leave a restore directory")
entries, err := os.ReadDir(workDir)
require.NoError(t, err)
for _, entry := range entries {
require.False(t, strings.HasPrefix(entry.Name(), "albyhub-restore-"), "failed restore must not leave a staging directory")
}
}
// legacyBackupFixture is a backup file created with the encryption scheme
// used by older versions (PBKDF2 key derivation), encrypted with the
// password "test-unlock-password". Its archive contains a single "nwc.db"
// entry with the contents "legacy backup contents".
const legacyBackupFixture = "0102030405060708101112131415161718191a1b1c1d1e1f8eca79631915f679a00cdd95d3f20d8d169eb9aa5d52642ca13b93886c3c7d7ba4b759462bc9dd8deccf638edcc9b5b9fda3d23dcd904cf6e99bc57ac59c4df6be5aa676542b7cbc9998029420c0ae5a6986c735150ababde5b382560acaebd5894aa4420924f1ced63fde570adc60c43b32e9e14a0ef60c379da5cac1be0000845992ea072ead036e336c7b859e8d018c4ef61667e3f520fe01"
// TestDecryptingReaderLegacyBackup verifies that backup files created by
// older versions can still be decrypted.
func TestDecryptingReaderLegacyBackup(t *testing.T) {
encrypted, err := hex.DecodeString(legacyBackupFixture)
require.NoError(t, err)
cr, err := decryptingReader(bytes.NewReader(encrypted), "test-unlock-password")
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
require.Equal(t, "legacy backup contents", string(dbContents))
}
// TestDecryptingReaderFragmentedReader verifies that a backup file is
// decrypted correctly even when the reader delivers one byte at a time,
// which would truncate the header if it were not read in full.
func TestDecryptingReaderFragmentedReader(t *testing.T) {
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, "test-unlock-password")
require.NoError(t, err)
zw := zip.NewWriter(cw)
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
require.NoError(t, zw.Close())
cr, err := decryptingReader(iotest.OneByteReader(bytes.NewReader(buf.Bytes())), "test-unlock-password")
require.NoError(t, err)
decrypted, err := io.ReadAll(cr)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(decrypted), int64(len(decrypted)))
require.NoError(t, err)
dbFile, err := zr.Open("nwc.db")
require.NoError(t, err)
dbContents, err := io.ReadAll(dbFile)
require.NoError(t, err)
require.NoError(t, dbFile.Close())
require.Equal(t, "backup contents", string(dbContents))
}
// TestDecryptingReaderWrongPassword verifies that decryption fails upfront
// when the password does not match the backup file.
func TestDecryptingReaderWrongPassword(t *testing.T) {
var buf bytes.Buffer
cw, err := encryptingWriter(&buf, "test-unlock-password")
require.NoError(t, err)
zw := zip.NewWriter(cw)
entryWriter, err := zw.Create("nwc.db")
require.NoError(t, err)
_, err = entryWriter.Write([]byte("backup contents"))
require.NoError(t, err)
require.NoError(t, zw.Close())
_, err = decryptingReader(bytes.NewReader(buf.Bytes()), "wrong-password")
require.Error(t, err)
}

View file

@ -46,15 +46,6 @@ func (api *api) RequestEsploraApi(ctx context.Context, endpoint string) (interfa
return nil, errors.New("failed to read response body")
}
if res.StatusCode != http.StatusOK {
logger.Logger.WithFields(logrus.Fields{
"endpoint": endpoint,
"status_code": res.StatusCode,
"body": string(body),
}).Error("Esplora endpoint returned non-success code")
return nil, fmt.Errorf("esplora endpoint returned non-success code: %s", string(body))
}
var jsonContent interface{}
jsonErr := json.Unmarshal(body, &jsonContent)
if jsonErr != nil {

View file

@ -17,9 +17,9 @@ import (
)
func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (*LSPOrderResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
if request.LSPType != lsp.LSP_TYPE_LSPS1 {
@ -28,7 +28,7 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (
logger.Logger.Info("Requesting own node info")
nodeInfo, err := lnClient.GetInfo(ctx)
nodeInfo, err := api.svc.GetLNClient().GetInfo(ctx)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"lspIdentifier": request.LSPIdentifier,
@ -46,7 +46,7 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (
logger.Logger.WithField("lspInfo", lspInfo).Info("Connecting to LSP node as a peer")
err = lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
err = api.svc.GetLNClient().ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
Pubkey: lspInfo.Pubkey,
Address: lspInfo.Address,
Port: lspInfo.Port,
@ -57,13 +57,9 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (
return nil, err
}
invoice, feeSat, err := api.requestLSPS1Invoice(ctx, lnClient, request, nodeInfo.Network, nodeInfo.Pubkey, lspInfo.MaxChannelExpiryBlocks, lspInfo.MinRequiredChannelConfirmations, lspInfo.MinFundingConfirmsWithinBlocks)
invoiceAmountSat := uint64(0)
incomingLiquiditySat := uint64(0)
resolvedAmountSat := ResolveToSat(request.AmountSat, nil, request.Amount, nil)
if resolvedAmountSat != nil {
incomingLiquiditySat = *resolvedAmountSat
}
invoice, fee, err := api.requestLSPS1Invoice(ctx, request, nodeInfo.Network, nodeInfo.Pubkey, lspInfo.MaxChannelExpiryBlocks, lspInfo.MinRequiredChannelConfirmations, lspInfo.MinFundingConfirmsWithinBlocks)
invoiceAmount := uint64(0)
incomingLiquidity := request.Amount
if err != nil {
logger.Logger.WithError(err).Error("Failed to request invoice")
@ -77,19 +73,15 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (
return nil, err
}
invoiceAmountSat = uint64(paymentRequest.MSatoshi / 1000)
invoiceAmount = uint64(paymentRequest.MSatoshi / 1000)
}
newChannelResponse := &LSPOrderResponse{
Invoice: invoice,
Fee: feeSat,
FeeSat: feeSat,
InvoiceAmount: invoiceAmountSat,
InvoiceAmountSat: invoiceAmountSat,
IncomingLiquidity: incomingLiquiditySat,
IncomingLiquiditySat: incomingLiquiditySat,
OutgoingLiquidity: uint64(0),
OutgoingLiquiditySat: uint64(0),
Invoice: invoice,
Fee: fee,
InvoiceAmount: invoiceAmount,
IncomingLiquidity: incomingLiquidity,
OutgoingLiquidity: uint64(0), // JIT channel no longer supported
}
logger.Logger.WithFields(logrus.Fields{
@ -99,8 +91,8 @@ func (api *api) RequestLSPOrder(ctx context.Context, request *LSPOrderRequest) (
return newChannelResponse, nil
}
func (api *api) requestLSPS1Invoice(ctx context.Context, lnClient lnclient.LNClient, request *LSPOrderRequest, network, pubkey string, channelExpiryBlocks uint64, minRequiredChannelConfirmations uint64, minFundingConfirmsWithinBlocks uint64) (invoice string, feeSat uint64, err error) {
refundAddress, err := lnClient.GetNewOnchainAddress(ctx)
func (api *api) requestLSPS1Invoice(ctx context.Context, request *LSPOrderRequest, network, pubkey string, channelExpiryBlocks uint64, minRequiredChannelConfirmations uint64, minFundingConfirmsWithinBlocks uint64) (invoice string, fee uint64, err error) {
refundAddress, err := api.svc.GetLNClient().GetNewOnchainAddress(ctx)
if err != nil {
logger.Logger.WithError(err).Error("Failed to request onchain address")
return "", 0, err
@ -144,16 +136,9 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, lnClient lnclient.LNCli
token = "AlbyHub/" + version.Tag
}
amountSat := uint64(0)
resolvedAmountSat := ResolveToSat(request.AmountSat, nil, request.Amount, nil)
if resolvedAmountSat != nil {
amountSat = *resolvedAmountSat
}
lspBalanceSat := strconv.FormatUint(amountSat, 10)
lsps1ChannelRequest := &alby.LSPChannelRequest{
PublicKey: pubkey,
LSPBalanceSat: lspBalanceSat,
LSPBalanceSat: strconv.FormatUint(request.Amount, 10),
ClientBalanceSat: "0",
RequiredChannelConfirmations: requiredChannelConfirmations,
FundingConfirmsWithinBlocks: minFundingConfirmsWithinBlocks,
@ -170,7 +155,7 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, lnClient lnclient.LNCli
if channelResponse.Payment != nil {
invoice = channelResponse.Payment.Bolt11.Invoice
feeSat, err = strconv.ParseUint(channelResponse.Payment.Bolt11.FeeTotalSat, 10, 64)
fee, err = strconv.ParseUint(channelResponse.Payment.Bolt11.FeeTotalSat, 10, 64)
if err != nil {
logger.Logger.WithError(err).WithFields(logrus.Fields{
"lspIdentifier": request.LSPIdentifier,
@ -179,5 +164,5 @@ func (api *api) requestLSPS1Invoice(ctx context.Context, lnClient lnclient.LNCli
}
}
return invoice, feeSat, nil
return invoice, fee, nil
}

View file

@ -2,35 +2,34 @@ package api
import (
"context"
"errors"
"io"
"time"
"github.com/getAlby/hub/alby"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/lnclient"
"github.com/getAlby/hub/swaps"
)
type API interface {
CreateApp(createAppRequest *CreateAppRequest) (*CreateAppResponse, error)
UpdateApp(app *db.App, updateAppRequest *UpdateAppRequest) error
Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64, description string) error
Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64) error
DeleteApp(app *db.App) error
GetApp(app *db.App) (*App, error)
GetApp(app *db.App) *App
ListApps(limit uint64, offset uint64, filters ListAppsFilters, orderBy string) (*ListAppsResponse, error)
CreateLightningAddress(ctx context.Context, createLightningAddressRequest *CreateLightningAddressRequest) error
DeleteLightningAddress(ctx context.Context, appId uint) error
ListChannels(ctx context.Context) ([]Channel, error)
GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPeerSuggestion, error)
GetStories(ctx context.Context) ([]alby.Story, error)
GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error)
ResetRouter(key string) error
ChangeUnlockPassword(changeUnlockPasswordRequest *ChangeUnlockPasswordRequest) error
SetAutoUnlockPassword(unlockPassword string) error
Stop() error
GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error)
GetNodeStatus(ctx context.Context) (*NodeStatus, error)
ListPeers(ctx context.Context) ([]PeerDetails, error)
GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error)
GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error)
ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error)
ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error
DisconnectPeer(ctx context.Context, peerId string) error
OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error)
@ -41,20 +40,21 @@ type API interface {
GetNewOnchainAddress(ctx context.Context) (string, error)
GetUnusedOnchainAddress(ctx context.Context) (string, error)
SignMessage(ctx context.Context, message string) (*SignMessageResponse, error)
RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error)
RedeemOnchainFunds(ctx context.Context, toAddress string, amount uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error)
GetBalances(ctx context.Context) (*BalancesResponse, error)
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error)
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, fromAppId *uint) (*SendPaymentResponse, error)
CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error)
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error)
SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}) (*SendPaymentResponse, error)
CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error)
LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error)
SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error
RequestMempoolApi(ctx context.Context, endpoint string) (interface{}, error)
GetInfo(ctx context.Context) (*InfoResponse, error)
GetMnemonic(unlockPassword string) (*MnemonicResponse, error)
SetNextBackupReminder(backupReminderRequest *BackupReminderRequest) error
Start(startRequest *StartRequest)
Setup(ctx context.Context, setupRequest *SetupRequest) error
SendPaymentProbes(ctx context.Context, sendPaymentProbesRequest *SendPaymentProbesRequest) (*SendPaymentProbesResponse, error)
SendSpontaneousPaymentProbes(ctx context.Context, sendSpontaneousPaymentProbesRequest *SendSpontaneousPaymentProbesRequest) (*SendSpontaneousPaymentProbesResponse, error)
GetNetworkGraph(ctx context.Context, nodeIds []string) (NetworkGraphResponse, error)
SyncWallet() error
GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error)
@ -64,6 +64,8 @@ type API interface {
MigrateNodeStorage(ctx context.Context, to string) error
GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error)
Health(ctx context.Context) (*HealthResponse, error)
SetCurrency(currency string) error
SetBitcoinDisplayFormat(format string) error
UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error
LookupSwap(swapId string) (*LookupSwapResponse, error)
ListSwaps() (*ListSwapsResponse, error)
@ -83,33 +85,24 @@ type API interface {
GetForwards() (*GetForwardsResponse, error)
}
var ErrLNClientNotStarted = errors.New("LNClient not started")
type App struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
AppPubkey string `json:"appPubkey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
LastSettledTransactionAt *time.Time `json:"lastSettledTransactionAt"`
ExpiresAt *time.Time `json:"expiresAt"`
Scopes []string `json:"scopes"`
MaxAmount uint64 `json:"maxAmount"` // deprecated
MaxAmountSat uint64 `json:"maxAmountSat"`
MaxAmountMsat uint64 `json:"maxAmountMsat"`
BudgetUsage uint64 `json:"budgetUsage"` // deprecated
BudgetUsageSat uint64 `json:"budgetUsageSat"`
BudgetUsageMsat uint64 `json:"budgetUsageMsat"`
BudgetRenewal string `json:"budgetRenewal"`
Isolated bool `json:"isolated"`
WalletPubkey string `json:"walletPubkey"`
UniqueWalletPubkey bool `json:"uniqueWalletPubkey"`
Balance int64 `json:"balance"` // deprecated
BalanceSat int64 `json:"balanceSat"`
BalanceMsat int64 `json:"balanceMsat"`
Metadata Metadata `json:"metadata,omitempty"`
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
AppPubkey string `json:"appPubkey"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
LastUsedAt *time.Time `json:"lastUsedAt"`
ExpiresAt *time.Time `json:"expiresAt"`
Scopes []string `json:"scopes"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetUsage uint64 `json:"budgetUsage"`
BudgetRenewal string `json:"budgetRenewal"`
Isolated bool `json:"isolated"`
WalletPubkey string `json:"walletPubkey"`
UniqueWalletPubkey bool `json:"uniqueWalletPubkey"`
Balance int64 `json:"balance"`
Metadata Metadata `json:"metadata,omitempty"`
}
type ListAppsFilters struct {
@ -120,18 +113,13 @@ type ListAppsFilters struct {
}
type ListAppsResponse struct {
Apps []App `json:"apps"`
TotalCount uint64 `json:"totalCount"`
TotalBalance *int64 `json:"totalBalance,omitempty"` // deprecated
TotalBalanceSat *int64 `json:"totalBalanceSat,omitempty"`
TotalBalanceMsat *int64 `json:"totalBalanceMsat,omitempty"`
Apps []App `json:"apps"`
TotalCount uint64 `json:"totalCount"`
}
type UpdateAppRequest struct {
Name *string `json:"name"`
MaxAmount *uint64 `json:"maxAmount"` // deprecated
MaxAmountSat *uint64 `json:"maxAmountSat"`
MaxAmountMsat *uint64 `json:"maxAmountMsat"`
MaxAmountSat *uint64 `json:"maxAmount"`
BudgetRenewal *string `json:"budgetRenewal"`
ExpiresAt *string `json:"expiresAt"`
UpdateExpiresAt bool `json:"updateExpiresAt"`
@ -141,19 +129,15 @@ type UpdateAppRequest struct {
}
type TransferRequest struct {
AmountSat *uint64 `json:"amountSat"`
AmountMsat *uint64 `json:"amountMsat"`
FromAppId *uint `json:"fromAppId"`
ToAppId *uint `json:"toAppId"`
Description string `json:"description"`
AmountSat uint64 `json:"amountSat"`
FromAppId *uint `json:"fromAppId"`
ToAppId *uint `json:"toAppId"`
}
type CreateAppRequest struct {
Name string `json:"name"`
Pubkey string `json:"pubkey"`
MaxAmount *uint64 `json:"maxAmount"` // deprecated
MaxAmountSat *uint64 `json:"maxAmountSat"`
MaxAmountMsat *uint64 `json:"maxAmountMsat"`
MaxAmountSat uint64 `json:"maxAmount"`
BudgetRenewal string `json:"budgetRenewal"`
ExpiresAt string `json:"expiresAt"`
Scopes []string `json:"scopes"`
@ -169,9 +153,8 @@ type CreateLightningAddressRequest struct {
}
type InitiateSwapRequest struct {
SwapAmount *uint64 `json:"swapAmount"` // deprecated
SwapAmountSat *uint64 `json:"swapAmountSat"`
Destination string `json:"destination"`
SwapAmount uint64 `json:"swapAmount"`
Destination string `json:"destination"`
}
type RefundSwapRequest struct {
@ -180,34 +163,25 @@ type RefundSwapRequest struct {
}
type EnableAutoSwapRequest struct {
BalanceThreshold *uint64 `json:"balanceThreshold"` // deprecated
BalanceThresholdSat *uint64 `json:"balanceThresholdSat"`
SwapAmount *uint64 `json:"swapAmount"` // deprecated
SwapAmountSat *uint64 `json:"swapAmountSat"`
Destination string `json:"destination"`
DestinationType string `json:"destinationType"`
UnlockPassword string `json:"unlockPassword"`
BalanceThreshold uint64 `json:"balanceThreshold"`
SwapAmount uint64 `json:"swapAmount"`
Destination string `json:"destination"`
}
type GetAutoSwapConfigResponse struct {
Type string `json:"type"`
Enabled bool `json:"enabled"`
BalanceThreshold uint64 `json:"balanceThreshold"` // deprecated
BalanceThresholdSat uint64 `json:"balanceThresholdSat"`
SwapAmount uint64 `json:"swapAmount"` // deprecated
SwapAmountSat uint64 `json:"swapAmountSat"`
Destination string `json:"destination"`
Type string `json:"type"`
Enabled bool `json:"enabled"`
BalanceThreshold uint64 `json:"balanceThreshold"`
SwapAmount uint64 `json:"swapAmount"`
Destination string `json:"destination"`
}
type SwapInfoResponse struct {
AlbyServiceFee float64 `json:"albyServiceFee"`
BoltzServiceFee float64 `json:"boltzServiceFee"`
BoltzNetworkFee uint64 `json:"boltzNetworkFee"` // deprecated
BoltzNetworkFeeSat uint64 `json:"boltzNetworkFeeSat"`
MinAmount uint64 `json:"minAmount"` // deprecated
MinAmountSat uint64 `json:"minAmountSat"`
MaxAmount uint64 `json:"maxAmount"` // deprecated
MaxAmountSat uint64 `json:"maxAmountSat"`
AlbyServiceFee float64 `json:"albyServiceFee"`
BoltzServiceFee float64 `json:"boltzServiceFee"`
BoltzNetworkFee uint64 `json:"boltzNetworkFee"`
MinAmount uint64 `json:"minAmount"`
MaxAmount uint64 `json:"maxAmount"`
}
type ListSwapsResponse struct {
@ -221,10 +195,8 @@ type Swap struct {
Type string `json:"type"`
State string `json:"state"`
Invoice string `json:"invoice"`
SendAmount uint64 `json:"sendAmount"` // deprecated
SendAmountSat uint64 `json:"sendAmountSat"`
ReceiveAmount uint64 `json:"receiveAmount"` // deprecated
ReceiveAmountSat uint64 `json:"receiveAmountSat"`
SendAmount uint64 `json:"sendAmount"`
ReceiveAmount uint64 `json:"receiveAmount"`
PaymentHash string `json:"paymentHash"`
DestinationAddress string `json:"destinationAddress"`
RefundAddress string `json:"refundAddress"`
@ -268,6 +240,8 @@ type SetupRequest struct {
LNDAddress string `json:"lndAddress"`
LNDCertFile string `json:"lndCertFile"`
LNDMacaroonFile string `json:"lndMacaroonFile"`
LNDCertHex string `json:"lndCertHex"`
LNDMacaroonHex string `json:"lndMacaroonHex"`
// Phoenixd fields
PhoenixdAddress string `json:"phoenixdAddress"`
@ -275,11 +249,6 @@ type SetupRequest struct {
// Cashu fields
CashuMintUrl string `json:"cashuMintUrl"`
// CLN fields
CLNAddress string `json:"clnAddress"`
CLNLightningDir string `json:"clnLightningDir"`
CLNAddressHold string `json:"clnAddressHold"`
}
type CreateAppResponse struct {
@ -304,47 +273,35 @@ type InfoResponseRelay struct {
}
type InfoResponse struct {
BackendType string `json:"backendType"`
SetupCompleted bool `json:"setupCompleted"`
OAuthRedirect bool `json:"oauthRedirect"`
Running bool `json:"running"`
Unlocked bool `json:"unlocked"`
AlbyAuthUrl string `json:"albyAuthUrl"`
NextBackupReminder string `json:"nextBackupReminder"`
AlbyUserIdentifier string `json:"albyUserIdentifier"`
AlbyAccountConnected bool `json:"albyAccountConnected"`
Version string `json:"version"`
Network string `json:"network"`
EnableAdvancedSetup bool `json:"enableAdvancedSetup"`
LdkVssEnabled bool `json:"ldkVssEnabled"`
LdkVssUrl string `json:"ldkVssUrl"`
VssSupported bool `json:"vssSupported"`
DatabaseType string `json:"databaseType"`
StartupState string `json:"startupState"`
StartupError string `json:"startupError"`
StartupErrorTime time.Time `json:"startupErrorTime"`
AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"`
AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"`
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
Relays []InfoResponseRelay `json:"relays"`
NodeAlias string `json:"nodeAlias"`
MempoolUrl string `json:"mempoolUrl"`
ChainDataSourceType string `json:"chainDataSourceType,omitempty"`
ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"`
JitChannelsLiquiditySource string `json:"jitChannelsLiquiditySource,omitempty"`
JitChannelsMinPaymentSizeMsat *uint64 `json:"jitChannelsMinPaymentSizeMsat,omitempty"`
JitChannelsMaxPaymentSizeMsat *uint64 `json:"jitChannelsMaxPaymentSizeMsat,omitempty"`
JitChannelsEnabled bool `json:"jitChannelsEnabled"`
HideUpdateBanner bool `json:"hideUpdateBanner"`
SupportsBolt12 bool `json:"supportsBolt12"`
NodeMigrationFileCreated bool `json:"nodeMigrationFileCreated"`
BackendType string `json:"backendType"`
SetupCompleted bool `json:"setupCompleted"`
OAuthRedirect bool `json:"oauthRedirect"`
Running bool `json:"running"`
Unlocked bool `json:"unlocked"`
AlbyAuthUrl string `json:"albyAuthUrl"`
NextBackupReminder string `json:"nextBackupReminder"`
AlbyUserIdentifier string `json:"albyUserIdentifier"`
AlbyAccountConnected bool `json:"albyAccountConnected"`
Version string `json:"version"`
Network string `json:"network"`
EnableAdvancedSetup bool `json:"enableAdvancedSetup"`
LdkVssEnabled bool `json:"ldkVssEnabled"`
VssSupported bool `json:"vssSupported"`
StartupState string `json:"startupState"`
StartupError string `json:"startupError"`
StartupErrorTime time.Time `json:"startupErrorTime"`
AutoUnlockPasswordSupported bool `json:"autoUnlockPasswordSupported"`
AutoUnlockPasswordEnabled bool `json:"autoUnlockPasswordEnabled"`
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
Relays []InfoResponseRelay `json:"relays"`
NodeAlias string `json:"nodeAlias"`
MempoolUrl string `json:"mempoolUrl"`
}
type UpdateSettingsRequest struct {
Currency string `json:"currency"`
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
JitChannelsEnabled *bool `json:"jitChannelsEnabled"`
}
type SetNodeAliasRequest struct {
@ -367,83 +324,23 @@ type AutoUnlockRequest struct {
UnlockPassword string `json:"unlockPassword"`
}
type ConnectPeerRequest struct {
Pubkey string `json:"pubkey"`
Address string `json:"address"`
Port uint16 `json:"port"`
}
type OpenChannelRequest struct {
Pubkey string `json:"pubkey"`
AmountSats int64 `json:"amountSats"`
Public bool `json:"public"`
}
type OpenChannelResponse struct {
FundingTxId string `json:"fundingTxId"`
}
type CloseChannelResponse struct {
}
type UpdateChannelRequest struct {
ChannelId string `json:"channelId"`
NodeId string `json:"nodeId"`
ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"`
ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"`
MaxDustHtlcExposureFromFeeRateMultiplier uint64 `json:"maxDustHtlcExposureFromFeeRateMultiplier"`
}
type NodeConnectionInfo struct {
Pubkey string `json:"pubkey"`
Address string `json:"address"`
Port int `json:"port"`
}
type NodeStatus struct {
IsReady bool `json:"isReady"`
InternalNodeStatus interface{} `json:"internalNodeStatus"`
}
type PeerDetails struct {
NodeId string `json:"nodeId"`
Address string `json:"address"`
IsPersisted bool `json:"isPersisted"`
IsConnected bool `json:"isConnected"`
}
type OnchainTransaction struct {
AmountSat uint64 `json:"amountSat"`
CreatedAt uint64 `json:"createdAt"`
State string `json:"state"`
Type string `json:"type"`
NumConfirmations uint32 `json:"numConfirmations"`
TxId string `json:"txId"`
}
type PendingBalanceDetails struct {
ChannelId string `json:"channelId"`
NodeId string `json:"nodeId"`
Amount uint64 `json:"amount"` // deprecated
AmountSat uint64 `json:"amountSat"`
FundingTxId string `json:"fundingTxId"`
FundingTxVout uint32 `json:"fundingTxVout"`
}
type ConnectPeerRequest = lnclient.ConnectPeerRequest
type OpenChannelRequest = lnclient.OpenChannelRequest
type OpenChannelResponse = lnclient.OpenChannelResponse
type CloseChannelResponse = lnclient.CloseChannelResponse
type UpdateChannelRequest = lnclient.UpdateChannelRequest
type RebalanceChannelRequest struct {
ReceiveThroughNodePubkey string `json:"receiveThroughNodePubkey"`
AmountSat *uint64 `json:"amountSat"`
AmountMsat *uint64 `json:"amountMsat"`
ReceiveThroughNodePubkey string `json:"receiveThroughNodePubkey"`
AmountSat uint64 `json:"amountSat"`
}
type RebalanceChannelResponse struct {
TotalFeeSat uint64 `json:"totalFeeSat"`
TotalFeeMsat uint64 `json:"totalFeeMsat"`
TotalFeeSat uint64 `json:"totalFeeSat"`
}
type RedeemOnchainFundsRequest struct {
ToAddress string `json:"toAddress"`
Amount *uint64 `json:"amount"` // deprecated
AmountSat *uint64 `json:"amountSat"`
Amount uint64 `json:"amount"`
FeeRate *uint64 `json:"feeRate"`
SendAll bool `json:"sendAll"`
}
@ -452,61 +349,13 @@ type RedeemOnchainFundsResponse struct {
TxId string `json:"txId"`
}
type OnchainBalanceResponse struct {
Spendable int64 `json:"spendable"` // deprecated
SpendableSat int64 `json:"spendableSat"`
Total int64 `json:"total"` // deprecated
TotalSat int64 `json:"totalSat"`
Reserved int64 `json:"reserved"` // deprecated
ReservedSat int64 `json:"reservedSat"`
PendingBalancesFromChannelClosures uint64 `json:"pendingBalancesFromChannelClosures"` // deprecated
PendingBalancesFromChannelClosuresSat uint64 `json:"pendingBalancesFromChannelClosuresSat"`
PendingBalancesDetails []PendingBalanceDetails `json:"pendingBalancesDetails"`
PendingSweepBalancesDetails []PendingBalanceDetails `json:"pendingSweepBalancesDetails"`
InternalBalances interface{} `json:"internalBalances"`
}
type LightningBalanceResponse struct {
TotalSpendable int64 `json:"totalSpendable"` // deprecated
TotalSpendableSat int64 `json:"totalSpendableSat"`
TotalSpendableMsat int64 `json:"totalSpendableMsat"`
TotalReceivable int64 `json:"totalReceivable"` // deprecated
TotalReceivableSat int64 `json:"totalReceivableSat"`
TotalReceivableMsat int64 `json:"totalReceivableMsat"`
NextMaxSpendable int64 `json:"nextMaxSpendable"` // deprecated
NextMaxSpendableSat int64 `json:"nextMaxSpendableSat"`
NextMaxSpendableMsat int64 `json:"nextMaxSpendableMsat"`
NextMaxReceivable int64 `json:"nextMaxReceivable"` // deprecated
NextMaxReceivableSat int64 `json:"nextMaxReceivableSat"`
NextMaxReceivableMsat int64 `json:"nextMaxReceivableMsat"`
NextMaxSpendableMPP int64 `json:"nextMaxSpendableMPP"` // deprecated
NextMaxSpendableMPPSat int64 `json:"nextMaxSpendableMPPSat"`
NextMaxSpendableMPPMsat int64 `json:"nextMaxSpendableMPPMsat"`
NextMaxReceivableMPP int64 `json:"nextMaxReceivableMPP"` // deprecated
NextMaxReceivableMPPSat int64 `json:"nextMaxReceivableMPPSat"`
NextMaxReceivableMPPMsat int64 `json:"nextMaxReceivableMPPMsat"`
}
type BalancesResponse struct {
Onchain OnchainBalanceResponse `json:"onchain"`
Lightning LightningBalanceResponse `json:"lightning"`
}
type OnchainBalanceResponse = lnclient.OnchainBalanceResponse
type BalancesResponse = lnclient.BalancesResponse
type SendPaymentResponse = Transaction
type MakeInvoiceResponse = Transaction
type LookupInvoiceResponse = Transaction
type SetTransactionUserLabelsRequest struct {
Labels map[string]string `json:"labels"`
}
type ListTransactionsFilters struct {
Type *string
MinAmountMsat *uint64
HideFailed bool
SearchTerm string
}
type ListTransactionsResponse struct {
TotalCount uint64 `json:"totalCount"`
Transactions []Transaction `json:"transactions"`
@ -514,7 +363,6 @@ type ListTransactionsResponse struct {
// TODO: camelCase
type Transaction struct {
ID uint `json:"id"`
Type string `json:"type"`
State string `json:"state"`
Invoice string `json:"invoice"`
@ -522,12 +370,8 @@ type Transaction struct {
DescriptionHash string `json:"descriptionHash"`
Preimage *string `json:"preimage"`
PaymentHash string `json:"paymentHash"`
Amount uint64 `json:"amount"` // deprecated
AmountSat uint64 `json:"amountSat"`
AmountMsat uint64 `json:"amountMsat"`
FeesPaid uint64 `json:"feesPaid"` // deprecated
FeesPaidSat uint64 `json:"feesPaidSat"`
FeesPaidMsat uint64 `json:"feesPaidMsat"`
Amount uint64 `json:"amount"`
FeesPaid uint64 `json:"feesPaid"`
UpdatedAt string `json:"updatedAt"`
CreatedAt string `json:"createdAt"`
SettledAt *string `json:"settledAt"`
@ -553,10 +397,27 @@ type Boostagram struct {
SenderName string `json:"senderName"`
Time string `json:"time"`
Action string `json:"action"`
ValueSatTotal int64 `json:"valueSatTotal"`
ValueMsatTotal int64 `json:"valueMsatTotal"`
}
// debug api
type SendPaymentProbesRequest struct {
Invoice string `json:"invoice"`
}
type SendPaymentProbesResponse struct {
Error string `json:"error"`
}
type SendSpontaneousPaymentProbesRequest struct {
Amount uint64 `json:"amount"`
NodeId string `json:"nodeId"`
}
type SendSpontaneousPaymentProbesResponse struct {
Error string `json:"error"`
}
const (
LogTypeNode = "node"
LogTypeApp = "app"
@ -580,11 +441,8 @@ type SignMessageResponse struct {
}
type PayInvoiceRequest struct {
Amount *uint64 `json:"amount"` // deprecated
AmountSat *uint64 `json:"amountSat"`
AmountMsat *uint64 `json:"amountMsat"`
Metadata Metadata `json:"metadata"`
FromAppID *uint `json:"fromAppId"`
Amount *uint64 `json:"amount"`
Metadata Metadata `json:"metadata"`
}
type MakeOfferRequest struct {
@ -592,11 +450,8 @@ type MakeOfferRequest struct {
}
type MakeInvoiceRequest struct {
Amount *uint64 `json:"amount"` // deprecated
AmountSat *uint64 `json:"amountSat"`
AmountMsat *uint64 `json:"amountMsat"`
Description string `json:"description"`
ToAppID *uint `json:"toAppId"`
Amount uint64 `json:"amount"`
Description string `json:"description"`
}
type ResetRouterRequest struct {
@ -611,26 +466,21 @@ type BasicRestoreWailsRequest struct {
UnlockPassword string `json:"unlockPassword"`
}
type NetworkGraphResponse = interface{}
type NetworkGraphResponse = lnclient.NetworkGraphResponse
type LSPOrderRequest struct {
Amount *uint64 `json:"amount"` // deprecated
AmountSat *uint64 `json:"amountSat"`
LSPType string `json:"lspType"`
LSPIdentifier string `json:"lspIdentifier"`
Public bool `json:"public"`
Amount uint64 `json:"amount"`
LSPType string `json:"lspType"`
LSPIdentifier string `json:"lspIdentifier"`
Public bool `json:"public"`
}
type LSPOrderResponse struct {
Invoice string `json:"invoice"`
Fee uint64 `json:"fee"` // deprecated
FeeSat uint64 `json:"feeSat"`
InvoiceAmount uint64 `json:"invoiceAmount"` // deprecated
InvoiceAmountSat uint64 `json:"invoiceAmountSat"`
IncomingLiquidity uint64 `json:"incomingLiquidity"` // deprecated
IncomingLiquiditySat uint64 `json:"incomingLiquiditySat"`
OutgoingLiquidity uint64 `json:"outgoingLiquidity"` // deprecated
OutgoingLiquiditySat uint64 `json:"outgoingLiquiditySat"`
Invoice string `json:"invoice"`
Fee uint64 `json:"fee"`
InvoiceAmount uint64 `json:"invoiceAmount"`
IncomingLiquidity uint64 `json:"incomingLiquidity"`
OutgoingLiquidity uint64 `json:"outgoingLiquidity"`
}
type WalletCapabilitiesResponse struct {
@ -640,33 +490,25 @@ type WalletCapabilitiesResponse struct {
}
type Channel struct {
LocalBalance int64 `json:"localBalance"` // deprecated
LocalBalanceSat int64 `json:"localBalanceSat"`
LocalBalanceMsat int64 `json:"localBalanceMsat"`
LocalSpendableBalance int64 `json:"localSpendableBalance"` // deprecated
LocalSpendableBalanceSat int64 `json:"localSpendableBalanceSat"`
LocalSpendableBalanceMsat int64 `json:"localSpendableBalanceMsat"`
RemoteBalance int64 `json:"remoteBalance"` // deprecated
RemoteBalanceSat int64 `json:"remoteBalanceSat"`
RemoteBalanceMsat int64 `json:"remoteBalanceMsat"`
Id string `json:"id"`
RemotePubkey string `json:"remotePubkey"`
FundingTxId string `json:"fundingTxId"`
FundingTxVout uint32 `json:"fundingTxVout"`
Active bool `json:"active"`
Public bool `json:"public"`
InternalChannel interface{} `json:"internalChannel"`
Confirmations *uint32 `json:"confirmations"`
ConfirmationsRequired *uint32 `json:"confirmationsRequired"`
ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"` // expressed only in msat as per Lightning spec
ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"`
UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"` // deprecated
UnspendablePunishmentReserveSat uint64 `json:"unspendablePunishmentReserveSat"`
CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"` // deprecated
CounterpartyUnspendablePunishmentReserveSat uint64 `json:"counterpartyUnspendablePunishmentReserveSat"`
Error *string `json:"error"`
Status string `json:"status"`
IsOutbound bool `json:"isOutbound"`
LocalBalance int64 `json:"localBalance"`
LocalSpendableBalance int64 `json:"localSpendableBalance"`
RemoteBalance int64 `json:"remoteBalance"`
Id string `json:"id"`
RemotePubkey string `json:"remotePubkey"`
FundingTxId string `json:"fundingTxId"`
FundingTxVout uint32 `json:"fundingTxVout"`
Active bool `json:"active"`
Public bool `json:"public"`
InternalChannel interface{} `json:"internalChannel"`
Confirmations *uint32 `json:"confirmations"`
ConfirmationsRequired *uint32 `json:"confirmationsRequired"`
ForwardingFeeBaseMsat uint32 `json:"forwardingFeeBaseMsat"`
ForwardingFeeProportionalMillionths uint32 `json:"forwardingFeeProportionalMillionths"`
UnspendablePunishmentReserve uint64 `json:"unspendablePunishmentReserve"`
CounterpartyUnspendablePunishmentReserve uint64 `json:"counterpartyUnspendablePunishmentReserve"`
Error *string `json:"error"`
Status string `json:"status"`
IsOutbound bool `json:"isOutbound"`
}
type MigrateNodeStorageRequest struct {
@ -719,53 +561,7 @@ type ExecuteCustomNodeCommandRequest struct {
}
type GetForwardsResponse struct {
OutboundAmountForwardedSat uint64 `json:"outboundAmountForwardedSat"`
OutboundAmountForwardedMsat uint64 `json:"outboundAmountForwardedMsat"`
TotalFeeEarnedSat uint64 `json:"totalFeeEarnedSat"`
TotalFeeEarnedMsat uint64 `json:"totalFeeEarnedMsat"`
NumForwards uint64 `json:"numForwards"`
}
func ResolveToSat(satValue *uint64, msatValue *uint64, legacyValueSat *uint64, legacyValueMsat *uint64) (resolvedSatValue *uint64) {
if legacyValueSat != nil {
resolvedSatValue = legacyValueSat
}
if legacyValueMsat != nil {
tmpSat := *legacyValueMsat / 1000
resolvedSatValue = &tmpSat
}
if satValue != nil {
resolvedSatValue = satValue
}
if msatValue != nil {
tmpSat := *msatValue / 1000
resolvedSatValue = &tmpSat
}
return resolvedSatValue
}
func ResolveToMsat(satValue *uint64, msatValue *uint64, legacyValueSat *uint64, legacyValueMsat *uint64) (resolvedMsatValue *uint64) {
if legacyValueSat != nil {
tmpMsat := *legacyValueSat * 1000
resolvedMsatValue = &tmpMsat
}
if legacyValueMsat != nil {
resolvedMsatValue = legacyValueMsat
}
if satValue != nil {
tmpMsat := *satValue * 1000
resolvedMsatValue = &tmpMsat
}
if msatValue != nil {
resolvedMsatValue = msatValue
}
return resolvedMsatValue
}

View file

@ -18,22 +18,15 @@ import (
)
func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *RebalanceChannelRequest) (*RebalanceChannelResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
}
amountSat := uint64(0)
resolvedAmountSat := ResolveToSat(rebalanceChannelRequest.AmountSat, rebalanceChannelRequest.AmountMsat, nil, nil)
if resolvedAmountSat != nil {
amountSat = *resolvedAmountSat
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
receiveMetadata := map[string]interface{}{
"receive_through": rebalanceChannelRequest.ReceiveThroughNodePubkey,
}
receiveInvoice, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountSat*1000, "Alby Hub Rebalance through "+rebalanceChannelRequest.ReceiveThroughNodePubkey, "", 0, receiveMetadata, lnClient, nil, nil, &rebalanceChannelRequest.ReceiveThroughNodePubkey)
receiveInvoice, err := api.svc.GetTransactionsService().MakeInvoice(ctx, rebalanceChannelRequest.AmountSat*1000, "Alby Hub Rebalance through "+rebalanceChannelRequest.ReceiveThroughNodePubkey, "", 0, receiveMetadata, api.svc.GetLNClient(), nil, nil, &rebalanceChannelRequest.ReceiveThroughNodePubkey)
if err != nil {
logger.Logger.WithError(err).Error("failed to generate rebalance receive invoice")
return nil, err
@ -90,7 +83,7 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R
return nil, errors.New("failed to read response body")
}
if res.StatusCode != http.StatusOK {
if res.StatusCode >= 300 {
logger.Logger.WithFields(logrus.Fields{
"request": newRspCreateOrderRequest,
"body": string(body),
@ -122,17 +115,17 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R
return nil, err
}
if paymentRequest.MSatoshi > int64(float64(amountSat)*float64(1000)*float64(1.005)+1 /*0.5% fees*/) {
if paymentRequest.MSatoshi > int64(float64(rebalanceChannelRequest.AmountSat)*float64(1000)*float64(1.003)+1 /*0.3% fees*/) {
return nil, errors.New("rebalance payment is more expensive than expected")
}
payMetadata := map[string]interface{}{
"receive_through": rebalanceChannelRequest.ReceiveThroughNodePubkey,
"amount_sat": amountSat,
"amount_sat": rebalanceChannelRequest.AmountSat,
"order_id": rebalanceCreateOrderResponse.OrderId,
}
payRebalanceInvoiceResponse, err := api.svc.GetTransactionsService().SendPaymentSync(rebalanceCreateOrderResponse.PayRequest, nil, payMetadata, lnClient, nil, nil)
payRebalanceInvoiceResponse, err := api.svc.GetTransactionsService().SendPaymentSync(rebalanceCreateOrderResponse.PayRequest, nil, payMetadata, api.svc.GetLNClient(), nil, nil)
if err != nil {
logger.Logger.WithError(err).Error("failed to pay rebalance invoice")
@ -144,10 +137,7 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R
Properties: map[string]interface{}{},
})
totalFeeMsat := uint64(paymentRequest.MSatoshi) + payRebalanceInvoiceResponse.FeeMsat - amountSat*1000
return &RebalanceChannelResponse{
TotalFeeSat: totalFeeMsat / 1000,
TotalFeeMsat: totalFeeMsat,
TotalFeeSat: uint64(paymentRequest.MSatoshi)/1000 + payRebalanceInvoiceResponse.FeeMsat/1000 - rebalanceChannelRequest.AmountSat,
}, nil
}

View file

@ -1,161 +0,0 @@
package api
import (
"crypto/ecdsa"
"crypto/elliptic"
crand "crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"math/big"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"gopkg.in/macaroon.v2"
)
// generateTestCert returns a self-signed certificate PEM block and its
// matching EC private key PEM block.
func generateTestCert(t *testing.T) (certPEM []byte, keyPEM []byte) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
require.NoError(t, err)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "test"},
NotBefore: time.Unix(0, 0),
NotAfter: time.Unix(1<<31, 0),
}
der, err := x509.CreateCertificate(crand.Reader, &template, &template, &key.PublicKey, key)
require.NoError(t, err)
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyDER, err := x509.MarshalPKCS8PrivateKey(key)
require.NoError(t, err)
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM
}
func TestReadAndCanonicalizeLNDCert(t *testing.T) {
certPEM, keyPEM := generateTestCert(t)
dir := t.TempDir()
t.Run("valid certificate", func(t *testing.T) {
path := filepath.Join(dir, "tls.cert")
require.NoError(t, os.WriteFile(path, certPEM, 0600))
got, err := readAndCanonicalizeLNDCert(path)
require.NoError(t, err)
raw, err := hex.DecodeString(got)
require.NoError(t, err)
require.True(t, x509.NewCertPool().AppendCertsFromPEM(raw))
})
t.Run("bundled private key is stripped", func(t *testing.T) {
path := filepath.Join(dir, "bundle.pem")
require.NoError(t, os.WriteFile(path, append(append([]byte{}, certPEM...), keyPEM...), 0600))
got, err := readAndCanonicalizeLNDCert(path)
require.NoError(t, err)
raw, err := hex.DecodeString(got)
require.NoError(t, err)
// Only the CERTIFICATE block must survive - the private key must not
// be persisted.
require.NotContains(t, string(raw), "PRIVATE KEY")
require.Contains(t, string(raw), "CERTIFICATE")
})
t.Run("arbitrary non-cert file is rejected", func(t *testing.T) {
path := filepath.Join(dir, "secret.txt")
require.NoError(t, os.WriteFile(path, []byte("root:x:0:0:root:/root:/bin/bash\n"), 0600))
_, err := readAndCanonicalizeLNDCert(path)
require.Error(t, err)
})
t.Run("missing file is rejected", func(t *testing.T) {
_, err := readAndCanonicalizeLNDCert(filepath.Join(dir, "does-not-exist"))
require.Error(t, err)
})
}
func TestReadAndCanonicalizeLNDMacaroon(t *testing.T) {
dir := t.TempDir()
t.Run("valid macaroon", func(t *testing.T) {
mac, err := macaroon.New([]byte("root-key"), []byte("id"), "location", macaroon.LatestVersion)
require.NoError(t, err)
raw, err := mac.MarshalBinary()
require.NoError(t, err)
path := filepath.Join(dir, "admin.macaroon")
require.NoError(t, os.WriteFile(path, raw, 0600))
got, err := readAndCanonicalizeLNDMacaroon(path)
require.NoError(t, err)
gotRaw, err := hex.DecodeString(got)
require.NoError(t, err)
roundTrip := &macaroon.Macaroon{}
require.NoError(t, roundTrip.UnmarshalBinary(gotRaw))
})
t.Run("arbitrary non-macaroon file is rejected", func(t *testing.T) {
path := filepath.Join(dir, "id_rsa")
require.NoError(t, os.WriteFile(path, []byte("-----BEGIN OPENSSH PRIVATE KEY-----\nsecret\n"), 0600))
_, err := readAndCanonicalizeLNDMacaroon(path)
require.Error(t, err)
})
t.Run("missing file is rejected", func(t *testing.T) {
_, err := readAndCanonicalizeLNDMacaroon(filepath.Join(dir, "does-not-exist"))
require.Error(t, err)
})
}
func TestValidateCLNLightningDir(t *testing.T) {
certPEM, keyPEM := generateTestCert(t)
writeCLNDir := func(t *testing.T, dir string) {
t.Helper()
require.NoError(t, os.WriteFile(filepath.Join(dir, "ca.pem"), certPEM, 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "client.pem"), certPEM, 0600))
require.NoError(t, os.WriteFile(filepath.Join(dir, "client-key.pem"), keyPEM, 0600))
}
t.Run("valid directory", func(t *testing.T) {
dir := t.TempDir()
writeCLNDir(t, dir)
require.NoError(t, validateCLNLightningDir(dir, false))
})
t.Run("valid directory with hold subdirectory", func(t *testing.T) {
dir := t.TempDir()
writeCLNDir(t, dir)
holdDir := filepath.Join(dir, "hold")
require.NoError(t, os.Mkdir(holdDir, 0700))
writeCLNDir(t, holdDir)
require.NoError(t, validateCLNLightningDir(dir, true))
})
t.Run("hold requested but subdirectory missing", func(t *testing.T) {
dir := t.TempDir()
writeCLNDir(t, dir)
require.Error(t, validateCLNLightningDir(dir, true))
})
t.Run("arbitrary directory is rejected", func(t *testing.T) {
require.Error(t, validateCLNLightningDir(t.TempDir(), false))
})
}

View file

@ -4,29 +4,19 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/transactions"
"github.com/sirupsen/logrus"
)
func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*MakeInvoiceResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
func (api *api) CreateInvoice(ctx context.Context, amount uint64, description string) (*MakeInvoiceResponse, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
if toAppId != nil && api.appsSvc.GetAppById(*toAppId) == nil {
return nil, errors.New("app does not exist")
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil)
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amount, description, "", 0, nil, api.svc.GetLNClient(), nil, nil, nil)
if err != nil {
return nil, err
}
@ -34,65 +24,19 @@ func (api *api) CreateInvoice(ctx context.Context, amountMsat uint64, descriptio
}
func (api *api) LookupInvoice(ctx context.Context, paymentHash string) (*LookupInvoiceResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
transaction, err := api.svc.GetTransactionsService().LookupTransaction(ctx, paymentHash, nil, lnClient, nil)
transaction, err := api.svc.GetTransactionsService().LookupTransaction(ctx, paymentHash, nil, api.svc.GetLNClient(), nil)
if err != nil {
return nil, err
}
return toApiTransaction(transaction), nil
}
func (api *api) SetTransactionUserLabels(ctx context.Context, id uint, labels map[string]string) error {
return api.svc.GetTransactionsService().SetTransactionUserLabels(ctx, id, labels)
}
// ParseListTransactionsFilters parses transaction filter query parameters
// shared by the HTTP and Wails transports. Invalid values return an error.
func ParseListTransactionsFilters(query url.Values) (ListTransactionsFilters, error) {
filters := ListTransactionsFilters{}
if transactionType := query.Get("type"); transactionType != "" {
if transactionType != constants.TRANSACTION_TYPE_INCOMING && transactionType != constants.TRANSACTION_TYPE_OUTGOING {
return filters, fmt.Errorf("invalid type: %s", transactionType)
}
filters.Type = &transactionType
}
if minAmountSatParam := query.Get("minAmountSat"); minAmountSatParam != "" {
minAmountSat, err := strconv.ParseUint(minAmountSatParam, 10, 64)
if err != nil || minAmountSat == 0 {
return filters, fmt.Errorf("invalid minAmountSat: %s", minAmountSatParam)
}
const msatPerSat = uint64(1000)
if minAmountSat > ^uint64(0)/msatPerSat {
return filters, fmt.Errorf("minAmountSat is too large")
}
minAmountMsat := minAmountSat * msatPerSat
filters.MinAmountMsat = &minAmountMsat
}
if hideFailedParam := query.Get("hideFailed"); hideFailedParam != "" {
hideFailed, err := strconv.ParseBool(hideFailedParam)
if err != nil {
return filters, fmt.Errorf("invalid hideFailed: %s", hideFailedParam)
}
filters.HideFailed = hideFailed
}
filters.SearchTerm = strings.TrimSpace(query.Get("search"))
return filters, nil
}
func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64, filters ListTransactionsFilters) (*ListTransactionsResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
forceFilterByAppId := false
@ -100,18 +44,13 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64,
forceFilterByAppId = true
}
dbTransactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, lnClient, appId, forceFilterByAppId, &transactions.ListTransactionsFilters{
Type: filters.Type,
MinAmountMsat: filters.MinAmountMsat,
HideFailed: filters.HideFailed,
SearchTerm: filters.SearchTerm,
})
transactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, api.svc.GetLNClient(), appId, forceFilterByAppId)
if err != nil {
return nil, err
}
apiTransactions := []Transaction{}
for _, transaction := range dbTransactions {
for _, transaction := range transactions {
apiTransactions = append(apiTransactions, *toApiTransaction(&transaction))
}
@ -121,13 +60,11 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64,
}, nil
}
func (api *api) SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}, appId *uint) (*SendPaymentResponse, error) {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return nil, ErrLNClientNotStarted
func (api *api) SendPayment(ctx context.Context, invoice string, amountMsat *uint64, metadata map[string]interface{}) (*SendPaymentResponse, error) {
if api.svc.GetLNClient() == nil {
return nil, errors.New("LNClient not started")
}
transaction, err := api.svc.GetTransactionsService().SendPaymentSync(invoice, amountMsat, metadata, lnClient, appId, nil)
transaction, err := api.svc.GetTransactionsService().SendPaymentSync(invoice, amountMsat, metadata, api.svc.GetLNClient(), nil, nil)
if err != nil {
return nil, err
}
@ -171,7 +108,6 @@ func toApiTransaction(transaction *transactions.Transaction) *Transaction {
}
return &Transaction{
ID: transaction.ID,
Type: transaction.Type,
State: strings.ToLower(transaction.State),
Invoice: transaction.PaymentRequest,
@ -180,12 +116,8 @@ func toApiTransaction(transaction *transactions.Transaction) *Transaction {
Preimage: preimage,
PaymentHash: transaction.PaymentHash,
Amount: transaction.AmountMsat,
AmountSat: transaction.AmountMsat / 1000,
AmountMsat: transaction.AmountMsat,
AppId: transaction.AppId,
FeesPaid: transaction.FeeMsat,
FeesPaidSat: transaction.FeeMsat / 1000,
FeesPaidMsat: transaction.FeeMsat,
UpdatedAt: updatedAt,
CreatedAt: createdAt,
SettledAt: settledAt,
@ -195,10 +127,9 @@ func toApiTransaction(transaction *transactions.Transaction) *Transaction {
}
}
func (api *api) Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64, description string) error {
lnClient := api.svc.GetLNClient()
if lnClient == nil {
return ErrLNClientNotStarted
func (api *api) Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, amountMsat uint64) error {
if api.svc.GetLNClient() == nil {
return errors.New("LNClient not started")
}
for _, appId := range []*uint{fromAppId, toAppId} {
@ -213,18 +144,13 @@ func (api *api) Transfer(ctx context.Context, fromAppId *uint, toAppId *uint, am
}
}
// default to "transfer"
if description == "" {
description = "transfer"
}
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, toAppId, nil, nil)
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, "transfer", "", 0, nil, api.svc.GetLNClient(), toAppId, nil, nil)
if err != nil {
return err
}
_, err = api.svc.GetTransactionsService().SendPaymentSync(transaction.PaymentRequest, nil, nil, lnClient, fromAppId, nil)
_, err = api.svc.GetTransactionsService().SendPaymentSync(transaction.PaymentRequest, nil, nil, api.svc.GetLNClient(), fromAppId, nil)
return err
}
@ -243,7 +169,6 @@ func toApiBoostagram(boostagram *transactions.Boostagram) *Boostagram {
SenderName: boostagram.SenderName,
Time: boostagram.Time,
Action: boostagram.Action,
ValueSatTotal: boostagram.ValueMsatTotal / 1000,
ValueMsatTotal: boostagram.ValueMsatTotal,
}
}

View file

@ -1,98 +0,0 @@
package api
import (
"context"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/getAlby/hub/tests"
"github.com/getAlby/hub/tests/mocks"
"github.com/getAlby/hub/transactions"
)
func TestCreateInvoice_ToApp(t *testing.T) {
ctx := context.TODO()
testSvc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer testSvc.Remove()
app, _, err := tests.CreateApp(testSvc)
require.NoError(t, err)
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(testSvc.LNClient)
svc.On("GetTransactionsService").Return(transactions.NewTransactionsService(testSvc.DB, testSvc.EventPublisher))
theAPI := &api{
appsSvc: testSvc.AppsService,
svc: svc,
}
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &app.ID)
require.NoError(t, err)
require.NotNil(t, transaction.AppId)
assert.Equal(t, app.ID, *transaction.AppId)
}
func TestCreateInvoice_ToAppNotFound(t *testing.T) {
ctx := context.TODO()
testSvc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer testSvc.Remove()
svc := mocks.NewMockService(t)
svc.On("GetLNClient").Return(testSvc.LNClient)
theAPI := &api{
appsSvc: testSvc.AppsService,
svc: svc,
}
missingAppId := uint(999)
transaction, err := theAPI.CreateInvoice(ctx, 1000, "Hello world", &missingAppId)
assert.Nil(t, transaction)
require.Error(t, err)
assert.Equal(t, "app does not exist", err.Error())
}
func TestParseListTransactionsFilters(t *testing.T) {
minAmountMsat := uint64(1000_000)
outgoing := "outgoing"
filters, err := ParseListTransactionsFilters(url.Values{
"type": {"outgoing"},
"minAmountSat": {"1000"},
"hideFailed": {"true"},
"search": {" coffee "},
})
require.NoError(t, err)
assert.Equal(t, ListTransactionsFilters{
Type: &outgoing,
MinAmountMsat: &minAmountMsat,
HideFailed: true,
SearchTerm: "coffee",
}, filters)
filters, err = ParseListTransactionsFilters(url.Values{})
require.NoError(t, err)
assert.Equal(t, ListTransactionsFilters{}, filters)
for _, invalidQuery := range []url.Values{
{"type": {"sideways"}},
{"minAmountSat": {"abc"}},
{"minAmountSat": {"-1"}},
{"minAmountSat": {"0"}},
{"minAmountSat": {"18446744073709551615"}},
{"hideFailed": {"maybe"}},
} {
_, err = ParseListTransactionsFilters(invalidQuery)
assert.Error(t, err, "query: %v", invalidQuery)
}
}

View file

@ -9,13 +9,13 @@ import (
"strings"
"time"
"github.com/getAlby/go-nostr"
"github.com/getAlby/hub/config"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/events"
"github.com/getAlby/hub/logger"
"github.com/getAlby/hub/service/keys"
"github.com/nbd-wtf/go-nostr"
"gorm.io/datatypes"
"gorm.io/gorm"
)
@ -58,11 +58,9 @@ func (svc *appsService) CreateApp(name string, pubkey string, maxAmountSat uint6
backendType, _ := svc.cfg.Get("LNBackendType", "")
if backendType != config.LDKBackendType &&
backendType != config.LNDBackendType &&
backendType != config.PhoenixBackendType &&
backendType != config.BarkBackendType &&
backendType != config.CLNBackendType {
backendType != config.PhoenixBackendType {
return nil, "", fmt.Errorf(
"sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN")
"sub-wallets are currently not supported on your node backend. Try LDK or LND")
}
}
@ -186,17 +184,11 @@ func (svc *appsService) DeleteApp(app *db.App) error {
if err != nil {
return err
}
walletPubkey := ""
if app.WalletPubkey != nil {
// only exists for non-legacy apps
walletPubkey = *app.WalletPubkey
}
svc.eventPublisher.Publish(&events.Event{
Event: "nwc_app_deleted",
Properties: map[string]interface{}{
"name": app.Name,
"id": app.ID,
"walletPubkey": walletPubkey,
"name": app.Name,
"id": app.ID,
},
})
return nil

View file

@ -54,5 +54,5 @@ func TestHandleCreateApp_IsolatedUnsupportedBackendType(t *testing.T) {
assert.Nil(t, app)
assert.Equal(t, "", secretKey)
require.Error(t, err)
assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN", err.Error())
assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK or LND", err.Error())
}

View file

@ -2,7 +2,9 @@ package main
import (
"flag"
"fmt"
"os"
"slices"
"strconv"
"github.com/sirupsen/logrus"
@ -12,6 +14,18 @@ import (
"github.com/getAlby/hub/logger"
)
var expectedTables = []string{
"apps",
"app_permissions",
"request_events",
"response_events",
"transactions",
"swaps",
"user_configs",
"migrations",
"forwards",
}
func main() {
var fromDSN, toDSN string
@ -50,29 +64,54 @@ func main() {
}
defer stopDB(toDB)
// When migrating to Postgres (e.g. a cloud deployment) the node data must
// be stored in VSS, since only the database is migrated by this tool.
if toDB.Dialector.Name() == "postgres" {
var vssConfig db.UserConfig
result := fromDB.Where("key = ?", "LdkVssEnabled").First(&vssConfig)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
logger.Logger.Error("LdkVssEnabled config not found in source DB. Migration will not proceed.")
} else {
logger.Logger.WithError(result.Error).Error("failed to query LdkVssEnabled config from source DB")
}
os.Exit(1)
}
// Migrations are applied to both the source and the target DB, so
// schemas should be equal at this point.
err = checkSchema(fromDB)
if err != nil {
logger.Logger.WithError(err).Error("database schema check failed; the migration tool may be outdated")
os.Exit(1)
}
if vssConfig.Value != "true" {
logger.Logger.Error("VSS is not enabled in the source DB (LdkVssEnabled is not 'true'). Migration will not proceed.")
os.Exit(1)
// Check if VSS is enabled in the source database
var vssConfig db.UserConfig
result := fromDB.Where("key = ?", "LdkVssEnabled").First(&vssConfig)
if result.Error != nil {
if result.Error == gorm.ErrRecordNotFound {
logger.Logger.Error("LdkVssEnabled config not found in source DB. Migration will not proceed.")
} else {
logger.Logger.WithError(result.Error).Error("failed to query LdkVssEnabled config from source DB")
}
logger.Logger.Info("LdkVssEnabled check passed.")
os.Exit(1)
}
if vssConfig.Value != "true" {
logger.Logger.Error("VSS is not enabled in the source DB (LdkVssEnabled is not 'true'). Migration will not proceed.")
os.Exit(1)
}
logger.Logger.Info("LdkVssEnabled check passed.")
// NOTE: we assume that excess request events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned request events.")
err = fromDB.Exec("DELETE FROM request_events WHERE app_id NOT IN (SELECT id FROM apps);").Error
if err != nil {
logger.Logger.WithError(err).Error("failed to delete orphaned request events")
os.Exit(1)
}
// NOTE: we assume that excess response events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned response events.")
err = fromDB.Exec("DELETE FROM response_events WHERE request_id NOT IN (SELECT id FROM request_events);").Error
if err != nil {
logger.Logger.WithError(err).Error("failed to delete orphaned response events")
os.Exit(1)
}
logger.Logger.Info("migrating...")
err = db.MigrateDB(fromDB, toDB)
err = migrateDB(fromDB, toDB)
if err != nil {
logger.Logger.WithError(err).Error("failed to migrate database")
os.Exit(1)
@ -80,3 +119,175 @@ func main() {
logger.Logger.Info("migration complete")
}
func migrateDB(from, to *gorm.DB) error {
tx := to.Begin()
defer tx.Rollback()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
// Table migration order matters: referenced tables must be migrated
// before referencing tables.
logger.Logger.Info("migrating apps...")
if err := migrateTable[db.App](from, tx); err != nil {
return fmt.Errorf("failed to migrate apps: %w", err)
}
logger.Logger.Info("migrating app_permissions...")
if err := migrateTable[db.AppPermission](from, tx); err != nil {
return fmt.Errorf("failed to migrate app_permissions: %w", err)
}
logger.Logger.Info("migrating request_events...")
if err := migrateTable[db.RequestEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate request_events: %w", err)
}
logger.Logger.Info("migrating response_events...")
if err := migrateTable[db.ResponseEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate response_events: %w", err)
}
logger.Logger.Info("migrating transactions...")
if err := migrateTable[db.Transaction](from, tx); err != nil {
return fmt.Errorf("failed to migrate transactions: %w", err)
}
logger.Logger.Info("migrating user_configs...")
if err := migrateTable[db.UserConfig](from, tx); err != nil {
return fmt.Errorf("failed to migrate user_configs: %w", err)
}
if to.Dialector.Name() == "postgres" {
logger.Logger.Info("resetting sequences...")
if err := resetSequences(tx); err != nil {
return fmt.Errorf("failed to reset sequences: %w", err)
}
}
tx.Commit()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func migrateTable[T any](from, to *gorm.DB) error {
var data []T
if err := from.Find(&data).Error; err != nil {
return fmt.Errorf("failed to fetch data: %w", err)
}
if len(data) == 0 {
return nil
}
// to avoid "failed to migrate transactions: failed to insert data: extended protocol limited to 65535 parameters"
// see https://stackoverflow.com/questions/77372430/extended-protocol-limited-to-65535-parameters-golang-gorm
// max statements is 65535
// but it's the number of records * columns
// to be safe, using a lower value of 1000.
// this will fail if any table has more than 65 columns, which I doubt we will have
max := 1000
for i := 0; i < len(data); i += max {
j := min(i+max, len(data))
if err := to.Create(data[i:j]).Error; err != nil {
return fmt.Errorf("failed to insert data: %w", err)
}
}
return nil
}
func checkSchema(db *gorm.DB) error {
tables, err := listTables(db)
if err != nil {
return fmt.Errorf("failed to list database tables: %w", err)
}
for _, table := range expectedTables {
if !slices.Contains(tables, table) {
return fmt.Errorf("table missing from the database: %q", table)
}
}
for _, table := range tables {
if !slices.Contains(expectedTables, table) {
return fmt.Errorf("unexpected table found in the database: %q", table)
}
}
return nil
}
func listTables(db *gorm.DB) ([]string, error) {
var query string
switch db.Dialector.Name() {
case "sqlite":
query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"
case "postgres":
query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';"
default:
return nil, fmt.Errorf("unsupported database: %q", db.Dialector.Name())
}
rows, err := db.Raw(query).Rows()
if err != nil {
return nil, fmt.Errorf("failed to query table names: %w", err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Logger.WithError(err).Error("failed to close rows")
}
}()
var tables []string
for rows.Next() {
var table string
if err := rows.Scan(&table); err != nil {
return nil, fmt.Errorf("failed to scan table name: %w", err)
}
tables = append(tables, table)
}
return tables, nil
}
func resetSequences(db *gorm.DB) error {
type resetReq struct {
table string
seq string
}
resetReqs := []resetReq{
{"apps", "apps_2_id_seq"},
{"app_permissions", "app_permissions_2_id_seq"},
{"request_events", "request_events_id_seq"},
{"response_events", "response_events_id_seq"},
{"transactions", "transactions_id_seq"},
{"user_configs", "user_configs_id_seq"},
}
for _, req := range resetReqs {
if err := resetPostgresSequence(db, req.table, req.seq); err != nil {
return fmt.Errorf("failed to reset sequence %q for %q: %w", req.seq, req.table, err)
}
}
return nil
}
func resetPostgresSequence(db *gorm.DB, table string, seq string) error {
query := fmt.Sprintf("SELECT setval('%s', (SELECT MAX(id) FROM %s));", seq, table)
if err := db.Exec(query).Error; err != nil {
return fmt.Errorf("failed to execute setval(): %w", err)
}
return nil
}

View file

@ -30,6 +30,40 @@ func (e *testEnvironment) cleanup(t *testing.T) {
require.NoError(t, err)
}
func TestSchemaCheck(t *testing.T) {
type testCase struct {
name string
uri string
}
tc := []testCase{
{
name: "schema check sqlite",
uri: getTestSqliteURI(0),
},
}
if pgUri := getTestPostgresURI(); pgUri != "" {
tc = append(tc, testCase{
name: "schema check postgres",
uri: pgUri,
})
}
logger.Init(strconv.Itoa(int(logrus.DebugLevel)))
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
dbConn, err := test_db.NewDBWithURI(t, tt.uri)
require.NoError(t, err)
defer db.Stop(dbConn)
err = checkSchema(dbConn)
require.NoError(t, err)
})
}
}
func TestMigrate(t *testing.T) {
type testCase struct {
name string
@ -70,17 +104,8 @@ func TestMigrate(t *testing.T) {
require.NoError(t, err)
defer env.cleanup(t)
err = db.MigrateDB(env.source, env.dest)
err = migrateDB(env.source, env.dest)
require.NoError(t, err)
requireCount[db.App](t, env.dest, 2)
requireCount[db.AppPermission](t, env.dest, 2)
requireCount[db.RequestEvent](t, env.dest, 1)
requireCount[db.ResponseEvent](t, env.dest, 1)
requireCount[db.Transaction](t, env.dest, 1)
requireCount[db.Swap](t, env.dest, 1)
requireCount[db.Forward](t, env.dest, 1)
requireCount[db.UserConfig](t, env.dest, 1)
})
}
}
@ -123,7 +148,7 @@ func insertMockData(t *testing.T, tx *gorm.DB) {
userCfg1 := &db.UserConfig{
Key: "Relay",
Value: "wss://relay.getalby.com",
Value: "wss://relay.getalby.com/v1",
Encrypted: false,
CreatedAt: baseTime,
UpdatedAt: baseTime,
@ -175,83 +200,6 @@ func insertMockData(t *testing.T, tx *gorm.DB) {
UpdatedAt: baseTime,
}
create(t, tx, app2Perm)
requestEvent1 := &db.RequestEvent{
AppId: &app1.ID,
NostrId: "a35a1ca6d1a06e08a509f2c8fe3edb2ba10811d030e2f6f3239e9f21203ac954",
ContentData: "{}",
Method: "pay_invoice",
State: "executed",
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, requestEvent1)
responseEvent1 := &db.ResponseEvent{
NostrId: "e30d55d0e4f0d5391a1a1379f1d8b7d38ad02b3554b06ca993aa8790a3153f61",
RequestId: requestEvent1.ID,
State: "confirmed",
RepliedAt: baseTime,
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, responseEvent1)
transaction1 := &db.Transaction{
AppId: &app1.ID,
RequestEventId: &requestEvent1.ID,
Type: "outgoing",
State: "settled",
AmountMsat: 21000,
FeeMsat: 1000,
PaymentRequest: "lnbc210n1invoice",
PaymentHash: "13d9764a54269fa4d5f4e7c410f4ffdbc839bbeaa2fcbb96343ca502f0c86e34",
Description: "test transaction",
Preimage: ptr("2c1ee1b464b1a1a147debe0ac0c8ce4b615f9bfa64d12a25c1c4d10ea45a5b02"),
CreatedAt: baseTime,
UpdatedAt: baseTime,
SettledAt: &baseTime,
Metadata: datatypes.JSON("{}"),
Boostagram: datatypes.JSON("{}"),
}
create(t, tx, transaction1)
swap1 := &db.Swap{
SwapId: "swap1",
Type: "out",
State: "success",
Invoice: "lnbc210n1swapinvoice",
SendAmountSat: 21000,
ReceiveAmountSat: 20000,
Preimage: "35a3f1a7a06a41b9ba3a1b1a8ff852e5085b3b593f8ba4677a35a1ca6d1a06e0",
PaymentHash: "e6b1a1379f1d8b7d38ad02b3554b06ca993aa8790a3153f61e30d55d0e4f0d53",
DestinationAddress: "bc1qtest",
LockupAddress: "bc1qlockup",
LockupTxId: "lockuptx",
ClaimTxId: "claimtx",
AutoSwap: false,
TimeoutBlockHeight: 900000,
BoltzPubkey: "02d1a06e08a509f2c8fe3edb2ba10811d030e2f6f3239e9f21203ac954a35a1c",
SwapTree: datatypes.JSON("{}"),
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, swap1)
forward1 := &db.Forward{
OutboundAmountForwardedMsat: 1000000,
TotalFeeEarnedMsat: 1000,
CreatedAt: baseTime,
UpdatedAt: baseTime,
}
create(t, tx, forward1)
}
func requireCount[T any](t *testing.T, tx *gorm.DB, expected int64) {
var count int64
var model T
require.NoError(t, tx.Model(&model).Count(&count).Error)
require.Equal(t, expected, count)
}
func create[T any](t *testing.T, tx *gorm.DB, v T) *gorm.DB {

View file

@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"os"
"path"
"strings"
"sync"
@ -19,12 +20,11 @@ import (
)
type config struct {
Env *AppConfig
db *gorm.DB
cache map[string]map[string]string // key -> encryptionKeyHash -> value
cacheMutex sync.Mutex
jwtSecret string
jwtSecretMutex sync.Mutex
Env *AppConfig
db *gorm.DB
cache map[string]map[string]string // key -> encryptionKeyHash -> value
cacheMutex sync.Mutex
jwtSecret string
}
const (
@ -112,66 +112,36 @@ func (cfg *config) init(env *AppConfig) error {
}
}
// CLN specific to support env variables
if cfg.Env.CLNAddress != "" {
err := cfg.SetUpdate("CLNAddress", cfg.Env.CLNAddress, "")
if err != nil {
return err
}
}
if cfg.Env.CLNLightningDir != "" {
err := cfg.SetUpdate("CLNLightningDir", cfg.Env.CLNLightningDir, "")
if err != nil {
return err
}
}
if cfg.Env.CLNAddressHold != "" {
err := cfg.SetUpdate("CLNAddressHold", cfg.Env.CLNAddressHold, "")
if err != nil {
return err
}
}
return nil
}
func (cfg *config) SetupCompleted() (bool, error) {
nodeLastStartTime, err := cfg.Get("NodeLastStartTime", "")
if err != nil {
return false, err
}
func (cfg *config) SetupCompleted() bool {
// TODO: remove hasLdkDir check after 2025/01/01
// to give time for users to update to 1.6.0+
nodeLastStartTime, _ := cfg.Get("NodeLastStartTime", "")
ldkDir, err := os.Stat(path.Join(cfg.GetEnv().Workdir, "ldk"))
hasLdkDir := err == nil && ldkDir != nil && ldkDir.IsDir()
logger.Logger.WithFields(logrus.Fields{
"has_ldk_dir": hasLdkDir,
"has_node_last_start_time": nodeLastStartTime != "",
}).Debug("Checking if setup is completed")
return nodeLastStartTime != "", nil
return nodeLastStartTime != "" || hasLdkDir
}
func (cfg *config) GetJWTSecret() (string, error) {
cfg.jwtSecretMutex.Lock()
jwtSecret := cfg.jwtSecret
cfg.jwtSecretMutex.Unlock()
if jwtSecret == "" {
if cfg.jwtSecret == "" {
return "", errors.New("config not unlocked")
}
return jwtSecret, nil
return cfg.jwtSecret, nil
}
// Decrypt and store the JWT secret in memory
func (cfg *config) LoadJWTSecret(encryptionKey string) error {
func (cfg *config) Unlock(encryptionKey string) error {
if !cfg.CheckUnlockPassword(encryptionKey) {
return errors.New("incorrect password")
}
cfg.jwtSecretMutex.Lock()
if cfg.jwtSecret != "" {
cfg.jwtSecretMutex.Unlock()
return nil
}
cfg.jwtSecretMutex.Unlock()
// TODO: remove encryptedJwtSecret check after 2027-01-01
// - all hubs should have updated to use an encrypted JWT secret by then
encryptedJwtSecret, err := cfg.Get("JWTSecret", "")
@ -198,9 +168,7 @@ func (cfg *config) LoadJWTSecret(encryptionKey string) error {
return err
}
}
cfg.jwtSecretMutex.Lock()
cfg.jwtSecret = jwtSecret
cfg.jwtSecretMutex.Unlock()
return nil
}
@ -385,9 +353,7 @@ func (cfg *config) ChangeUnlockPassword(currentUnlockPassword string, newUnlockP
}
// JWT secret will be set on config unlock (required after password change)
cfg.jwtSecretMutex.Lock()
cfg.jwtSecret = ""
cfg.jwtSecretMutex.Unlock()
return nil
}
@ -408,18 +374,7 @@ func (cfg *config) SetAutoUnlockPassword(unlockPassword string) error {
func (cfg *config) CheckUnlockPassword(encryptionKey string) bool {
decryptedValue, err := cfg.Get("UnlockPasswordCheck", encryptionKey)
// require a non-empty match so an absent or empty canary always fails
return err == nil && decryptedValue != "" && decryptedValue == unlockPasswordCheck
}
func (cfg *config) IsUnlockPasswordCheckSet() (bool, error) {
// Read the raw value with an empty encryption key so we can detect the
// presence of the canary row without needing the (possibly wrong) password.
value, err := cfg.Get("UnlockPasswordCheck", "")
if err != nil {
return false, fmt.Errorf("read unlock password check: %w", err)
}
return value != "", nil
return err == nil && (decryptedValue == "" || decryptedValue == unlockPasswordCheck)
}
func (cfg *config) SaveUnlockPasswordCheck(encryptionKey string) error {

View file

@ -56,6 +56,8 @@ func TestCheckUnlockPasswordCache(t *testing.T) {
Workdir: ".test",
}, db)
require.NoError(t, err)
err = cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)

View file

@ -5,8 +5,6 @@ const (
LDKBackendType = "LDK"
PhoenixBackendType = "PHOENIX"
CashuBackendType = "CASHU"
CLNBackendType = "CLN"
BarkBackendType = "BARK"
)
const (
@ -18,7 +16,7 @@ const (
)
type AppConfig struct {
Relay string `envconfig:"RELAY" default:"wss://relay.getalby.com,wss://relay2.getalby.com"`
Relay string `envconfig:"RELAY" default:"wss://relay.getalby.com/v1"`
LNBackendType string `envconfig:"LN_BACKEND_TYPE"`
LNDAddress string `envconfig:"LND_ADDRESS"`
LNDCertFile string `envconfig:"LND_CERT_FILE"`
@ -36,9 +34,7 @@ type AppConfig struct {
LDKLogLevel string `envconfig:"LDK_LOG_LEVEL" default:"3"`
LDKMaxChannelSaturationPowerOfHalf uint8 `envconfig:"LDK_MAX_CHANNEL_SATURATION" default:"2"`
LDKMaxPathCount uint8 `envconfig:"LDK_MAX_PATH_COUNT" default:"5"`
LDKChannelMonitorWarningSizeBytes uint64 `envconfig:"LDK_CHANNEL_MONITOR_WARNING_SIZE_BYTES" default:"5000000"`
LDKVssUrl string `envconfig:"LDK_VSS_URL" default:"https://vss.getalbypro.com/vss"`
LDKLiquiditySourceLsps2 string `envconfig:"LDK_LSPS2_ADDRESSES"`
LDKListeningAddresses string `envconfig:"LDK_LISTENING_ADDRESSES" default:"[::]:9735"`
LDKAnnouncementAddresses string `envconfig:"LDK_ANNOUNCEMENT_ADDRESSES"`
LDKTransientNetworkGraph bool `envconfig:"LDK_TRANSIENT_NETWORK_GRAPH" default:"false"`
@ -61,14 +57,6 @@ type AppConfig struct {
AutoUnlockPassword string `envconfig:"AUTO_UNLOCK_PASSWORD"`
LogDBQueries bool `envconfig:"LOG_DB_QUERIES" default:"false"`
BoltzApi string `envconfig:"BOLTZ_API" default:"https://api.boltz.exchange"`
HideUpdateBanner bool `envconfig:"HIDE_UPDATE_BANNER" default:"false"`
CLNAddress string `envconfig:"CLN_ADDRESS"`
CLNLightningDir string `envconfig:"CLN_LIGHTNING_DIR"`
CLNAddressHold string `envconfig:"CLN_ADDRESS_HOLD"`
BarkServer string `envconfig:"BARK_SERVER" default:"https://ark.second.tech"`
BarkEsploraServer string `envconfig:"BARK_ESPLORA_SERVER" default:"https://mempool.second.tech/api"`
BarkServerAccessToken string `envconfig:"BARK_SERVER_ACCESS_TOKEN"`
BarkLogLevel string `envconfig:"BARK_LOG_LEVEL" default:"3"`
}
func (c *AppConfig) IsDefaultClientId() bool {
@ -84,21 +72,20 @@ func (c *AppConfig) GetBaseFrontendUrl() string {
}
type Config interface {
Unlock(encryptionKey string) error
Get(key string, encryptionKey string) (string, error)
SetIgnore(key string, value string, encryptionKey string) error
SetUpdate(key string, value string, encryptionKey string) error
LoadJWTSecret(encryptionKey string) error
GetJWTSecret() (string, error)
GetRelayUrls() []string
GetNetwork() string
GetMempoolUrl() string
GetEnv() *AppConfig
CheckUnlockPassword(password string) bool
IsUnlockPasswordCheckSet() (bool, error)
ChangeUnlockPassword(currentUnlockPassword string, newUnlockPassword string) error
SetAutoUnlockPassword(unlockPassword string) error
SaveUnlockPasswordCheck(encryptionKey string) error
SetupCompleted() (bool, error)
SetupCompleted() bool
GetCurrency() string
SetCurrency(value string) error
GetBitcoinDisplayFormat() string

View file

@ -16,6 +16,8 @@ func TestCheckUnlockPasswordCache_InvalidSecond(t *testing.T) {
require.NoError(t, err)
defer svc.Remove()
err = svc.Cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)
@ -33,6 +35,8 @@ func TestCheckUnlockPasswordCache_InvalidFirst(t *testing.T) {
require.NoError(t, err)
defer svc.Remove()
err = svc.Cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)
@ -54,6 +58,8 @@ func TestCheckUnlockPassword_ChangePassword(t *testing.T) {
require.NoError(t, err)
defer svc.Remove()
err = svc.Cfg.ChangeUnlockPassword("", unlockPassword)
require.NoError(t, err)
err = svc.Cfg.SaveUnlockPasswordCheck(unlockPassword)
require.NoError(t, err)
@ -75,50 +81,6 @@ func TestCheckUnlockPassword_ChangePassword(t *testing.T) {
assert.True(t, svc.Cfg.CheckUnlockPassword(newUnlockPassword))
}
func TestCheckUnlockPassword_MissingCanaryFailsClosed(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
// A fresh hub has not saved the unlock-password canary yet.
set, err := svc.Cfg.IsUnlockPasswordCheckSet()
require.NoError(t, err)
assert.False(t, set)
// Without the canary, no password may validate - including an empty one.
assert.False(t, svc.Cfg.CheckUnlockPassword(""))
assert.False(t, svc.Cfg.CheckUnlockPassword("any-password"))
// After the canary is saved, only the correct password validates.
err = svc.Cfg.SaveUnlockPasswordCheck("correct")
require.NoError(t, err)
set, err = svc.Cfg.IsUnlockPasswordCheckSet()
require.NoError(t, err)
assert.True(t, set)
assert.True(t, svc.Cfg.CheckUnlockPassword("correct"))
assert.False(t, svc.Cfg.CheckUnlockPassword("wrong"))
assert.False(t, svc.Cfg.CheckUnlockPassword(""))
}
func TestCheckUnlockPassword_NoPasswordHub(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
// A hub configured without an unlock password stores the canary unencrypted;
// the empty password must still validate after the fail-closed change.
err = svc.Cfg.SaveUnlockPasswordCheck("")
require.NoError(t, err)
set, err := svc.Cfg.IsUnlockPasswordCheckSet()
require.NoError(t, err)
assert.True(t, set)
assert.True(t, svc.Cfg.CheckUnlockPassword(""))
}
func TestSetIgnore_NoEncryptionKey(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
@ -249,7 +211,7 @@ func TestSetUpdate_EncryptionKeyToNoEncryptionKey(t *testing.T) {
assert.Equal(t, "value2", updatedValue)
}
func TestJWTSecret_GeneratedOnLoad(t *testing.T) {
func TestJWTSecret_GeneratedOnUnlock(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
@ -257,10 +219,10 @@ func TestJWTSecret_GeneratedOnLoad(t *testing.T) {
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck("123")
err = cfg.ChangeUnlockPassword("", "123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("123")
err = cfg.Unlock("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()
@ -273,32 +235,14 @@ func TestJWTSecret_GeneratedOnLoad(t *testing.T) {
require.NoError(t, err)
assert.NotEqual(t, encryptedSecret, decryptedSecret)
// load again without doing anything, ensure the same JWT secret is returned
err = cfg.LoadJWTSecret("123")
// unlock again without doing anything, ensure the same JWT secret is returned
err = cfg.Unlock("123")
require.NoError(t, err)
jwtSecret2, err := cfg.GetJWTSecret()
require.NoError(t, err)
assert.Equal(t, jwtSecret, jwtSecret2)
}
func TestJWTSecret_WrongPassword(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck("123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("wrong")
require.ErrorContains(t, err, "incorrect password")
}
func TestJWTSecret_ChangePassword(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
@ -307,23 +251,23 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck("123")
err = cfg.ChangeUnlockPassword("", "123")
require.NoError(t, err)
err = cfg.LoadJWTSecret("123")
err = cfg.Unlock("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()
require.NoError(t, err)
assert.NotEmpty(t, jwtSecret)
err = cfg.ChangeUnlockPassword("123", "1234")
err = cfg.ChangeUnlockPassword("", "1234")
require.NoError(t, err)
newJwtSecret, err := cfg.GetJWTSecret()
require.ErrorContains(t, err, "unlock")
err = cfg.LoadJWTSecret("1234")
err = cfg.Unlock("1234")
require.NoError(t, err)
// a new JWT secret must be generated after password change
@ -333,7 +277,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
assert.NotEqual(t, newJwtSecret, jwtSecret)
}
func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(t *testing.T) {
func TestJWTSecret_ReplaceUnencryptedSecretOnUnlock(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
@ -341,7 +285,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(t *testing.T) {
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
require.NoError(t, err)
err = cfg.SaveUnlockPasswordCheck("123")
err = cfg.ChangeUnlockPassword("", "123")
require.NoError(t, err)
// simulate a hub that had an unencrypted JWT secret
@ -349,7 +293,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(t *testing.T) {
err = svc.Cfg.SetUpdate("JWTSecret", oldJwtSecret, "")
require.NoError(t, err)
err = cfg.LoadJWTSecret("123")
err = cfg.Unlock("123")
require.NoError(t, err)
jwtSecret, err := cfg.GetJWTSecret()

View file

@ -76,8 +76,6 @@ const (
ENCRYPTION_TYPE_NIP44_V2 = "nip44_v2"
)
const METADATA_APPSTORE_APP_ID_KEY = "app_store_app_id"
const SUBWALLET_APPSTORE_APP_ID = "uncle-jim"
const (

View file

@ -1,235 +0,0 @@
package db
import (
"fmt"
"slices"
"gorm.io/gorm"
"github.com/getAlby/hub/logger"
)
var expectedTables = []string{
"apps",
"app_permissions",
"request_events",
"response_events",
"transactions",
"swaps",
"user_configs",
"migrations",
"forwards",
}
// MigrateDB copies all rows from one database to another. Both databases
// must have an up-to-date schema (they are checked against expectedTables).
// Orphaned request and response events are deleted from the source database
// before copying, as they would violate foreign key constraints in the
// destination database.
func MigrateDB(from, to *gorm.DB) error {
if err := checkSchema(from); err != nil {
return fmt.Errorf("source database schema check failed: %w", err)
}
if err := checkSchema(to); err != nil {
return fmt.Errorf("destination database schema check failed: %w", err)
}
// NOTE: we assume that excess request events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned request events.")
err := from.Exec("DELETE FROM request_events WHERE app_id NOT IN (SELECT id FROM apps);").Error
if err != nil {
return fmt.Errorf("failed to delete orphaned request events: %w", err)
}
// NOTE: we assume that excess response events have already been cleaned up due to the background task
// and only a maximum of ~1000 remain.
logger.Logger.Info("Deleting orphaned response events.")
err = from.Exec("DELETE FROM response_events WHERE request_id NOT IN (SELECT id FROM request_events);").Error
if err != nil {
return fmt.Errorf("failed to delete orphaned response events: %w", err)
}
tx := to.Begin()
defer tx.Rollback()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to start transaction: %w", err)
}
// Table migration order matters: referenced tables must be migrated
// before referencing tables.
logger.Logger.Info("migrating apps...")
if err := migrateTable[App](from, tx); err != nil {
return fmt.Errorf("failed to migrate apps: %w", err)
}
logger.Logger.Info("migrating app_permissions...")
if err := migrateTable[AppPermission](from, tx); err != nil {
return fmt.Errorf("failed to migrate app_permissions: %w", err)
}
logger.Logger.Info("migrating request_events...")
if err := migrateTable[RequestEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate request_events: %w", err)
}
logger.Logger.Info("migrating response_events...")
if err := migrateTable[ResponseEvent](from, tx); err != nil {
return fmt.Errorf("failed to migrate response_events: %w", err)
}
logger.Logger.Info("migrating transactions...")
if err := migrateTable[Transaction](from, tx); err != nil {
return fmt.Errorf("failed to migrate transactions: %w", err)
}
logger.Logger.Info("migrating swaps...")
if err := migrateTable[Swap](from, tx); err != nil {
return fmt.Errorf("failed to migrate swaps: %w", err)
}
logger.Logger.Info("migrating forwards...")
if err := migrateTable[Forward](from, tx); err != nil {
return fmt.Errorf("failed to migrate forwards: %w", err)
}
logger.Logger.Info("migrating user_configs...")
if err := migrateTable[UserConfig](from, tx); err != nil {
return fmt.Errorf("failed to migrate user_configs: %w", err)
}
if to.Dialector.Name() == "postgres" {
logger.Logger.Info("resetting sequences...")
if err := resetSequences(tx); err != nil {
return fmt.Errorf("failed to reset sequences: %w", err)
}
}
tx.Commit()
if err := tx.Error; err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
func migrateTable[T any](from, to *gorm.DB) error {
var data []T
if err := from.Find(&data).Error; err != nil {
return fmt.Errorf("failed to fetch data: %w", err)
}
if len(data) == 0 {
return nil
}
// to avoid "failed to migrate transactions: failed to insert data: extended protocol limited to 65535 parameters"
// see https://stackoverflow.com/questions/77372430/extended-protocol-limited-to-65535-parameters-golang-gorm
// max statements is 65535
// but it's the number of records * columns
// to be safe, using a lower value of 1000.
// this will fail if any table has more than 65 columns, which I doubt we will have
max := 1000
for i := 0; i < len(data); i += max {
j := min(i+max, len(data))
if err := to.Create(data[i:j]).Error; err != nil {
return fmt.Errorf("failed to insert data: %w", err)
}
}
return nil
}
func checkSchema(db *gorm.DB) error {
tables, err := listTables(db)
if err != nil {
return fmt.Errorf("failed to list database tables: %w", err)
}
for _, table := range expectedTables {
if !slices.Contains(tables, table) {
return fmt.Errorf("table missing from the database: %q", table)
}
}
for _, table := range tables {
if !slices.Contains(expectedTables, table) {
return fmt.Errorf("unexpected table found in the database: %q", table)
}
}
return nil
}
func listTables(db *gorm.DB) ([]string, error) {
var query string
switch db.Dialector.Name() {
case "sqlite":
query = "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';"
case "postgres":
query = "SELECT tablename FROM pg_tables WHERE schemaname = 'public';"
default:
return nil, fmt.Errorf("unsupported database: %q", db.Dialector.Name())
}
rows, err := db.Raw(query).Rows()
if err != nil {
return nil, fmt.Errorf("failed to query table names: %w", err)
}
defer func() {
if err := rows.Close(); err != nil {
logger.Logger.WithError(err).Error("failed to close rows")
}
}()
var tables []string
for rows.Next() {
var table string
if err := rows.Scan(&table); err != nil {
return nil, fmt.Errorf("failed to scan table name: %w", err)
}
tables = append(tables, table)
}
return tables, nil
}
func resetSequences(db *gorm.DB) error {
type resetReq struct {
table string
seq string
}
resetReqs := []resetReq{
{"apps", "apps_2_id_seq"},
{"app_permissions", "app_permissions_2_id_seq"},
{"request_events", "request_events_id_seq"},
{"response_events", "response_events_id_seq"},
{"transactions", "transactions_id_seq"},
{"swaps", "swaps_id_seq"},
{"forwards", "forwards_id_seq"},
{"user_configs", "user_configs_id_seq"},
}
for _, req := range resetReqs {
if err := resetPostgresSequence(db, req.table, req.seq); err != nil {
return fmt.Errorf("failed to reset sequence %q for %q: %w", req.seq, req.table, err)
}
}
return nil
}
func resetPostgresSequence(db *gorm.DB, table string, seq string) error {
query := fmt.Sprintf("SELECT setval('%s', (SELECT MAX(id) FROM %s));", seq, table)
if err := db.Exec(query).Error; err != nil {
return fmt.Errorf("failed to execute setval(): %w", err)
}
return nil
}

View file

@ -1,29 +0,0 @@
package migrations
import (
_ "embed"
"text/template"
"github.com/go-gormigrate/gormigrate/v2"
"gorm.io/gorm"
)
const appLastSettledTransactionMigration = `ALTER TABLE apps ADD COLUMN last_settled_transaction_at {{ .Timestamp }};`
var appLastSettledTransactionMigrationTmpl = template.Must(template.New("appLastSettledTransactionMigration").Parse(appLastSettledTransactionMigration))
var _202604081200_app_last_settled_transaction = &gormigrate.Migration{
ID: "202604081200_app_last_settled_transaction",
Migrate: func(tx *gorm.DB) error {
err := exec(tx, appLastSettledTransactionMigrationTmpl)
if err != nil {
return err
}
return nil
},
Rollback: func(tx *gorm.DB) error {
return nil
},
}

View file

@ -38,7 +38,6 @@ func Migrate(gormDB *gorm.DB) error {
_202508151405_swap_xpub,
_202508192137_forwards,
_202509031250_transactions_updated_at_index,
_202604081200_app_last_settled_transaction,
})
return m.Migrate()

View file

@ -16,17 +16,16 @@ type UserConfig struct {
}
type App struct {
ID uint
Name string `validate:"required"`
Description string
AppPubkey string `validate:"required"`
WalletPubkey *string
CreatedAt time.Time
UpdatedAt time.Time
LastUsedAt *time.Time
LastSettledTransactionAt *time.Time
Isolated bool
Metadata datatypes.JSON
ID uint
Name string `validate:"required"`
Description string
AppPubkey string `validate:"required"`
WalletPubkey *string
CreatedAt time.Time
UpdatedAt time.Time
LastUsedAt *time.Time
Isolated bool
Metadata datatypes.JSON
}
type AppPermission struct {
@ -97,8 +96,8 @@ type Swap struct {
Type string
State string
Invoice string
SendAmountSat uint64 `gorm:"column:send_amount"`
ReceiveAmountSat uint64 `gorm:"column:receive_amount"`
SendAmount uint64
ReceiveAmount uint64
Preimage string
PaymentHash string
DestinationAddress string

View file

@ -8,18 +8,15 @@ import (
"gorm.io/gorm"
)
func GetBudgetUsageMsat(tx *gorm.DB, appPermission *db.AppPermission) (uint64, error) {
func GetBudgetUsageSat(tx *gorm.DB, appPermission *db.AppPermission) uint64 {
var result struct {
Sum uint64
}
err := tx.
tx.
Table("transactions").
Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum").
Where("app_id = ? AND type = ? AND (state = ? OR state = ?) AND created_at > ?", appPermission.AppId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING, getStartOfBudget(appPermission.BudgetRenewal)).Scan(&result).Error
if err != nil {
return 0, err
}
return result.Sum, nil
Where("app_id = ? AND type = ? AND (state = ? OR state = ?) AND created_at > ?", appPermission.AppId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING, getStartOfBudget(appPermission.BudgetRenewal)).Scan(&result)
return result.Sum / 1000
}
func getStartOfBudget(budget_type string) time.Time {

View file

@ -1,234 +0,0 @@
package queries
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/tests"
)
func TestGetBudgetUsage_IncludesPendingAndSettledOutgoing(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
app, _, err := tests.CreateApp(svc)
require.NoError(t, err)
appPermission := &db.AppPermission{
AppId: app.ID,
App: *app,
Scope: constants.PAY_INVOICE_SCOPE,
BudgetRenewal: constants.BUDGET_RENEWAL_NEVER,
}
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_PENDING,
AmountMsat: 50000,
FeeMsat: 1000,
FeeReserveMsat: 2000,
}).Error)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 25000,
FeeMsat: 500,
FeeReserveMsat: 500,
}).Error)
budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermission)
require.NoError(t, err)
assert.Equal(t, uint64(79000), budgetUsageMsat)
}
func TestGetBudgetUsage_ExcludesWrongStateTypeAndApp(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
app, _, err := tests.CreateApp(svc)
require.NoError(t, err)
otherApp, _, err := tests.CreateApp(svc)
require.NoError(t, err)
appPermission := &db.AppPermission{
AppId: app.ID,
App: *app,
Scope: constants.PAY_INVOICE_SCOPE,
BudgetRenewal: constants.BUDGET_RENEWAL_NEVER,
}
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 20000,
FeeMsat: 1000,
FeeReserveMsat: 0,
}).Error)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_INCOMING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 90000,
FeeMsat: 0,
FeeReserveMsat: 0,
}).Error)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_FAILED,
AmountMsat: 90000,
FeeMsat: 0,
FeeReserveMsat: 0,
}).Error)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &otherApp.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 90000,
FeeMsat: 0,
FeeReserveMsat: 0,
}).Error)
budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermission)
require.NoError(t, err)
assert.Equal(t, uint64(21000), budgetUsageMsat)
}
func TestGetBudgetUsage_BudgetWindowDaily(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
app, _, err := tests.CreateApp(svc)
require.NoError(t, err)
appPermissionDaily := &db.AppPermission{
AppId: app.ID,
App: *app,
Scope: constants.PAY_INVOICE_SCOPE,
BudgetRenewal: constants.BUDGET_RENEWAL_DAILY,
}
dailyStart := getStartOfBudget(constants.BUDGET_RENEWAL_DAILY)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 20000,
FeeMsat: 1000,
FeeReserveMsat: 0,
CreatedAt: dailyStart.Add(1 * time.Minute),
}).Error)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_PENDING,
AmountMsat: 20000,
FeeMsat: 0,
FeeReserveMsat: 1000,
CreatedAt: dailyStart.Add(2 * time.Hour),
}).Error)
budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermissionDaily)
require.NoError(t, err)
assert.Equal(t, uint64(42000), budgetUsageMsat)
}
func TestGetBudgetUsage_BudgetWindowWeekly(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
app, _, err := tests.CreateApp(svc)
require.NoError(t, err)
appPermissionWeekly := &db.AppPermission{
AppId: app.ID,
App: *app,
Scope: constants.PAY_INVOICE_SCOPE,
BudgetRenewal: constants.BUDGET_RENEWAL_WEEKLY,
}
weeklyStart := getStartOfBudget(constants.BUDGET_RENEWAL_WEEKLY)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 10000,
FeeMsat: 1000,
FeeReserveMsat: 0,
CreatedAt: weeklyStart.Add(30 * time.Minute),
}).Error)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_PENDING,
AmountMsat: 7000,
FeeMsat: 0,
FeeReserveMsat: 0,
CreatedAt: weeklyStart.Add(-1 * time.Hour),
}).Error)
budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermissionWeekly)
require.NoError(t, err)
assert.Equal(t, uint64(11000), budgetUsageMsat)
}
func TestGetBudgetUsage_BudgetWindowNever(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
app, _, err := tests.CreateApp(svc)
require.NoError(t, err)
appPermission := &db.AppPermission{
AppId: app.ID,
App: *app,
Scope: constants.PAY_INVOICE_SCOPE,
BudgetRenewal: constants.BUDGET_RENEWAL_NEVER,
}
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 5000,
FeeMsat: 1000,
FeeReserveMsat: 0,
CreatedAt: time.Now().AddDate(-2, 0, 0),
}).Error)
require.NoError(t, svc.DB.Create(&db.Transaction{
AppId: &app.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_PENDING,
AmountMsat: 4000,
FeeMsat: 0,
FeeReserveMsat: 1000,
CreatedAt: time.Now().AddDate(-1, 0, 0),
}).Error)
budgetUsageMsat, err := GetBudgetUsageMsat(svc.DB, appPermission)
require.NoError(t, err)
assert.Equal(t, uint64(11000), budgetUsageMsat)
}

View file

@ -5,29 +5,23 @@ import (
"gorm.io/gorm"
)
func GetIsolatedBalanceMsat(tx *gorm.DB, appId uint) (int64, error) {
func GetIsolatedBalance(tx *gorm.DB, appId uint) int64 {
var received struct {
Sum int64
}
err := tx.
tx.
Table("transactions").
Select("SUM(amount_msat) as sum").
Where("app_id = ? AND type = ? AND state = ?", appId, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).Scan(&received).Error
if err != nil {
return 0, err
}
Where("app_id = ? AND type = ? AND state = ?", appId, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).Scan(&received)
var spent struct {
Sum int64
}
err = tx.
tx.
Table("transactions").
Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum").
Where("app_id = ? AND type = ? AND (state = ? OR state = ?)", appId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).Scan(&spent).Error
if err != nil {
return 0, err
}
Where("app_id = ? AND type = ? AND (state = ? OR state = ?)", appId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).Scan(&spent)
return received.Sum - spent.Sum, nil
return received.Sum - spent.Sum
}

View file

@ -36,9 +36,8 @@ func TestGetIsolatedBalance_PendingNoOverflow(t *testing.T) {
}
svc.DB.Save(&tx)
balanceMsat, err := GetIsolatedBalanceMsat(svc.DB, app.ID)
require.NoError(t, err)
assert.Equal(t, int64(-11000), balanceMsat)
balance := GetIsolatedBalance(svc.DB, app.ID)
assert.Equal(t, int64(-11000), balance)
}
func TestGetIsolatedBalance_SettledNoOverflow(t *testing.T) {
@ -66,7 +65,6 @@ func TestGetIsolatedBalance_SettledNoOverflow(t *testing.T) {
}
svc.DB.Save(&tx)
balanceMsat, err := GetIsolatedBalanceMsat(svc.DB, app.ID)
require.NoError(t, err)
assert.Equal(t, int64(-1000), balanceMsat)
balance := GetIsolatedBalance(svc.DB, app.ID)
assert.Equal(t, int64(-1000), balance)
}

View file

@ -1,40 +0,0 @@
package queries
import (
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"gorm.io/datatypes"
"gorm.io/gorm"
)
func GetTotalSubwalletBalanceMsat(tx *gorm.DB) (int64, error) {
subwalletAppIDsQuery := tx.Model(&db.App{}).
Select("id").
Where(datatypes.JSONQuery("metadata").Equals(constants.SUBWALLET_APPSTORE_APP_ID, constants.METADATA_APPSTORE_APP_ID_KEY))
var received struct {
Sum int64
}
res := tx.
Table("transactions").
Select("SUM(amount_msat) as sum").
Where("app_id IN (?) AND type = ? AND state = ?", subwalletAppIDsQuery, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).
Scan(&received)
if res.Error != nil {
return 0, res.Error
}
var spent struct {
Sum int64
}
res = tx.
Table("transactions").
Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum").
Where("app_id IN (?) AND type = ? AND (state = ? OR state = ?)", subwalletAppIDsQuery, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).
Scan(&spent)
if res.Error != nil {
return 0, res.Error
}
return received.Sum - spent.Sum, nil
}

View file

@ -1,64 +0,0 @@
package queries
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/datatypes"
"github.com/getAlby/hub/constants"
"github.com/getAlby/hub/db"
"github.com/getAlby/hub/tests"
)
func TestGetTotalSubwalletBalance(t *testing.T) {
svc, err := tests.CreateTestService(t)
require.NoError(t, err)
defer svc.Remove()
subwalletA, _, err := tests.CreateApp(svc)
require.NoError(t, err)
subwalletA.Isolated = true
subwalletA.Metadata = datatypes.JSON([]byte(fmt.Sprintf(`{"%s":"%s"}`, constants.METADATA_APPSTORE_APP_ID_KEY, constants.SUBWALLET_APPSTORE_APP_ID)))
svc.DB.Save(&subwalletA)
subwalletB, _, err := tests.CreateApp(svc)
require.NoError(t, err)
subwalletB.Isolated = true
subwalletB.Metadata = datatypes.JSON([]byte(fmt.Sprintf(`{"%s":"%s"}`, constants.METADATA_APPSTORE_APP_ID_KEY, constants.SUBWALLET_APPSTORE_APP_ID)))
svc.DB.Save(&subwalletB)
incomingSubwalletTx := db.Transaction{
AppId: &subwalletA.ID,
Type: constants.TRANSACTION_TYPE_INCOMING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 5000,
}
svc.DB.Save(&incomingSubwalletTx)
outgoingSettledSubwalletTx := db.Transaction{
AppId: &subwalletA.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_SETTLED,
AmountMsat: 1000,
FeeMsat: 100,
FeeReserveMsat: 0,
}
svc.DB.Save(&outgoingSettledSubwalletTx)
outgoingPendingSubwalletTx := db.Transaction{
AppId: &subwalletB.ID,
Type: constants.TRANSACTION_TYPE_OUTGOING,
State: constants.TRANSACTION_STATE_PENDING,
AmountMsat: 2000,
FeeMsat: 0,
FeeReserveMsat: 300,
}
svc.DB.Save(&outgoingPendingSubwalletTx)
totalBalanceMsat, err := GetTotalSubwalletBalanceMsat(svc.DB)
require.NoError(t, err)
assert.Equal(t, int64(1600), totalBalanceMsat)
}

View file

@ -1,2 +1,2 @@
# set a custom messageboard wallet (should be a sub-wallet with only make invoice and list transactions permissions)
#VITE_LIGHTNING_MESSAGEBOARD_NWC_URL="nostr+walletconnect://5f8e7c098137ccca853327be44a9b2e956cf79a8e2336e27a4f27b3fb55325b6?relay=wss://relay.getalby.com&relay=wss://relay2.getalby.com&secret=ace5c4b9e08138a2ef91b4ccf1379952c77c651866b29f5872b5165134417894"
#VITE_LIGHTNING_MESSAGEBOARD_NWC_URL="nostr+walletconnect://5f8e7c098137ccca853327be44a9b2e956cf79a8e2336e27a4f27b3fb55325b6?relay=wss://relay.getalby.com/v1&secret=ace5c4b9e08138a2ef91b4ccf1379952c77c651866b29f5872b5165134417894"

View file

@ -4,7 +4,7 @@
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"config": "tailwind.config.js",
"css": "src/index.css",
"baseColor": "zinc",
"cssVariables": true,

View file

@ -1,10 +1,20 @@
import eslint from "@eslint/js";
import { fixupConfigRules, fixupPluginRules } from "@eslint/compat";
import { FlatCompat } from "@eslint/eslintrc";
import js from "@eslint/js";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import tsParser from "@typescript-eslint/parser";
import eslintConfigPrettier from "eslint-config-prettier/flat";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
@ -15,12 +25,20 @@ export default [
"src/components/ui/navigation-menu.tsx",
],
},
eslint.configs.recommended,
...tseslint.configs.recommended,
reactRefresh.configs.vite,
reactHooks.configs.flat["recommended-latest"],
eslintConfigPrettier,
...fixupConfigRules(
compat.extends(
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react-hooks/recommended",
"prettier"
)
),
{
plugins: {
"react-refresh": reactRefresh,
"@typescript-eslint": fixupPluginRules(typescriptEslint),
},
languageOptions: {
globals: {
...globals.browser,
@ -30,7 +48,20 @@ export default [
},
files: ["**/*.ts", "**/*.tsx"],
rules: {
"react-hooks/set-state-in-effect": "off",
"react-refresh/only-export-components": [
"warn",
{
allowConstantExport: true,
},
],
"@typescript-eslint/ban-ts-comment": [
"error",
{
"ts-ignore": "allow-with-description",
},
],
"@typescript-eslint/no-unused-vars": [
"warn",
{

View file

@ -20,63 +20,97 @@
"prepare": "cd .. && husky frontend/.husky"
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@fontsource-variable/figtree": "^5.3.0",
"@fontsource-variable/inter": "^5.3.0",
"@getalby/lightning-tools": "^8.1.0",
"@getalby/sdk": "^8.0.3",
"@scure/bip39": "^2.2.0",
"@stepperize/react": "^6.1.0",
"@getalby/lightning-tools": "^6.0.0",
"@getalby/sdk": "^6.0.1",
"@hookform/resolvers": "^5.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.14",
"@radix-ui/react-aspect-ratio": "^1.1.7",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.15",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.13",
"@radix-ui/react-popover": "^1.1.14",
"@radix-ui/react-progress": "^1.1.7",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.9",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slider": "^1.3.5",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.7",
"@scure/bip39": "^2.0.1",
"@stepperize/react": "^5.1.8",
"argon2-wasm-esm": "^1.0.3",
"bitcoin-address-validation": "^3.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"compare-versions": "^6.1.1",
"date-fns": "^4.1.0",
"dayjs": "^1.11.20",
"dayjs": "^1.11.10",
"embla-carousel-react": "^8.6.0",
"lottie-react": "^2.4.1",
"lucide-react": "^1.28.0",
"qr-code-styling": "^1.9.2",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-day-picker": "^9.14.0",
"react-dom": "^19.2.6",
"react-router": "^7.18.2",
"input-otp": "^1.4.2",
"lucide-react": "^0.544.0",
"next-themes": "^0.4.6",
"react": "18.3.1",
"react-day-picker": "^9.11.0",
"react-dom": "18.3.1",
"react-hook-form": "^7.60.0",
"react-lottie": "^1.2.4",
"react-qr-code": "^2.0.12",
"react-resizable-panels": "^3.0.6",
"react-router-dom": "^6.21.0",
"recharts": "2.15.4",
"sonner": "^2.0.7",
"swr": "^2.4.1",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"swr": "^2.3.6",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.5",
"vaul": "^1.1.2",
"zustand": "^5.0.12"
"zod": "^4.0.2",
"zustand": "^4.5.0"
},
"devDependencies": {
"@commitlint/cli": "^20.5.3",
"@commitlint/config-conventional": "^21.2.0",
"@eslint/eslintrc": "^3.3.5",
"@eslint/js": "^10.0.1",
"@commitlint/cli": "^19.3.0",
"@commitlint/config-conventional": "^20.0.0",
"@eslint/compat": "^1.0.3",
"@eslint/eslintrc": "^3.1.0",
"@eslint/js": "^9.4.0",
"@tailwindcss/aspect-ratio": "^0.4.2",
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.9.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react-swc": "^4.3.1",
"eslint": "^10.4.1",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^24.7.2",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/react-lottie": "^1.2.10",
"@typescript-eslint/eslint-plugin": "^7.11.0",
"@typescript-eslint/parser": "^7.11.0",
"@vitejs/plugin-react-swc": "^3.3.2",
"eslint": "^9.4.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.3",
"globals": "^15.4.0",
"husky": "^9.0.11",
"lint-staged": "^16.4.0",
"prettier": "3.8.3",
"lint-staged": "^15.2.5",
"prettier": "3.6.2",
"shx": "^0.4.0",
"tailwindcss": "^4.3.0",
"tailwindcss": "^4.1.16",
"typescript": "^5.9.3",
"typescript-eslint": "^8.61.0",
"vite": "^8.2.1",
"vite-plugin-pwa": "^1.3.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
"vite": "^5.4.0",
"vite-plugin-pwa": "^0.20.1",
"vite-tsconfig-paths": "^5.1.4"
}
}

View file

@ -10,7 +10,7 @@ export const request = async <T>(
args[1]?.body?.toString() || ""
);
console.info("Wails request", args[0].toString(), args[1]?.method || "GET");
console.info("Wails request", ...args, res);
if (res.error) {
throw new Error(res.error);
}

Binary file not shown.

Binary file not shown.

View file

@ -1,32 +1,31 @@
import { createBrowserRouter, createHashRouter } from "react-router";
import { RouterProvider } from "react-router/dom";
import {
RouterProvider,
createBrowserRouter,
createHashRouter,
} from "react-router-dom";
import { Toaster } from "src/components/ui/sonner";
import { ThemeProvider } from "src/components/ui/theme-provider";
import { TouchProvider } from "src/components/ui/tooltip";
import { useInfo } from "src/hooks/useInfo";
import { useRegisterProtocolHandler } from "src/hooks/useRegisterProtocolHandler";
import routes from "src/routes.tsx";
import { isHttpMode } from "src/utils/isHttpMode";
const createRouterFunc = isHttpMode() ? createBrowserRouter : createHashRouter;
const basePath =
import.meta.env.BASE_URL !== "/" ? import.meta.env.BASE_URL : "";
const router = createRouterFunc(routes, {
// if running on a subpath, use the subpath as the router basename
// BASE_URL is set via process.env.BASE_PATH, see https://vite.dev/guide/build#public-base-path
basename: basePath || undefined,
basename:
import.meta.env.BASE_URL !== "/" ? import.meta.env.BASE_URL : undefined,
});
function App() {
const { data: info } = useInfo();
useRegisterProtocolHandler(basePath);
return (
<>
<TouchProvider>
<ThemeProvider
defaultTheme="alby"
defaultTheme="default"
defaultDarkMode="system"
storageKey="vite-ui-theme"
>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,12 +0,0 @@
<svg width="672" height="660" viewBox="0 0 672 660" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_531_1058)">
<path d="M672 0H0V660H672V0Z" fill="#070707"/>
<path d="M214.167 255.106L137.061 267.979C124.805 270.011 117.322 282.607 121.387 294.339C138.046 342.411 159.232 374.006 183.343 370.188C203.543 366.985 224.822 325.566 236.923 280.081C240.68 265.977 228.609 252.704 214.198 255.106H214.167Z" fill="white"/>
<path d="M446.871 660L443.391 634.471C442.191 625.664 437.787 617.595 431.074 611.806C366.47 556.251 324.961 499.065 300.142 457.03C296.569 450.963 304.299 444.589 309.564 449.3C375.338 508.057 475.663 569.586 525.794 535.064C584.086 494.938 561.175 374.037 505.994 270.535C503.561 266.008 487.703 239.216 484.039 233.427C450.813 180.736 368.841 51.4279 330.103 72.1529C301.496 87.4581 336.939 158.533 351.782 207.066C354.245 215.073 350.673 223.695 343.283 227.637C337.678 230.624 332.413 234.104 323.052 240.232C239.109 298.035 208.778 329.723 256.785 393.53C261.373 399.628 261.342 408.004 256.6 414.009C228.055 450.101 127.268 554.373 112.057 660M515.94 462.758C525.271 476.307 523.885 504.947 512.769 512.584C501.683 520.221 476.155 510.12 466.825 496.571C457.495 483.021 466.764 474.798 480.343 465.468C493.892 456.137 506.61 449.177 515.94 462.758ZM442.375 317.282C455.154 306.042 473.723 304.933 490.844 314.387C493.738 315.988 495.801 318.606 496.725 321.747C497.649 324.919 497.249 328.245 495.678 331.109C494.077 334.003 491.46 336.098 488.288 336.991C485.116 337.914 481.791 337.514 478.927 335.944C471.167 331.663 463.407 331.601 458.634 335.79C454.354 339.547 453.461 345.952 456.232 352.942C457.433 355.991 457.372 359.348 456.078 362.366C454.785 365.384 452.352 367.724 449.304 368.925C447.856 369.51 446.348 369.787 444.777 369.787C439.696 369.787 435.17 366.739 433.322 361.996C426.671 345.182 430.15 328.06 442.375 317.282ZM348.579 385.092C361.358 373.852 379.927 372.744 397.048 382.198C399.942 383.799 402.005 386.417 402.929 389.558C403.853 392.73 403.453 396.055 401.882 398.919C400.281 401.814 397.664 403.908 394.492 404.801C391.32 405.725 387.995 405.325 385.131 403.754C377.371 399.474 369.611 399.412 364.838 403.6C360.558 407.357 359.665 413.763 362.436 420.753C364.93 427.066 361.82 434.241 355.508 436.736C354.06 437.321 352.521 437.598 350.981 437.598C345.9 437.598 341.404 434.549 339.526 429.807C332.875 412.993 336.354 395.84 348.579 385.092Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_531_1058">
<rect width="672" height="660" rx="330" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -1,12 +0,0 @@
<svg width="672" height="660" viewBox="0 0 672 660" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_531_1063)">
<path d="M672 0H0V660H672V0Z" fill="white"/>
<path d="M214.167 255.106L137.061 267.979C124.805 270.011 117.322 282.607 121.387 294.339C138.046 342.411 159.232 374.006 183.343 370.188C203.543 366.985 224.822 325.566 236.923 280.081C240.68 265.977 228.609 252.704 214.198 255.106H214.167Z" fill="#060606"/>
<path d="M446.871 660L443.391 634.471C442.191 625.664 437.787 617.595 431.074 611.806C366.47 556.251 324.961 499.065 300.142 457.03C296.569 450.963 304.299 444.589 309.564 449.3C375.338 508.057 475.663 569.586 525.794 535.064C584.086 494.938 561.175 374.037 505.994 270.535C503.561 266.008 487.703 239.216 484.039 233.427C450.813 180.736 368.841 51.4279 330.103 72.1529C301.496 87.4581 336.939 158.533 351.782 207.066C354.245 215.073 350.673 223.695 343.283 227.637C337.678 230.624 332.413 234.104 323.052 240.232C239.109 298.035 208.778 329.723 256.785 393.53C261.373 399.628 261.342 408.004 256.6 414.009C228.055 450.101 127.268 554.373 112.057 660M515.94 462.758C525.271 476.307 523.885 504.947 512.769 512.584C501.683 520.221 476.155 510.12 466.825 496.571C457.495 483.021 466.764 474.798 480.343 465.468C493.892 456.137 506.61 449.177 515.94 462.758ZM442.375 317.282C455.154 306.042 473.723 304.933 490.844 314.387C493.738 315.988 495.801 318.606 496.725 321.747C497.649 324.919 497.249 328.245 495.678 331.109C494.077 334.003 491.46 336.098 488.288 336.991C485.116 337.914 481.791 337.514 478.927 335.944C471.167 331.663 463.407 331.601 458.634 335.79C454.354 339.547 453.461 345.952 456.232 352.942C457.433 355.991 457.372 359.348 456.078 362.366C454.785 365.384 452.352 367.724 449.304 368.925C447.856 369.51 446.348 369.787 444.777 369.787C439.696 369.787 435.17 366.739 433.322 361.996C426.671 345.182 430.15 328.06 442.375 317.282ZM348.579 385.092C361.358 373.852 379.927 372.744 397.048 382.198C399.942 383.799 402.005 386.417 402.929 389.558C403.853 392.73 403.453 396.055 401.882 398.919C400.281 401.814 397.664 403.908 394.492 404.801C391.32 405.725 387.995 405.325 385.131 403.754C377.371 399.474 369.611 399.412 364.838 403.6C360.558 407.357 359.665 413.763 362.436 420.753C364.93 427.066 361.82 434.241 355.508 436.736C354.06 437.321 352.521 437.598 350.981 437.598C345.9 437.598 341.404 434.549 339.526 429.807C332.875 412.993 336.354 395.84 348.579 385.092Z" fill="#060606"/>
</g>
<defs>
<clipPath id="clip0_531_1063">
<rect width="672" height="660" rx="330" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 998 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

View file

@ -5,10 +5,10 @@ import { useBalances } from "src/hooks/useBalances";
import { useChannels } from "src/hooks/useChannels";
export function AnchorReserveAlert({
amountSat,
amount,
className,
}: {
amountSat: number;
amount: number;
className?: string;
}) {
const { data: balances } = useBalances();
@ -19,9 +19,9 @@ export function AnchorReserveAlert({
}
const showAlert =
amountSat &&
amount &&
!!channels.length &&
+amountSat > balances.onchain.spendableSat - channels.length * 25000;
+amount > balances.onchain.spendable - channels.length * 25000;
if (!showAlert) {
return null;
@ -32,14 +32,12 @@ export function AnchorReserveAlert({
<AlertTriangleIcon className="h-4 w-4" />
<AlertTitle>Channel Anchor Reserves will be depleted</AlertTitle>
<AlertDescription>
<p>
You have channels open and by spending your entire on-chain balance
including your anchor reserves may put your node at risk of unable to
reclaim funds in your channel after a force-closure. To prevent this,
set aside at least{" "}
<FormattedBitcoinAmount amountMsat={channels.length * 25000 * 1000} />{" "}
on-chain.
</p>
You have channels open and by spending your entire on-chain balance
including your anchor reserves may put your node at risk of unable to
reclaim funds in your channel after a force-closure. To prevent this,
set aside at least{" "}
<FormattedBitcoinAmount amount={channels.length * 25000 * 1000} />{" "}
on-chain.
</AlertDescription>
</Alert>
);

View file

@ -1,32 +1,9 @@
import claudeLogo from "src/assets/suggested-apps/claude.png";
import clineLogo from "src/assets/suggested-apps/cline.png";
import codexLogo from "src/assets/suggested-apps/codex.png";
import cursorLogo from "src/assets/suggested-apps/cursor.png";
import geminiLogo from "src/assets/suggested-apps/gemini.png";
import gooseLogo from "src/assets/suggested-apps/goose.png";
import hermesLogo from "src/assets/suggested-apps/hermes.png";
import openclawLogo from "src/assets/suggested-apps/openclaw.png";
import opencodeLogo from "src/assets/suggested-apps/opencode.png";
import { appStoreApps } from "src/components/connections/SuggestedAppData";
import UserAvatar from "src/components/UserAvatar";
import { ALBY_ACCOUNT_APP_NAME } from "src/constants";
import { cn } from "src/lib/utils";
import { App } from "src/types";
// Lightweight logo map for agents that don't have full app store entries.
// Matches on app_store_app_id metadata set when the connection was created.
const agentLogos: Record<string, string> = {
claude: claudeLogo,
goose: gooseLogo,
hermes: hermesLogo,
openclaw: openclawLogo,
cursor: cursorLogo,
codex: codexLogo,
cline: clineLogo,
gemini: geminiLogo,
opencode: opencodeLogo,
};
type Props = {
app: App;
className?: string;
@ -36,20 +13,13 @@ export default function AppAvatar({ app, className }: Props) {
if (app.name === ALBY_ACCOUNT_APP_NAME) {
return <UserAvatar className={className} />;
}
const agentLogo =
(app?.metadata?.app_store_app_id
? agentLogos[app.metadata.app_store_app_id]
: undefined) ||
Object.entries(agentLogos).find(([key]) =>
app.name.toLowerCase().includes(key)
)?.[1];
const appStoreApp = appStoreApps.find(
(suggestedApp) =>
(app?.metadata?.app_store_app_id &&
suggestedApp.id === app.metadata?.app_store_app_id) ||
app.name.includes(suggestedApp.title)
);
const image = agentLogo || appStoreApp?.logo;
const image = appStoreApp?.logo;
const gradient =
app.name

View file

@ -9,7 +9,6 @@ type Props = {
contentRight?: React.ReactNode;
breadcrumb?: boolean;
addSidebarTrigger?: boolean;
pageTitle?: string;
};
function AppHeader({
@ -18,11 +17,9 @@ function AppHeader({
description = "",
contentRight,
addSidebarTrigger = true,
pageTitle,
}: Props) {
return (
<>
{pageTitle && <title>{`${pageTitle} · Alby Hub`}</title>}
<header className="flex flex-row flex-wrap items-center border-b border-border pb-4 gap-2">
{addSidebarTrigger && <SidebarTrigger className="-ml-1 md:hidden" />}
<Separator orientation="vertical" className="mr-2 h-4 md:hidden" />

View file

@ -1,23 +1,21 @@
import {
BotIcon,
BoxIcon,
ChevronsUpDownIcon,
CreditCardIcon,
CircleHelpIcon,
HandCoinsIcon,
ChevronsUpDown,
CircleHelp,
HomeIcon,
LogOutIcon,
LogOut,
LucideIcon,
Plug2Icon,
PlugZapIcon,
SettingsIcon,
SparklesIcon,
SquareStackIcon,
Settings,
Sparkles,
SquareStack,
StarIcon,
WalletIcon,
} from "lucide-react";
import React from "react";
import { Link, NavLink, useLocation, useNavigate } from "react-router";
import { Link, NavLink, useLocation, useNavigate } from "react-router-dom";
import ExternalLink from "src/components/ExternalLink";
import { AlbyIcon } from "src/components/icons/Alby";
@ -25,7 +23,6 @@ import { AlbyHubIcon } from "src/components/icons/AlbyHubIcon";
import { AlbyHubLogo } from "src/components/icons/AlbyHubLogo";
import { ProBadge } from "src/components/ProBadge";
import SidebarHint from "src/components/SidebarHint";
import { Badge } from "src/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
@ -54,10 +51,6 @@ import { useInfo } from "src/hooks/useInfo";
import { deleteAuthToken } from "src/lib/auth";
import { isHttpMode } from "src/utils/isHttpMode";
function isPathActive(pathname: string, url: string) {
return pathname === url || pathname.startsWith(`${url}/`);
}
export function AppSidebar() {
const { data: albyMe } = useAlbyMe();
@ -95,24 +88,13 @@ export function AppSidebar() {
{
title: "Sub-wallets",
url: "/sub-wallets",
icon: SquareStackIcon,
icon: SquareStack,
},
{
title: "Connections",
url: "/apps",
icon: Plug2Icon,
},
{
title: "AI & Agents",
url: "/ai",
icon: BotIcon,
},
{
title: "Cards",
url: "/cards",
icon: CreditCardIcon,
badge: "NEW",
},
],
navSecondary: [
...(hasChannelManagement
@ -127,12 +109,12 @@ export function AppSidebar() {
{
title: "Settings",
url: "/settings",
icon: SettingsIcon,
icon: Settings,
},
{
title: "Earn",
url: "/earn",
icon: HandCoinsIcon,
title: "Review & Earn",
url: "/review-earn",
icon: StarIcon,
},
],
};
@ -145,7 +127,7 @@ export function AppSidebar() {
<SidebarHeader>
<div className="p-2 flex flex-row items-center justify-between">
<Link to="/home" onClick={() => setOpenMobile(false)}>
<AlbyHubLogo className="h-7" />
<AlbyHubLogo className="w-32" />
</Link>
<div className="flex gap-3 items-center">
<HealthIndicator />
@ -160,7 +142,7 @@ export function AppSidebar() {
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isPathActive(location.pathname, item.url)}
isActive={location.pathname === item.url}
>
<Link
to={item.url}
@ -170,11 +152,6 @@ export function AppSidebar() {
>
<item.icon />
<span>{item.title}</span>
{item.badge && (
<Badge className="ml-auto text-[10px] px-1.5 py-0">
{item.badge}
</Badge>
)}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
@ -223,7 +200,7 @@ export function AppSidebar() {
</div>
</>
)}
<ChevronsUpDownIcon className="ml-auto size-4" />
<ChevronsUpDown className="ml-auto size-4" />
</DropdownMenuTrigger>
</SidebarMenuButton>
<DropdownMenuContent
@ -278,7 +255,7 @@ export function AppSidebar() {
<>
<UpgradeDialog>
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<SparklesIcon />
<Sparkles />
Upgrade to Pro
</DropdownMenuItem>
</UpgradeDialog>
@ -288,7 +265,7 @@ export function AppSidebar() {
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={logout}>
<LogOutIcon className="size-4" />
<LogOut className="size-4" />
Log out
</DropdownMenuItem>
</>
@ -323,7 +300,7 @@ export function NavSecondary({
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
isActive={isPathActive(location.pathname, item.url)}
isActive={location.pathname === item.url}
>
<NavLink
to={item.url}
@ -341,7 +318,7 @@ export function NavSecondary({
<SidebarMenuItem>
<SidebarMenuButton asChild>
<ExternalLink to="https://support.getalby.com">
<CircleHelpIcon className="h-4 w-4" />
<CircleHelp className="h-4 w-4" />
Help
</ExternalLink>
</SidebarMenuButton>

View file

@ -1,6 +1,6 @@
import { RefreshCwIcon } from "lucide-react";
import React, { useState } from "react";
import { useNavigate } from "react-router";
import { useNavigate } from "react-router-dom";
import Container from "src/components/Container";
import PasswordInput from "src/components/password/PasswordInput";
import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
@ -62,7 +62,6 @@ function AuthCodeForm({ url }: AuthCodeFormProps) {
<div className="grid gap-5">
<TwoColumnLayoutHeader
title="Connect your Alby Account"
pageTitle="Connect your Alby Account"
description="A new window will open. Sign in with your Alby Account, copy the Authorization Code, and paste it here."
/>
{!hasRequestedCode && (

View file

@ -1,5 +1,5 @@
import { XIcon } from "lucide-react";
import { Link } from "react-router";
import { Link } from "react-router-dom";
import ExternalLink from "src/components/ExternalLink";
import { useAlbyInfo } from "src/hooks/useAlbyInfo";

View file

@ -1,5 +1,5 @@
import { Fragment } from "react";
import { Link, useMatches } from "react-router";
import { Link, useMatches } from "react-router-dom";
import {
Breadcrumb,
BreadcrumbItem,

View file

@ -2,77 +2,97 @@ import React from "react";
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
import { Input } from "src/components/ui/input";
import { Label } from "src/components/ui/label";
import { cn } from "src/lib/utils";
import { budgetOptionsSat as defaultBudgetOptionsSat } from "src/types";
import { budgetOptions as defaultBudgetOptions } from "src/types";
function BudgetAmountSelect({
valueSat,
value,
onChange,
minAmountSat,
budgetOptionsSat = defaultBudgetOptionsSat,
minAmount,
budgetOptions = defaultBudgetOptions,
}: {
valueSat: number;
value: number;
onChange: (value: number) => void;
minAmountSat?: number;
budgetOptionsSat?: typeof defaultBudgetOptionsSat;
minAmount?: number;
budgetOptions?: typeof defaultBudgetOptions;
}) {
const [inputValue, setInputValue] = React.useState(
valueSat ? String(valueSat) : ""
const [customBudget, setCustomBudget] = React.useState(
value ? !Object.values(budgetOptions).includes(value) : false
);
React.useEffect(() => {
setInputValue(valueSat ? String(valueSat) : "");
}, [valueSat]);
return (
<>
<div className="grid grid-cols-3 gap-3 text-xs mb-3">
{Object.keys(budgetOptionsSat)
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 text-xs mb-4">
{Object.keys(budgetOptions)
.filter(
(budget) =>
!minAmountSat || budgetOptionsSat[budget] >= minAmountSat
!minAmount ||
!budgetOptions[budget] ||
budgetOptions[budget] > minAmount
)
.map((budget) => (
<button
type="button"
key={budget}
onClick={() => {
onChange(budgetOptionsSat[budget]);
}}
className={cn(
"cursor-pointer rounded text-nowrap border-2 text-center p-3 py-4 slashed-zero",
valueSat === budgetOptionsSat[budget]
? "border-primary"
: "border-muted"
)}
>
<FormattedBitcoinAmount
amountMsat={budgetOptionsSat[budget] * 1000}
/>
<FormattedFiatAmount
className="text-xs"
showApprox
amountSat={budgetOptionsSat[budget]}
/>
</button>
))}
</div>
<div className="mb-3">
<Input
id="budget"
name="budget"
type="number"
min={1}
required
placeholder="Custom amount in sats"
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
const n = parseInt(e.target.value);
onChange(!isNaN(n) && n > 0 ? n : 0);
.map((budget) => {
return (
<button
type="button"
key={budget}
onClick={() => {
setCustomBudget(false);
onChange(budgetOptions[budget]);
}}
className={cn(
"cursor-pointer rounded text-nowrap border-2 text-center p-2 py-4 slashed-zero",
!customBudget && value == budgetOptions[budget]
? "border-primary"
: "border-muted"
)}
>
{budgetOptions[budget] ? (
<>
<FormattedBitcoinAmount
amount={budgetOptions[budget] * 1000}
/>
<FormattedFiatAmount
className="text-xs"
showApprox
amount={budgetOptions[budget]}
/>
</>
) : (
budget
)}
</button>
);
})}
<button
onClick={() => {
setCustomBudget(true);
onChange(0);
}}
/>
className={cn(
"cursor-pointer rounded border-2 text-center p-4 dark:text-white",
customBudget ? "border-primary" : "border-muted"
)}
>
Custom
</button>
</div>
{customBudget && (
<div className="grid gap-2 mb-5">
<Label htmlFor="budget">Custom budget amount (sats)</Label>
<Input
id="budget"
name="budget"
type="number"
required
autoFocus
min={minAmount || 1}
value={value || ""}
onChange={(e) => {
onChange(parseInt(e.target.value));
}}
/>
</div>
)}
</>
);
}

View file

@ -1,3 +1,4 @@
import { XIcon } from "lucide-react";
import React from "react";
import { Label } from "src/components/ui/label";
import {
@ -12,28 +13,40 @@ import { BudgetRenewalType, validBudgetRenewals } from "src/types";
interface BudgetRenewalProps {
value: BudgetRenewalType;
onChange: (value: BudgetRenewalType) => void;
onClose?: () => void;
}
const BudgetRenewalSelect: React.FC<BudgetRenewalProps> = ({
value,
onChange,
onClose,
}) => {
return (
<div className="flex gap-3 items-center mb-3">
<Label htmlFor="budget-renewal">Renewal</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="budget-renewal" className="w-[150px] capitalize">
<SelectValue placeholder={value} />
</SelectTrigger>
<SelectContent className="capitalize">
{validBudgetRenewals.map((renewalOption) => (
<SelectItem key={renewalOption} value={renewalOption}>
{renewalOption}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<>
<Label htmlFor="budget-renewal" className="block mb-2">
Budget Renewal
</Label>
<div className="flex gap-2 items-center text-muted-foreground mb-4 text-sm">
<Select value={value} onValueChange={onChange}>
<SelectTrigger id="budget-renewal" className="w-[150px] capitalize">
<SelectValue placeholder={value} />
</SelectTrigger>
<SelectContent className="capitalize">
{validBudgetRenewals.map((renewalOption) => (
<SelectItem key={renewalOption} value={renewalOption}>
{renewalOption}
</SelectItem>
))}
</SelectContent>
{onClose && (
<XIcon
className="cursor-pointer w-4 text-muted-foreground"
onClick={onClose}
/>
)}
</Select>
</div>
</>
);
};

View file

@ -0,0 +1,32 @@
import { ChevronRightIcon } from "lucide-react";
import { ReactElement } from "react";
import { Link } from "react-router-dom";
import { Card } from "src/components/ui/card";
type Props = {
title: string | ReactElement;
description: string | ReactElement;
to: string;
};
function CardButton({ title, description, to }: Props) {
return (
<Link to={to}>
<Card className="p-4 shadow-none hover:bg-accent">
<div className="flex flex-row justify-between items-center">
<div>
<div className="font-medium flex flex-row items-center gap-2">
{title}
</div>
<div className="text-muted-foreground text-sm">{description}</div>
</div>
<div>
<ChevronRightIcon />
</div>
</div>
</Card>
</Link>
);
}
export default CardButton;

View file

@ -118,24 +118,19 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
<AlertDescription>
<div>
Closing this channel will move{" "}
<FormattedBitcoinAmount
amountMsat={channel.localBalanceMsat}
/>{" "}
in this channel to your on-chain balance and reduce your
<FormattedBitcoinAmount amount={channel.localBalance} /> in
this channel to your on-chain balance and reduce your
receive limit by{" "}
<FormattedBitcoinAmount
amountMsat={channel.remoteBalanceMsat}
/>
.
<FormattedBitcoinAmount amount={channel.remoteBalance} />.
</div>
</AlertDescription>
</Alert>
<div>
<p className="font-medium text-foreground">Node ID</p>
<p className="text-primary font-medium">Node ID</p>
<p className="break-all">{channel.remotePubkey}</p>
</div>
<div className="mt-4">
<p className="font-medium text-foreground">Channel ID</p>
<p className="text-primary font-medium">Channel ID</p>
<p className="break-all">{channel.id}</p>
</div>
</AlertDialogDescription>
@ -182,7 +177,7 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
<div className="grid gap-1.5">
<Label
htmlFor="normal"
className="text-foreground cursor-pointer"
className="text-primary font-medium cursor-pointer"
>
Normal Close (Recommended)
</Label>
@ -203,7 +198,7 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
<div className="grid gap-1.5">
<Label
htmlFor="force"
className="text-foreground cursor-pointer"
className="text-primary font-medium cursor-pointer"
>
Force Close
</Label>
@ -236,9 +231,7 @@ export function CloseChannelDialogContent({ alias, channel }: Props) {
<AlertDialogHeader>
<AlertDialogTitle>Channel closed successfully</AlertDialogTitle>
<AlertDialogDescription className="text-left">
<p className="font-medium text-foreground">
Funding Transaction Id
</p>
<p className="text-primary font-medium">Funding Transaction Id</p>
<div className="flex items-center justify-between gap-4">
<p className="break-all">{fundingTxId}</p>
<CopyIcon

View file

@ -1,28 +1,28 @@
import {
ArrowUpDownIcon,
Code2Icon,
CreditCardIcon,
FileSignatureIcon,
FileTextIcon,
HomeIcon,
InfoIcon,
LayoutGridIcon,
LinkIcon,
NetworkIcon,
PlugIcon,
QrCodeIcon,
RefreshCwIcon,
SendIcon,
SettingsIcon,
ShieldIcon,
ShuffleIcon,
SquareStackIcon,
UserIcon,
UserPlus2Icon,
WalletIcon,
ArrowUpDown,
Code2,
CreditCard,
FileSignature,
FileText,
Home,
Info,
LayoutGrid,
Link,
Network,
Plug,
QrCode,
RefreshCw,
Send,
Settings,
Shield,
Shuffle,
SquareStack,
User,
UserPlus2,
Wallet,
} from "lucide-react";
import * as React from "react";
import { useNavigate } from "react-router";
import { useNavigate } from "react-router-dom";
import AppAvatar from "src/components/AppAvatar";
import {
@ -79,11 +79,11 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
onSelect={() => runCommand(() => navigate("/home"))}
keywords={["dashboard"]}
>
<HomeIcon />
<Home />
<span>Home</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => navigate("/wallet"))}>
<WalletIcon />
<Wallet />
<span>Wallet</span>
</CommandItem>
<CommandItem
@ -91,30 +91,30 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
runCommand(() => navigate("/apps?tab=connected-apps"))
}
>
<PlugIcon />
<Plug />
<span>Connected Apps</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/sub-wallets"))}
>
<CreditCardIcon />
<CreditCard />
<span>Sub-wallets</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/channels"))}
keywords={["node", "liquidity", "channels"]}
>
<NetworkIcon />
<Network />
<span>Node</span>
</CommandItem>
<CommandItem onSelect={() => runCommand(() => navigate("/peers"))}>
<NetworkIcon />
<Network />
<span>Peers</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/apps?tab=app-store"))}
>
<LayoutGridIcon />
<LayoutGrid />
<span>App Store</span>
</CommandItem>
</CommandGroup>
@ -123,25 +123,25 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
<CommandItem
onSelect={() => runCommand(() => navigate("/wallet/send"))}
>
<SendIcon />
<Send />
<span>Send Payment</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/wallet/receive"))}
>
<QrCodeIcon />
<QrCode />
<span>Receive Payment</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/wallet/swap"))}
>
<ShuffleIcon />
<Shuffle />
<span>Swap</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/wallet/swap/auto"))}
>
<RefreshCwIcon />
<RefreshCw />
<span>Auto Swap</span>
</CommandItem>
<CommandItem
@ -149,21 +149,21 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
runCommand(() => navigate("/wallet/receive/invoice"))
}
>
<FileTextIcon />
<FileText />
<span>Create Invoice</span>
</CommandItem>
<CommandItem
onSelect={() =>
runCommand(() => navigate("/wallet/receive?type=onchain"))
runCommand(() => navigate("/wallet/receive/onchain"))
}
>
<LinkIcon />
<Link />
<span>Receive On-chain</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/wallet/sign-message"))}
>
<FileSignatureIcon />
<FileSignature />
<span>Sign Message</span>
</CommandItem>
</CommandGroup>
@ -173,13 +173,13 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
onSelect={() => runCommand(() => navigate("/settings"))}
keywords={["theme", "fiat", "currency", "dark"]}
>
<SettingsIcon />
<Settings />
<span>Settings</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/settings/backup"))}
>
<ShieldIcon />
<Shield />
<span>Backup</span>
</CommandItem>
<CommandItem
@ -187,47 +187,47 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
runCommand(() => navigate("/settings/alby-account"))
}
>
<UserIcon />
<User />
<span>Alby Account</span>
</CommandItem>
<CommandItem
keywords={["info"]}
onSelect={() => runCommand(() => navigate("/settings/about"))}
>
<InfoIcon />
<Info />
<span>About</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/settings/developer"))}
>
<Code2Icon />
<Code2 />
<span>Developer Settings</span>
</CommandItem>
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Quick Actions">
<CommandItem onSelect={() => runCommand(() => navigate("/apps/new"))}>
<PlugIcon />
<Plug />
<span>Connect New App</span>
</CommandItem>
<CommandItem
keywords={["New Sub-Wallet"]}
onSelect={() => runCommand(() => navigate("/sub-wallets/new"))}
>
<SquareStackIcon />
<SquareStack />
<span>Create Sub-wallet</span>
</CommandItem>
<CommandItem
keywords={["New Channel"]}
onSelect={() => runCommand(() => navigate("/channels/incoming"))}
>
<ArrowUpDownIcon />
<ArrowUpDown />
<span>Open Channel</span>
</CommandItem>
<CommandItem
onSelect={() => runCommand(() => navigate("/peers/new"))}
>
<UserPlus2Icon />
<UserPlus2 />
<span>Connect Peer</span>
</CommandItem>
</CommandGroup>

View file

@ -1,32 +0,0 @@
import { CoinsIcon, ExternalLinkIcon } from "lucide-react";
import { FixedFloatButton } from "src/components/FixedFloatButton";
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
type CryptoSwapAlertProps = {
className?: string;
};
export function CryptoSwapAlert({ className }: CryptoSwapAlertProps) {
return (
<Alert className={className}>
<AlertTitle className="flex items-center gap-2">
<CoinsIcon className="h-4 w-4" />
Looking to pay to other Cryptocurrency?
</AlertTitle>
<AlertDescription className="text-xs gap-2 mt-1">
<p>
If you are trying to pay a non-Bitcoin payment destination, use
FixedFloat to swap and complete the payment across 70+ supported
cryptocurrencies.
</p>
<FixedFloatButton
from="BTCLN"
variant="outline"
className="text-foreground"
>
Pay with FixedFloat
<ExternalLinkIcon className="size-4" />
</FixedFloatButton>
</AlertDescription>
</Alert>
);
}

View file

@ -1,528 +0,0 @@
import * as React from "react";
import { toast } from "sonner";
import {
Field,
FieldDescription,
FieldError,
FieldLabel,
} from "src/components/ui/field";
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupInput,
} from "src/components/ui/input-group";
import { Skeleton } from "src/components/ui/skeleton";
import { BITCOIN_DISPLAY_FORMAT_BIP177 } from "src/constants";
import { useBitcoinRate } from "src/hooks/useBitcoinRate";
import { useInfo } from "src/hooks/useInfo";
import { cn } from "src/lib/utils";
type CurrencyInputMode = "bitcoin" | "fiat";
type BitcoinDenomination = "sats" | "btc";
export type CurrencyInputContextRow = {
label: string;
amountSat?: number | null;
value?: React.ReactNode;
};
type CurrencyInputFieldProps = Omit<
React.ComponentProps<typeof InputGroupInput>,
"max" | "min" | "onChange" | "step" | "type" | "value"
> & {
contextRows?: CurrencyInputContextRow[];
description?: React.ReactNode;
error?: React.ReactNode;
label?: React.ReactNode;
maxSat?: number;
minSat?: number;
onValueSatChange: (valueSat: string) => void;
valueSat: string;
};
const SATS_PER_BTC = 100_000_000;
function getNumericValue(value: string | number | null | undefined) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function getCurrencyFractionDigits(currency: string) {
try {
return new Intl.NumberFormat("en-US", {
currency,
style: "currency",
}).resolvedOptions().maximumFractionDigits;
} catch {
return 2;
}
}
function getCurrencySymbol(currency: string) {
try {
return (
new Intl.NumberFormat("en-US", {
currency,
style: "currency",
})
.formatToParts(0)
.find((part) => part.type === "currency")?.value || currency
);
} catch {
return currency;
}
}
function formatFiatValue(
amountSat: string | number | undefined,
rate: number | undefined,
currency: string | undefined
) {
if (!rate || !currency) {
return null;
}
return new Intl.NumberFormat("en-US", {
currency,
style: "currency",
}).format((getNumericValue(amountSat) / SATS_PER_BTC) * rate);
}
function formatFiatInput(amountSat: string, rate: number, currency: string) {
const fractionDigits = getCurrencyFractionDigits(currency);
const amountFiat = (getNumericValue(amountSat) / SATS_PER_BTC) * rate;
if (!amountFiat) {
return "";
}
return amountFiat.toFixed(fractionDigits);
}
function formatBitcoinValue(
amountSat: string | number | null | undefined,
displayFormat: string | undefined,
denomination: BitcoinDenomination = "sats"
) {
const { amount, unit } = formatBitcoinValueParts(
amountSat,
displayFormat,
denomination
);
if (unit === "₿") {
return `${unit}${amount}`;
}
return `${amount} ${unit}`;
}
function formatBitcoinValueParts(
amountSat: string | number | null | undefined,
displayFormat: string | undefined,
denomination: BitcoinDenomination = "sats"
) {
if (denomination === "btc") {
return {
amount: formatBtcDisplay(amountSat),
unit: "BTC",
};
}
const formattedAmount = new Intl.NumberFormat().format(
Math.floor(getNumericValue(amountSat))
);
if (displayFormat === BITCOIN_DISPLAY_FORMAT_BIP177) {
return {
amount: formattedAmount,
unit: "₿",
};
}
return {
amount: formattedAmount,
unit: "sats",
};
}
function BitcoinValueText({
amountSat,
denomination,
displayFormat,
}: {
amountSat: string | number | null | undefined;
denomination: BitcoinDenomination;
displayFormat: string | undefined;
}) {
const { amount, unit } = formatBitcoinValueParts(
amountSat,
displayFormat,
denomination
);
return (
<span className="inline-flex min-w-0 items-center justify-end gap-1">
{unit === "₿" && <span>{unit}</span>}
<span className="min-w-0 truncate">{amount}</span>
{unit !== "₿" && <span>{unit}</span>}
</span>
);
}
function formatBtcDisplay(amountSat: string | number | null | undefined) {
return (getNumericValue(amountSat) / SATS_PER_BTC).toFixed(8);
}
function formatBtcInput(amountSat: string | number | null | undefined) {
const amount = getNumericValue(amountSat);
if (!amount) {
return "";
}
return (amount / SATS_PER_BTC).toFixed(8);
}
export function CurrencyInputField({
className,
contextRows,
description,
disabled,
error,
id,
label = "Amount",
maxSat,
minSat,
onValueSatChange,
required,
valueSat,
...props
}: CurrencyInputFieldProps) {
const generatedId = React.useId();
const { data: info } = useInfo();
const { data: bitcoinRate, error: bitcoinRateError } = useBitcoinRate(
info?.currency
);
const [mode, setMode] = React.useState<CurrencyInputMode>("bitcoin");
const [fiatValue, setFiatValue] = React.useState("");
const [bitcoinDenomination, setBitcoinDenomination] =
React.useState<BitcoinDenomination>("sats");
const [btcValue, setBtcValue] = React.useState("");
const currency = info?.currency || "USD";
const rate = bitcoinRate?.rate_float;
const canUseFiat = currency !== "SATS" && !!rate && !bitcoinRateError;
const bitcoinUnit =
info?.bitcoinDisplayFormat === BITCOIN_DISPLAY_FORMAT_BIP177 ? "₿" : "sats";
const invalid =
props["aria-invalid"] === true ||
props["aria-invalid"] === "true" ||
!!error;
const inputId = id || generatedId;
const isFiatMode = mode === "fiat";
const isBtcDenominated = bitcoinDenomination === "btc";
const inputValue = isFiatMode
? fiatValue
: isBtcDenominated
? btcValue
: valueSat;
const alternateBitcoinValue = formatBitcoinValueParts(
valueSat,
info?.bitcoinDisplayFormat,
bitcoinDenomination
);
const alternateValue = isFiatMode
? formatBitcoinValue(
valueSat,
info?.bitcoinDisplayFormat,
bitcoinDenomination
)
: formatFiatValue(valueSat, rate, currency);
React.useEffect(() => {
if (mode === "fiat" && !valueSat) {
setFiatValue("");
}
}, [mode, valueSat]);
React.useEffect(() => {
if (mode === "bitcoin" && isBtcDenominated && !valueSat) {
setBtcValue("");
}
}, [isBtcDenominated, mode, valueSat]);
function handleToggleMode() {
if (disabled) {
return;
}
if (mode === "bitcoin") {
if (!canUseFiat) {
return;
}
setFiatValue(formatFiatInput(valueSat, rate, currency));
setMode("fiat");
return;
}
if (isBtcDenominated) {
setBtcValue(formatBtcInput(valueSat));
}
setMode("bitcoin");
}
function handleAlternateValueClick() {
if (disabled || isFiatMode || !canUseFiat) {
return;
}
handleToggleMode();
}
function handleToggleBitcoinDenomination() {
if (disabled) {
return;
}
if (isBtcDenominated) {
setBitcoinDenomination("sats");
return;
}
setBtcValue(formatBtcInput(valueSat));
setBitcoinDenomination("btc");
}
function handleChangeMode(event: React.ChangeEvent<HTMLInputElement>) {
// clear any custom validity set via onInvalid so the field re-validates
// on the next submit
event.currentTarget.setCustomValidity("");
const nextValue = event.target.value.trim();
if (mode === "bitcoin") {
if (!isBtcDenominated && nextValue.includes(".")) {
setBitcoinDenomination("btc");
setBtcValue(nextValue);
toast("Switched to BTC for decimal amount");
if (!nextValue) {
onValueSatChange("");
return;
}
const amountBtc = Number(nextValue);
if (!Number.isFinite(amountBtc)) {
onValueSatChange("");
return;
}
onValueSatChange(
Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString()
);
return;
}
if (isBtcDenominated) {
setBtcValue(nextValue);
if (!nextValue) {
onValueSatChange("");
return;
}
const amountBtc = Number(nextValue);
if (!Number.isFinite(amountBtc)) {
onValueSatChange("");
return;
}
onValueSatChange(
Math.max(0, Math.round(amountBtc * SATS_PER_BTC)).toString()
);
return;
}
onValueSatChange(nextValue);
return;
}
setFiatValue(nextValue);
if (!nextValue || !rate) {
onValueSatChange("");
return;
}
const amountFiat = Number(nextValue);
if (!Number.isFinite(amountFiat)) {
onValueSatChange("");
return;
}
onValueSatChange(
Math.max(0, Math.round((amountFiat / rate) * SATS_PER_BTC)).toString()
);
}
function getModeBound(amountSat: number | undefined) {
if (amountSat === undefined) {
return undefined;
}
if (!isFiatMode) {
if (isBtcDenominated) {
return amountSat / SATS_PER_BTC;
}
return amountSat;
}
if (!rate) {
return amountSat;
}
return ((amountSat / SATS_PER_BTC) * rate).toFixed(
getCurrencyFractionDigits(currency)
);
}
return (
<Field
className={cn("w-full min-w-0", className)}
data-disabled={disabled || undefined}
data-invalid={invalid || undefined}
>
{label && <FieldLabel htmlFor={inputId}>{label}</FieldLabel>}
<InputGroup className="h-9 min-w-0 overflow-hidden has-[>[data-align=inline-start]]:[&>input]:pl-1">
<InputGroupInput
{...props}
id={inputId}
aria-invalid={invalid || undefined}
autoComplete="off"
className={cn(
"sensitive slashed-zero min-w-0 [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
)}
disabled={disabled}
inputMode="decimal"
max={getModeBound(maxSat)}
min={getModeBound(minSat)}
onChange={handleChangeMode}
placeholder={
isFiatMode ? "0.00" : isBtcDenominated ? "0.00000000" : "0"
}
required={required}
step={isFiatMode ? "any" : isBtcDenominated ? 0.00000001 : 1}
type="number"
value={inputValue}
/>
<InputGroupAddon align="inline-start">
{isFiatMode ? (
<InputGroupButton
aria-label="Enter amount in bitcoin"
disabled={disabled}
onClick={handleToggleMode}
size="xs"
className="h-full rounded-none bg-transparent pl-2 pr-0 text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
title="Enter amount in bitcoin"
>
{getCurrencySymbol(currency)}
</InputGroupButton>
) : (
<InputGroupButton
aria-label={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
aria-pressed={isBtcDenominated}
disabled={disabled}
onClick={handleToggleBitcoinDenomination}
size="xs"
className="h-full rounded-none bg-transparent pl-2 pr-0 text-muted-foreground hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
title={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
>
{isBtcDenominated ? "BTC" : bitcoinUnit}
</InputGroupButton>
)}
</InputGroupAddon>
<InputGroupAddon
align="inline-end"
className="mr-0 min-w-0 self-stretch py-0 pr-4"
>
{isFiatMode ? (
<InputGroupButton
aria-label={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
aria-pressed={isBtcDenominated}
disabled={disabled}
onClick={handleToggleBitcoinDenomination}
size="xs"
className="sensitive slashed-zero h-full min-w-0 justify-end truncate rounded-none bg-transparent px-0.5 text-muted-foreground tabular-nums hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0"
title={
isBtcDenominated
? "Display bitcoin amounts in satoshis"
: "Display bitcoin amounts in BTC"
}
>
{alternateBitcoinValue.unit === "₿" && (
<span>{alternateBitcoinValue.unit}</span>
)}
<span className="min-w-0 truncate">
{alternateBitcoinValue.amount}
</span>
{alternateBitcoinValue.unit !== "₿" && (
<span>{alternateBitcoinValue.unit}</span>
)}
</InputGroupButton>
) : (
<InputGroupButton
aria-label="Enter amount in fiat"
disabled={disabled || !canUseFiat}
onClick={handleAlternateValueClick}
size="xs"
className="sensitive slashed-zero h-full min-w-0 max-w-28 justify-end truncate rounded-none bg-transparent px-1 text-muted-foreground tabular-nums hover:bg-transparent hover:text-foreground focus-visible:text-foreground focus-visible:ring-0 sm:max-w-none"
title="Enter amount in fiat"
>
{alternateValue ?? <Skeleton className="h-4 w-16" />}
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
{!!contextRows?.length && (
<div className="flex min-w-0 cursor-default flex-col gap-1 text-sm text-muted-foreground">
{contextRows.map((row) => (
<div
className="flex min-w-0 items-center justify-between gap-3"
key={row.label}
>
<span className="truncate">{row.label}:</span>
<span className="sensitive slashed-zero min-w-0 max-w-[55%] truncate text-right tabular-nums">
{row.value ?? (
<BitcoinValueText
amountSat={row.amountSat}
displayFormat={info?.bitcoinDisplayFormat}
denomination={bitcoinDenomination}
/>
)}
</span>
</div>
))}
</div>
)}
{description && <FieldDescription>{description}</FieldDescription>}
{error && <FieldError>{error}</FieldError>}
</Field>
);
}

View file

@ -53,7 +53,7 @@ export function DisconnectPeerDialogContent({ peer }: Props) {
Are you sure you wish to disconnect from{" "}
{peerDetails?.alias || "this peer"}?
</p>
<p className="font-medium text-foreground mt-4">Peer Pubkey</p>
<p className="text-primary font-medium mt-4">Peer Pubkey</p>
<p className="break-all">{peer.nodeId}</p>
</div>
</AlertDialogDescription>

View file

@ -3,44 +3,37 @@ import React from "react";
import { LinkButton } from "src/components/ui/custom/link-button";
import { cn } from "src/lib/utils";
type Variant = "dashed" | "muted" | "none";
type Props = {
interface Props {
icon: LucideIcon;
title: string;
description: string;
variant?: Variant;
} & (
| { buttonText: string; buttonLink: string }
| { buttonText?: never; buttonLink?: never }
);
const variantClasses: Record<Variant, string> = {
dashed: "shadow-xs border border-dashed",
muted: "bg-muted",
none: "",
};
buttonText: string;
buttonLink: string;
showButton?: boolean;
showBorder?: boolean;
}
const EmptyState: React.FC<Props> = ({
icon: Icon,
title: message,
description: subMessage,
variant = "muted",
buttonText,
buttonLink,
showButton = true,
showBorder = true,
}) => {
return (
<div
className={cn(
"flex flex-1 items-center justify-center rounded-lg p-8",
variantClasses[variant]
showBorder && "shadow-xs border border-dashed"
)}
>
<div className="flex flex-col items-center gap-1 text-center max-w-sm">
<Icon className="w-10 h-10 text-muted-foreground" />
<h3 className="mt-4 text-lg font-semibold">{message}</h3>
<p className="text-sm text-muted-foreground">{subMessage}</p>
{buttonText && buttonLink && (
{showButton && (
<LinkButton to={buttonLink} className="mt-4">
{buttonText}
</LinkButton>

View file

@ -32,7 +32,7 @@ export function ExecuteCustomNodeCommandDialogContent({
null,
2
);
} catch {
} catch (error) {
// ignore unexpected json
}
@ -60,24 +60,17 @@ export function ExecuteCustomNodeCommandDialogContent({
}
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
executeCommand();
};
return (
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Execute Custom Node Command</AlertDialogTitle>
<AlertDialogDescription className="text-left">
<form id="execute-command-form" onSubmit={handleSubmit}>
<Textarea
className="h-36 font-mono"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="commandname --arg1=value1"
/>
</form>
<Textarea
className="h-36 font-mono"
value={command}
onChange={(e) => setCommand(e.target.value)}
placeholder="commandname --arg1=value1"
/>
<p className="mt-2">Available commands</p>
<Textarea
readOnly
@ -87,13 +80,11 @@ export function ExecuteCustomNodeCommandDialogContent({
/>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogFooter className="mt-4">
<AlertDialogCancel onClick={() => setCommand("")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction type="submit" form="execute-command-form">
Execute
</AlertDialogAction>
<AlertDialogAction onClick={executeCommand}>Execute</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
);

Some files were not shown because too many files have changed in this diff Show more