Compare commits
No commits in common. "master" and "v1.21.1" have entirely different histories.
13
.env.example
|
|
@ -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
|
||||
2
.github/dependabot.yaml
vendored
|
|
@ -4,8 +4,6 @@ updates:
|
|||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
ignore:
|
||||
- dependency-name: google.golang.org/grpc
|
||||
- package-ecosystem: npm
|
||||
directory: /frontend
|
||||
schedule:
|
||||
|
|
|
|||
3
.github/workflows/build-docker.yaml
vendored
|
|
@ -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
|
||||
|
|
|
|||
34
.github/workflows/http.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
2
.github/workflows/linting.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
34
.github/workflows/wails.yml
vendored
|
|
@ -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
|
|
@ -30,5 +30,4 @@ glalby
|
|||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
albyhub-data
|
||||
.claude/worktrees
|
||||
albyhub-data
|
||||
232
AGENTS.md
|
|
@ -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`.
|
||||
|
|
@ -1 +0,0 @@
|
|||
AGENTS.md
|
||||
|
|
@ -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
|
||||
|
|
|
|||
49
README.md
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
231
api/backup.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
53
api/lsp.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
496
api/models.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
235
db/db_migrate.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
25
db/models.go
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -4,7 +4,7 @@
|
|||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
BIN
frontend/public/fonts/Inter-italic.var.woff2
Normal file
BIN
frontend/public/fonts/Inter-roman.var.woff2
Normal 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"
|
||||
>
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
BIN
frontend/src/assets/suggested-apps/paper-scissors-hodl.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 998 B |
|
Before Width: | Height: | Size: 2.3 KiB |
BIN
frontend/src/assets/suggested-apps/satoshis-auction-house.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Fragment } from "react";
|
||||
import { Link, useMatches } from "react-router";
|
||||
import { Link, useMatches } from "react-router-dom";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
32
frontend/src/components/CardButton.tsx
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||