Compare commits
136 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8ef0e70e0 | ||
|
|
d2cebc5c6f | ||
|
|
edd283cdb2 | ||
|
|
1c7abc62e9 | ||
|
|
1c7c026e54 | ||
|
|
5125418188 | ||
|
|
363c22f6d3 | ||
|
|
3b3c37dd0c | ||
|
|
979644cf68 | ||
|
|
f4010e239a | ||
|
|
4c5bef42c6 | ||
|
|
0c24ab84c1 | ||
|
|
459b825cfb | ||
|
|
e3278beffb | ||
|
|
852b30fa02 | ||
|
|
ffc705e536 | ||
|
|
d3847fdaae | ||
|
|
7c7dfa6876 | ||
|
|
621db07fc8 | ||
|
|
4402d2fff8 | ||
|
|
5f9a88843c | ||
|
|
3d22993389 | ||
|
|
0b0cbbd985 | ||
|
|
fa5cc3511e | ||
|
|
037765794d | ||
|
|
56d118a851 | ||
|
|
ffee8cbcbe | ||
|
|
6d0cb6fd2c | ||
|
|
d198b19bef | ||
|
|
5f4e52bd88 | ||
|
|
6175489cb0 | ||
|
|
bdce8fe8d2 | ||
|
|
d94f6933f5 | ||
|
|
35d666d469 | ||
|
|
3b3e784fa6 | ||
|
|
fbaff5d8a0 | ||
|
|
355454a4cc | ||
|
|
806dfd4e1b | ||
|
|
d3455eee7d | ||
|
|
9971aa1ac8 | ||
|
|
43d37d75d1 | ||
|
|
8a9ba49807 | ||
|
|
4484046ff7 | ||
|
|
b051e5eb5f | ||
|
|
2bafad7a6c | ||
|
|
8f9d6f73f3 | ||
|
|
ec2ec911be | ||
|
|
816e0dda6e | ||
|
|
417cb16d97 | ||
|
|
5b49783478 | ||
|
|
98288463de | ||
|
|
857f145784 | ||
|
|
c99ca51ad5 | ||
|
|
fa03a9ba70 | ||
|
|
e0c173ae1e | ||
|
|
bf0ebe1f33 | ||
|
|
32af89bc8c | ||
|
|
ce46a0f8a0 | ||
|
|
be17bc4e26 | ||
|
|
afdb842a54 | ||
|
|
754acfc9df | ||
|
|
005ee814a8 | ||
|
|
b6bce030d0 | ||
|
|
e6fcc3e3c1 | ||
|
|
270d23d273 | ||
|
|
b70f40e42a | ||
|
|
222031a97e | ||
|
|
bf9c346a98 | ||
|
|
b1c0d4eac8 | ||
|
|
a21c320fde | ||
|
|
864c841c07 | ||
|
|
d7ebee5058 | ||
|
|
2f258138bc | ||
|
|
5249ebe7db | ||
|
|
cc429c4553 | ||
|
|
d8faf09109 | ||
|
|
b55978d7bc | ||
|
|
e5dc19ae68 | ||
|
|
98e7d987bb | ||
|
|
c6f45e75e3 | ||
|
|
3453b69a1c | ||
|
|
8fc5cb25b4 | ||
|
|
d1636cbcce | ||
|
|
2657c89aa8 | ||
|
|
10771b7c0a | ||
|
|
4585ac6da3 | ||
|
|
ab22e60224 | ||
|
|
ee726c0df9 | ||
|
|
91c634c85f | ||
|
|
7b963e944f | ||
|
|
2215b04da6 | ||
|
|
2d4aab5bee | ||
|
|
6cacd12dcc | ||
|
|
8c06706471 | ||
|
|
ef1b4a088b | ||
|
|
586c02251b | ||
|
|
64afc2227f | ||
|
|
6a15ebadad | ||
|
|
d2acf9ccb1 | ||
|
|
5c23375c08 | ||
|
|
a4dec48322 | ||
|
|
d2cd4a5ed2 | ||
|
|
d36f050a11 | ||
|
|
df67a2c24e | ||
|
|
f25ce761ac | ||
|
|
804075faff | ||
|
|
1f647482a3 | ||
|
|
2af517d577 | ||
|
|
fa3d516d02 | ||
|
|
d03d70c6c9 | ||
|
|
e042e01fb7 | ||
|
|
feb4aef935 | ||
|
|
89deef164d | ||
|
|
94332c617d | ||
|
|
bc49cb7d33 | ||
|
|
84e56a23db | ||
|
|
56b1ce895f | ||
|
|
23dccc6c6f | ||
|
|
81b1b2f695 | ||
|
|
efee2b85e8 | ||
|
|
48ab9efe2c | ||
|
|
318c622887 | ||
|
|
e29fe81eb4 | ||
|
|
e3373474f8 | ||
|
|
4053a4a722 | ||
|
|
40dcf11a2b | ||
|
|
d58b9f3ead | ||
|
|
3bb36611ae | ||
|
|
ee001d16b4 | ||
|
|
dd04326f76 | ||
|
|
4a7123a4b9 | ||
|
|
12a3b114ff | ||
|
|
3a2f935a75 | ||
|
|
a0d3da2803 | ||
|
|
55d665db74 | ||
|
|
162965300e |
|
|
@ -6,6 +6,8 @@ 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
|
||||
|
||||
|
|
|
|||
34
.github/workflows/http.yml
vendored
|
|
@ -25,6 +25,7 @@ on:
|
|||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
build:
|
||||
[
|
||||
|
|
@ -85,7 +86,7 @@ jobs:
|
|||
- name: Setup NodeJS
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20.x"
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Run tests
|
||||
run: mkdir frontend/dist && touch frontend/dist/tmp && go test ./...
|
||||
|
|
@ -128,7 +129,15 @@ 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'
|
||||
if: >-
|
||||
runner.os == 'macOS' &&
|
||||
(
|
||||
!github.event.pull_request ||
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
)
|
||||
)
|
||||
uses: Apple-Actions/import-codesign-certs@v3
|
||||
with:
|
||||
# The certificates in a PKCS12 file encoded as a base64 string
|
||||
|
|
@ -165,7 +174,15 @@ jobs:
|
|||
shell: bash
|
||||
|
||||
- name: Sign the MacOS binary and libraries
|
||||
if: runner.os == 'macOS'
|
||||
if: >-
|
||||
runner.os == 'macOS' &&
|
||||
(
|
||||
!github.event.pull_request ||
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
)
|
||||
)
|
||||
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
|
||||
|
|
@ -189,7 +206,16 @@ jobs:
|
|||
cd ../../..
|
||||
|
||||
- name: Notarize the zip file
|
||||
if: runner.os == 'macOS' && inputs.build-release
|
||||
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]'
|
||||
)
|
||||
)
|
||||
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: 20.x
|
||||
node-version: 22.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: "20.x"
|
||||
node-version: "22.x"
|
||||
|
||||
- name: Install Wails
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.11.0
|
||||
run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.14.0
|
||||
shell: bash
|
||||
|
||||
- name: Install Linux Wails deps
|
||||
|
|
@ -129,7 +129,15 @@ jobs:
|
|||
shell: bash
|
||||
|
||||
- name: Import Code-Signing Certificates for macOS
|
||||
if: runner.os == 'macOS'
|
||||
if: >-
|
||||
runner.os == 'macOS' &&
|
||||
(
|
||||
!github.event.pull_request ||
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
)
|
||||
)
|
||||
uses: Apple-Actions/import-codesign-certs@v3
|
||||
with:
|
||||
# The certificates in a PKCS12 file encoded as a base64 string
|
||||
|
|
@ -190,7 +198,15 @@ jobs:
|
|||
mv ./build/out/${{ env.PACKAGE_NAME }}.tar.bz2 ./build/bin/
|
||||
|
||||
- name: Sign the macOS binary
|
||||
if: runner.os == 'macOS'
|
||||
if: >-
|
||||
runner.os == 'macOS' &&
|
||||
(
|
||||
!github.event.pull_request ||
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
)
|
||||
)
|
||||
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"
|
||||
|
|
@ -222,7 +238,15 @@ jobs:
|
|||
EOF
|
||||
|
||||
- name: Notarize the DMG image
|
||||
if: runner.os == 'macOS'
|
||||
if: >-
|
||||
runner.os == 'macOS' &&
|
||||
(
|
||||
!github.event.pull_request ||
|
||||
(
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
github.actor != 'dependabot[bot]'
|
||||
)
|
||||
)
|
||||
run: |
|
||||
echo "Notarizing Zip Files"
|
||||
gon -log-level=info -log-json ./build/darwin/gon-notarize.json
|
||||
|
|
|
|||
3
.gitignore
vendored
|
|
@ -30,4 +30,5 @@ glalby
|
|||
*.db-shm
|
||||
*.db-wal
|
||||
*.db-journal
|
||||
albyhub-data
|
||||
albyhub-data
|
||||
.claude/worktrees
|
||||
12
AGENTS.md
|
|
@ -88,6 +88,8 @@ go run cmd/http/main.go
|
|||
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
|
||||
|
|
@ -188,6 +190,16 @@ Code under `frontend/platform_specific/http/` and `frontend/platform_specific/wa
|
|||
- 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.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:20-alpine AS frontend
|
||||
FROM node:22-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.25 AS builder
|
||||
FROM golang:1.26.2 AS builder
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
|
|
|
|||
22
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.
|
||||
|
||||
## Run on Alby Cloud
|
||||
## Learn more about Alby Hub
|
||||
|
||||
Visit [albyhub.com](https://albyhub.com) to learn more and get started and get Alby Hub running in minutes.
|
||||
|
||||
|
|
@ -207,6 +207,12 @@ 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)
|
||||
|
|
@ -220,6 +226,7 @@ Can be configured via env or the UI
|
|||
- `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.
|
||||
|
|
@ -245,6 +252,7 @@ _To configure via env, the following parameters must be provided:_
|
|||
- `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
|
||||
|
||||
|
|
@ -283,6 +291,16 @@ _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`.
|
||||
|
|
@ -384,6 +402,8 @@ 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?
|
||||
|
|
|
|||
7
SECURITY.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# 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.
|
||||
|
|
@ -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 delete lightning address endpoint")
|
||||
logger.Logger.WithError(err).Error("Failed to request delete lightning address endpoint")
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -418,6 +418,13 @@ 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))
|
||||
}
|
||||
|
|
@ -1369,6 +1376,52 @@ func (svc *albyOAuthService) requestAutoChannel(ctx context.Context, url string,
|
|||
}, 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)
|
||||
|
|
@ -1413,5 +1466,7 @@ func getEventWhitelist() []string {
|
|||
|
||||
// client-side events
|
||||
"payment_failed_details",
|
||||
"debit_card_url_clicked",
|
||||
"debit_card_connect",
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ 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 {
|
||||
|
|
@ -118,6 +119,7 @@ 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"`
|
||||
|
|
@ -153,6 +155,20 @@ 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"`
|
||||
|
|
|
|||
431
api/api.go
|
|
@ -2,8 +2,11 @@ package api
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
|
@ -11,13 +14,16 @@ import (
|
|||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/macaroon.v2"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
|
||||
|
|
@ -50,6 +56,9 @@ type api struct {
|
|||
startupError error
|
||||
startupErrorTime time.Time
|
||||
eventPublisher events.EventPublisher
|
||||
// set after a migration file is created; the hub is halted at that point
|
||||
// and the frontend should keep showing the migration success page
|
||||
nodeMigrationFileCreated atomic.Bool
|
||||
}
|
||||
|
||||
func NewAPI(svc service.Service, gormDB *gorm.DB, config config.Config, keys keys.Keys, albySvc alby.AlbyService, albyOAuthSvc alby.AlbyOAuthService, eventPublisher events.EventPublisher) *api {
|
||||
|
|
@ -126,21 +135,7 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
|
|||
responseBody.RelayUrls = relayUrls
|
||||
responseBody.Lud16 = lightningAddress
|
||||
|
||||
if createAppRequest.ReturnTo != "" {
|
||||
returnToUrl, err := url.Parse(createAppRequest.ReturnTo)
|
||||
if err == nil {
|
||||
query := returnToUrl.Query()
|
||||
for _, relayUrl := range relayUrls {
|
||||
query.Add("relay", relayUrl)
|
||||
}
|
||||
query.Add("pubkey", *app.WalletPubkey)
|
||||
if lightningAddress != "" && !app.Isolated {
|
||||
query.Add("lud16", lightningAddress)
|
||||
}
|
||||
returnToUrl.RawQuery = query.Encode()
|
||||
responseBody.ReturnTo = returnToUrl.String()
|
||||
}
|
||||
}
|
||||
responseBody.ReturnTo = buildReturnToUrl(createAppRequest.ReturnTo, relayUrls, *app.WalletPubkey, lightningAddress, app.Isolated)
|
||||
|
||||
var lud16 string
|
||||
if lightningAddress != "" && !app.Isolated {
|
||||
|
|
@ -151,6 +146,28 @@ func (api *api) CreateApp(createAppRequest *CreateAppRequest) (*CreateAppRespons
|
|||
return responseBody, nil
|
||||
}
|
||||
|
||||
// buildReturnToUrl adds the connection query parameters to the return_to
|
||||
// URL the user will be redirected to. Only http and https URLs are accepted.
|
||||
func buildReturnToUrl(returnTo string, relayUrls []string, walletPubkey string, lightningAddress string, isolated bool) string {
|
||||
if returnTo == "" {
|
||||
return ""
|
||||
}
|
||||
returnToUrl, err := url.Parse(returnTo)
|
||||
if err != nil || (returnToUrl.Scheme != "http" && returnToUrl.Scheme != "https") {
|
||||
return ""
|
||||
}
|
||||
query := returnToUrl.Query()
|
||||
for _, relayUrl := range relayUrls {
|
||||
query.Add("relay", relayUrl)
|
||||
}
|
||||
query.Add("pubkey", walletPubkey)
|
||||
if lightningAddress != "" && !isolated {
|
||||
query.Add("lud16", lightningAddress)
|
||||
}
|
||||
returnToUrl.RawQuery = query.Encode()
|
||||
return returnToUrl.String()
|
||||
}
|
||||
|
||||
func (api *api) UpdateApp(userApp *db.App, updateAppRequest *UpdateAppRequest) error {
|
||||
resolvedMaxAmountSat := ResolveToSat(updateAppRequest.MaxAmountSat, updateAppRequest.MaxAmountMsat, updateAppRequest.MaxAmount, nil)
|
||||
|
||||
|
|
@ -756,6 +773,10 @@ func (api *api) GetChannelPeerSuggestions(ctx context.Context) ([]alby.ChannelPe
|
|||
return api.albySvc.GetChannelPeerSuggestions(ctx)
|
||||
}
|
||||
|
||||
func (api *api) GetStories(ctx context.Context) ([]alby.Story, error) {
|
||||
return api.albyOAuthSvc.GetStories(ctx)
|
||||
}
|
||||
|
||||
func (api *api) GetLSPChannelOffer(ctx context.Context) (*alby.LSPChannelOffer, error) {
|
||||
return api.albyOAuthSvc.GetLSPChannelOffer(ctx)
|
||||
}
|
||||
|
|
@ -834,12 +855,20 @@ func (api *api) Stop() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (api *api) GetNodeConnectionInfo(ctx context.Context) (*lnclient.NodeConnectionInfo, error) {
|
||||
func (api *api) GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
return nil, ErrLNClientNotStarted
|
||||
}
|
||||
return lnClient.GetNodeConnectionInfo(ctx)
|
||||
info, err := lnClient.GetNodeConnectionInfo(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &NodeConnectionInfo{
|
||||
Pubkey: info.Pubkey,
|
||||
Address: info.Address,
|
||||
Port: info.Port,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (api *api) RefundSwap(refundSwapRequest *RefundSwapRequest) error {
|
||||
|
|
@ -1127,20 +1156,47 @@ func (api *api) GetSwapMnemonic() string {
|
|||
return api.keys.GetSwapMnemonic()
|
||||
}
|
||||
|
||||
func (api *api) GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error) {
|
||||
func (api *api) GetNodeStatus(ctx context.Context) (*NodeStatus, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
return nil, ErrLNClientNotStarted
|
||||
}
|
||||
return lnClient.GetNodeStatus(ctx)
|
||||
nodeStatus, err := lnClient.GetNodeStatus(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if nodeStatus == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return toApiNodeStatus(nodeStatus), nil
|
||||
}
|
||||
|
||||
func (api *api) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) {
|
||||
func toApiNodeStatus(nodeStatus *lnclient.NodeStatus) *NodeStatus {
|
||||
return &NodeStatus{
|
||||
IsReady: nodeStatus.IsReady,
|
||||
InternalNodeStatus: nodeStatus.InternalNodeStatus,
|
||||
}
|
||||
}
|
||||
|
||||
func (api *api) ListPeers(ctx context.Context) ([]PeerDetails, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
return nil, ErrLNClientNotStarted
|
||||
}
|
||||
return lnClient.ListPeers(ctx)
|
||||
peers, err := lnClient.ListPeers(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiPeers := make([]PeerDetails, 0, len(peers))
|
||||
for _, peer := range peers {
|
||||
apiPeers = append(apiPeers, PeerDetails{
|
||||
NodeId: peer.NodeId,
|
||||
Address: peer.Address,
|
||||
IsPersisted: peer.IsPersisted,
|
||||
IsConnected: peer.IsConnected,
|
||||
})
|
||||
}
|
||||
return apiPeers, nil
|
||||
}
|
||||
|
||||
func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error {
|
||||
|
|
@ -1148,7 +1204,11 @@ func (api *api) ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeer
|
|||
if lnClient == nil {
|
||||
return ErrLNClientNotStarted
|
||||
}
|
||||
return lnClient.ConnectPeer(ctx, connectPeerRequest)
|
||||
return lnClient.ConnectPeer(ctx, &lnclient.ConnectPeerRequest{
|
||||
Pubkey: connectPeerRequest.Pubkey,
|
||||
Address: connectPeerRequest.Address,
|
||||
Port: connectPeerRequest.Port,
|
||||
})
|
||||
}
|
||||
|
||||
func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) {
|
||||
|
|
@ -1156,7 +1216,17 @@ func (api *api) OpenChannel(ctx context.Context, openChannelRequest *OpenChannel
|
|||
if lnClient == nil {
|
||||
return nil, ErrLNClientNotStarted
|
||||
}
|
||||
return lnClient.OpenChannel(ctx, openChannelRequest)
|
||||
resp, err := lnClient.OpenChannel(ctx, &lnclient.OpenChannelRequest{
|
||||
Pubkey: openChannelRequest.Pubkey,
|
||||
AmountSats: openChannelRequest.AmountSats,
|
||||
Public: openChannelRequest.Public,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &OpenChannelResponse{
|
||||
FundingTxId: resp.FundingTxId,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (api *api) DisconnectPeer(ctx context.Context, peerId string) error {
|
||||
|
|
@ -1180,11 +1250,15 @@ func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, forc
|
|||
"channel_id": channelId,
|
||||
"force": force,
|
||||
}).Info("Closing channel")
|
||||
return lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{
|
||||
err := lnClient.CloseChannel(ctx, &lnclient.CloseChannelRequest{
|
||||
NodeId: peerId,
|
||||
ChannelId: channelId,
|
||||
Force: force,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CloseChannelResponse{}, nil
|
||||
}
|
||||
|
||||
func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateChannelRequest) error {
|
||||
|
|
@ -1195,7 +1269,13 @@ func (api *api) UpdateChannel(ctx context.Context, updateChannelRequest *UpdateC
|
|||
logger.Logger.WithFields(logrus.Fields{
|
||||
"request": updateChannelRequest,
|
||||
}).Info("updating channel")
|
||||
return lnClient.UpdateChannel(ctx, updateChannelRequest)
|
||||
return lnClient.UpdateChannel(ctx, &lnclient.UpdateChannelRequest{
|
||||
ChannelId: updateChannelRequest.ChannelId,
|
||||
NodeId: updateChannelRequest.NodeId,
|
||||
ForwardingFeeBaseMsat: updateChannelRequest.ForwardingFeeBaseMsat,
|
||||
ForwardingFeeProportionalMillionths: updateChannelRequest.ForwardingFeeProportionalMillionths,
|
||||
MaxDustHtlcExposureFromFeeRateMultiplier: updateChannelRequest.MaxDustHtlcExposureFromFeeRateMultiplier,
|
||||
})
|
||||
}
|
||||
|
||||
func (api *api) MakeOffer(ctx context.Context, description string) (string, error) {
|
||||
|
|
@ -1306,7 +1386,70 @@ func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) {
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return balances, nil
|
||||
return toApiBalances(balances), nil
|
||||
}
|
||||
|
||||
func toApiBalances(balances *lnclient.BalancesResponse) *BalancesResponse {
|
||||
totalSpendableMsat := balances.Lightning.TotalSpendableMsat
|
||||
totalReceivableMsat := balances.Lightning.TotalReceivableMsat
|
||||
nextMaxSpendableMsat := balances.Lightning.NextMaxSpendableMsat
|
||||
nextMaxReceivableMsat := balances.Lightning.NextMaxReceivableMsat
|
||||
nextMaxSpendableMPPMsat := balances.Lightning.NextMaxSpendableMPPMsat
|
||||
nextMaxReceivableMPPMsat := balances.Lightning.NextMaxReceivableMPPMsat
|
||||
|
||||
return &BalancesResponse{
|
||||
Onchain: OnchainBalanceResponse{
|
||||
Spendable: balances.Onchain.SpendableSat,
|
||||
SpendableSat: balances.Onchain.SpendableSat,
|
||||
Total: balances.Onchain.TotalSat,
|
||||
TotalSat: balances.Onchain.TotalSat,
|
||||
Reserved: balances.Onchain.ReservedSat,
|
||||
ReservedSat: balances.Onchain.ReservedSat,
|
||||
PendingBalancesFromChannelClosures: balances.Onchain.PendingBalancesFromChannelClosuresSat,
|
||||
PendingBalancesFromChannelClosuresSat: balances.Onchain.PendingBalancesFromChannelClosuresSat,
|
||||
PendingBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingBalancesDetails),
|
||||
PendingSweepBalancesDetails: toApiPendingBalanceDetails(balances.Onchain.PendingSweepBalancesDetails),
|
||||
InternalBalances: balances.Onchain.InternalBalances,
|
||||
},
|
||||
Lightning: LightningBalanceResponse{
|
||||
TotalSpendable: totalSpendableMsat,
|
||||
TotalSpendableSat: totalSpendableMsat / 1000,
|
||||
TotalSpendableMsat: totalSpendableMsat,
|
||||
TotalReceivable: totalReceivableMsat,
|
||||
TotalReceivableSat: totalReceivableMsat / 1000,
|
||||
TotalReceivableMsat: totalReceivableMsat,
|
||||
NextMaxSpendable: nextMaxSpendableMsat,
|
||||
NextMaxSpendableSat: nextMaxSpendableMsat / 1000,
|
||||
NextMaxSpendableMsat: nextMaxSpendableMsat,
|
||||
NextMaxReceivable: nextMaxReceivableMsat,
|
||||
NextMaxReceivableSat: nextMaxReceivableMsat / 1000,
|
||||
NextMaxReceivableMsat: nextMaxReceivableMsat,
|
||||
NextMaxSpendableMPP: nextMaxSpendableMPPMsat,
|
||||
NextMaxSpendableMPPSat: nextMaxSpendableMPPMsat / 1000,
|
||||
NextMaxSpendableMPPMsat: nextMaxSpendableMPPMsat,
|
||||
NextMaxReceivableMPP: nextMaxReceivableMPPMsat,
|
||||
NextMaxReceivableMPPSat: nextMaxReceivableMPPMsat / 1000,
|
||||
NextMaxReceivableMPPMsat: nextMaxReceivableMPPMsat,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func toApiPendingBalanceDetails(details []lnclient.PendingBalanceDetails) []PendingBalanceDetails {
|
||||
if details == nil {
|
||||
return nil
|
||||
}
|
||||
apiDetails := make([]PendingBalanceDetails, 0, len(details))
|
||||
for _, d := range details {
|
||||
apiDetails = append(apiDetails, PendingBalanceDetails{
|
||||
ChannelId: d.ChannelId,
|
||||
NodeId: d.NodeId,
|
||||
Amount: d.AmountSat,
|
||||
AmountSat: d.AmountSat,
|
||||
FundingTxId: d.FundingTxId,
|
||||
FundingTxVout: d.FundingTxVout,
|
||||
})
|
||||
}
|
||||
return apiDetails
|
||||
}
|
||||
|
||||
// TODO: remove dependency on this endpoint
|
||||
|
|
@ -1365,8 +1508,22 @@ func (api *api) RequestMempoolApi(ctx context.Context, endpoint string) (interfa
|
|||
|
||||
func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
|
||||
info := InfoResponse{}
|
||||
|
||||
if api.nodeMigrationFileCreated.Load() {
|
||||
// the hub is halted and the database is closed after a migration file
|
||||
// is created, so return a minimal response without reading any config
|
||||
// or node state; the frontend only needs the flag to keep showing the
|
||||
// migration success page
|
||||
info.NodeMigrationFileCreated = true
|
||||
info.SetupCompleted = true
|
||||
info.Version = version.Tag
|
||||
info.Relays = []InfoResponseRelay{}
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
backendType, _ := api.cfg.Get("LNBackendType", "")
|
||||
ldkVssEnabled, _ := api.cfg.Get("LdkVssEnabled", "")
|
||||
jitChannelsEnabled, _ := api.cfg.Get("JitChannelsEnabled", "")
|
||||
autoUnlockPassword, _ := api.cfg.Get("AutoUnlockPassword", "")
|
||||
setupCompleted, err := api.cfg.SetupCompleted()
|
||||
if err != nil {
|
||||
|
|
@ -1383,6 +1540,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
|
|||
}
|
||||
lnClient := api.svc.GetLNClient()
|
||||
info.Running = lnClient != nil
|
||||
info.NodeMigrationFileCreated = api.nodeMigrationFileCreated.Load()
|
||||
info.BackendType = backendType
|
||||
info.AlbyAuthUrl = api.albyOAuthSvc.GetAuthUrl()
|
||||
info.OAuthRedirect = !api.cfg.GetEnv().IsDefaultClientId()
|
||||
|
|
@ -1390,7 +1548,11 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
|
|||
info.EnableAdvancedSetup = api.cfg.GetEnv().EnableAdvancedSetup
|
||||
info.HideUpdateBanner = api.cfg.GetEnv().HideUpdateBanner
|
||||
info.LdkVssEnabled = ldkVssEnabled == "true"
|
||||
info.JitChannelsEnabled = jitChannelsEnabled != "false"
|
||||
info.VssSupported = backendType == config.LDKBackendType && api.cfg.GetEnv().LDKVssUrl != ""
|
||||
info.LdkVssUrl = api.cfg.GetEnv().LDKVssUrl
|
||||
info.DatabaseType = api.db.Dialector.Name()
|
||||
info.SupportsBolt12 = backendType == config.LDKBackendType || backendType == config.CLNBackendType
|
||||
info.AutoUnlockPasswordEnabled = autoUnlockPassword != ""
|
||||
info.AutoUnlockPasswordSupported = api.cfg.GetEnv().IsDefaultClientId()
|
||||
info.Relays = []InfoResponseRelay{}
|
||||
|
|
@ -1425,10 +1587,28 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
|
|||
type chainSourceProvider interface {
|
||||
GetChainDataSource() (string, string)
|
||||
}
|
||||
type lsps2SourceProvider interface {
|
||||
GetLiquiditySourceLsps2() string
|
||||
}
|
||||
type lsps2MinPaymentSizeProvider interface {
|
||||
GetLiquiditySourceLsps2MinPaymentSizeMsat() *uint64
|
||||
}
|
||||
type lsps2MaxPaymentSizeProvider interface {
|
||||
GetLiquiditySourceLsps2MaxPaymentSizeMsat() *uint64
|
||||
}
|
||||
|
||||
if ldkService, ok := api.svc.GetLNClient().(chainSourceProvider); ok {
|
||||
info.ChainDataSourceType, info.ChainDataSourceAddress = ldkService.GetChainDataSource()
|
||||
}
|
||||
if ldkService, ok := api.svc.GetLNClient().(lsps2SourceProvider); ok {
|
||||
info.JitChannelsLiquiditySource = ldkService.GetLiquiditySourceLsps2()
|
||||
}
|
||||
if ldkService, ok := api.svc.GetLNClient().(lsps2MinPaymentSizeProvider); ok {
|
||||
info.JitChannelsMinPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MinPaymentSizeMsat()
|
||||
}
|
||||
if ldkService, ok := api.svc.GetLNClient().(lsps2MaxPaymentSizeProvider); ok {
|
||||
info.JitChannelsMaxPaymentSizeMsat = ldkService.GetLiquiditySourceLsps2MaxPaymentSizeMsat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1439,7 +1619,7 @@ func (api *api) GetInfo(ctx context.Context) (*InfoResponse, error) {
|
|||
return &info, nil
|
||||
}
|
||||
|
||||
func (api *api) SetCurrency(currency string) error {
|
||||
func (api *api) setCurrency(currency string) error {
|
||||
if currency == "" {
|
||||
return fmt.Errorf("currency value cannot be empty")
|
||||
}
|
||||
|
|
@ -1453,7 +1633,7 @@ func (api *api) SetCurrency(currency string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (api *api) SetBitcoinDisplayFormat(format string) error {
|
||||
func (api *api) setBitcoinDisplayFormat(format string) error {
|
||||
if format != constants.BITCOIN_DISPLAY_FORMAT_SATS && format != constants.BITCOIN_DISPLAY_FORMAT_BIP177 {
|
||||
return fmt.Errorf("bitcoin display format must be '%s' or '%s'", constants.BITCOIN_DISPLAY_FORMAT_SATS, constants.BITCOIN_DISPLAY_FORMAT_BIP177)
|
||||
}
|
||||
|
|
@ -1467,21 +1647,43 @@ func (api *api) SetBitcoinDisplayFormat(format string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (api *api) setJitChannelsEnabled(enabled bool) error {
|
||||
value := "true"
|
||||
if !enabled {
|
||||
value = "false"
|
||||
}
|
||||
|
||||
err := api.cfg.SetUpdate("JitChannelsEnabled", value, "")
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to update JIT channels setting")
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *api) UpdateSettings(updateSettingsRequest *UpdateSettingsRequest) error {
|
||||
if updateSettingsRequest.Currency != "" {
|
||||
err := api.SetCurrency(updateSettingsRequest.Currency)
|
||||
err := api.setCurrency(updateSettingsRequest.Currency)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set currency: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if updateSettingsRequest.BitcoinDisplayFormat != "" {
|
||||
err := api.SetBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
|
||||
err := api.setBitcoinDisplayFormat(updateSettingsRequest.BitcoinDisplayFormat)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set bitcoin display format: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if updateSettingsRequest.JitChannelsEnabled != nil {
|
||||
err := api.setJitChannelsEnabled(*updateSettingsRequest.JitChannelsEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set JIT channels setting: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -1561,6 +1763,19 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
return errors.New("no unlock password provided")
|
||||
}
|
||||
|
||||
// Bark and Cashu both store wallet state on local disk, so they cannot
|
||||
// run in environments without persistent volumes (e.g. Alby Cloud). Bark
|
||||
// can recover spendable VTXOs from the mnemonic alone, but in-flight
|
||||
// payment checkpoints and wallet metadata are local-only, so persistent
|
||||
// storage is still required. The default OAuth client ID identifies a
|
||||
// local / self-hosted deployment.
|
||||
if !api.cfg.GetEnv().IsDefaultClientId() {
|
||||
switch setupRequest.LNBackendType {
|
||||
case config.BarkBackendType, config.CashuBackendType:
|
||||
return fmt.Errorf("%s backend is not supported in this environment (no persistent storage)", setupRequest.LNBackendType)
|
||||
}
|
||||
}
|
||||
|
||||
err = api.cfg.SaveUnlockPasswordCheck(setupRequest.UnlockPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -1595,12 +1810,18 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
}
|
||||
}
|
||||
if setupRequest.LNDCertFile != "" {
|
||||
certBytes, err := os.ReadFile(setupRequest.LNDCertFile)
|
||||
// The file path is provided by the (unauthenticated) setup request, so
|
||||
// only persist the content if it parses as a certificate. Storing the
|
||||
// re-encoded certificate(s) guarantees nothing but the parsed structure
|
||||
// reaches the database - e.g. a private key bundled in the same PEM file
|
||||
// is dropped rather than persisted.
|
||||
certHex, err := readAndCanonicalizeLNDCert(setupRequest.LNDCertFile)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to read lnd cert file")
|
||||
return err
|
||||
// Return a generic error and log the detail server-side so the
|
||||
// response is not a file existence/readability oracle.
|
||||
logger.Logger.WithError(err).Error("Failed to process lnd cert file")
|
||||
return errors.New("invalid LND certificate file")
|
||||
}
|
||||
certHex := hex.EncodeToString(certBytes)
|
||||
err = api.cfg.SetUpdate("LNDCertHex", certHex, setupRequest.UnlockPassword)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to save lnd cert hex")
|
||||
|
|
@ -1608,12 +1829,17 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
}
|
||||
}
|
||||
if setupRequest.LNDMacaroonFile != "" {
|
||||
macaroonBytes, err := os.ReadFile(setupRequest.LNDMacaroonFile)
|
||||
// The file path is provided by the (unauthenticated) setup request, so
|
||||
// only persist the content if it parses as a macaroon. Storing the
|
||||
// re-marshalled macaroon guarantees only the parsed structure reaches
|
||||
// the database.
|
||||
macaroonHex, err := readAndCanonicalizeLNDMacaroon(setupRequest.LNDMacaroonFile)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to read lnd macaroon file")
|
||||
return err
|
||||
// Return a generic error and log the detail server-side so the
|
||||
// response is not a file existence/readability oracle.
|
||||
logger.Logger.WithError(err).Error("Failed to process lnd macaroon file")
|
||||
return errors.New("invalid LND macaroon file")
|
||||
}
|
||||
macaroonHex := hex.EncodeToString(macaroonBytes)
|
||||
err = api.cfg.SetUpdate("LNDMacaroonHex", macaroonHex, setupRequest.UnlockPassword)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to save lnd macaroon hex")
|
||||
|
|
@ -1653,6 +1879,15 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
}
|
||||
|
||||
if setupRequest.CLNLightningDir != "" {
|
||||
// The directory path is provided by the (unauthenticated) setup request.
|
||||
// Validate that it holds the expected CLN TLS credentials before saving,
|
||||
// so the path cannot be used as an existence/readability oracle for
|
||||
// arbitrary directories (the failure otherwise surfaces via startupError
|
||||
// on the anonymous /api/info response).
|
||||
if err := validateCLNLightningDir(setupRequest.CLNLightningDir, setupRequest.CLNAddressHold != ""); err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to validate CLN lightning directory")
|
||||
return errors.New("invalid CLN lightning directory")
|
||||
}
|
||||
err = api.cfg.SetUpdate("CLNLightningDir", setupRequest.CLNLightningDir, setupRequest.UnlockPassword)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("Failed to save CLN Lightning directory path")
|
||||
|
|
@ -1671,6 +1906,101 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// readAndCanonicalizeLNDCert reads the LND TLS certificate at the given path,
|
||||
// validates that it contains at least one parseable certificate, and returns
|
||||
// the hex-encoded re-encoding of only the parsed certificate(s). Any non
|
||||
// CERTIFICATE PEM blocks (e.g. a bundled private key) are discarded so they are
|
||||
// never persisted. Callers must not reflect the returned error to the client.
|
||||
func readAndCanonicalizeLNDCert(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read LND cert file: %w", err)
|
||||
}
|
||||
|
||||
var canonical []byte
|
||||
rest := raw
|
||||
for {
|
||||
var block *pem.Block
|
||||
block, rest = pem.Decode(rest)
|
||||
if block == nil {
|
||||
break
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse LND certificate: %w", err)
|
||||
}
|
||||
canonical = append(canonical, pem.EncodeToMemory(&pem.Block{
|
||||
Type: "CERTIFICATE",
|
||||
Bytes: cert.Raw,
|
||||
})...)
|
||||
}
|
||||
if len(canonical) == 0 {
|
||||
return "", errors.New("no valid certificate found in LND cert file")
|
||||
}
|
||||
|
||||
return hex.EncodeToString(canonical), nil
|
||||
}
|
||||
|
||||
// readAndCanonicalizeLNDMacaroon reads the LND macaroon at the given path,
|
||||
// validates that it is a well-formed macaroon, and returns the hex-encoded
|
||||
// re-marshalling so that only the parsed structure is persisted. Callers must
|
||||
// not reflect the returned error to the client.
|
||||
func readAndCanonicalizeLNDMacaroon(path string) (string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read LND macaroon file: %w", err)
|
||||
}
|
||||
|
||||
mac := &macaroon.Macaroon{}
|
||||
if err := mac.UnmarshalBinary(raw); err != nil {
|
||||
return "", fmt.Errorf("failed to parse LND macaroon: %w", err)
|
||||
}
|
||||
canonical, err := mac.MarshalBinary()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal LND macaroon: %w", err)
|
||||
}
|
||||
|
||||
return hex.EncodeToString(canonical), nil
|
||||
}
|
||||
|
||||
// validateCLNLightningDir checks that the given directory holds the CLN TLS
|
||||
// credentials that will later be loaded at connect time (ca.pem, client.pem,
|
||||
// client-key.pem), for each gRPC server name the config will use. This mirrors
|
||||
// the parses performed by the CLN client's loadTLSCredentials so a directory
|
||||
// that passes here is one CLN can actually use. Callers must not reflect the
|
||||
// returned error to the client.
|
||||
func validateCLNLightningDir(lightningDir string, hold bool) error {
|
||||
// "cln" reads the directory directly; other server names are joined as a
|
||||
// subdirectory, matching loadTLSCredentials in lnclient/cln.
|
||||
serverNames := []string{"cln"}
|
||||
if hold {
|
||||
serverNames = append(serverNames, "hold")
|
||||
}
|
||||
|
||||
for _, serverName := range serverNames {
|
||||
dir := lightningDir
|
||||
if serverName != "cln" {
|
||||
dir = filepath.Join(dir, serverName)
|
||||
}
|
||||
|
||||
caPEM, err := os.ReadFile(filepath.Join(dir, "ca.pem"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read CLN CA cert (%s): %w", serverName, err)
|
||||
}
|
||||
if !x509.NewCertPool().AppendCertsFromPEM(caPEM) {
|
||||
return fmt.Errorf("failed to parse CLN CA cert (%s)", serverName)
|
||||
}
|
||||
if _, err := tls.LoadX509KeyPair(filepath.Join(dir, "client.pem"), filepath.Join(dir, "client-key.pem")); err != nil {
|
||||
return fmt.Errorf("failed to load CLN client cert/key (%s): %w", serverName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (api *api) GetWalletCapabilities(ctx context.Context) (*WalletCapabilitiesResponse, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
|
|
@ -1737,12 +2067,27 @@ func (api *api) SyncWallet() error {
|
|||
lnClient.UpdateLastWalletSyncRequest()
|
||||
return nil
|
||||
}
|
||||
func (api *api) ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, error) {
|
||||
func (api *api) ListOnchainTransactions(ctx context.Context) ([]OnchainTransaction, error) {
|
||||
lnClient := api.svc.GetLNClient()
|
||||
if lnClient == nil {
|
||||
return nil, ErrLNClientNotStarted
|
||||
}
|
||||
return lnClient.ListOnchainTransactions(ctx)
|
||||
transactions, err := lnClient.ListOnchainTransactions(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
apiTransactions := make([]OnchainTransaction, 0, len(transactions))
|
||||
for _, t := range transactions {
|
||||
apiTransactions = append(apiTransactions, OnchainTransaction{
|
||||
AmountSat: t.AmountSat,
|
||||
CreatedAt: t.CreatedAt,
|
||||
State: t.State,
|
||||
Type: t.Type,
|
||||
NumConfirmations: t.NumConfirmations,
|
||||
TxId: t.TxId,
|
||||
})
|
||||
}
|
||||
return apiTransactions, nil
|
||||
}
|
||||
|
||||
func (api *api) GetLogOutput(ctx context.Context, logType string, getLogRequest *GetLogOutputRequest) (*GetLogOutputResponse, error) {
|
||||
|
|
@ -1818,7 +2163,11 @@ func (api *api) Health(ctx context.Context) (*HealthResponse, error) {
|
|||
if lnClient != nil {
|
||||
nodeStatus, _ := lnClient.GetNodeStatus(ctx)
|
||||
if nodeStatus == nil || !nodeStatus.IsReady {
|
||||
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, nodeStatus))
|
||||
var apiNodeStatus *NodeStatus
|
||||
if nodeStatus != nil {
|
||||
apiNodeStatus = toApiNodeStatus(nodeStatus)
|
||||
}
|
||||
alarms = append(alarms, NewHealthAlarm(HealthAlarmKindNodeNotReady, apiNodeStatus))
|
||||
}
|
||||
|
||||
channels, err := lnClient.ListChannels(ctx)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,32 @@ 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)
|
||||
|
|
|
|||
224
api/backup.go
|
|
@ -1,9 +1,11 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -16,12 +18,48 @@ 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
|
||||
|
|
@ -38,8 +76,9 @@ func (api *api) CreateBackup(unlockPassword string, w io.Writer) error {
|
|||
return errors.New("Please disable auto-unlock before using this feature")
|
||||
}
|
||||
|
||||
if api.db.Dialector.Name() != "sqlite" {
|
||||
return errors.New("Migration with non-sqlite backend is currently not supported")
|
||||
dbBackend := api.db.Dialector.Name()
|
||||
if dbBackend != "sqlite" && dbBackend != "postgres" {
|
||||
return fmt.Errorf("migration with %s backend is currently not supported", dbBackend)
|
||||
}
|
||||
|
||||
workDir, err := filepath.Abs(api.cfg.GetEnv().Workdir)
|
||||
|
|
@ -76,6 +115,50 @@ 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.
|
||||
|
|
@ -126,8 +209,6 @@ 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")
|
||||
|
|
@ -152,8 +233,18 @@ 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
|
||||
}
|
||||
|
||||
|
|
@ -204,8 +295,38 @@ 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 {
|
||||
fsFilePath := filepath.Join(workDir, "restore", filepath.FromSlash(zipFile.Name))
|
||||
// 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)
|
||||
}
|
||||
|
||||
if err = os.MkdirAll(filepath.Dir(fsFilePath), 0700); err != nil {
|
||||
return fmt.Errorf("failed to create directory for zip entry: %w", err)
|
||||
|
|
@ -239,13 +360,20 @@ 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 {
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
logger.Logger.WithError(err).WithField("filename", filename).Error("failed to remove old nwc db file before restore")
|
||||
}
|
||||
}
|
||||
|
|
@ -259,12 +387,17 @@ func (api *api) RestoreBackup(unlockPassword string, r io.Reader) error {
|
|||
}
|
||||
|
||||
func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
|
||||
salt := make([]byte, 8)
|
||||
scheme := backupCiphers[0]
|
||||
|
||||
salt := make([]byte, scheme.saltSize)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
|
||||
encKey := pbkdf2.Key([]byte(password), salt, 4096, 32, sha256.New)
|
||||
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)
|
||||
|
|
@ -285,9 +418,8 @@ 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: stream,
|
||||
S: scheme.newStream(block, iv),
|
||||
W: w,
|
||||
}
|
||||
|
||||
|
|
@ -295,27 +427,61 @@ func encryptingWriter(w io.Writer, password string) (io.Writer, error) {
|
|||
}
|
||||
|
||||
func decryptingReader(r io.Reader, password string) (io.Reader, error) {
|
||||
salt := make([]byte, 8)
|
||||
if _, err := io.ReadFull(r, salt); err != nil {
|
||||
return nil, fmt.Errorf("failed to read salt: %w", err)
|
||||
// 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)
|
||||
}
|
||||
|
||||
iv := make([]byte, aes.BlockSize)
|
||||
if _, err := io.ReadFull(r, iv); err != nil {
|
||||
return nil, fmt.Errorf("failed to read IV: %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
|
||||
}
|
||||
|
||||
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
|
||||
return nil, errors.New("invalid unlock password or backup file")
|
||||
}
|
||||
|
|
|
|||
261
api/backup_test.go
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
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)
|
||||
}
|
||||
197
api/models.go
|
|
@ -8,7 +8,6 @@ import (
|
|||
|
||||
"github.com/getAlby/hub/alby"
|
||||
"github.com/getAlby/hub/db"
|
||||
"github.com/getAlby/hub/lnclient"
|
||||
"github.com/getAlby/hub/swaps"
|
||||
)
|
||||
|
||||
|
|
@ -23,14 +22,15 @@ type API interface {
|
|||
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) (*lnclient.NodeConnectionInfo, error)
|
||||
GetNodeStatus(ctx context.Context) (*lnclient.NodeStatus, error)
|
||||
ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error)
|
||||
GetNodeConnectionInfo(ctx context.Context) (*NodeConnectionInfo, error)
|
||||
GetNodeStatus(ctx context.Context) (*NodeStatus, error)
|
||||
ListPeers(ctx context.Context) ([]PeerDetails, error)
|
||||
ConnectPeer(ctx context.Context, connectPeerRequest *ConnectPeerRequest) error
|
||||
DisconnectPeer(ctx context.Context, peerId string) error
|
||||
OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error)
|
||||
|
|
@ -43,10 +43,10 @@ type API interface {
|
|||
SignMessage(ctx context.Context, message string) (*SignMessageResponse, error)
|
||||
RedeemOnchainFunds(ctx context.Context, toAddress string, amountSat uint64, feeRate *uint64, sendAll bool) (*RedeemOnchainFundsResponse, error)
|
||||
GetBalances(ctx context.Context) (*BalancesResponse, error)
|
||||
ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error)
|
||||
ListOnchainTransactions(ctx context.Context) ([]lnclient.OnchainTransaction, 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) (*MakeInvoiceResponse, error)
|
||||
CreateInvoice(ctx context.Context, amountMsat uint64, description string, toAppId *uint) (*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)
|
||||
|
|
@ -64,8 +64,6 @@ 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)
|
||||
|
|
@ -306,38 +304,47 @@ 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"`
|
||||
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"`
|
||||
ChainDataSourceType string `json:"chainDataSourceType,omitempty"`
|
||||
ChainDataSourceAddress string `json:"chainDataSourceAddress,omitempty"`
|
||||
HideUpdateBanner bool `json:"hideUpdateBanner"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type UpdateSettingsRequest struct {
|
||||
Currency string `json:"currency"`
|
||||
BitcoinDisplayFormat string `json:"bitcoinDisplayFormat"`
|
||||
JitChannelsEnabled *bool `json:"jitChannelsEnabled"`
|
||||
}
|
||||
|
||||
type SetNodeAliasRequest struct {
|
||||
|
|
@ -360,11 +367,68 @@ type AutoUnlockRequest struct {
|
|||
UnlockPassword string `json:"unlockPassword"`
|
||||
}
|
||||
|
||||
type ConnectPeerRequest = lnclient.ConnectPeerRequest
|
||||
type OpenChannelRequest = lnclient.OpenChannelRequest
|
||||
type OpenChannelResponse = lnclient.OpenChannelResponse
|
||||
type CloseChannelResponse = lnclient.CloseChannelResponse
|
||||
type UpdateChannelRequest = lnclient.UpdateChannelRequest
|
||||
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 RebalanceChannelRequest struct {
|
||||
ReceiveThroughNodePubkey string `json:"receiveThroughNodePubkey"`
|
||||
|
|
@ -388,8 +452,45 @@ type RedeemOnchainFundsResponse struct {
|
|||
TxId string `json:"txId"`
|
||||
}
|
||||
|
||||
type OnchainBalanceResponse = lnclient.OnchainBalanceResponse
|
||||
type BalancesResponse = lnclient.BalancesResponse
|
||||
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 SendPaymentResponse = Transaction
|
||||
type MakeInvoiceResponse = Transaction
|
||||
|
|
@ -399,6 +500,13 @@ 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"`
|
||||
|
|
@ -488,6 +596,7 @@ type MakeInvoiceRequest struct {
|
|||
AmountSat *uint64 `json:"amountSat"`
|
||||
AmountMsat *uint64 `json:"amountMsat"`
|
||||
Description string `json:"description"`
|
||||
ToAppID *uint `json:"toAppId"`
|
||||
}
|
||||
|
||||
type ResetRouterRequest struct {
|
||||
|
|
@ -502,7 +611,7 @@ type BasicRestoreWailsRequest struct {
|
|||
UnlockPassword string `json:"unlockPassword"`
|
||||
}
|
||||
|
||||
type NetworkGraphResponse = lnclient.NetworkGraphResponse
|
||||
type NetworkGraphResponse = interface{}
|
||||
|
||||
type LSPOrderRequest struct {
|
||||
Amount *uint64 `json:"amount"` // deprecated
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@ func (api *api) RebalanceChannel(ctx context.Context, rebalanceChannelRequest *R
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if paymentRequest.MSatoshi > int64(float64(amountSat)*float64(1000)*float64(1.003)+1 /*0.3% fees*/) {
|
||||
if paymentRequest.MSatoshi > int64(float64(amountSat)*float64(1000)*float64(1.005)+1 /*0.5% fees*/) {
|
||||
return nil, errors.New("rebalance payment is more expensive than expected")
|
||||
}
|
||||
|
||||
|
|
|
|||
161
api/setup_test.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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,20 +4,29 @@ 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) (*MakeInvoiceResponse, error) {
|
||||
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
|
||||
}
|
||||
transaction, err := api.svc.GetTransactionsService().MakeInvoice(ctx, amountMsat, description, "", 0, nil, lnClient, nil, nil, nil)
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -40,7 +49,47 @@ func (api *api) SetTransactionUserLabels(ctx context.Context, id uint, labels ma
|
|||
return api.svc.GetTransactionsService().SetTransactionUserLabels(ctx, id, labels)
|
||||
}
|
||||
|
||||
func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64, offset uint64) (*ListTransactionsResponse, error) {
|
||||
// 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
|
||||
|
|
@ -51,13 +100,18 @@ func (api *api) ListTransactions(ctx context.Context, appId *uint, limit uint64,
|
|||
forceFilterByAppId = true
|
||||
}
|
||||
|
||||
transactions, totalCount, err := api.svc.GetTransactionsService().ListTransactions(ctx, 0, 0, limit, offset, true, false, nil, lnClient, appId, forceFilterByAppId)
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiTransactions := []Transaction{}
|
||||
for _, transaction := range transactions {
|
||||
for _, transaction := range dbTransactions {
|
||||
apiTransactions = append(apiTransactions, *toApiTransaction(&transaction))
|
||||
}
|
||||
|
||||
|
|
|
|||
98
api/transactions_test.go
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -58,9 +58,11 @@ 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.PhoenixBackendType &&
|
||||
backendType != config.BarkBackendType &&
|
||||
backendType != config.CLNBackendType {
|
||||
return nil, "", fmt.Errorf(
|
||||
"sub-wallets are currently not supported on your node backend. Try LDK or LND")
|
||||
"sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -184,11 +186,17 @@ 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,
|
||||
"name": app.Name,
|
||||
"id": app.ID,
|
||||
"walletPubkey": walletPubkey,
|
||||
},
|
||||
})
|
||||
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 or LND", err.Error())
|
||||
assert.Equal(t, "sub-wallets are currently not supported on your node backend. Try LDK, LND, PHOENIX, BARK, or CLN", err.Error())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
|
|
@ -14,18 +12,6 @@ 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
|
||||
|
||||
|
|
@ -64,54 +50,29 @@ func main() {
|
|||
}
|
||||
defer stopDB(toDB)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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")
|
||||
// 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)
|
||||
}
|
||||
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)
|
||||
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.")
|
||||
}
|
||||
|
||||
logger.Logger.Info("migrating...")
|
||||
err = migrateDB(fromDB, toDB)
|
||||
err = db.MigrateDB(fromDB, toDB)
|
||||
if err != nil {
|
||||
logger.Logger.WithError(err).Error("failed to migrate database")
|
||||
os.Exit(1)
|
||||
|
|
@ -119,175 +80,3 @@ 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,40 +30,6 @@ 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
|
||||
|
|
@ -104,8 +70,17 @@ func TestMigrate(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
defer env.cleanup(t)
|
||||
|
||||
err = migrateDB(env.source, env.dest)
|
||||
err = db.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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -200,6 +175,83 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -408,7 +408,18 @@ func (cfg *config) SetAutoUnlockPassword(unlockPassword string) error {
|
|||
func (cfg *config) CheckUnlockPassword(encryptionKey string) bool {
|
||||
decryptedValue, err := cfg.Get("UnlockPasswordCheck", encryptionKey)
|
||||
|
||||
return err == nil && (decryptedValue == "" || decryptedValue == unlockPasswordCheck)
|
||||
// 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
|
||||
}
|
||||
|
||||
func (cfg *config) SaveUnlockPasswordCheck(encryptionKey string) error {
|
||||
|
|
|
|||
|
|
@ -56,8 +56,6 @@ 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ const (
|
|||
PhoenixBackendType = "PHOENIX"
|
||||
CashuBackendType = "CASHU"
|
||||
CLNBackendType = "CLN"
|
||||
BarkBackendType = "BARK"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -37,6 +38,7 @@ type AppConfig struct {
|
|||
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"`
|
||||
|
|
@ -63,6 +65,10 @@ type AppConfig struct {
|
|||
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 {
|
||||
|
|
@ -88,6 +94,7 @@ type Config interface {
|
|||
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
|
||||
|
|
|
|||
|
|
@ -16,8 +16,6 @@ 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)
|
||||
|
||||
|
|
@ -35,8 +33,6 @@ 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)
|
||||
|
||||
|
|
@ -58,8 +54,6 @@ 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)
|
||||
|
||||
|
|
@ -81,6 +75,50 @@ 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)
|
||||
|
|
@ -219,7 +257,7 @@ func TestJWTSecret_GeneratedOnLoad(t *testing.T) {
|
|||
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.ChangeUnlockPassword("", "123")
|
||||
err = cfg.SaveUnlockPasswordCheck("123")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.LoadJWTSecret("123")
|
||||
|
|
@ -251,9 +289,6 @@ func TestJWTSecret_WrongPassword(t *testing.T) {
|
|||
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.ChangeUnlockPassword("", "123")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.SaveUnlockPasswordCheck("123")
|
||||
require.NoError(t, err)
|
||||
|
||||
|
|
@ -272,7 +307,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
|
|||
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.ChangeUnlockPassword("", "123")
|
||||
err = cfg.SaveUnlockPasswordCheck("123")
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.LoadJWTSecret("123")
|
||||
|
|
@ -282,7 +317,7 @@ func TestJWTSecret_ChangePassword(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, jwtSecret)
|
||||
|
||||
err = cfg.ChangeUnlockPassword("", "1234")
|
||||
err = cfg.ChangeUnlockPassword("123", "1234")
|
||||
require.NoError(t, err)
|
||||
|
||||
newJwtSecret, err := cfg.GetJWTSecret()
|
||||
|
|
@ -306,7 +341,7 @@ func TestJWTSecret_ReplaceUnencryptedSecretOnLoad(t *testing.T) {
|
|||
cfg, err := config.NewConfig(&config.AppConfig{}, svc.DB)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = cfg.ChangeUnlockPassword("", "123")
|
||||
err = cfg.SaveUnlockPasswordCheck("123")
|
||||
require.NoError(t, err)
|
||||
|
||||
// simulate a hub that had an unencrypted JWT secret
|
||||
|
|
|
|||
235
db/db_migrate.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -20,14 +20,13 @@
|
|||
"prepare": "cd .. && husky frontend/.husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@fontsource-variable/figtree": "^5.2.10",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@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": "^7.0.0",
|
||||
"@getalby/sdk": "^8.0.3",
|
||||
"@scure/bip39": "^2.2.0",
|
||||
"@stepperize/react": "^6.1.0",
|
||||
"argon2-wasm-esm": "^1.0.3",
|
||||
"bitcoin-address-validation": "^3.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -36,36 +35,35 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"lucide-react": "^1.7.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-lottie": "^1.2.4",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router": "^7.14.2",
|
||||
"react-router": "^7.18.2",
|
||||
"sonner": "^2.0.7",
|
||||
"swr": "^2.4.1",
|
||||
"tailwind-merge": "^3.4.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.5.3",
|
||||
"@commitlint/config-conventional": "^20.5.0",
|
||||
"@commitlint/config-conventional": "^21.2.0",
|
||||
"@eslint/eslintrc": "^3.3.5",
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/forms": "^0.5.11",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/node": "^25.9.3",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@types/react-lottie": "^1.2.10",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"eslint": "^10.3.0",
|
||||
"@vitejs/plugin-react-swc": "^4.3.1",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
|
|
@ -74,12 +72,11 @@
|
|||
"lint-staged": "^16.4.0",
|
||||
"prettier": "3.8.3",
|
||||
"shx": "^0.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"vite": "^5.4.0",
|
||||
"vite-plugin-pwa": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^6.1.1"
|
||||
"typescript-eslint": "^8.61.0",
|
||||
"vite": "^8.2.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ export const request = async <T>(
|
|||
args[1]?.body?.toString() || ""
|
||||
);
|
||||
|
||||
console.info("Wails request", ...args, res);
|
||||
console.info("Wails request", args[0].toString(), args[1]?.method || "GET");
|
||||
if (res.error) {
|
||||
throw new Error(res.error);
|
||||
}
|
||||
|
|
|
|||
BIN
frontend/src/assets/cards/2fiat.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
frontend/src/assets/cards/freedomia.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
frontend/src/assets/cards/redotpay.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
frontend/src/assets/cards/solvocard.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
12
frontend/src/assets/images/node/bark-dark.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
12
frontend/src/assets/images/node/bark-light.svg
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<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>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
8588
frontend/src/assets/lotties/success-check.json
Normal file
BIN
frontend/src/assets/suggested-apps/bitcoin-card-topup.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
frontend/src/assets/suggested-apps/hermes.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 21 KiB |
BIN
frontend/src/assets/suggested-apps/pi.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
frontend/src/assets/suggested-apps/satora.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
|
@ -32,12 +32,14 @@ export function AnchorReserveAlert({
|
|||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Channel Anchor Reserves will be depleted</AlertTitle>
|
||||
<AlertDescription>
|
||||
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 amountMsat={channels.length * 25000 * 1000} />{" "}
|
||||
on-chain.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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";
|
||||
|
|
@ -17,6 +18,7 @@ import { App } from "src/types";
|
|||
const agentLogos: Record<string, string> = {
|
||||
claude: claudeLogo,
|
||||
goose: gooseLogo,
|
||||
hermes: hermesLogo,
|
||||
openclaw: openclawLogo,
|
||||
cursor: cursorLogo,
|
||||
codex: codexLogo,
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
BotIcon,
|
||||
BoxIcon,
|
||||
ChevronsUpDownIcon,
|
||||
CreditCardIcon,
|
||||
CircleHelpIcon,
|
||||
HandCoinsIcon,
|
||||
HomeIcon,
|
||||
|
|
@ -105,6 +106,11 @@ export function AppSidebar() {
|
|||
title: "AI & Agents",
|
||||
url: "/ai",
|
||||
icon: BotIcon,
|
||||
},
|
||||
{
|
||||
title: "Cards",
|
||||
url: "/cards",
|
||||
icon: CreditCardIcon,
|
||||
badge: "NEW",
|
||||
},
|
||||
],
|
||||
|
|
|
|||
528
frontend/src/components/CurrencyInputField.tsx
Normal file
|
|
@ -0,0 +1,528 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,37 +3,44 @@ import React from "react";
|
|||
import { LinkButton } from "src/components/ui/custom/link-button";
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
interface Props {
|
||||
type Variant = "dashed" | "muted" | "none";
|
||||
|
||||
type Props = {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
buttonText: string;
|
||||
buttonLink: string;
|
||||
showButton?: boolean;
|
||||
showBorder?: boolean;
|
||||
}
|
||||
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: "",
|
||||
};
|
||||
|
||||
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",
|
||||
showBorder && "shadow-xs border border-dashed"
|
||||
variantClasses[variant]
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{showButton && (
|
||||
{buttonText && buttonLink && (
|
||||
<LinkButton to={buttonLink} className="mt-4">
|
||||
{buttonText}
|
||||
</LinkButton>
|
||||
|
|
|
|||
183
frontend/src/components/FirstChannelJitAlert.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { AlertTriangleIcon, InfoIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import ExternalLink from "src/components/ExternalLink";
|
||||
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
|
||||
import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
|
||||
import { useBalances } from "src/hooks/useBalances";
|
||||
import { useChannels } from "src/hooks/useChannels";
|
||||
import { useInfo } from "src/hooks/useInfo";
|
||||
import { CreateInvoiceRequest, Transaction } from "src/types";
|
||||
import { request } from "src/utils/request";
|
||||
|
||||
const PROBE_TIMEOUT_MS = 5000;
|
||||
|
||||
export default function FirstChannelJitAlert() {
|
||||
const { data: info } = useInfo();
|
||||
const { data: channels } = useChannels();
|
||||
const { data: balances } = useBalances();
|
||||
|
||||
// a JIT channel only opens when the feature is enabled AND an LSPS2 liquidity
|
||||
// source is actually configured (jitChannelsEnabled alone is just a settings
|
||||
// toggle and can be true on backends without an LSPS2 source).
|
||||
const lsps2Source = info?.jitChannelsEnabled
|
||||
? info.jitChannelsLiquiditySource
|
||||
: undefined;
|
||||
|
||||
const minPaymentSizeMsat = info?.jitChannelsMinPaymentSizeMsat;
|
||||
|
||||
const isJitEnabled = !!lsps2Source && !!channels;
|
||||
// the user's first received payment opens the channel when they have none yet.
|
||||
const isFirstChannel = isJitEnabled && channels.length === 0;
|
||||
|
||||
// probe whether a JIT channel can actually be obtained by requesting an
|
||||
// invoice for the minimum payment size. If it (or waiting for the minimum
|
||||
// payment size) doesn't succeed within the timeout, we surface a fallback
|
||||
// alert depending on whether the user already has channels.
|
||||
const [probeState, setProbeState] = React.useState<
|
||||
"loading" | "ok" | "failed"
|
||||
>("loading");
|
||||
const deadlineRef = React.useRef<number | null>(null);
|
||||
// single-flight the non-idempotent probe invoice: hold the in-flight request
|
||||
// so effect re-entry (e.g. StrictMode remount) reuses the same POST instead
|
||||
// of creating a duplicate invoice. Reset when the probe window ends.
|
||||
const probeRequestRef = React.useRef<Promise<Transaction | undefined> | null>(
|
||||
null
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isJitEnabled) {
|
||||
deadlineRef.current = null;
|
||||
probeRequestRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// start the 5s clock once when we enter JIT mode - it keeps ticking while
|
||||
// we wait for the minimum payment size to become available.
|
||||
if (deadlineRef.current === null) {
|
||||
deadlineRef.current = Date.now() + PROBE_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const remainingMs = deadlineRef.current - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
setProbeState("failed");
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (!cancelled) {
|
||||
setProbeState("failed");
|
||||
}
|
||||
}, remainingMs);
|
||||
|
||||
// wait for the minimum payment size before requesting the probe invoice.
|
||||
if (minPaymentSizeMsat) {
|
||||
// reuse an already in-flight probe so a re-run doesn't issue a second POST.
|
||||
const probeRequest =
|
||||
probeRequestRef.current ??
|
||||
(probeRequestRef.current = request<Transaction>("/api/invoices", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
amountMsat: minPaymentSizeMsat,
|
||||
description: "",
|
||||
} as CreateInvoiceRequest),
|
||||
}));
|
||||
probeRequest
|
||||
.then(() => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timer);
|
||||
setProbeState("ok");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timer);
|
||||
setProbeState("failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [isJitEnabled, minPaymentSizeMsat]);
|
||||
|
||||
if (!isJitEnabled || probeState === "loading") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (probeState === "failed") {
|
||||
// no channels yet and a JIT channel couldn't be obtained - the user has no
|
||||
// receiving capacity at all and will fail to receive until a channel opens.
|
||||
if (isFirstChannel) {
|
||||
return (
|
||||
<Alert variant="warning">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertTitle>Can't receive payments yet</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
You won't be able to receive payments until you{" "}
|
||||
<Link className="underline" to="/channels/incoming">
|
||||
open a channel
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// they already have channels but a JIT channel couldn't be obtained, so they
|
||||
// can only receive up to their current capacity without opening one. Wait for
|
||||
// balances so we don't claim a misleading "0" receivable amount.
|
||||
if (!balances) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription className="inline">
|
||||
You can currently receive up to{" "}
|
||||
<FormattedBitcoinAmount
|
||||
amountMsat={balances.lightning.totalReceivableMsat}
|
||||
/>
|
||||
. If you want to receive a larger payment,{" "}
|
||||
<Link className="underline" to="/channels/incoming">
|
||||
open a channel
|
||||
</Link>
|
||||
.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// probe succeeded - only the first-channel case needs an informational alert.
|
||||
if (!isFirstChannel) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertTitle>First payment opens a channel</AlertTitle>
|
||||
<AlertDescription className="inline">
|
||||
A channel fee applies.{" "}
|
||||
{!!minPaymentSizeMsat && (
|
||||
<>
|
||||
Minimum payment{" "}
|
||||
<FormattedBitcoinAmount amountMsat={minPaymentSizeMsat} />.{" "}
|
||||
</>
|
||||
)}
|
||||
<ExternalLink
|
||||
to="https://guides.getalby.com/user-guide/alby-hub/faq/what-are-just-in-time-channels"
|
||||
className="underline"
|
||||
>
|
||||
Learn more
|
||||
</ExternalLink>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
|
@ -4,12 +4,12 @@ import {
|
|||
ExternalLinkIcon,
|
||||
HandCoinsIcon,
|
||||
} from "lucide-react";
|
||||
import TickSVG from "public/images/illustrations/tick.svg";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FixedFloatButton } from "src/components/FixedFloatButton";
|
||||
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
|
||||
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
|
||||
import LottieLoading from "src/components/LottieLoading";
|
||||
import LottieSuccess from "src/components/LottieSuccess";
|
||||
import { Button } from "src/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -77,7 +77,7 @@ export function FixedFloatSwapInFlow({
|
|||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-center">Waiting for Payment</CardTitle>
|
||||
<CardTitle className="text-center">Waiting for Payment...</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
<LottieLoading size={288} />
|
||||
|
|
@ -91,7 +91,7 @@ export function FixedFloatSwapInFlow({
|
|||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 pt-2">
|
||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
|
|
@ -123,7 +123,7 @@ export function FixedFloatSwapInFlow({
|
|||
<CardTitle className="text-center">Transaction Received!</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
<img src={TickSVG} className="w-48" />
|
||||
<LottieSuccess />
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<p className="text-2xl font-medium slashed-zero">
|
||||
<FormattedBitcoinAmount amountMsat={transaction.amountMsat} />
|
||||
|
|
@ -134,7 +134,7 @@ export function FixedFloatSwapInFlow({
|
|||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 pt-2">
|
||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -144,11 +144,11 @@ export function FixedFloatSwapInFlow({
|
|||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<HandCoinsIcon className="w-4 h-4 mr-2" />
|
||||
<HandCoinsIcon className="size-4" />
|
||||
{resetLabel}
|
||||
</Button>
|
||||
<LinkButton to="/wallet" variant="link" className="w-full">
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back to Wallet
|
||||
</LinkButton>
|
||||
</CardFooter>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { useMemo } from "react";
|
||||
import Lottie from "react-lottie";
|
||||
import Lottie from "lottie-react";
|
||||
import animationDataDark from "src/assets/lotties/loading-dark.json";
|
||||
import animationDataLight from "src/assets/lotties/loading-light.json";
|
||||
import { useTheme } from "src/components/ui/theme-provider";
|
||||
|
|
@ -7,15 +6,13 @@ import { useTheme } from "src/components/ui/theme-provider";
|
|||
export default function LottieLoading({ size }: { size?: number }) {
|
||||
const { isDarkMode } = useTheme();
|
||||
|
||||
const options = useMemo(
|
||||
() => ({
|
||||
loop: true,
|
||||
autoplay: true,
|
||||
animationData: isDarkMode ? animationDataDark : animationDataLight,
|
||||
rendererSettings: { preserveAspectRatio: "xMidYMid slice" },
|
||||
}),
|
||||
[isDarkMode]
|
||||
return (
|
||||
<Lottie
|
||||
animationData={isDarkMode ? animationDataDark : animationDataLight}
|
||||
loop
|
||||
autoplay
|
||||
rendererSettings={{ preserveAspectRatio: "xMidYMid slice" }}
|
||||
style={{ width: size ?? "100%", height: size ?? "100%" }}
|
||||
/>
|
||||
);
|
||||
|
||||
return <Lottie options={options} height={size} width={size} />;
|
||||
}
|
||||
|
|
|
|||
16
frontend/src/components/LottieSuccess.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import Lottie from "lottie-react";
|
||||
import animationData from "src/assets/lotties/success-check.json";
|
||||
|
||||
export default function LottieSuccess({ size = 288 }: { size?: number }) {
|
||||
return (
|
||||
<div className="[&_path[fill='rgb(75,177,0)']]:fill-positive-foreground [&_path[stroke='rgb(75,177,0)']]:stroke-positive-foreground [&_path[stroke='rgb(255,255,255)']]:stroke-card">
|
||||
<Lottie
|
||||
animationData={animationData}
|
||||
loop={false}
|
||||
autoplay
|
||||
rendererSettings={{ preserveAspectRatio: "xMidYMid meet" }}
|
||||
style={{ width: size, height: size }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { LinkIcon } from "lucide-react";
|
||||
import { BitcoinIcon } from "lucide-react";
|
||||
import EmptyState from "src/components/EmptyState";
|
||||
import Loading from "src/components/Loading";
|
||||
import OnchainTransactionItem from "src/components/OnchainTransactionItem";
|
||||
|
|
@ -18,12 +18,9 @@ export function OnchainTransactionsList() {
|
|||
return (
|
||||
<div className="flex w-full flex-1 flex-col">
|
||||
<EmptyState
|
||||
icon={LinkIcon}
|
||||
icon={BitcoinIcon}
|
||||
title="No on-chain transactions yet"
|
||||
description="Your most recent incoming and outgoing on-chain transactions will show up here."
|
||||
buttonText="Receive to On-chain Balance"
|
||||
buttonLink="/wallet/receive?type=onchain"
|
||||
showBorder={false}
|
||||
description="Your bitcoin transactions will appear here as you start using your wallet."
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { CopyIcon, ExternalLinkIcon } from "lucide-react";
|
|||
import React from "react";
|
||||
import { FixedFloatButton } from "src/components/FixedFloatButton";
|
||||
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
|
||||
import { LightningIcon } from "src/components/icons/Lightning";
|
||||
import Loading from "src/components/Loading";
|
||||
import QRCode from "src/components/QRCode";
|
||||
import { Button } from "src/components/ui/button";
|
||||
|
|
@ -28,16 +27,13 @@ export function PayLightningInvoice({ invoice }: PayLightningInvoiceProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="w-96 flex flex-col gap-6 p-6 items-center justify-center">
|
||||
<div className="flex items-center justify-center gap-2 text-muted-foreground">
|
||||
<div className="flex w-full flex-col items-center justify-center gap-6">
|
||||
<div className="flex items-center justify-center gap-2 font-semibold leading-none">
|
||||
<Loading variant="loader" />
|
||||
<p>Waiting for lightning payment...</p>
|
||||
<p>Waiting for Payment...</p>
|
||||
</div>
|
||||
<div className="w-full relative flex items-center justify-center">
|
||||
<QRCode value={invoice} className="w-full" />
|
||||
<div className="bg-white absolute rounded-full p-1">
|
||||
<LightningIcon className="w-12 h-12" />
|
||||
</div>
|
||||
<div className="relative flex w-full items-center justify-center">
|
||||
<QRCode value={invoice} className="w-full" paymentType="lightning" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold">
|
||||
|
|
@ -50,23 +46,19 @@ export function PayLightningInvoice({ invoice }: PayLightningInvoiceProps) {
|
|||
}).format(fiatAmount)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Button
|
||||
onClick={copy}
|
||||
variant="outline"
|
||||
className="flex-1 flex gap-2 items-center justify-center"
|
||||
>
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<Button onClick={copy} variant="secondary" className="w-full">
|
||||
<CopyIcon />
|
||||
Copy Invoice
|
||||
</Button>
|
||||
<FixedFloatButton
|
||||
to="BTCLN"
|
||||
address={invoice}
|
||||
className="flex-1 flex gap-2 items-center justify-center"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
variant="outline"
|
||||
>
|
||||
Pay with other Cryptocurrency
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Pay with Crypto
|
||||
</FixedFloatButton>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
|
|||
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
|
||||
import { LoadingButton } from "src/components/ui/custom/loading-button";
|
||||
import { useChannels } from "src/hooks/useChannels";
|
||||
import { request } from "src/utils/request";
|
||||
import { sendEvent } from "src/utils/sendEvent";
|
||||
|
||||
export function PaymentFailedAlert({
|
||||
invoice,
|
||||
|
|
@ -19,25 +19,12 @@ export function PaymentFailedAlert({
|
|||
|
||||
async function sendDetailsToAlby() {
|
||||
setSendingDetailsToAlby(true);
|
||||
try {
|
||||
await request(`/api/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
event: "payment_failed_details",
|
||||
properties: {
|
||||
invoice,
|
||||
errorMessage,
|
||||
channels,
|
||||
},
|
||||
}),
|
||||
});
|
||||
toast("Thanks for improving Alby Hub.");
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
await sendEvent("payment_failed_details", {
|
||||
invoice,
|
||||
errorMessage,
|
||||
channels,
|
||||
});
|
||||
toast("Thanks for improving Alby Hub.");
|
||||
setSendingDetailsToAlby(false);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,38 +1,116 @@
|
|||
import ReactQRCode from "react-qr-code";
|
||||
import { type ReactNode, useEffect, useMemo, useRef } from "react";
|
||||
import QRCodeStyling, { type Options } from "qr-code-styling";
|
||||
import { BitcoinPaymentIcon } from "src/components/icons/BitcoinPayment";
|
||||
import { LightningIcon } from "src/components/icons/Lightning";
|
||||
import { cn } from "src/lib/utils";
|
||||
|
||||
export type Props = {
|
||||
value: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
showAvatar?: boolean;
|
||||
frameType?: "lightning" | "onchain";
|
||||
paymentType?: "lightning" | "onchain";
|
||||
centerContent?: ReactNode;
|
||||
|
||||
// set the level to Q if there are overlays
|
||||
// Q will improve error correction (so we can add overlays covering up to 25% of the QR)
|
||||
// at the price of decreased information density (meaning the QR codes "pixels" have to be
|
||||
// smaller to encode the same information).
|
||||
// While that isn't that much of a problem for lightning addresses (because they are usually quite short),
|
||||
// for invoices that contain larger amount of data those QR codes can get "harder" to read.
|
||||
// (meaning you have to aim your phone very precisely and have to wait longer for the reader
|
||||
// to recognize the QR code)
|
||||
// Use Q when an external overlay covers part of the QR code.
|
||||
level?: "Q" | undefined;
|
||||
};
|
||||
|
||||
function QRCode({ value, size, level, className }: Props) {
|
||||
// Do not use dark mode: some apps do not handle it well (e.g. Phoenix)
|
||||
// const { isDarkMode } = useTheme();
|
||||
const fgColor = "#242424"; // isDarkMode ? "#FFFFFF" : "#242424";
|
||||
const bgColor = "#FFFFFF"; // isDarkMode ? "#242424" : "#FFFFFF";
|
||||
function QRCode({
|
||||
value,
|
||||
size = 256,
|
||||
level,
|
||||
className,
|
||||
showAvatar = false,
|
||||
frameType,
|
||||
paymentType,
|
||||
centerContent,
|
||||
}: Props) {
|
||||
const resolvedFrameType = paymentType ?? frameType;
|
||||
const hasCenterContent = Boolean(centerContent);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const options = useMemo<Options>(
|
||||
() => ({
|
||||
type: "svg",
|
||||
width: size,
|
||||
height: size,
|
||||
data: value,
|
||||
image: showAvatar ? "/icon-lightmode.svg" : undefined,
|
||||
margin: 0,
|
||||
qrOptions: {
|
||||
errorCorrectionLevel:
|
||||
level ?? (showAvatar || paymentType || hasCenterContent ? "Q" : "M"),
|
||||
},
|
||||
imageOptions: {
|
||||
crossOrigin: "anonymous",
|
||||
hideBackgroundDots: true,
|
||||
imageSize: 0.15,
|
||||
margin: 4,
|
||||
},
|
||||
dotsOptions: {
|
||||
color: "var(--qr-foreground)",
|
||||
type: "dots",
|
||||
roundSize: false,
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
color: "var(--qr-foreground)",
|
||||
type: "extra-rounded",
|
||||
},
|
||||
cornersDotOptions: {
|
||||
color: "var(--qr-foreground)",
|
||||
type: "dot",
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: "var(--qr-background)",
|
||||
},
|
||||
}),
|
||||
[hasCenterContent, level, paymentType, showAvatar, size, value]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const qrCode = new QRCodeStyling(options);
|
||||
qrCode.append(container);
|
||||
|
||||
return () => {
|
||||
container.replaceChildren();
|
||||
};
|
||||
}, [options]);
|
||||
|
||||
return (
|
||||
<div className="bg-white p-2 rounded-md">
|
||||
<ReactQRCode
|
||||
value={value}
|
||||
size={size}
|
||||
fgColor={fgColor}
|
||||
bgColor={bgColor}
|
||||
className={cn("rounded", className)}
|
||||
level={level}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"relative w-full rounded-[28px] p-2",
|
||||
resolvedFrameType === "lightning"
|
||||
? "bg-payment-lightning"
|
||||
: resolvedFrameType === "onchain"
|
||||
? "bg-payment-onchain"
|
||||
: "bg-primary",
|
||||
className
|
||||
)}
|
||||
style={{ maxWidth: size }}
|
||||
>
|
||||
<div className="rounded-3xl bg-qr-background p-4">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="aspect-square w-full overflow-hidden [&_svg]:size-full"
|
||||
/>
|
||||
</div>
|
||||
{(paymentType || centerContent) && (
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-qr-background p-1 text-qr-background">
|
||||
{centerContent ??
|
||||
(paymentType === "lightning" ? (
|
||||
<LightningIcon className="size-12" />
|
||||
) : (
|
||||
<BitcoinPaymentIcon className="size-12" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,7 +134,7 @@ export function RebalanceChannelDialogContent({
|
|||
}}
|
||||
/>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Fee: 0.3%
|
||||
Fee: 0.5%
|
||||
{!!amountSat && (
|
||||
<>
|
||||
(
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
import { CopyIcon, LinkIcon, ReceiptTextIcon, ZapIcon } from "lucide-react";
|
||||
import {
|
||||
ChevronRightIcon,
|
||||
CopyIcon,
|
||||
LinkIcon,
|
||||
ReceiptTextIcon,
|
||||
ZapIcon,
|
||||
} from "lucide-react";
|
||||
import { Link } from "react-router";
|
||||
import FirstChannelJitAlert from "src/components/FirstChannelJitAlert";
|
||||
import Loading from "src/components/Loading";
|
||||
import QRCode from "src/components/QRCode";
|
||||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "src/components/ui/accordion";
|
||||
import { Button } from "src/components/ui/button";
|
||||
import { Card, CardContent, CardFooter } from "src/components/ui/card";
|
||||
import { LinkButton } from "src/components/ui/custom/link-button";
|
||||
import { Separator } from "src/components/ui/separator";
|
||||
import { Card, CardContent } from "src/components/ui/card";
|
||||
import { useAlbyMe } from "src/hooks/useAlbyMe";
|
||||
import { useInfo } from "src/hooks/useInfo";
|
||||
import { copyToClipboard } from "src/lib/clipboard";
|
||||
|
|
@ -18,43 +30,87 @@ export function ReceiveToLightning() {
|
|||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
<QRCode value={me.lightning_address} className="w-full h-auto" />
|
||||
<p className="text-center font-medium text-lg break-all">
|
||||
{me.lightning_address}
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 pt-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
copyToClipboard(me.lightning_address);
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<CopyIcon className="size-4" /> Copy Lightning Address
|
||||
</Button>
|
||||
<Separator className="my-4" />
|
||||
<LinkButton to="invoice" variant="outline" className="w-full">
|
||||
<ZapIcon className="w-4 h-4 mr-2" />
|
||||
Create Invoice
|
||||
</LinkButton>
|
||||
{info.backendType === "LDK" && (
|
||||
<LinkButton
|
||||
to="/wallet/receive/offer"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ReceiptTextIcon className="h-4 w-4 mr-2" />
|
||||
Lightning Offer
|
||||
</LinkButton>
|
||||
)}
|
||||
<LinkButton to="onchain" variant="outline" className="w-full">
|
||||
<LinkIcon className="w-4 h-4 mr-2" />
|
||||
Receive from On-chain / Other Cryptocurrency
|
||||
</LinkButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<div className="flex flex-col gap-5">
|
||||
<FirstChannelJitAlert />
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
<QRCode
|
||||
value={me.lightning_address}
|
||||
className="h-auto w-full"
|
||||
frameType="lightning"
|
||||
/>
|
||||
<div className="flex max-w-full items-center justify-center gap-1">
|
||||
<p className="min-w-0 text-center font-medium text-lg break-all">
|
||||
{me.lightning_address}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="Copy Lightning Address"
|
||||
className="shrink-0"
|
||||
onClick={() => {
|
||||
copyToClipboard(me.lightning_address);
|
||||
}}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="py-2">
|
||||
<CardContent>
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
<AccordionItem value="more-options">
|
||||
<AccordionTrigger>Other ways to receive</AccordionTrigger>
|
||||
<AccordionContent className="flex flex-col divide-y pb-1">
|
||||
<Link
|
||||
to="/wallet/receive/invoice"
|
||||
className="group flex items-center gap-3 py-3"
|
||||
>
|
||||
<ZapIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Create Invoice</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Request a specific amount with a one-time invoice
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
{info.supportsBolt12 && (
|
||||
<Link
|
||||
to="/wallet/receive/offer"
|
||||
className="group flex items-center gap-3 py-3"
|
||||
>
|
||||
<ReceiptTextIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">Lightning Offer</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Share a reusable payment code that never expires
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
to="/wallet/receive/onchain"
|
||||
className="group flex items-center gap-3 py-3"
|
||||
>
|
||||
<LinkIcon className="size-5 shrink-0 text-muted-foreground" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium">
|
||||
On-chain or Other Cryptocurrency
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Swap funds from on-chain bitcoin or other cryptocurrencies
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRightIcon className="size-4 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
|
||||
</Link>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ import {
|
|||
HandCoinsIcon,
|
||||
RefreshCwIcon,
|
||||
} from "lucide-react";
|
||||
import TickSVG from "public/images/illustrations/tick.svg";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { FixedFloatButton } from "src/components/FixedFloatButton";
|
||||
import { FormattedBitcoinAmount } from "src/components/FormattedBitcoinAmount";
|
||||
import FormattedFiatAmount from "src/components/FormattedFiatAmount";
|
||||
import Loading from "src/components/Loading";
|
||||
import LottieLoading from "src/components/LottieLoading";
|
||||
import LottieSuccess from "src/components/LottieSuccess";
|
||||
import OnchainAddressDisplay from "src/components/OnchainAddressDisplay";
|
||||
import QRCode from "src/components/QRCode";
|
||||
import { Button } from "src/components/ui/button";
|
||||
|
|
@ -120,13 +120,13 @@ export function ReceiveToOnchain() {
|
|||
target="_blank"
|
||||
className="flex justify-center"
|
||||
>
|
||||
<QRCode value={onchainAddress} />
|
||||
<QRCode value={onchainAddress} paymentType="onchain" />
|
||||
</a>
|
||||
<div className="flex flex-wrap max-w-64 gap-2 items-center justify-center">
|
||||
<OnchainAddressDisplay address={onchainAddress} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 pt-2">
|
||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
|
|
@ -150,10 +150,10 @@ export function ReceiveToOnchain() {
|
|||
to="BTC"
|
||||
address={onchainAddress}
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
Top up using other Cryptocurrency
|
||||
Top Up with Crypto
|
||||
</FixedFloatButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
|
@ -174,8 +174,7 @@ function DepositPending({
|
|||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-center gap-2">
|
||||
<Loading className="w-4 h-4" />
|
||||
<CardTitle className="text-center">
|
||||
Waiting for On-chain Confirmation...
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
|
@ -190,13 +189,13 @@ function DepositPending({
|
|||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 pt-2">
|
||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||
<ExternalLinkButton
|
||||
to={`${info?.mempoolUrl}/tx/${txId}`}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ExternalLinkIcon className="w-4 h-4 mr-2" />
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
View on Mempool
|
||||
</ExternalLinkButton>
|
||||
</CardFooter>
|
||||
|
|
@ -221,7 +220,7 @@ function DepositSuccess({
|
|||
<CardTitle className="text-center">Transaction Received!</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col items-center gap-6">
|
||||
<img src={TickSVG} className="w-48" />
|
||||
<LottieSuccess />
|
||||
<div className="flex flex-col gap-1 items-center">
|
||||
<p className="text-2xl font-medium slashed-zero">
|
||||
<FormattedBitcoinAmount amountMsat={amountSat * 1000} />
|
||||
|
|
@ -229,13 +228,13 @@ function DepositSuccess({
|
|||
<FormattedFiatAmount amountSat={amountSat} className="text-xl" />
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex flex-col gap-2 pt-2">
|
||||
<CardFooter className="flex flex-col gap-3 pt-2">
|
||||
<ExternalLinkButton
|
||||
to={`${info?.mempoolUrl}/tx/${txId}`}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<ExternalLinkIcon className="w-4 h-4 mr-2" />
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
View on Mempool
|
||||
</ExternalLinkButton>
|
||||
<Button
|
||||
|
|
@ -244,11 +243,11 @@ function DepositSuccess({
|
|||
className="w-full"
|
||||
onClick={onReceiveAnother}
|
||||
>
|
||||
<HandCoinsIcon className="w-4 h-4 mr-2" />
|
||||
<HandCoinsIcon className="size-4" />
|
||||
Receive Another Payment
|
||||
</Button>
|
||||
<LinkButton to="/wallet" variant="link" className="w-full">
|
||||
<ArrowLeftIcon className="w-4 h-4 mr-2" />
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
Back to Wallet
|
||||
</LinkButton>
|
||||
</CardFooter>
|
||||
|
|
|
|||
|
|
@ -19,7 +19,12 @@ import {
|
|||
SheetTitle,
|
||||
} from "src/components/ui/sheet";
|
||||
import { cn } from "src/lib/utils";
|
||||
import { Scope, WalletCapabilities, scopeDescriptions } from "src/types";
|
||||
import {
|
||||
READ_ONLY_SCOPES,
|
||||
Scope,
|
||||
WalletCapabilities,
|
||||
scopeDescriptions,
|
||||
} from "src/types";
|
||||
|
||||
const scopeGroups = ["full_access", "read_only", "isolated", "custom"] as const;
|
||||
type ScopeGroup = (typeof scopeGroups)[number];
|
||||
|
|
@ -65,17 +70,8 @@ const Scopes: React.FC<ScopesProps> = ({
|
|||
}, [capabilities.scopes]);
|
||||
|
||||
const readOnlyScopes: Scope[] = React.useMemo(() => {
|
||||
const readOnlyScopes: Scope[] = [
|
||||
"get_balance",
|
||||
"get_info",
|
||||
"make_invoice",
|
||||
"lookup_invoice",
|
||||
"list_transactions",
|
||||
"notifications",
|
||||
];
|
||||
|
||||
return capabilities.scopes.filter((scope) =>
|
||||
readOnlyScopes.includes(scope)
|
||||
READ_ONLY_SCOPES.includes(scope)
|
||||
);
|
||||
}, [capabilities.scopes]);
|
||||
|
||||
|
|
|
|||
|
|
@ -277,10 +277,10 @@ function TransactionItem({ tx, transactionListKey }: Props) {
|
|||
<TransactionDetailRow label="Date & Time">
|
||||
{updatedAt.format("D MMMM YYYY, HH:mm")}
|
||||
</TransactionDetailRow>
|
||||
{tx.state != "failed" && type == "outgoing" && (
|
||||
{tx.state != "failed" && tx.feesPaidMsat > 0 && (
|
||||
<TransactionDetailRow label="Fee">
|
||||
<FormattedBitcoinAmount amountMsat={tx.feesPaidMsat} />
|
||||
{tx.feesPaidMsat > 0 && (
|
||||
{type == "outgoing" && (
|
||||
<>
|
||||
(
|
||||
{((tx.feesPaidMsat / tx.amountMsat) * 100).toFixed(2)}%)
|
||||
|
|
|
|||
140
frontend/src/components/TransactionsFilterDialog.tsx
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import React from "react";
|
||||
import { Button } from "src/components/ui/button";
|
||||
import { Checkbox } from "src/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "src/components/ui/dialog";
|
||||
import { Input } from "src/components/ui/input";
|
||||
import { Label } from "src/components/ui/label";
|
||||
import { ToggleGroup, ToggleGroupItem } from "src/components/ui/toggle-group";
|
||||
import {
|
||||
defaultTransactionFilters,
|
||||
type TransactionFilters,
|
||||
} from "src/hooks/useTransactions";
|
||||
|
||||
const TYPE_OPTIONS: { label: string; value: string }[] = [
|
||||
{ label: "All", value: "all" },
|
||||
{ label: "Sent", value: "outgoing" },
|
||||
{ label: "Received", value: "incoming" },
|
||||
];
|
||||
|
||||
type TransactionsFilterDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
filters: TransactionFilters;
|
||||
onFiltersChange: (filters: TransactionFilters) => void;
|
||||
};
|
||||
|
||||
export function TransactionsFilterDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
filters,
|
||||
onFiltersChange,
|
||||
}: TransactionsFilterDialogProps) {
|
||||
const [searchTerm, setSearchTerm] = React.useState("");
|
||||
const [type, setType] = React.useState("all");
|
||||
const [minAmountSat, setMinAmountSat] = React.useState("");
|
||||
const [hideFailed, setHideFailed] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setSearchTerm(filters.searchTerm ?? "");
|
||||
setType(filters.type ?? "all");
|
||||
setMinAmountSat(filters.minAmountSat ? String(filters.minAmountSat) : "");
|
||||
setHideFailed(!!filters.hideFailed);
|
||||
}
|
||||
}, [open, filters]);
|
||||
|
||||
function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const parsedMinAmountSat = Number(minAmountSat);
|
||||
onFiltersChange({
|
||||
searchTerm: searchTerm.trim() || undefined,
|
||||
type: type === "incoming" || type === "outgoing" ? type : undefined,
|
||||
minAmountSat:
|
||||
Number.isSafeInteger(parsedMinAmountSat) && parsedMinAmountSat > 0
|
||||
? parsedMinAmountSat
|
||||
: undefined,
|
||||
hideFailed,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
onFiltersChange({ ...defaultTransactionFilters });
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form onSubmit={onSubmit}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Filter Transactions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose which payments appear in your transaction list.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-2 mt-5">
|
||||
<Label htmlFor="searchTerm">Search</Label>
|
||||
<Input
|
||||
autoFocus
|
||||
id="searchTerm"
|
||||
type="text"
|
||||
placeholder="Description, payment hash, invoice or label"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2 mt-4">
|
||||
<Label>Direction</Label>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
variant="outline"
|
||||
value={type}
|
||||
onValueChange={(value) => value && setType(value)}
|
||||
>
|
||||
{TYPE_OPTIONS.map((option) => (
|
||||
<ToggleGroupItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<div className="grid gap-2 mt-4">
|
||||
<Label htmlFor="minAmountSat">Minimum amount (sats)</Label>
|
||||
<Input
|
||||
id="minAmountSat"
|
||||
type="number"
|
||||
min="1"
|
||||
placeholder="Show all amounts"
|
||||
value={minAmountSat}
|
||||
onChange={(e) => setMinAmountSat(e.target.value.trim())}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center mt-4">
|
||||
<Checkbox
|
||||
id="hideFailed"
|
||||
checked={hideFailed}
|
||||
onCheckedChange={(checked) => setHideFailed(checked === true)}
|
||||
/>
|
||||
<Label htmlFor="hideFailed" className="ml-2 cursor-pointer">
|
||||
Hide failed payments
|
||||
</Label>
|
||||
</div>
|
||||
<DialogFooter className="mt-5">
|
||||
<Button type="button" variant="secondary" onClick={onReset}>
|
||||
Reset
|
||||
</Button>
|
||||
<Button type="submit">Apply Filters</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +1,63 @@
|
|||
import { DrumIcon } from "lucide-react";
|
||||
import { LucideIcon, ZapIcon } from "lucide-react";
|
||||
import { useRef, useState } from "react";
|
||||
import { CustomPagination } from "src/components/CustomPagination";
|
||||
import EmptyState from "src/components/EmptyState";
|
||||
import Loading from "src/components/Loading";
|
||||
import TransactionItem from "src/components/TransactionItem";
|
||||
import { LIST_TRANSACTIONS_LIMIT } from "src/constants";
|
||||
import { getTransactionsUrl, useTransactions } from "src/hooks/useTransactions";
|
||||
import {
|
||||
getTransactionsUrl,
|
||||
hasActiveTransactionFilters,
|
||||
useTransactions,
|
||||
} from "src/hooks/useTransactions";
|
||||
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
|
||||
|
||||
type TransactionsListProps = {
|
||||
appId?: number;
|
||||
showReceiveButton?: boolean;
|
||||
emptyIcon?: LucideIcon;
|
||||
emptyTitle?: string;
|
||||
emptyDescription?: string;
|
||||
emptyVariant?: "dashed" | "muted" | "none";
|
||||
};
|
||||
|
||||
function TransactionsList({
|
||||
appId,
|
||||
showReceiveButton = true,
|
||||
emptyIcon = ZapIcon,
|
||||
emptyTitle = "No lightning payments yet",
|
||||
emptyDescription = "Your payments will appear here as you start using your wallet.",
|
||||
emptyVariant,
|
||||
}: TransactionsListProps) {
|
||||
const [page, setPage] = useState(1);
|
||||
const { filters } = useTransactionFiltersStore();
|
||||
|
||||
// Reset pagination during render when the filters or app change, so no
|
||||
// request is made for a page that may not exist under the new list.
|
||||
const [prevListIdentity, setPrevListIdentity] = useState({ appId, filters });
|
||||
if (
|
||||
prevListIdentity.appId !== appId ||
|
||||
prevListIdentity.filters !== filters
|
||||
) {
|
||||
setPrevListIdentity({ appId, filters });
|
||||
setPage(1);
|
||||
}
|
||||
|
||||
const transactionListRef = useRef<HTMLDivElement>(null);
|
||||
const transactionListKey = getTransactionsUrl(
|
||||
appId,
|
||||
LIST_TRANSACTIONS_LIMIT,
|
||||
page
|
||||
page,
|
||||
filters
|
||||
);
|
||||
const { data: transactionData, isLoading } = useTransactions(
|
||||
appId,
|
||||
false,
|
||||
LIST_TRANSACTIONS_LIMIT,
|
||||
page
|
||||
page,
|
||||
filters
|
||||
);
|
||||
const transactions = transactionData?.transactions || [];
|
||||
const totalCount = transactionData?.totalCount || 0;
|
||||
const hasActiveFilters = hasActiveTransactionFilters(filters);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setPage(page);
|
||||
|
|
@ -48,13 +75,14 @@ function TransactionsList({
|
|||
<div ref={transactionListRef} className="flex flex-col flex-1">
|
||||
{!transactions.length ? (
|
||||
<EmptyState
|
||||
icon={DrumIcon}
|
||||
title="No transactions yet"
|
||||
description="Your most recent incoming and outgoing payments will show up here."
|
||||
buttonText="Receive Your First Payment"
|
||||
buttonLink="/wallet/receive"
|
||||
showButton={showReceiveButton}
|
||||
showBorder={false}
|
||||
icon={emptyIcon}
|
||||
title={hasActiveFilters ? "No matching payments" : emptyTitle}
|
||||
description={
|
||||
hasActiveFilters
|
||||
? "Try changing your filters to see more payments."
|
||||
: emptyDescription
|
||||
}
|
||||
variant={emptyVariant}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,27 +1,46 @@
|
|||
import { DownloadIcon, EllipsisVerticalIcon } from "lucide-react";
|
||||
import { DownloadIcon, EllipsisVerticalIcon, FunnelIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog";
|
||||
import { Button } from "src/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "src/components/ui/dropdown-menu";
|
||||
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
|
||||
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
|
||||
import { handleExportTransactions } from "./transactions-utils";
|
||||
|
||||
export const TransactionsListMenu = ({ appId }: { appId?: number }) => {
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const { filters, setFilters } = useTransactionFiltersStore();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Button asChild size="icon" variant="ghost">
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</Button>
|
||||
<DropdownMenuContent align="end">
|
||||
<ProDropdownMenuItem onClick={() => handleExportTransactions(appId)}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
Export Transactions
|
||||
</ProDropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<Button asChild size="icon" variant="ghost">
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</Button>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setFilterDialogOpen(true)}>
|
||||
<FunnelIcon className="h-4 w-4" />
|
||||
Filter Transactions
|
||||
</DropdownMenuItem>
|
||||
<ProDropdownMenuItem onClick={() => handleExportTransactions(appId)}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
Export Transactions
|
||||
</ProDropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<TransactionsFilterDialog
|
||||
open={filterDialogOpen}
|
||||
onOpenChange={setFilterDialogOpen}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,9 +4,12 @@ import {
|
|||
CreditCardIcon,
|
||||
DownloadIcon,
|
||||
EllipsisVerticalIcon,
|
||||
FunnelIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import ExternalLink from "src/components/ExternalLink";
|
||||
import { TransactionsFilterDialog } from "src/components/TransactionsFilterDialog";
|
||||
import { Button } from "src/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
|
|
@ -16,55 +19,84 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "src/components/ui/dropdown-menu";
|
||||
import { ProDropdownMenuItem } from "src/components/UpgradeDialog";
|
||||
import useTransactionFiltersStore from "src/state/TransactionFiltersStore";
|
||||
import { handleExportTransactions } from "./transactions-utils";
|
||||
|
||||
export function WalletActionsMenu({
|
||||
hasChannelManagement,
|
||||
isOnchain,
|
||||
}: {
|
||||
hasChannelManagement: boolean;
|
||||
isOnchain: boolean;
|
||||
}) {
|
||||
const [filterDialogOpen, setFilterDialogOpen] = useState(false);
|
||||
const { filters, setFilters } = useTransactionFiltersStore();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<Button asChild size="icon" variant="ghost">
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</Button>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="sm:hidden">
|
||||
{hasChannelManagement && (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<Button
|
||||
asChild
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className={isOnchain ? "sm:hidden" : undefined}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<EllipsisVerticalIcon className="h-4 w-4" />
|
||||
</DropdownMenuTrigger>
|
||||
</Button>
|
||||
<DropdownMenuContent align="end">
|
||||
<div className="sm:hidden">
|
||||
{hasChannelManagement && (
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/wallet/swap" className="w-full cursor-pointer">
|
||||
<ArrowDownUpIcon className="h-4 w-4" />
|
||||
Swap
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/wallet/swap" className="w-full cursor-pointer">
|
||||
<ArrowDownUpIcon className="h-4 w-4" />
|
||||
Swap
|
||||
<Link
|
||||
to="/internal-apps/zapplanner"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<CalendarSyncIcon className="h-4 w-4" />
|
||||
Recurring
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<ExternalLink
|
||||
to="https://www.getalby.com/topup"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<CreditCardIcon className="h-4 w-4" />
|
||||
Buy
|
||||
</ExternalLink>
|
||||
</DropdownMenuItem>
|
||||
{!isOnchain && <DropdownMenuSeparator />}
|
||||
</div>
|
||||
{!isOnchain && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => setFilterDialogOpen(true)}>
|
||||
<FunnelIcon className="h-4 w-4" />
|
||||
Filter Transactions
|
||||
</DropdownMenuItem>
|
||||
<ProDropdownMenuItem onClick={() => handleExportTransactions()}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
Export Transactions
|
||||
</ProDropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuItem asChild>
|
||||
<Link
|
||||
to="/internal-apps/zapplanner"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<CalendarSyncIcon className="h-4 w-4" />
|
||||
Recurring
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<ExternalLink
|
||||
to="https://www.getalby.com/topup"
|
||||
className="w-full cursor-pointer"
|
||||
>
|
||||
<CreditCardIcon className="h-4 w-4" />
|
||||
Buy
|
||||
</ExternalLink>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</div>
|
||||
<ProDropdownMenuItem onClick={() => handleExportTransactions()}>
|
||||
<DownloadIcon className="h-4 w-4" />
|
||||
Export Transactions
|
||||
</ProDropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{!isOnchain && (
|
||||
<TransactionsFilterDialog
|
||||
open={filterDialogOpen}
|
||||
onOpenChange={setFilterDialogOpen}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export function ChannelWaitingForConfirmations({
|
|||
icon={FootprintsIcon}
|
||||
title="Browse While You Wait"
|
||||
description="Feel free to leave this page or browse around Alby Hub! We'll send you an email as soon as your channel is active."
|
||||
variant="dashed"
|
||||
buttonText="Explore Apps"
|
||||
buttonLink="/apps?tab=app-store"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -24,36 +24,27 @@ export function AppStoreDetailHeader({
|
|||
<>
|
||||
<AppHeader
|
||||
pageTitle={appStoreApp.title}
|
||||
title={
|
||||
<>
|
||||
<div className="flex flex-row items-center">
|
||||
<img
|
||||
src={appStoreApp.logo}
|
||||
className="w-14 h-14 rounded-lg mr-4"
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
{appStoreApp.title}
|
||||
{!!connectedApps.length && (
|
||||
<Badge
|
||||
variant="positive"
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<CheckCircleIcon className="w-3 h-3" />{" "}
|
||||
{connectedApps.length > 1
|
||||
? `${connectedApps.length} Connections`
|
||||
: "Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm font-normal text-muted-foreground">
|
||||
{appStoreApp.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
icon={
|
||||
<img
|
||||
src={appStoreApp.logo}
|
||||
alt="logo"
|
||||
className="inline rounded-lg w-14 h-14"
|
||||
/>
|
||||
}
|
||||
description=""
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
{appStoreApp.title}
|
||||
{!!connectedApps.length && (
|
||||
<Badge variant="positive" className="flex items-center gap-1">
|
||||
<CheckCircleIcon className="w-3 h-3" />{" "}
|
||||
{connectedApps.length > 1
|
||||
? `${connectedApps.length} Connections`
|
||||
: "Connected"}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={appStoreApp.description}
|
||||
contentRight={
|
||||
contentRight !== undefined ? (
|
||||
contentRight
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ArrowDownUpIcon } from "lucide-react";
|
||||
import TransactionsList from "src/components/TransactionsList";
|
||||
import { TransactionsListMenu } from "src/components/TransactionsListMenu";
|
||||
import {
|
||||
|
|
@ -15,7 +16,13 @@ export function AppTransactionList({ appId }: { appId: number }) {
|
|||
<TransactionsListMenu appId={appId} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<TransactionsList appId={appId} showReceiveButton={false} />
|
||||
<TransactionsList
|
||||
appId={appId}
|
||||
emptyIcon={ArrowDownUpIcon}
|
||||
emptyTitle="No transactions yet"
|
||||
emptyDescription="Payments made through this app will appear here."
|
||||
emptyVariant="none"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { useLocation } from "react-router";
|
||||
import ExternalLink from "src/components/ExternalLink";
|
||||
|
||||
// The card topup app supports configuration presets selected via a `provider`
|
||||
// query param (e.g. linked from the Cards page). Read it straight from the URL
|
||||
// so the link opens card.albylabs.com pre-configured for the chosen provider.
|
||||
export function BitcoinCardTopupInstallGuide() {
|
||||
const provider = new URLSearchParams(useLocation().search).get("provider");
|
||||
const url = provider
|
||||
? `https://card.albylabs.com?provider=${encodeURIComponent(provider)}`
|
||||
: "https://card.albylabs.com";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>
|
||||
Open{" "}
|
||||
<ExternalLink to={url} className="underline">
|
||||
card.albylabs.com
|
||||
</ExternalLink>{" "}
|
||||
on the device you'll top up from.
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium text-foreground">
|
||||
Add it to your home screen
|
||||
</span>{" "}
|
||||
(or bookmark it) so you can reopen it later.
|
||||
</li>
|
||||
<li>Enter your card's deposit details to set it up.</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -88,6 +88,7 @@ function ConnectedApps() {
|
|||
icon={CableIcon}
|
||||
title="Connect Your First App"
|
||||
description="Connect your app of choice, fine-tune permissions and enjoy a seamless and secure wallet experience."
|
||||
variant="dashed"
|
||||
buttonText="See Recommended Apps"
|
||||
buttonLink="/apps?tab=app-store"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import albyGo from "src/assets/suggested-apps/alby-go.png";
|
|||
import albySandbox from "src/assets/suggested-apps/alby-sandbox.png";
|
||||
import albyCli from "src/assets/suggested-apps/alby.png";
|
||||
import amethyst from "src/assets/suggested-apps/amethyst.png";
|
||||
import bitcoinCardTopup from "src/assets/suggested-apps/bitcoin-card-topup.png";
|
||||
import bitrefill from "src/assets/suggested-apps/bitrefill.png";
|
||||
import bitrequest from "src/assets/suggested-apps/bitrequest.png";
|
||||
import bringin from "src/assets/suggested-apps/bringin.png";
|
||||
|
|
@ -19,7 +20,6 @@ import fountain from "src/assets/suggested-apps/fountain.png";
|
|||
import hablanews from "src/assets/suggested-apps/habla-news.png";
|
||||
import iris from "src/assets/suggested-apps/iris.png";
|
||||
import jumble from "src/assets/suggested-apps/jumble.png";
|
||||
import lendaswap from "src/assets/suggested-apps/lendaswap.png";
|
||||
import lightningMessageboard from "src/assets/suggested-apps/lightning-messageboard.png";
|
||||
import lnbits from "src/assets/suggested-apps/lnbits.png";
|
||||
import lnvps from "src/assets/suggested-apps/lnvps.png";
|
||||
|
|
@ -30,14 +30,13 @@ import nostrcheckserver from "src/assets/suggested-apps/nostrcheck-server.png";
|
|||
import nostrudel from "src/assets/suggested-apps/nostrudel.png";
|
||||
import nostter from "src/assets/suggested-apps/nostter.png";
|
||||
import nostur from "src/assets/suggested-apps/nostur.png";
|
||||
import paperScissorsHodl from "src/assets/suggested-apps/paper-scissors-hodl.png";
|
||||
import payperq from "src/assets/suggested-apps/payperq.png";
|
||||
import primal from "src/assets/suggested-apps/primal.png";
|
||||
import pullthatupjamie from "src/assets/suggested-apps/pullthatupjamie.png";
|
||||
import runstr from "src/assets/suggested-apps/runstr.png";
|
||||
import sats4ai from "src/assets/suggested-apps/sats4ai.png";
|
||||
import satsorter from "src/assets/suggested-apps/sat-sorter.png";
|
||||
import satoshisauctionhouse from "src/assets/suggested-apps/satoshis-auction-house.png";
|
||||
import satoraLogo from "src/assets/suggested-apps/satora.png";
|
||||
import sats4ai from "src/assets/suggested-apps/sats4ai.png";
|
||||
import simpleboost from "src/assets/suggested-apps/simple-boost.png";
|
||||
import snort from "src/assets/suggested-apps/snort.png";
|
||||
import stackernews from "src/assets/suggested-apps/stacker-news.png";
|
||||
|
|
@ -54,12 +53,14 @@ import zapplepay from "src/assets/suggested-apps/zapple-pay.png";
|
|||
import zappybird from "src/assets/suggested-apps/zappy-bird.png";
|
||||
import zapstore from "src/assets/suggested-apps/zapstore.png";
|
||||
import zeus from "src/assets/suggested-apps/zeus.png";
|
||||
import { BitcoinCardTopupInstallGuide } from "src/components/connections/BitcoinCardTopupInstallGuide";
|
||||
import ExternalLink from "src/components/ExternalLink";
|
||||
import { App } from "src/types";
|
||||
|
||||
export type AppStoreApp = {
|
||||
id: string;
|
||||
title: string;
|
||||
legacyTitles?: string[];
|
||||
description: string;
|
||||
extendedDescription: string;
|
||||
|
||||
|
|
@ -83,6 +84,9 @@ export type AppStoreApp = {
|
|||
hideConnectionQr?: boolean;
|
||||
internal?: boolean;
|
||||
superuser?: boolean;
|
||||
// Receive-only apps (e.g. merchant payment receivers) default to read-only
|
||||
// permissions in the new connection flow.
|
||||
readonly?: boolean;
|
||||
addedDate?: string;
|
||||
};
|
||||
|
||||
|
|
@ -219,6 +223,33 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
categories: ["audio"],
|
||||
addedDate: "2026-03-12",
|
||||
},
|
||||
{
|
||||
id: "bitcoin-card-topup",
|
||||
title: "Bitcoin Card Topup",
|
||||
description: "Top up any crypto debit card instantly with bitcoin",
|
||||
logo: bitcoinCardTopup,
|
||||
categories: ["payment-tools"],
|
||||
extendedDescription:
|
||||
"A generic top-up app that swaps Lightning sats to a stablecoin and sends them to your card's deposit address. Works with RedotPay, Freedomia, Nexo, Bybit, and any other card that accepts on-chain crypto deposits.",
|
||||
webLink: "https://card.albylabs.com",
|
||||
installGuide: <BitcoinCardTopupInstallGuide />,
|
||||
finalizeGuide: (
|
||||
<>
|
||||
<div>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>Copy the connection secret below.</li>
|
||||
<li>
|
||||
In the topup app, tap{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Connect Wallet
|
||||
</span>{" "}
|
||||
and paste the connection secret.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "2fiat",
|
||||
title: "2fiat Top up",
|
||||
|
|
@ -768,6 +799,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
},
|
||||
{
|
||||
id: "sat-sorter",
|
||||
readonly: true,
|
||||
title: "Sat Sorter",
|
||||
description: "A Bitcoin Budgeting App",
|
||||
webLink: "https://satsorter.com",
|
||||
|
|
@ -867,6 +899,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
},
|
||||
{
|
||||
id: "bitrequest",
|
||||
readonly: true,
|
||||
title: "Bitrequest",
|
||||
description: "Non-custodial payment requests",
|
||||
webLink: "https://www.bitrequest.io",
|
||||
|
|
@ -889,17 +922,6 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
</ExternalLink>{" "}
|
||||
in your browser, or download the app on iOS or Android
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-4">
|
||||
In the next step, set wallet permissions to{" "}
|
||||
<span className="font-medium text-foreground">Custom</span> and
|
||||
enable:
|
||||
</p>
|
||||
<ul className="list-inside list-disc text-muted-foreground mt-1">
|
||||
<li>Read your node info</li>
|
||||
<li>Create invoices</li>
|
||||
<li>Lookup status of invoices</li>
|
||||
<li>Read transaction history</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
|
|
@ -939,6 +961,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
},
|
||||
{
|
||||
id: "btcpay",
|
||||
readonly: true,
|
||||
title: "BTCPay Server",
|
||||
description: "Bitcoin payment processor",
|
||||
webLink: "https://btcpayserver.org/",
|
||||
|
|
@ -1245,8 +1268,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
title: "wavecard® by wave.space",
|
||||
description:
|
||||
"Spend Bitcoin from your AlbyHub at 150M+ merchants worldwide",
|
||||
webLink:
|
||||
"https://app.wave.space/spend/?utm_source=albyhub&affiliate=AlbyHub",
|
||||
webLink: "https://app.wave.space/?utm_source=albyhub&affiliate=AlbyHub",
|
||||
logo: wavespace,
|
||||
extendedDescription:
|
||||
"The world's first Bitcoin VISA Debit Card that allows you to spend BTC globally, anywhere VISA is accepted – straight from the safety of your own NWC-enabled wallet. ✨ EXCLUSIVE ALBYHUB SPECIAL🐝 → Get 21% cashback on your wavecard transactions (up to 10,000 sats) using code »AlbyHub«",
|
||||
|
|
@ -1258,10 +1280,10 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
<li>
|
||||
Open{" "}
|
||||
<ExternalLink
|
||||
to="https://app.wave.space/spend/?utm_source=albyhub&affiliate=AlbyHub"
|
||||
to="https://app.wave.space/?utm_source=albyhub&affiliate=AlbyHub"
|
||||
className="font-medium text-foreground underline"
|
||||
>
|
||||
wave.space/spend
|
||||
wave.space
|
||||
</ExternalLink>{" "}
|
||||
in your browser and{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
|
|
@ -1427,6 +1449,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
},
|
||||
{
|
||||
id: "clams",
|
||||
readonly: true,
|
||||
title: "Clams",
|
||||
description: "Multi wallet accounting tool",
|
||||
webLink: "https://clams.tech/",
|
||||
|
|
@ -1463,6 +1486,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
},
|
||||
{
|
||||
id: "nostrcheck-server",
|
||||
readonly: true,
|
||||
title: "Nostrcheck Server",
|
||||
description: "Sovereign Nostr services",
|
||||
webLink: "https://github.com/quentintaranpino/nostrcheck-server",
|
||||
|
|
@ -1770,6 +1794,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
},
|
||||
{
|
||||
id: "nakapay",
|
||||
readonly: true,
|
||||
title: "NakaPay",
|
||||
description: "Non-custodial Lightning payments for businesses via NWC",
|
||||
webLink: "https://www.nakapay.app",
|
||||
|
|
@ -2042,47 +2067,6 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
),
|
||||
categories: ["social-media"],
|
||||
},
|
||||
{
|
||||
id: "paper-scissors-hodl",
|
||||
title: "Paper Scissors HODL",
|
||||
description: "Paper Scissors Rock with bitcoin at stake",
|
||||
webLink: "https://paper-scissors-hodl.fly.dev",
|
||||
logo: paperScissorsHodl,
|
||||
extendedDescription:
|
||||
"Uses your Hub to pay to play a round, and receive the reward if you win",
|
||||
installGuide: (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
Open{" "}
|
||||
<ExternalLink
|
||||
to="https://paper-scissors-hodl.fly.dev/"
|
||||
className="font-medium text-foreground underline"
|
||||
>
|
||||
Paper Scissors HODL
|
||||
</ExternalLink>{" "}
|
||||
in your browser
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
finalizeGuide: (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="font-medium">In Paper Scissors HODL</h3>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>Start playing until the Bitcoin Connect screen pops up </li>
|
||||
<li>
|
||||
Choose{" "}
|
||||
<span className="font-medium text-foreground">
|
||||
Nostr Wallet Connect
|
||||
</span>
|
||||
</li>
|
||||
<li>Paste the connection secret from Alby Hub</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
categories: ["games"],
|
||||
},
|
||||
{
|
||||
id: "pullthatupjamie-ai",
|
||||
title: "Pull That Up Jamie!",
|
||||
|
|
@ -2220,10 +2204,40 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
title: "Bitrefill",
|
||||
description: "Live on bitcoin",
|
||||
extendedDescription: "Buy gift cards and e-sims with no KYC",
|
||||
internal: true,
|
||||
webLink: "https://bitrefill.com",
|
||||
logo: bitrefill,
|
||||
categories: ["shopping"],
|
||||
appleLink:
|
||||
"https://apps.apple.com/us/app/bitrefill-esims-gift-cards/id1378102623",
|
||||
playLink:
|
||||
"https://play.google.com/store/apps/details?id=com.bitrefill.app&hl=en",
|
||||
installGuide: (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
Open{" "}
|
||||
<ExternalLink
|
||||
to="https://bitrefill.com"
|
||||
className="font-medium text-foreground underline"
|
||||
>
|
||||
Bitrefill
|
||||
</ExternalLink>{" "}
|
||||
in your browser, or download the app on iOS or Android
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
finalizeGuide: (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="font-medium">In Bitrefill</h3>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>
|
||||
Go to settings {"->"} wallets {"->"} lightning {"->"} payments.
|
||||
</li>
|
||||
<li>Paste the connection secret to connect Alby Hub</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "bringin",
|
||||
|
|
@ -2319,49 +2333,9 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
),
|
||||
categories: ["social-media"],
|
||||
},
|
||||
{
|
||||
id: "satoshis-auction-house",
|
||||
title: "Satoshi's Auction House",
|
||||
description: "Bitcoin-powered auction platform",
|
||||
webLink: "https://satoshisauction.house",
|
||||
logo: satoshisauctionhouse,
|
||||
extendedDescription:
|
||||
"Buy and sell items through Bitcoin-powered auctions directly from your Hub",
|
||||
installGuide: (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
Open{" "}
|
||||
<ExternalLink
|
||||
to="https://satoshisauction.house"
|
||||
className="font-medium text-foreground underline"
|
||||
>
|
||||
Satoshi's Auction House
|
||||
</ExternalLink>{" "}
|
||||
in your browser
|
||||
</p>
|
||||
</>
|
||||
),
|
||||
finalizeGuide: (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="font-medium">In Satoshi's Auction House</h3>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>
|
||||
Click on the Hamburger menu on the top right and click{" "}
|
||||
<span className="font-medium text-foreground">Settings</span>
|
||||
</li>
|
||||
<li>
|
||||
Paste the connection secret from Alby Hub into the receive-only
|
||||
connection secret field
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
categories: ["shopping"],
|
||||
},
|
||||
{
|
||||
id: "takemysats",
|
||||
readonly: true,
|
||||
title: "Take My Sats",
|
||||
description: "Create your online store and accept Bitcoin payments",
|
||||
webLink: "https://www.takemysats.com",
|
||||
|
|
@ -2467,22 +2441,24 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
categories: ["misc"],
|
||||
},
|
||||
{
|
||||
// Keep the legacy app store ID so existing LendaSwap connections still match this entry.
|
||||
id: "lendaswap",
|
||||
title: "LendaSwap",
|
||||
title: "Satora",
|
||||
legacyTitles: ["LendaSwap"],
|
||||
description: "Self-custodial Bitcoin ↔ Stablecoin atomic swaps",
|
||||
webLink: "https://lendaswap.com/?ref=lnds_e3f8dd88_f7db93dbf176",
|
||||
logo: lendaswap,
|
||||
webLink: "https://app.satora.io/?ref=lnds_e3f8dd88_f7db93dbf176",
|
||||
logo: satoraLogo,
|
||||
extendedDescription:
|
||||
"Swap between Lightning Bitcoin and stablecoins (USDC, USDT) on Polygon, Arbitrum, and Ethereum. LendaSwap uses your Hub to pay swap invoices and generate receiving invoices — all self-custodial via HTLCs.",
|
||||
"Swap between Lightning Bitcoin and stablecoins (USDC, USDT) on Polygon, Arbitrum, and Ethereum. Satora uses your Hub to pay swap invoices and generate receiving invoices — all self-custodial via HTLCs.",
|
||||
installGuide: (
|
||||
<>
|
||||
<p className="text-muted-foreground">
|
||||
Open{" "}
|
||||
<ExternalLink
|
||||
to="https://lendaswap.com/?ref=lnds_e3f8dd88_f7db93dbf176"
|
||||
to="https://app.satora.io/?ref=lnds_e3f8dd88_f7db93dbf176"
|
||||
className="font-medium text-foreground underline"
|
||||
>
|
||||
LendaSwap
|
||||
Satora
|
||||
</ExternalLink>{" "}
|
||||
in your browser
|
||||
</p>
|
||||
|
|
@ -2491,7 +2467,7 @@ export const appStoreApps: AppStoreApp[] = (
|
|||
finalizeGuide: (
|
||||
<>
|
||||
<div>
|
||||
<h3 className="font-medium">In LendaSwap</h3>
|
||||
<h3 className="font-medium">In Satora</h3>
|
||||
<ul className="list-inside list-decimal text-muted-foreground">
|
||||
<li>
|
||||
Click the{" "}
|
||||
|
|
@ -2533,6 +2509,7 @@ export const getAppStoreApp = (app: App) => {
|
|||
return appStoreApps.find(
|
||||
(suggestedApp) =>
|
||||
suggestedApp.id === (app.metadata?.app_store_app_id ?? "") ||
|
||||
app.name.includes(suggestedApp.title)
|
||||
app.name.includes(suggestedApp.title) ||
|
||||
suggestedApp.legacyTitles?.some((title) => app.name.includes(title))
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,7 +28,12 @@ function AppCard(app: AppStoreApp) {
|
|||
/>
|
||||
<div className="grow">
|
||||
<CardTitle>{app.title}</CardTitle>
|
||||
<CardDescription>{app.description}</CardDescription>
|
||||
<CardDescription>
|
||||
{app.description}
|
||||
{app.legacyTitles?.length
|
||||
? ` (Previously ${app.legacyTitles.join(", ")})`
|
||||
: ""}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { AlbyHead } from "src/components/images/AlbyHead";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "src/components/ui/card";
|
||||
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
|
||||
import { useInfo } from "src/hooks/useInfo";
|
||||
|
||||
export function AlbyAccountWidget() {
|
||||
const { data: info } = useInfo();
|
||||
|
||||
if (!info || !info.albyAccountConnected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="shrink-0">
|
||||
<AlbyHead className="h-12 w-12 rounded-xl p-1 border" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>
|
||||
<div className="flex-1 leading-5 font-semibold text-xl whitespace-nowrap text-ellipsis overflow-hidden ml-4">
|
||||
Alby Account
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription className="ml-4">
|
||||
Get an Alby Account with a web wallet interface, lightning address
|
||||
and other features.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardFooter className="justify-end px-6 pt-0">
|
||||
<ExternalLinkButton
|
||||
to="https://www.getalby.com/dashboard"
|
||||
variant="outline"
|
||||
>
|
||||
Open Alby Account
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</ExternalLinkButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
import { ExternalLinkIcon } from "lucide-react";
|
||||
import { AlbyHead } from "src/components/images/AlbyHead";
|
||||
import {
|
||||
Card,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "src/components/ui/card";
|
||||
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
|
||||
|
||||
export function AlbyExtensionWidget() {
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
const extensionInstalled = (window as any).alby !== undefined;
|
||||
|
||||
if (extensionInstalled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="shrink-0">
|
||||
<AlbyHead className="w-12 h-12 rounded-xl p-1 border bg-[#FFDF6F]" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>
|
||||
<div className="flex-1 leading-5 font-semibold text-xl whitespace-nowrap text-ellipsis overflow-hidden ml-4">
|
||||
Alby Browser Extension
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription className="ml-4">
|
||||
Seamless bitcoin payments in your favorite internet browser.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardFooter className="justify-end px-6 pt-0">
|
||||
<ExternalLinkButton
|
||||
to="https://getalby.com/products/browser-extension"
|
||||
variant="outline"
|
||||
>
|
||||
Install Alby Extension
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</ExternalLinkButton>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import albyGo from "src/assets/suggested-apps/alby-go.png";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "src/components/ui/card";
|
||||
import { LinkButton } from "src/components/ui/custom/link-button";
|
||||
|
||||
export function AlbyGoWidget() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-row items-center">
|
||||
<div className="shrink-0">
|
||||
<img
|
||||
src={albyGo}
|
||||
alt="Alby Go"
|
||||
className="h-12 w-12 rounded-xl border"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle>
|
||||
<div className="flex-1 leading-5 font-semibold text-xl whitespace-nowrap text-ellipsis overflow-hidden ml-4">
|
||||
Alby Go
|
||||
</div>
|
||||
</CardTitle>
|
||||
<CardDescription className="ml-4">
|
||||
The easiest Bitcoin mobile app that works great with Alby Hub.
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="text-right">
|
||||
<LinkButton to="/appstore/alby-go" variant="outline">
|
||||
Open
|
||||
</LinkButton>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
254
frontend/src/components/home/widgets/StoriesWidget.tsx
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
import { XIcon } from "lucide-react";
|
||||
import React from "react";
|
||||
import { Link } from "react-router";
|
||||
import useSWR from "swr";
|
||||
import ExternalLink from "src/components/ExternalLink";
|
||||
import { Button } from "src/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "src/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "src/components/ui/dialog";
|
||||
import { localStorageKeys } from "src/constants";
|
||||
import { cn } from "src/lib/utils";
|
||||
import { swrFetcher } from "src/utils/swr";
|
||||
|
||||
type StoryCta = {
|
||||
label: string;
|
||||
url: string;
|
||||
openInNewTab: boolean;
|
||||
};
|
||||
|
||||
type Story = {
|
||||
id: string;
|
||||
title: string;
|
||||
avatar: string;
|
||||
videoId?: string;
|
||||
cta?: StoryCta;
|
||||
};
|
||||
|
||||
type StoryApiResponse = {
|
||||
id: number;
|
||||
title: string;
|
||||
avatar: string;
|
||||
videoId?: string;
|
||||
cta?: StoryCta;
|
||||
};
|
||||
|
||||
function youTubeEmbedUrl(videoId: string) {
|
||||
return `https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1&rel=0`;
|
||||
}
|
||||
|
||||
function loadViewedStoryIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(localStorageKeys.homeStoriesViewed);
|
||||
if (!raw) {
|
||||
return new Set();
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) {
|
||||
return new Set();
|
||||
}
|
||||
return new Set(parsed.filter((id): id is string => typeof id === "string"));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function persistViewedStoryIds(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
localStorageKeys.homeStoriesViewed,
|
||||
JSON.stringify([...ids])
|
||||
);
|
||||
} catch {
|
||||
// ignore quota / private mode
|
||||
}
|
||||
}
|
||||
|
||||
function StoryAvatar({ story, viewed }: { story: Story; viewed: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative box-border flex size-16 shrink-0 items-center justify-center rounded-full border-2 p-0.5",
|
||||
viewed ? "border-accent" : "border-primary"
|
||||
)}
|
||||
>
|
||||
<div className="relative flex size-full items-center justify-center overflow-hidden rounded-full bg-white dark:bg-muted">
|
||||
<img
|
||||
src={story.avatar}
|
||||
alt={`${story.title} story`}
|
||||
className="size-full rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StoriesWidget() {
|
||||
const { data, error, isLoading } = useSWR<StoryApiResponse[]>(
|
||||
"/api/alby/stories",
|
||||
swrFetcher
|
||||
);
|
||||
const [activeStory, setActiveStory] = React.useState<Story | null>(null);
|
||||
const [viewedIds, setViewedIds] =
|
||||
React.useState<Set<string>>(loadViewedStoryIds);
|
||||
|
||||
const stories = React.useMemo<Story[]>(
|
||||
() =>
|
||||
error || !data
|
||||
? []
|
||||
: data.map((story) => ({
|
||||
id: String(story.id),
|
||||
title: story.title,
|
||||
avatar: story.avatar,
|
||||
videoId: story.videoId,
|
||||
cta: story.cta,
|
||||
})),
|
||||
[data, error]
|
||||
);
|
||||
|
||||
const markStoryViewed = React.useCallback((storyId: string) => {
|
||||
setViewedIds((prev) => {
|
||||
if (prev.has(storyId)) {
|
||||
return prev;
|
||||
}
|
||||
const next = new Set(prev);
|
||||
next.add(storyId);
|
||||
persistViewedStoryIds(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!isLoading && stories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="overflow-hidden rounded-[14px] shadow-none">
|
||||
<CardHeader className="px-6 pb-0">
|
||||
<CardTitle className="text-base font-semibold">Stories</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-0 py-0">
|
||||
<div className="flex gap-3 overflow-x-auto px-6 pb-1">
|
||||
{isLoading && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Loading stories...
|
||||
</span>
|
||||
)}
|
||||
{!isLoading &&
|
||||
stories.map((story) => {
|
||||
const viewed = viewedIds.has(story.id);
|
||||
return (
|
||||
<button
|
||||
key={story.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
markStoryViewed(story.id);
|
||||
setActiveStory(story);
|
||||
}}
|
||||
className="flex w-21 shrink-0 flex-col items-center gap-2 text-center"
|
||||
>
|
||||
<StoryAvatar story={story} viewed={viewed} />
|
||||
<span
|
||||
className={cn(
|
||||
"w-full truncate text-xs leading-tight",
|
||||
viewed
|
||||
? "font-medium text-muted-foreground"
|
||||
: "font-semibold text-foreground"
|
||||
)}
|
||||
>
|
||||
{story.title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={!!activeStory}
|
||||
onOpenChange={(open) => !open && setActiveStory(null)}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="w-[95vw] max-w-[min(95vw,calc((90vh-80px)*16/9))] sm:max-w-[min(95vw,calc((90vh-80px)*16/9))] max-h-[90vh] overflow-hidden border-0 bg-zinc-950 p-0 text-white sm:rounded-2xl"
|
||||
>
|
||||
{activeStory && (
|
||||
<div className="flex flex-col">
|
||||
<DialogTitle className="sr-only">{activeStory.title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Watch the latest update
|
||||
</DialogDescription>
|
||||
|
||||
{activeStory.videoId && (
|
||||
<div className="relative aspect-video w-full overflow-hidden rounded-t-2xl bg-black [transform:translateZ(0)]">
|
||||
<iframe
|
||||
className="absolute inset-0 size-full"
|
||||
src={youTubeEmbedUrl(activeStory.videoId)}
|
||||
title={activeStory.title}
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
// the server's global no-referrer policy breaks YouTube
|
||||
// embeds (error 153); send the origin for this frame only
|
||||
referrerPolicy="strict-origin-when-cross-origin"
|
||||
allowFullScreen
|
||||
/>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-3 top-3 z-10 rounded-full bg-black/60 text-white backdrop-blur hover:bg-black/80 hover:text-white"
|
||||
>
|
||||
<XIcon className="size-5" />
|
||||
<span className="sr-only">Close story</span>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeStory.videoId && (
|
||||
<div className="flex items-center justify-between gap-3 px-6 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-base font-semibold text-white">
|
||||
{activeStory.title}
|
||||
</div>
|
||||
</div>
|
||||
{activeStory.cta && (
|
||||
<div className="flex items-center gap-2">
|
||||
{activeStory.cta.openInNewTab ? (
|
||||
<ExternalLink
|
||||
to={activeStory.cta.url}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{activeStory.cta.label}
|
||||
</ExternalLink>
|
||||
) : (
|
||||
<Link
|
||||
to={activeStory.cta.url}
|
||||
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground transition-colors hover:bg-primary/90"
|
||||
>
|
||||
{activeStory.cta.label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/icons/BitcoinPayment.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function BitcoinPaymentIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M63.0394 39.7416C58.7654 56.8846 41.4024 67.3176 24.2574 63.0426C7.11937 58.7686 -3.31363 41.4046 0.962367 24.2626C5.23437 7.1176 22.5974 -3.3164 39.7374 0.957596C56.8814 5.2316 67.3134 22.5976 63.0394 39.7416Z"
|
||||
fill="#FB923C"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M33.0171 11.2938C34.6304 11.7261 35.5879 13.3844 35.1556 14.9977L34.8575 16.11L36.1559 16.4579L36.4539 15.3456C36.8862 13.7323 38.5445 12.7748 40.1579 13.2071C41.7712 13.6394 42.7286 15.2977 42.2963 16.9111L41.9448 18.2229C45.7919 20.1883 47.8803 24.6345 46.7172 28.9754C46.2003 30.9045 45.1168 32.5306 43.6938 33.7225C45.3314 35.9478 45.9793 38.8701 45.2085 41.7464L45.1384 42.0082C43.831 46.8875 38.8157 49.783 33.9365 48.4757L33.8452 48.4512L33.5472 49.5635C33.1149 51.1768 31.4565 52.1342 29.8432 51.7019C28.2298 51.2696 27.2724 49.6113 27.7047 47.998L28.0027 46.8857L26.7044 46.5378L26.4064 47.6501C25.9741 49.2634 24.3158 50.2209 22.7024 49.7886C21.0891 49.3563 20.1316 47.698 20.5639 46.0846L20.862 44.9723L16.6424 43.8417C15.0291 43.4094 14.0716 41.7511 14.5039 40.1378C14.9362 38.5244 16.5946 37.567 18.2079 37.9993L19.0671 38.2295L24.0892 19.4866L23.2301 19.2563C21.6167 18.824 20.6593 16.5946 21.0916 15.5524C21.5239 13.939 23.1822 12.9816 24.7955 13.4139L29.0151 14.5445L29.3131 13.4322C29.7454 11.8189 31.4037 10.8615 33.0171 11.2938ZM29.9317 21.052L28.2188 27.4445L36.8221 29.7497C38.5873 30.2227 40.4017 29.1752 40.8747 27.4099C41.3477 25.6447 40.3001 23.8303 38.5349 23.3573L29.9317 21.052ZM26.6533 33.287L24.9095 39.795L35.502 42.6332C37.1545 43.076 38.8531 42.0953 39.296 40.4427L39.3661 40.181C39.8476 38.3838 38.7811 36.5366 36.984 36.055L26.6533 33.287Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
28
frontend/src/components/icons/GooglePay.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function GooglePayIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
width="16"
|
||||
height="16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.5 8a5.5 5.5 0 1 1-2.4-4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M8.5 8h5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ export function LightningIcon(props: SVGAttributes<SVGElement>) {
|
|||
/>
|
||||
<path
|
||||
d="M33.3099 12.4881C32.5628 12.3958 31.9053 12.6446 31.3666 13.0015C30.8466 13.346 30.3632 13.8383 29.8999 14.3907C28.9936 15.4713 27.9225 17.0832 26.5847 19.0983L19.0419 30.4596C18.1163 31.8537 17.3375 33.0266 16.8825 33.9955C16.4166 34.9876 16.1007 36.1567 16.7178 37.3017C17.335 38.4468 18.4871 38.8294 19.5742 38.9896C20.6357 39.1461 22.0465 39.146 23.7235 39.146H24.9235C25.8369 39.146 26.4043 39.1481 26.8221 39.197C27.2053 39.2418 27.2901 39.3106 27.3097 39.3265C27.3533 39.3618 27.3937 39.4022 27.4291 39.4457C27.445 39.4652 27.5135 39.549 27.5584 39.9311C27.6074 40.3476 27.6096 40.9132 27.6096 41.8239V41.9194C27.6095 44.3546 27.6095 46.3017 27.7679 47.7093C27.8484 48.4249 27.979 49.1014 28.2219 49.6748C28.4735 50.2688 28.884 50.8381 29.5581 51.1722C29.9135 51.3484 30.2962 51.4633 30.6901 51.5119C31.4372 51.6042 32.0947 51.3554 32.6334 50.9985C33.1534 50.654 33.6368 50.1617 34.1001 49.6093C35.0114 48.5227 36.0894 46.899 37.4375 44.8683L44.9581 33.5404C45.8837 32.1463 46.6625 30.9734 47.1175 30.0045C47.5834 29.0124 47.8993 27.8433 47.2822 26.6983C46.665 25.5532 45.5129 25.1706 44.4258 25.0104C43.3643 24.8539 41.9535 24.854 40.2765 24.854L39.0765 24.854C38.1631 24.854 37.5957 24.8519 37.1779 24.803C36.7947 24.7582 36.7099 24.6894 36.6903 24.6735C36.6467 24.6382 36.6063 24.5978 36.5709 24.5543C36.555 24.5348 36.4865 24.4509 36.4416 24.0689C36.3926 23.6524 36.3904 23.0868 36.3904 22.1761V22.0806C36.3905 19.6454 36.3905 17.6982 36.2321 16.2907C36.1516 15.5751 36.021 14.8986 35.7781 14.3252C35.5265 13.7312 35.116 13.1619 34.4419 12.8278C34.0865 12.6516 33.7038 12.5367 33.3099 12.4881Z"
|
||||
fill="white"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
|
|
|||
19
frontend/src/components/icons/MastercardLogo.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function MastercardLogo(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 152 108"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-label="Mastercard"
|
||||
>
|
||||
<circle cx="60" cy="54" r="36" fill="#EB001B" />
|
||||
<circle cx="92" cy="54" r="36" fill="#F79E1B" />
|
||||
<path
|
||||
fill="#FF5F00"
|
||||
d="M 76 21.75 A 36 36 0 0 1 76 86.25 A 36 36 0 0 1 76 21.75 Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/icons/VisaLogo.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function VisaLogo(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
{...props}
|
||||
viewBox="0 0 750 471"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
aria-label="Visa"
|
||||
>
|
||||
<path d="M278.197 334.228l32.71-202.319h52.298l-32.74 202.319zM520.084 137.092c-10.37-3.99-26.62-8.27-46.91-8.27-51.71 0-88.13 26.71-88.45 65.01-.29 28.31 26.06 44.1 45.95 53.51 20.41 9.65 27.27 15.81 27.18 24.42-.14 13.18-16.13 19.21-31.06 19.21-20.78 0-31.83-2.97-48.89-10.27l-6.69-3.13-7.29 43.84c12.13 5.46 34.55 10.19 57.83 10.43 54.99 0 90.71-26.4 91.11-67.31.21-22.41-13.71-39.46-43.86-53.51-18.27-9.11-29.45-15.18-29.33-24.41 0-8.19 9.55-16.94 30.18-16.94 17.24-.27 29.72 3.59 39.45 7.58l4.72 2.3 7.13-42.45zM653.929 131.909h-40.42c-12.53 0-21.89 3.5-27.4 16.32l-77.67 186h54.93s8.98-24.34 11.01-29.69c6 0 59.36.08 86.99.08 1.57 6.92 6.41 29.61 6.41 29.61h48.54l-42.39-202.32zm-64.42 130.13c4.33-11.34 20.85-54.31 20.85-54.31-.31.52 4.3-11.4 6.94-18.79l3.54 16.97s10.02 46.18 12.11 55.6h-43.45v0.53zM233.585 131.909l-51.21 137.97-5.46-27.16c-9.53-31.32-39.21-65.25-72.4-82.24l46.83 173.59 55.34-.06 82.37-202.1h-55.47" />
|
||||
<path d="M132.221 131.909H47.851l-.67 4.13c65.66 16.06 109.11 54.93 127.13 101.65l-18.34-89.16c-3.17-12.46-12.35-16.25-23.71-16.62" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import { useInfo } from "src/hooks/useInfo";
|
|||
|
||||
import {
|
||||
ArrowRightLeftIcon,
|
||||
BoxIcon,
|
||||
BugIcon,
|
||||
CloudBackupIcon,
|
||||
CodeIcon,
|
||||
|
|
@ -162,6 +163,11 @@ export default function SettingsLayout() {
|
|||
</NavGroup>
|
||||
|
||||
<NavGroup label="Advanced">
|
||||
{info?.backendType === "LDK" && (
|
||||
<MenuItem to="/settings/node" icon={BoxIcon}>
|
||||
Node
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem to="/settings/developer" icon={CodeIcon}>
|
||||
Developer
|
||||
</MenuItem>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
CalendarSyncIcon,
|
||||
CreditCardIcon,
|
||||
} from "lucide-react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Outlet, useMatch } from "react-router";
|
||||
import AppHeader from "src/components/AppHeader";
|
||||
import Loading from "src/components/Loading";
|
||||
import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
|
||||
|
|
@ -17,6 +17,7 @@ export default function WalletLayout() {
|
|||
useSyncWallet();
|
||||
const { data: info, hasChannelManagement } = useInfo();
|
||||
const { data: balances } = useBalances(true);
|
||||
const isOnchain = !!useMatch("/wallet/onchain");
|
||||
|
||||
if (!info || !balances) {
|
||||
return <Loading />;
|
||||
|
|
@ -59,7 +60,10 @@ export default function WalletLayout() {
|
|||
<CreditCardIcon />
|
||||
Buy
|
||||
</ExternalLinkButton>
|
||||
<WalletActionsMenu hasChannelManagement={!!hasChannelManagement} />
|
||||
<WalletActionsMenu
|
||||
hasChannelManagement={!!hasChannelManagement}
|
||||
isOnchain={isOnchain}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ export function HomeRedirect() {
|
|||
return;
|
||||
}
|
||||
|
||||
if (info.nodeMigrationFileCreated) {
|
||||
// the hub is halted after creating a migration file and should not be
|
||||
// used anymore, so keep showing the migration success instructions
|
||||
navigate("/create-node-migration-file-success", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const setupReturnTo = window.localStorage.getItem(
|
||||
localStorageKeys.setupReturnTo
|
||||
);
|
||||
|
|
|
|||
|
|
@ -9,6 +9,12 @@ export function StartRedirect({ children }: React.PropsWithChildren) {
|
|||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (info?.nodeMigrationFileCreated) {
|
||||
// the hub is halted after creating a migration file and should not be
|
||||
// used anymore, so keep showing the migration success instructions
|
||||
navigate("/create-node-migration-file-success", { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!info || (info.setupCompleted && !info.running)) {
|
||||
if (info && !info.albyAccountConnected && info.albyUserIdentifier) {
|
||||
navigate("/alby/auth");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import { toast } from "sonner";
|
||||
import { LIST_TRANSACTIONS_LIMIT } from "src/constants";
|
||||
import { ListTransactionsResponse, Transaction } from "src/types";
|
||||
import { request } from "src/utils/request";
|
||||
|
||||
const LABEL_COLUMN_PREFIX = "label_";
|
||||
|
||||
// Use a large page size to keep the number of round-trips low when
|
||||
// exporting wallets that have thousands of transactions.
|
||||
const EXPORT_TRANSACTIONS_PAGE_SIZE = 1000;
|
||||
|
||||
// based on https://stackoverflow.com/a/68146412
|
||||
const escapeCsvCell = (raw: string) => {
|
||||
const safe = /^[\t\r ]*[=+\-@]/.test(raw) ? `'${raw}` : raw;
|
||||
|
|
@ -57,13 +60,14 @@ export const convertToCSV = (transactions: Transaction[]) => {
|
|||
};
|
||||
|
||||
export const handleExportTransactions = async (appId?: number) => {
|
||||
const toastId = toast.loading("Exporting transactions…");
|
||||
try {
|
||||
// Fetch all transactions by paginating through all pages
|
||||
let allTransactions: Transaction[] = [];
|
||||
let offset = 0;
|
||||
|
||||
while (true) {
|
||||
let url = `/api/transactions?limit=${LIST_TRANSACTIONS_LIMIT}&offset=${offset}`;
|
||||
let url = `/api/transactions?limit=${EXPORT_TRANSACTIONS_PAGE_SIZE}&offset=${offset}`;
|
||||
if (appId) {
|
||||
url += `&appId=${appId}`;
|
||||
}
|
||||
|
|
@ -75,11 +79,15 @@ export const handleExportTransactions = async (appId?: number) => {
|
|||
}
|
||||
|
||||
allTransactions = [...allTransactions, ...data.transactions];
|
||||
toast.loading(
|
||||
`Exporting ${allTransactions.length.toLocaleString()} transactions…`,
|
||||
{ id: toastId }
|
||||
);
|
||||
|
||||
if (data.transactions.length < LIST_TRANSACTIONS_LIMIT) {
|
||||
if (data.transactions.length < EXPORT_TRANSACTIONS_PAGE_SIZE) {
|
||||
break;
|
||||
}
|
||||
offset += LIST_TRANSACTIONS_LIMIT;
|
||||
offset += EXPORT_TRANSACTIONS_PAGE_SIZE;
|
||||
}
|
||||
|
||||
// Convert to CSV and create download
|
||||
|
|
@ -96,9 +104,11 @@ export const handleExportTransactions = async (appId?: number) => {
|
|||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast("Transactions saved to your downloads folder");
|
||||
toast.success("Transactions saved to your downloads folder", {
|
||||
id: toastId,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error downloading transactions:", error);
|
||||
toast.error("Failed to export transactions");
|
||||
toast.error("Failed to export transactions", { id: toastId });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
83
frontend/src/components/ui/toggle-group.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
import { toggleVariants } from "src/components/ui/toggleVariants";
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
});
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number;
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem };
|
||||
24
frontend/src/components/ui/toggle.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import * as React from "react";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { Toggle as TogglePrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "src/lib/utils";
|
||||
import { toggleVariants } from "src/components/ui/toggleVariants";
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Toggle };
|
||||
23
frontend/src/components/ui/toggleVariants.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { cva } from "class-variance-authority";
|
||||
|
||||
export const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[color,box-shadow] outline-none hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2",
|
||||
sm: "h-8 min-w-8 px-1.5",
|
||||
lg: "h-10 min-w-10 px-2.5",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
|
@ -5,6 +5,8 @@ export const localStorageKeys = {
|
|||
authToken: "authToken",
|
||||
supportAlbySidebarHintHiddenUntil: "supportAlbySidebarHintHiddenUntil",
|
||||
aiHeroDismissed: "aiHeroDismissed",
|
||||
cardsHeroDismissed: "cardsHeroDismissed",
|
||||
homeStoriesViewed: "homeStoriesViewed",
|
||||
};
|
||||
|
||||
export const ONCHAIN_DUST_SATS = 1000;
|
||||
|
|
@ -14,6 +16,7 @@ export const ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL = 10_000;
|
|||
export const LIST_TRANSACTIONS_LIMIT = 20;
|
||||
export const LIST_APPS_LIMIT = 20;
|
||||
export const MAX_FREE_SUBWALLETS = 3;
|
||||
export const APP_SELECT_APPS_LIMIT = 100;
|
||||
export const PAY_FROM_SELECT_APPS_LIMIT = 100;
|
||||
|
||||
export const SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { BalancesResponse } from "src/types";
|
|||
import { swrFetcher } from "src/utils/swr";
|
||||
|
||||
const pollConfiguration: SWRConfiguration = {
|
||||
refreshInterval: 3000,
|
||||
refreshInterval: 10000,
|
||||
};
|
||||
|
||||
export function useBalances(poll = false) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { ALBY_ACCOUNT_APP_NAME } from "src/constants";
|
||||
import { useAlbyMe } from "src/hooks/useAlbyMe";
|
||||
import { useApps } from "src/hooks/useApps";
|
||||
import { useBalances } from "src/hooks/useBalances";
|
||||
import { useChannels } from "src/hooks/useChannels";
|
||||
import { useInfo } from "src/hooks/useInfo";
|
||||
import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo";
|
||||
|
|
@ -24,6 +25,7 @@ interface UseOnboardingDataResponse {
|
|||
export const useOnboardingData = (): UseOnboardingDataResponse => {
|
||||
const { data: albyMe } = useAlbyMe();
|
||||
const { data: appsData } = useApps();
|
||||
const { data: balances } = useBalances();
|
||||
const { data: channels } = useChannels();
|
||||
const { data: info, hasChannelManagement, hasMnemonic } = useInfo();
|
||||
const { data: nodeConnectionInfo } = useNodeConnectionInfo();
|
||||
|
|
@ -31,6 +33,7 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
|
|||
|
||||
const isLoading =
|
||||
!appsData ||
|
||||
!balances ||
|
||||
!channels ||
|
||||
!info ||
|
||||
!nodeConnectionInfo ||
|
||||
|
|
@ -55,10 +58,12 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
|
|||
const hasCustomApp =
|
||||
appsData &&
|
||||
appsData.apps.find((x) => x.name !== ALBY_ACCOUNT_APP_NAME) !== undefined;
|
||||
const hasTransaction = transactions.totalCount > 0;
|
||||
const hasFundsOrTransaction =
|
||||
transactions.totalCount > 0 || balances.lightning.totalSpendableSat > 0;
|
||||
|
||||
const checklistItems: Omit<ChecklistItem, "disabled">[] = [
|
||||
...(hasChannelManagement
|
||||
...(hasChannelManagement &&
|
||||
!(info.jitChannelsEnabled && info.jitChannelsLiquiditySource)
|
||||
? [
|
||||
{
|
||||
title: "Open your first channel",
|
||||
|
|
@ -81,10 +86,9 @@ export const useOnboardingData = (): UseOnboardingDataResponse => {
|
|||
]
|
||||
: []),
|
||||
{
|
||||
title: "Send or receive your first payment",
|
||||
description:
|
||||
"Use your newly opened channel to make a transaction on the Lightning Network.",
|
||||
checked: hasTransaction,
|
||||
title: "Receive your first payment",
|
||||
description: "Add funds to your wallet to start using it.",
|
||||
checked: hasFundsOrTransaction,
|
||||
to: "/wallet",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import React from "react";
|
|||
|
||||
import { isHttpMode } from "src/utils/isHttpMode";
|
||||
|
||||
const STORAGE_KEY = "bitcoin-protocol-handler-registered";
|
||||
|
||||
export function useRegisterProtocolHandler(basePath: string) {
|
||||
React.useEffect(() => {
|
||||
if (!isHttpMode() || !("registerProtocolHandler" in navigator)) {
|
||||
|
|
@ -9,9 +11,20 @@ export function useRegisterProtocolHandler(basePath: string) {
|
|||
}
|
||||
|
||||
try {
|
||||
// Browsers re-prompt every time registerProtocolHandler is called if the
|
||||
// user previously dismissed the prompt without accepting or denying it.
|
||||
// Limit to once per browser session so we don't ask on every page load,
|
||||
// but users still get re-asked in a new session if they didn't opt in.
|
||||
// sessionStorage access can throw in restricted/private modes, so it's
|
||||
// inside the same try/catch as registerProtocolHandler.
|
||||
if (sessionStorage.getItem(STORAGE_KEY)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalizedBasePath = basePath.replace(/\/$/, "");
|
||||
const handlerUrl = `${window.location.origin}${normalizedBasePath}/wallet/send?bip21=%s`;
|
||||
navigator.registerProtocolHandler("bitcoin", handlerUrl);
|
||||
sessionStorage.setItem(STORAGE_KEY, "true");
|
||||
} catch (e) {
|
||||
console.error("Failed to register bitcoin protocol handler", e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,56 @@ import { ListTransactionsResponse } from "src/types";
|
|||
import { swrFetcher } from "src/utils/swr";
|
||||
|
||||
const pollConfiguration: SWRConfiguration = {
|
||||
refreshInterval: 3000,
|
||||
refreshInterval: 10000,
|
||||
};
|
||||
|
||||
export function getTransactionsUrl(appId?: number, limit = 100, page = 1) {
|
||||
export type TransactionFilters = {
|
||||
searchTerm?: string;
|
||||
type?: "incoming" | "outgoing";
|
||||
minAmountSat?: number;
|
||||
hideFailed?: boolean;
|
||||
};
|
||||
|
||||
export const defaultTransactionFilters: TransactionFilters = {};
|
||||
|
||||
export function hasActiveTransactionFilters(filters: TransactionFilters) {
|
||||
return (
|
||||
!!filters.searchTerm ||
|
||||
!!filters.type ||
|
||||
(filters.minAmountSat ?? 0) > 0 ||
|
||||
!!filters.hideFailed
|
||||
);
|
||||
}
|
||||
|
||||
export function getTransactionsUrl(
|
||||
appId?: number,
|
||||
limit = 100,
|
||||
page = 1,
|
||||
filters?: TransactionFilters
|
||||
) {
|
||||
const offset = (page - 1) * limit;
|
||||
let url = `/api/transactions?limit=${limit}&offset=${offset}`;
|
||||
const searchParams = new URLSearchParams({
|
||||
limit: String(limit),
|
||||
offset: String(offset),
|
||||
});
|
||||
|
||||
if (appId) {
|
||||
url += `&appId=${appId}`;
|
||||
searchParams.set("appId", String(appId));
|
||||
}
|
||||
if (filters?.searchTerm) {
|
||||
searchParams.set("search", filters.searchTerm);
|
||||
}
|
||||
if (filters?.type) {
|
||||
searchParams.set("type", filters.type);
|
||||
}
|
||||
if (filters?.minAmountSat && filters.minAmountSat > 0) {
|
||||
searchParams.set("minAmountSat", String(filters.minAmountSat));
|
||||
}
|
||||
if (filters?.hideFailed) {
|
||||
searchParams.set("hideFailed", "true");
|
||||
}
|
||||
|
||||
const url = `/api/transactions?${searchParams.toString()}`;
|
||||
|
||||
return url;
|
||||
}
|
||||
|
|
@ -21,9 +62,10 @@ export function useTransactions(
|
|||
appId?: number,
|
||||
poll = false,
|
||||
limit = 100,
|
||||
page = 1
|
||||
page = 1,
|
||||
filters?: TransactionFilters
|
||||
) {
|
||||
const url = getTransactionsUrl(appId, limit, page);
|
||||
const url = getTransactionsUrl(appId, limit, page, filters);
|
||||
|
||||
return useSWR<ListTransactionsResponse>(
|
||||
url,
|
||||
|
|
|
|||
|
|
@ -1,35 +0,0 @@
|
|||
import { BackendType } from "src/types";
|
||||
|
||||
type BackendTypeConfig = {
|
||||
hasMnemonic: boolean;
|
||||
hasChannelManagement: boolean;
|
||||
hasNodeBackup: boolean;
|
||||
};
|
||||
|
||||
export const backendTypeConfigs: Record<BackendType, BackendTypeConfig> = {
|
||||
LND: {
|
||||
hasMnemonic: false,
|
||||
hasChannelManagement: true,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
LDK: {
|
||||
hasMnemonic: true,
|
||||
hasChannelManagement: true,
|
||||
hasNodeBackup: true,
|
||||
},
|
||||
PHOENIX: {
|
||||
hasMnemonic: false,
|
||||
hasChannelManagement: false,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
CASHU: {
|
||||
hasMnemonic: true,
|
||||
hasChannelManagement: false,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
CLN: {
|
||||
hasMnemonic: false,
|
||||
hasChannelManagement: true,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
};
|
||||
68
frontend/src/lib/backendType.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { ReactElement } from "react";
|
||||
import { LDKIcon } from "src/components/icons/LDK";
|
||||
import { PhoenixdIcon } from "src/components/icons/Phoenixd";
|
||||
import { BackendType } from "src/types";
|
||||
|
||||
import barkDark from "src/assets/images/node/bark-dark.svg";
|
||||
import barkLight from "src/assets/images/node/bark-light.svg";
|
||||
import cashu from "src/assets/images/node/cashu.png";
|
||||
import cln from "src/assets/images/node/cln.png";
|
||||
import lnd from "src/assets/images/node/lnd.png";
|
||||
|
||||
type BackendTypeConfig = {
|
||||
title: string;
|
||||
icon: ReactElement;
|
||||
hasMnemonic: boolean;
|
||||
hasChannelManagement: boolean;
|
||||
hasNodeBackup: boolean;
|
||||
};
|
||||
|
||||
export const backendTypeConfigs: Record<BackendType, BackendTypeConfig> = {
|
||||
LDK: {
|
||||
title: "LDK",
|
||||
icon: <LDKIcon />,
|
||||
hasMnemonic: true,
|
||||
hasChannelManagement: true,
|
||||
hasNodeBackup: true,
|
||||
},
|
||||
PHOENIX: {
|
||||
title: "phoenixd",
|
||||
icon: <PhoenixdIcon />,
|
||||
hasMnemonic: false,
|
||||
hasChannelManagement: false,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
LND: {
|
||||
title: "LND",
|
||||
icon: <img src={lnd} />,
|
||||
hasMnemonic: false,
|
||||
hasChannelManagement: true,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
CASHU: {
|
||||
title: "Cashu Mint",
|
||||
icon: <img src={cashu} />,
|
||||
hasMnemonic: true,
|
||||
hasChannelManagement: false,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
CLN: {
|
||||
title: "CLN",
|
||||
icon: <img src={cln} />,
|
||||
hasMnemonic: false,
|
||||
hasChannelManagement: true,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
BARK: {
|
||||
title: "Bark",
|
||||
icon: (
|
||||
<>
|
||||
<img src={barkLight} className="dark:hidden" />
|
||||
<img src={barkDark} className="hidden dark:block" />
|
||||
</>
|
||||
),
|
||||
hasMnemonic: true,
|
||||
hasChannelManagement: false,
|
||||
hasNodeBackup: false,
|
||||
},
|
||||
};
|
||||
|
|
@ -18,6 +18,7 @@ import Unlock from "src/screens/Unlock";
|
|||
import { Welcome } from "src/screens/Welcome";
|
||||
import AlbyAuthRedirect from "src/screens/alby/AlbyAuthRedirect";
|
||||
import { AI } from "src/screens/ai/AI";
|
||||
import { Cards } from "src/screens/cards/Cards";
|
||||
import { AlbyEarn } from "src/screens/alby/AlbyEarn";
|
||||
import SupportAlby from "src/screens/alby/SupportAlby";
|
||||
import AppDetails from "src/screens/apps/AppDetails";
|
||||
|
|
@ -36,7 +37,6 @@ import { FirstChannel } from "src/screens/channels/first/FirstChannel";
|
|||
import { OpenedFirstChannel } from "src/screens/channels/first/OpenedFirstChannel";
|
||||
import { OpeningFirstChannel } from "src/screens/channels/first/OpeningFirstChannel";
|
||||
import { AlbyCliSkill } from "src/screens/internal-apps/AlbyCliSkill";
|
||||
import { Bitrefill } from "src/screens/internal-apps/Bitrefill";
|
||||
import { BuzzPay } from "src/screens/internal-apps/BuzzPay";
|
||||
import { LightningMessageboard } from "src/screens/internal-apps/LightningMessageboard";
|
||||
import { SimpleBoost } from "src/screens/internal-apps/SimpleBoost";
|
||||
|
|
@ -49,6 +49,7 @@ import Peers from "src/screens/peers/Peers";
|
|||
import { About } from "src/screens/settings/About";
|
||||
import { AlbyAccount } from "src/screens/settings/AlbyAccount";
|
||||
import { AutoUnlock } from "src/screens/settings/AutoUnlock";
|
||||
import { NodeSettings } from "src/screens/settings/NodeSettings";
|
||||
import Backup from "src/screens/settings/Backup";
|
||||
import { ChangeUnlockPassword } from "src/screens/settings/ChangeUnlockPassword";
|
||||
import DebugTools from "src/screens/settings/DebugTools";
|
||||
|
|
@ -62,6 +63,7 @@ import { SetupFinish } from "src/screens/setup/SetupFinish";
|
|||
import { SetupNode } from "src/screens/setup/SetupNode";
|
||||
import { SetupPassword } from "src/screens/setup/SetupPassword";
|
||||
import { SetupSecurity } from "src/screens/setup/SetupSecurity";
|
||||
import { BarkForm } from "src/screens/setup/node/BarkForm";
|
||||
import { CLNForm } from "src/screens/setup/node/CLNForm";
|
||||
import { CashuForm } from "src/screens/setup/node/CashuForm";
|
||||
import { LDKForm } from "src/screens/setup/node/LDKForm";
|
||||
|
|
@ -252,6 +254,11 @@ const routes: RouteObject[] = [
|
|||
element: <AutoUnlock />,
|
||||
handle: { crumb: () => "Auto Unlock" },
|
||||
},
|
||||
{
|
||||
path: "node",
|
||||
element: <NodeSettings />,
|
||||
handle: { crumb: () => "Node" },
|
||||
},
|
||||
{
|
||||
path: "change-unlock-password",
|
||||
element: <ChangeUnlockPassword />,
|
||||
|
|
@ -347,10 +354,6 @@ const routes: RouteObject[] = [
|
|||
path: "zapplanner",
|
||||
element: <ZapPlanner />,
|
||||
},
|
||||
{
|
||||
path: "bitrefill",
|
||||
element: <Bitrefill />,
|
||||
},
|
||||
{
|
||||
path: "alby-cli-skill",
|
||||
element: <AlbyCliSkill />,
|
||||
|
|
@ -474,6 +477,12 @@ const routes: RouteObject[] = [
|
|||
element: <AI />,
|
||||
handle: { crumb: () => "AI & Agents" },
|
||||
},
|
||||
{
|
||||
path: "cards",
|
||||
element: <DefaultRedirect />,
|
||||
handle: { crumb: () => "Cards" },
|
||||
children: [{ index: true, element: <Cards /> }],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
@ -553,6 +562,10 @@ const routes: RouteObject[] = [
|
|||
path: "cln",
|
||||
element: <CLNForm />,
|
||||
},
|
||||
{
|
||||
path: "bark",
|
||||
element: <BarkForm />,
|
||||
},
|
||||
{
|
||||
path: "preset",
|
||||
element: <PresetNodeForm />,
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export function ConnectAlbyAccount({ connectUrl }: ConnectAlbyAccountProps) {
|
|||
</CardHeader>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center gap-2 mt-10">
|
||||
<div className="flex flex-col items-center justify-center gap-2 mt-5">
|
||||
<LinkButton to={connectUrl || "/alby/auth"} size="lg">
|
||||
Connect
|
||||
</LinkButton>
|
||||
|
|
|
|||