diff --git a/.github/workflows/linting.yml b/.github/workflows/linting.yml new file mode 100644 index 000000000..4bd5ecb83 --- /dev/null +++ b/.github/workflows/linting.yml @@ -0,0 +1,35 @@ +name: Code quality - linting and typechecking + +on: + push: + branches: + - master + pull_request: + types: [opened, synchronize] + +jobs: + linting: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./frontend + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: 20.x + cache: "yarn" + cache-dependency-path: frontend/yarn.lock + + - run: yarn install + - run: yarn prepare:http + + - name: Linting + run: yarn lint:js + + - name: Prettier + run: yarn format + + - name: Typechecking + run: yarn tsc:compile diff --git a/.github/workflows/wails2.yaml b/.github/workflows/wails2.yaml index 5d79db736..e5ffd1fc7 100644 --- a/.github/workflows/wails2.yaml +++ b/.github/workflows/wails2.yaml @@ -8,11 +8,12 @@ jobs: strategy: fail-fast: false matrix: - build: [ - {name: albyhub, platform: linux/amd64, os: ubuntu-20.04}, - {name: albyhub, platform: windows/amd64, os: windows-2019}, - {name: albyhub, platform: darwin/universal, os: macos-12} - ] + build: + [ + { name: albyhub, platform: linux/amd64, os: ubuntu-20.04 }, + { name: albyhub, platform: windows/amd64, os: windows-2019 }, + { name: albyhub, platform: darwin/universal, os: macos-12 }, + ] runs-on: ${{ matrix.build.os }} steps: - uses: actions/checkout@v4 @@ -47,7 +48,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: '20.x' + node-version: "20.x" - name: Install Wails run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.7.1 @@ -60,7 +61,9 @@ jobs: - name: Install macOS Wails deps if: runner.os == 'macOS' - run: brew install mitchellh/gon/gon + run: | + brew install create-dmg + brew install Bearer/tap/gon shell: bash - name: Wails Doctor @@ -84,19 +87,28 @@ jobs: - name: Build App if: runner.os == 'macOS' - run: wails build --platform darwin/universal -webview2 download -o ${{ matrix.build.name }} -tags "wails" + run: wails build --platform darwin/universal -webview2 embed -o ${{ matrix.build.name }} -tags "wails" shell: bash - name: Build App if: runner.os == 'Linux' - run: wails build --platform linux/amd64 -webview2 download -o ${{ matrix.build.name }} -tags "wails" + run: wails build --platform linux/amd64 -webview2 embed -o ${{ matrix.build.name }} -tags "wails" shell: bash - name: Build Windows App if: runner.os == 'Windows' - run: wails build --platform windows/amd64 -webview2 download -o ${{ matrix.build.name }}.exe -tags "wails" + run: wails build --platform windows/amd64 -webview2 embed -o ${{ matrix.build.name }}.exe -tags "wails" shell: bash + - name: Import Code-Signing Certificates for macOS + if: runner.os == 'macOS' + uses: Apple-Actions/import-codesign-certs@v1 + with: + # The certificates in a PKCS12 file encoded as a base64 string + p12-file-base64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }} + # The password used to import the PKCS12 file. + p12-password: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }} + - name: Copy DLLs to the output directory if: runner.os == 'Windows' run: | @@ -155,14 +167,34 @@ jobs: rm -Rf ./build/bin/* mv ./build/out/albyhub-${{runner.os}}.tar.bz2 ./build/bin/ + - name: Sign the macOS binary + if: runner.os == 'macOS' + run: | + echo "Signing Package" + /usr/bin/codesign -s "Developer ID Application: Alby Inc." -f -v --deep --timestamp --options runtime --entitlements ./build/darwin/entitlements.plist ./build/bin/AlbyHub.app + env: + AC_USERNAME: ${{ secrets.APPLE_USERNAME }} + AC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + AC_PROVIDER: ${{ secrets.APPLE_TEAM_ID }} + - name: Make DMG image for macOS if: runner.os == 'macOS' run: | mkdir -p ./build/out - hdiutil create -volname "AlbyHub" -srcfolder ./build/bin/${{ matrix.build.name }}.app -ov -format UDZO ./build/out/albyhub-${{runner.os}}.dmg + create-dmg --volname "AlbyHub" --background "./build/darwin/dmgcover.png" --window-pos 200 120 --window-size 800 400 --icon-size 80 --icon "AlbyHub.app" 200 160 --hide-extension "AlbyHub.app" --app-drop-link 600 160 "./build/out/albyhub-${{runner.os}}.dmg" "./build/bin/${{ matrix.build.name }}.app" rm -Rf ./build/bin/* mv ./build/out/albyhub-${{runner.os}}.dmg ./build/bin/ + - name: Notarize the DMG image + if: runner.os == 'macOS' + run: | + echo "Notarizing Zip Files" + gon -log-level=info -log-json ./build/darwin/gon-notarize.json + env: + AC_USERNAME: ${{ secrets.APPLE_USERNAME }} + AC_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + AC_PROVIDER: ${{ secrets.APPLE_TEAM_ID }} + - name: Make Windows ZIP archive if: runner.os == 'Windows' run: | diff --git a/.gitignore b/.gitignore index d2e89e340..3d56ca075 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,7 @@ frontend/node_modules frontend/wailsjs package.json.md5 -build +build/bin *.log diff --git a/README.md b/README.md index 4ffbc341f..4a55c3fb6 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ Follow the steps to integrate Mutinynet with your NWC Next setup: 3. During onboarding, after setting your password and authorizing via Alby OAuth, you'll be directed to `/onboarding/lightning/migrate-alby`. Click "Skip For Now" to access your wallet interface -4. Navigate to `channels/onchain/new-address`, copy your On-Chain Address, then visit the [Mutinynet Faucet](https://faucet.mutinynet.com/) to deposit sats. Ensure the transaction confirms on [mempool.space](https://mutinynet.com/) +4. Navigate to `channels/onchain/deposit-bitcoin`, copy your On-Chain Address, then visit the [Mutinynet Faucet](https://faucet.mutinynet.com/) to deposit sats. Ensure the transaction confirms on [mempool.space](https://mutinynet.com/) 5. Your On-chain balance will update under `/channels` diff --git a/alby/alby_oauth_service.go b/alby/alby_oauth_service.go index 970e235c5..75fa1679c 100644 --- a/alby/alby_oauth_service.go +++ b/alby/alby_oauth_service.go @@ -72,24 +72,27 @@ func (svc *albyOAuthService) CallbackHandler(ctx context.Context, code string) e } svc.saveToken(token) + me, err := svc.GetMe(ctx) + if err != nil { + svc.logger.WithError(err).Error("Failed to fetch user me") + // remove token so user can retry + svc.config.SetUpdate(accessTokenKey, "", "") + return err + } + existingUserIdentifier, err := svc.GetUserIdentifier() if err != nil { svc.logger.WithError(err).Error("Failed to get alby user identifier") return err } - // setup Alby account on first time login + // save the user's alby account ID on first time login if existingUserIdentifier == "" { - // fetch and save the user's alby account ID. This cannot be changed. - me, err := svc.GetMe(ctx) - if err != nil { - svc.logger.WithError(err).Error("Failed to fetch user me") - // remove token so user can retry - svc.config.SetUpdate(accessTokenKey, me.Identifier, "") - return err - } - svc.config.SetUpdate(userIdentifierKey, me.Identifier, "") + } else if me.Identifier != existingUserIdentifier { + // remove token so user can retry with correct account + svc.config.SetUpdate(accessTokenKey, "", "") + return errors.New("Alby Hub is connected to a different alby account. Please log out of your Alby Account at getalby.com and try again.") } return nil diff --git a/api/api.go b/api/api.go index 70dc7e16d..2cc11fa61 100644 --- a/api/api.go +++ b/api/api.go @@ -16,6 +16,7 @@ import ( "github.com/getAlby/nostr-wallet-connect/alby" "github.com/getAlby/nostr-wallet-connect/backup" + "github.com/getAlby/nostr-wallet-connect/config" "github.com/getAlby/nostr-wallet-connect/db" "github.com/getAlby/nostr-wallet-connect/lnclient" "github.com/getAlby/nostr-wallet-connect/lsp" @@ -375,17 +376,57 @@ func (api *api) CloseChannel(ctx context.Context, peerId, channelId string, forc }) } -func (api *api) GetNewOnchainAddress(ctx context.Context) (*NewOnchainAddressResponse, error) { +func (api *api) GetNewOnchainAddress(ctx context.Context) (string, error) { if api.svc.GetLNClient() == nil { - return nil, errors.New("LNClient not started") + return "", errors.New("LNClient not started") } address, err := api.svc.GetLNClient().GetNewOnchainAddress(ctx) if err != nil { - return nil, err + return "", err } - return &NewOnchainAddressResponse{ - Address: address, - }, nil + + api.svc.GetConfig().SetUpdate(config.OnchainAddressKey, address, "") + + return address, nil +} + +func (api *api) GetUnusedOnchainAddress(ctx context.Context) (string, error) { + if api.svc.GetLNClient() == nil { + return "", errors.New("LNClient not started") + } + + currentAddress, err := api.svc.GetConfig().Get(config.OnchainAddressKey, "") + if err != nil { + api.logger.WithError(err).Error("Failed to get current address from config") + return "", err + } + + if currentAddress != "" { + // check if address has any transactions + response, err := api.RequestEsploraApi("/address/" + currentAddress + "/txs") + if err != nil { + api.logger.WithError(err).Error("Failed to get current address transactions") + return currentAddress, nil + } + + transactions, ok := response.([]interface{}) + if !ok { + api.logger.WithField("response", response).Error("Failed to cast esplora address txs response", response) + return currentAddress, nil + } + + if len(transactions) == 0 { + // address has not been used yet + return currentAddress, nil + } + } + + newAddress, err := api.GetNewOnchainAddress(ctx) + if err != nil { + api.logger.WithError(err).Error("Failed to retrieve new onchain address") + return "", err + } + return newAddress, nil } func (api *api) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) { @@ -426,6 +467,7 @@ func (api *api) GetBalances(ctx context.Context) (*BalancesResponse, error) { return balances, nil } +// TODO: remove dependency on this endpoint func (api *api) RequestMempoolApi(endpoint string) (interface{}, error) { url := api.svc.GetConfig().GetEnv().MempoolApi + endpoint @@ -558,12 +600,16 @@ func (api *api) Setup(ctx context.Context, setupRequest *SetupRequest) error { } if setupRequest.PhoenixdAddress != "" { - api.svc.GetConfig().SetUpdate("PhoenixAddress", setupRequest.PhoenixdAddress, setupRequest.UnlockPassword) + api.svc.GetConfig().SetUpdate("PhoenixdAddress", setupRequest.PhoenixdAddress, setupRequest.UnlockPassword) } if setupRequest.PhoenixdAuthorization != "" { api.svc.GetConfig().SetUpdate("PhoenixdAuthorization", setupRequest.PhoenixdAuthorization, setupRequest.UnlockPassword) } + if setupRequest.CashuMintUrl != "" { + api.svc.GetConfig().SetUpdate("CashuMintUrl", setupRequest.CashuMintUrl, setupRequest.UnlockPassword) + } + return nil } diff --git a/api/esplora.go b/api/esplora.go new file mode 100644 index 000000000..f1d185501 --- /dev/null +++ b/api/esplora.go @@ -0,0 +1,56 @@ +package api + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "time" + + "github.com/sirupsen/logrus" +) + +func (api *api) RequestEsploraApi(endpoint string) (interface{}, error) { + url := api.svc.GetConfig().GetEnv().LDKEsploraServer + endpoint + + client := http.Client{ + Timeout: time.Second * 10, + } + + req, err := http.NewRequest(http.MethodGet, url, nil) + if err != nil { + api.logger.WithError(err).WithFields(logrus.Fields{ + "url": url, + }).Error("Failed to create http request") + return nil, err + } + + res, err := client.Do(req) + if err != nil { + api.logger.WithError(err).WithFields(logrus.Fields{ + "url": url, + }).Error("Failed to send request") + return nil, err + } + + defer res.Body.Close() + + body, readErr := io.ReadAll(res.Body) + if readErr != nil { + api.logger.WithError(err).WithFields(logrus.Fields{ + "url": url, + }).Error("Failed to read response body") + return nil, errors.New("failed to read response body") + } + + var jsonContent interface{} + jsonErr := json.Unmarshal(body, &jsonContent) + if jsonErr != nil { + api.logger.WithError(jsonErr).WithFields(logrus.Fields{ + "url": url, + }).Error("Failed to deserialize json") + return nil, fmt.Errorf("failed to deserialize json %s %s", url, string(body)) + } + return jsonContent, nil +} diff --git a/api/models.go b/api/models.go index 7e9462964..1f912721f 100644 --- a/api/models.go +++ b/api/models.go @@ -29,7 +29,8 @@ type API interface { DisconnectPeer(ctx context.Context, peerId string) error OpenChannel(ctx context.Context, openChannelRequest *OpenChannelRequest) (*OpenChannelResponse, error) CloseChannel(ctx context.Context, peerId, channelId string, force bool) (*CloseChannelResponse, error) - GetNewOnchainAddress(ctx context.Context) (*NewOnchainAddressResponse, error) + GetNewOnchainAddress(ctx context.Context) (string, error) + GetUnusedOnchainAddress(ctx context.Context) (string, error) SignMessage(ctx context.Context, message string) (*SignMessageResponse, error) RedeemOnchainFunds(ctx context.Context, toAddress string) (*RedeemOnchainFundsResponse, error) GetBalances(ctx context.Context) (*BalancesResponse, error) @@ -98,7 +99,7 @@ type BackupReminderRequest struct { } type SetupRequest struct { - LNBackendType string `json:"backendType"` + LNBackendType string `json:"backendType"` UnlockPassword string `json:"unlockPassword"` // Breez / Greenlight @@ -174,10 +175,6 @@ type RedeemOnchainFundsResponse struct { type OnchainBalanceResponse = lnclient.OnchainBalanceResponse type BalancesResponse = lnclient.BalancesResponse -type NewOnchainAddressResponse struct { - Address string `json:"address"` -} - // debug api type SendPaymentProbesRequest struct { Invoice string `json:"invoice"` diff --git a/appicon.png b/appicon.png index 37a74c5bf..27ff80fa0 100644 Binary files a/appicon.png and b/appicon.png differ diff --git a/build/darwin/Info.dev.plist b/build/darwin/Info.dev.plist new file mode 100644 index 000000000..836d49860 --- /dev/null +++ b/build/darwin/Info.dev.plist @@ -0,0 +1,68 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.Name}} + CFBundleIdentifier + com.getalby.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.getalby.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/build/darwin/Info.plist b/build/darwin/Info.plist new file mode 100644 index 000000000..ceaced927 --- /dev/null +++ b/build/darwin/Info.plist @@ -0,0 +1,63 @@ + + + + CFBundlePackageType + APPL + CFBundleName + {{.Info.ProductName}} + CFBundleExecutable + {{.Name}} + CFBundleIdentifier + com.getalby.{{.Name}} + CFBundleVersion + {{.Info.ProductVersion}} + CFBundleGetInfoString + {{.Info.Comments}} + CFBundleShortVersionString + {{.Info.ProductVersion}} + CFBundleIconFile + iconfile + LSMinimumSystemVersion + 10.13.0 + NSHighResolutionCapable + true + NSHumanReadableCopyright + {{.Info.Copyright}} + {{if .Info.FileAssociations}} + CFBundleDocumentTypes + + {{range .Info.FileAssociations}} + + CFBundleTypeExtensions + + {{.Ext}} + + CFBundleTypeName + {{.Name}} + CFBundleTypeRole + {{.Role}} + CFBundleTypeIconFile + {{.IconName}} + + {{end}} + + {{end}} + {{if .Info.Protocols}} + CFBundleURLTypes + + {{range .Info.Protocols}} + + CFBundleURLName + com.getalby.{{.Scheme}} + CFBundleURLSchemes + + {{.Scheme}} + + CFBundleTypeRole + {{.Role}} + + {{end}} + + {{end}} + + diff --git a/build/darwin/dmgcover.png b/build/darwin/dmgcover.png new file mode 100644 index 000000000..1c83e2dcf Binary files /dev/null and b/build/darwin/dmgcover.png differ diff --git a/build/darwin/entitlements.plist b/build/darwin/entitlements.plist new file mode 100644 index 000000000..d415eaaed --- /dev/null +++ b/build/darwin/entitlements.plist @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + com.apple.security.network.server + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.downloads.read-write + + + diff --git a/build/darwin/gon-notarize.json b/build/darwin/gon-notarize.json new file mode 100644 index 000000000..a44abc20f --- /dev/null +++ b/build/darwin/gon-notarize.json @@ -0,0 +1,8 @@ +{ + "notarize": [ + { + "path": "./build/bin/albyhub-macOS.dmg", + "bundle_id": "com.getalby.AlbyHub" + } + ] +} diff --git a/build/darwin/gon-sign.json b/build/darwin/gon-sign.json new file mode 100644 index 000000000..7f1f7a7ef --- /dev/null +++ b/build/darwin/gon-sign.json @@ -0,0 +1,13 @@ +{ + "source": [ + "./build/bin/AlbyHub.app", + "./build/bin/AlbyHub.app/Contents/MacOS/AlbyHub", + "./build/bin/AlbyHub.app/Contents/Frameworks/libbreez_sdk_bindings.dylib", + "./build/bin/AlbyHub.app/Contents/Frameworks/libglalby_bindings.dylib" + ], + "bundle_id": "com.getalby.AlbyHub", + "sign": { + "application_identity": "Developer ID Application: Alby Inc.", + "entitlements_file": "./build/darwin/entitlements.plist" + } +} diff --git a/config/models.go b/config/models.go index 1d3ed0ca5..dd397b658 100644 --- a/config/models.go +++ b/config/models.go @@ -9,6 +9,10 @@ const ( CashuBackendType = "CASHU" ) +const ( + OnchainAddressKey = "OnchainAddress" +) + type AppConfig struct { Relay string `envconfig:"RELAY" default:"wss://relay.getalby.com/v1"` LNBackendType string `envconfig:"LN_BACKEND_TYPE"` @@ -21,7 +25,7 @@ type AppConfig struct { CookieSecret string `envconfig:"COOKIE_SECRET"` LogLevel string `envconfig:"LOG_LEVEL"` LDKNetwork string `envconfig:"LDK_NETWORK" default:"bitcoin"` - LDKEsploraServer string `envconfig:"LDK_ESPLORA_SERVER" default:"https://electrs.albylabs.com"` + LDKEsploraServer string `envconfig:"LDK_ESPLORA_SERVER" default:"https://electrs.albylabs.com"` // TODO: remove LDK prefix LDKGossipSource string `envconfig:"LDK_GOSSIP_SOURCE" default:"https://rapidsync.lightningdevkit.org/snapshot"` LDKLogLevel string `envconfig:"LDK_LOG_LEVEL"` MempoolApi string `envconfig:"MEMPOOL_API" default:"https://mempool.space/api"` @@ -32,7 +36,7 @@ type AppConfig struct { BaseUrl string `envconfig:"BASE_URL" default:"http://localhost:8080"` FrontendUrl string `envconfig:"FRONTEND_URL"` LogEvents bool `envconfig:"LOG_EVENTS" default:"false"` - PhoenixdAddress string `envconfig:"PHOENIXD_ADDRESS" default:"http://127.0.0.1:9740"` + PhoenixdAddress string `envconfig:"PHOENIXD_ADDRESS"` PhoenixdAuthorization string `envconfig:"PHOENIXD_AUTHORIZATION"` GoProfilerAddr string `envconfig:"GO_PROFILER_ADDR"` DdProfilerEnabled bool `envconfig:"DD_PROFILER_ENABLED" default:"false"` diff --git a/events/models.go b/events/models.go index 315b1917f..bd4987968 100644 --- a/events/models.go +++ b/events/models.go @@ -29,9 +29,10 @@ type ChannelBackupEvent struct { } type ChannelBackupInfo struct { - ChannelID string `json:"channel_id"` - NodeID string `json:"node_id"` - PeerID string `json:"peer_id"` - ChannelSize uint64 `json:"channel_size"` - FundingTxID string `json:"funding_tx_id"` + ChannelID string `json:"channel_id"` + NodeID string `json:"node_id"` + PeerID string `json:"peer_id"` + ChannelSize uint64 `json:"channel_size"` + FundingTxID string `json:"funding_tx_id"` + FundingTxVout uint32 `json:"funding_tx_vout"` } diff --git a/frontend/.eslintignore b/frontend/.eslintignore deleted file mode 100644 index 76add878f..000000000 --- a/frontend/.eslintignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist \ No newline at end of file diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs deleted file mode 100644 index 05ceb4551..000000000 --- a/frontend/.eslintrc.cjs +++ /dev/null @@ -1,29 +0,0 @@ -module.exports = { - root: true, - env: { browser: true, es2020: true }, - extends: [ - "eslint:recommended", - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - "prettier", - ], - ignorePatterns: ["dist", ".eslintrc.cjs"], - parser: "@typescript-eslint/parser", - plugins: ["react-refresh", "@typescript-eslint"], - rules: { - "react-refresh/only-export-components": [ - "warn", - { allowConstantExport: true }, - ], - "@typescript-eslint/ban-ts-comment": [ - "error", - { - "ts-ignore": "allow-with-description", - }, - ], - "@typescript-eslint/no-unused-vars": ["warn", { args: "none" }], - "no-console": ["error", { allow: ["info", "warn", "error"] }], - "no-constant-binary-expression": "error", - curly: "error", - }, -}; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs new file mode 100644 index 000000000..11d0af740 --- /dev/null +++ b/frontend/eslint.config.mjs @@ -0,0 +1,83 @@ +import { fixupConfigRules, fixupPluginRules } from "@eslint/compat"; +import { FlatCompat } from "@eslint/eslintrc"; +import js from "@eslint/js"; +import typescriptEslint from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; +import reactRefresh from "eslint-plugin-react-refresh"; +import globals from "globals"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended, + allConfig: js.configs.all, +}); + +export default [ + { + ignores: [ + "**/dist", + "**/node_modules", + "**/dist", + "src/components/ui/navigation-menu.tsx", + ], + }, + ...fixupConfigRules( + compat.extends( + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended", + "prettier" + ) + ), + { + plugins: { + "react-refresh": reactRefresh, + "@typescript-eslint": fixupPluginRules(typescriptEslint), + }, + + languageOptions: { + globals: { + ...globals.browser, + }, + + parser: tsParser, + }, + files: ["**/*.ts", "**/*.tsx"], + rules: { + "react-refresh/only-export-components": [ + "warn", + { + allowConstantExport: true, + }, + ], + + "@typescript-eslint/ban-ts-comment": [ + "error", + { + "ts-ignore": "allow-with-description", + }, + ], + + "@typescript-eslint/no-unused-vars": [ + "warn", + { + args: "none", + }, + ], + + "no-console": [ + "error", + { + allow: ["info", "warn", "error"], + }, + ], + + "no-constant-binary-expression": "error", + curly: "error", + }, + }, +]; diff --git a/frontend/lint-staged.config.js b/frontend/lint-staged.config.js index 653218efc..36a197e72 100644 --- a/frontend/lint-staged.config.js +++ b/frontend/lint-staged.config.js @@ -1,5 +1,8 @@ export default { - "src/**/*.{ts,tsx,json}": ["eslint --fix --max-warnings 0","prettier --write"], + "src/**/*.{ts,tsx,json}": [ + "eslint --fix --no-warn-ignored --max-warnings 0", + "prettier --write", + ], "platform_specific/**/*.ts": ["prettier --write"], "package.json": ["prettier --write"], "src/**/*.ts": () => "tsc --noEmit", diff --git a/frontend/package.json b/frontend/package.json index 0d4544c0a..27f39f839 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,8 +11,8 @@ "prepare:wails": "shx cp ./platform_specific/wails/src/utils/*.ts src/utils/", "prepare:http": "shx cp ./platform_specific/http/src/utils/*.ts src/utils/", "lint": "yarn lint:js && yarn tsc:compile && yarn format:fix --report-unused-disable-directives --max-warnings 0", - "lint:js": "eslint src --ext .js,.ts,.tsx --max-warnings 0", - "lint:js:fix": "eslint src --ext .js,.ts,.tsx --fix", + "lint:js": "eslint src --max-warnings 0", + "lint:js:fix": "eslint src --fix", "tsc:compile": "tsc --noEmit", "format": "prettier --check '**/*.(md|json)' 'src/**/*.(js|ts|tsx)' '!src/assets/**/*.json' 'platform_specific/**/*.ts'", "format:fix": "prettier --loglevel silent --write '**/*.(md|json)' 'src/**/*.(js|ts|tsx)' '!src/assets/**/*.json' 'platform_specific/**/*.ts'", @@ -59,6 +59,9 @@ "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", + "@eslint/compat": "^1.0.3", + "@eslint/eslintrc": "^3.1.0", + "@eslint/js": "^9.4.0", "@tailwindcss/aspect-ratio": "^0.4.2", "@tailwindcss/forms": "^0.5.7", "@tailwindcss/typography": "^0.5.10", @@ -71,10 +74,11 @@ "@typescript-eslint/parser": "^7.11.0", "@vitejs/plugin-react-swc": "^3.3.2", "autoprefixer": "^10.4.16", - "eslint": "^8.45.0", + "eslint": "^9.4.0", "eslint-config-prettier": "^9.1.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", + "globals": "^15.4.0", "husky": "^9.0.11", "lint-staged": "^15.2.5", "postcss": "^8.4.32", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8acb296d3..63434df7b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,138 +1,21 @@ -import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; +import { RouterProvider, createHashRouter } from "react-router-dom"; -import AppLayout from "src/components/layouts/AppLayout"; -import { DefaultRedirect } from "src/components/redirects/DefaultRedirect"; -import { HomeRedirect } from "src/components/redirects/HomeRedirect"; -import { SetupRedirect } from "src/components/redirects/SetupRedirect"; -import { StartRedirect } from "src/components/redirects/StartRedirect"; import { ThemeProvider } from "src/components/ui/theme-provider"; -import { BackupMnemonic } from "src/screens/BackupMnemonic"; -import NotFound from "src/screens/NotFound"; -import Start from "src/screens/Start"; -import Unlock from "src/screens/Unlock"; -import { Welcome } from "src/screens/Welcome"; -import AppCreated from "src/screens/apps/AppCreated"; -import AppList from "src/screens/apps/AppList"; -import NewApp from "src/screens/apps/NewApp"; -import ShowApp from "src/screens/apps/ShowApp"; -import AppStore from "src/screens/appstore/AppStore"; -import Channels from "src/screens/channels/Channels"; -import NewChannel from "src/screens/channels/NewChannel"; -import MigrateAlbyFunds from "src/screens/onboarding/MigrateAlbyFunds"; -import NewOnchainAddress from "src/screens/onchain/NewAddress"; -import ConnectPeer from "src/screens/peers/ConnectPeer"; -import Settings from "src/screens/settings/Settings"; -import { ImportMnemonic } from "src/screens/setup/ImportMnemonic"; -import { SetupFinish } from "src/screens/setup/SetupFinish"; -import { SetupNode } from "src/screens/setup/SetupNode"; -import { SetupPassword } from "src/screens/setup/SetupPassword"; -import { SetupWallet } from "src/screens/setup/SetupWallet"; -import Wallet from "src/screens/wallet"; -import SignMessage from "src/screens/wallet/SignMessage"; import { usePosthog } from "./hooks/usePosthog"; -import SettingsLayout from "src/components/layouts/SettingsLayout"; -import TwoColumnFullScreenLayout from "src/components/layouts/TwoColumnFullScreenLayout"; -import { OnboardingRedirect } from "src/components/redirects/OnboardingRedirect"; import { Toaster } from "src/components/ui/toaster"; -import { BackupNode } from "src/screens/BackupNode"; -import { BackupNodeSuccess } from "src/screens/BackupNodeSuccess"; -import { Intro } from "src/screens/Intro"; -import AlbyAuthRedirect from "src/screens/alby/AlbyAuthRedirect"; -import { CurrentChannelOrder } from "src/screens/channels/CurrentChannelOrder"; -import { Success } from "src/screens/onboarding/Success"; -import Peers from "src/screens/peers/Peers"; -import { ChangeUnlockPassword } from "src/screens/settings/ChangeUnlockPassword"; -import DebugTools from "src/screens/settings/DebugTools"; -import { RestoreNode } from "src/screens/setup/RestoreNode"; +import routes from "src/routes.tsx"; function App() { usePosthog(); + + const router = createHashRouter(routes); + return ( <> - - - } - /> - }> - } /> - }> - }> - } /> - } - /> - } /> - } /> - - - }> - } /> - } /> - - }> - } /> - - }> - } /> - } /> - } /> - } /> - - }> - } /> - - }> - } /> - } /> - } /> - } - /> - - }> - } /> - } /> - - - } /> - }> - - - - } - /> - } /> - } /> - } /> - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - }> - - } /> - - } /> - - - } /> - - + ); diff --git a/frontend/src/assets/images/node/cashu.png b/frontend/src/assets/images/node/cashu.png new file mode 100644 index 000000000..b86012b32 Binary files /dev/null and b/frontend/src/assets/images/node/cashu.png differ diff --git a/frontend/src/assets/images/node/lnd.png b/frontend/src/assets/images/node/lnd.png new file mode 100644 index 000000000..fbfeedae5 Binary files /dev/null and b/frontend/src/assets/images/node/lnd.png differ diff --git a/frontend/src/components/AlbyConnectionCard.tsx b/frontend/src/components/AlbyConnectionCard.tsx new file mode 100644 index 000000000..5116441bb --- /dev/null +++ b/frontend/src/components/AlbyConnectionCard.tsx @@ -0,0 +1,156 @@ +import { + CheckCircle2, + CircleX, + Edit, + ExternalLinkIcon, + Link2Icon, + ZapIcon, +} from "lucide-react"; +import { Link } from "react-router-dom"; +import ExternalLink from "src/components/ExternalLink"; +import Loading from "src/components/Loading"; +import UserAvatar from "src/components/UserAvatar"; +import { Button } from "src/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "src/components/ui/card"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { Progress } from "src/components/ui/progress"; +import { Separator } from "src/components/ui/separator"; +import { useAlbyMe } from "src/hooks/useAlbyMe"; +import { LinkStatus, useLinkAccount } from "src/hooks/useLinkAccount"; +import { App } from "src/types"; + +function AlbyConnectionCard({ connection }: { connection?: App }) { + const { data: albyMe } = useAlbyMe(); + const { loading, linkStatus, loadingLinkStatus, linkAccount } = + useLinkAccount(); + + return ( + + + Alby Account + + Link Your Alby Account to use your lightning address with Alby Hub and + use apps that you connected to your Alby Account. + + + + +
+
+
+ +
+
{albyMe?.name}
+
+ + {albyMe?.lightning_address} +
+
+
+
+ {loadingLinkStatus && } + {!connection || linkStatus === LinkStatus.SharedNode ? ( + + {!loading && } + Link your Alby Account + + ) : linkStatus === LinkStatus.ThisNode ? ( + + ) : ( + linkStatus === LinkStatus.OtherNode && ( + + ) + )} + + + +
+
+
+ {connection && ( + <> + {connection.maxAmount > 0 && ( + <> +
+
+

+ You've spent +

+

+ {new Intl.NumberFormat().format( + connection.budgetUsage + )}{" "} + sats +

+
+
+ {" "} +

+ Left in budget +

+

+ {new Intl.NumberFormat().format( + connection.maxAmount - connection.budgetUsage + )}{" "} + sats +

+
+
+ +
+ {connection.maxAmount > 0 ? ( + <> + {new Intl.NumberFormat().format(connection.maxAmount)}{" "} + sats / {connection.budgetRenewal} + + ) : ( + "Not set" + )} +
+ + + +
+
+ + )} + + )} +
+
+
+
+ ); +} + +export default AlbyConnectionCard; diff --git a/frontend/src/components/AppHeader.tsx b/frontend/src/components/AppHeader.tsx index 9d5cec875..a4e59af43 100644 --- a/frontend/src/components/AppHeader.tsx +++ b/frontend/src/components/AppHeader.tsx @@ -1,4 +1,5 @@ import { ReactElement } from "react"; +import Breadcrumbs from "src/components/Breadcrumbs"; type Props = { title: string | ReactElement; @@ -8,13 +9,18 @@ type Props = { function AppHeader({ title, description, contentRight }: Props) { return ( -
-
-

{title}

-

{description}

+ <> + +
+
+

{title}

+

+ {description} +

+
+
{contentRight}
-
{contentRight}
-
+ ); } diff --git a/frontend/src/components/AuthCodeForm.tsx b/frontend/src/components/AuthCodeForm.tsx index ff2cdcf17..138125a6f 100644 --- a/frontend/src/components/AuthCodeForm.tsx +++ b/frontend/src/components/AuthCodeForm.tsx @@ -61,12 +61,14 @@ function AuthCodeForm() {
{!hasRequestedCode && ( <> - + )} {hasRequestedCode && ( diff --git a/frontend/src/components/Breadcrumbs.tsx b/frontend/src/components/Breadcrumbs.tsx new file mode 100644 index 000000000..ed0085df3 --- /dev/null +++ b/frontend/src/components/Breadcrumbs.tsx @@ -0,0 +1,70 @@ +import { Fragment } from "react"; +import { Link, useMatches } from "react-router-dom"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbSeparator, +} from "src/components/ui/breadcrumb"; + +type MatchWithCrumb = { + pathname: string; + handle?: { + crumb?: () => React.ReactNode; + }; +}; + +function Breadcrumbs() { + const matches = useMatches() as MatchWithCrumb[]; // Type-cast useMatches result to MatchWithCrumb array + + const crumbs = matches + // First, get rid of any matches that don't have a handle or crumb + .filter( + ( + match + ): match is MatchWithCrumb & { + handle: { crumb: () => React.ReactNode }; + } => Boolean(match.handle?.crumb) + ); + + // Compare pathnames of index routes to remove duplicates + const isIndexRoute = + crumbs.length >= 2 && crumbs[crumbs.length - 1].pathname + ? crumbs[crumbs.length - 1].pathname.slice(0, -1) === + crumbs[crumbs.length - 2].pathname + : false; + + // Remove the last item if it's an index route to prevent e.g. Wallet > Wallet + const filteredCrumbs = isIndexRoute ? crumbs.slice(0, -1) : crumbs; + + // Don't render anything if there is only one item + if (filteredCrumbs.length < 2) { + return null; + } + + return ( + <> + + + {filteredCrumbs.map((crumb, index) => ( + + + {index + 1 < filteredCrumbs.length ? ( + + {crumb.handle.crumb()} + + ) : ( + <>{crumb.handle.crumb()} + )} + + {index + 1 < filteredCrumbs.length && } + + ))} + + + + ); +} + +export default Breadcrumbs; diff --git a/frontend/src/components/SidebarHint.tsx b/frontend/src/components/SidebarHint.tsx index 594b6349a..370b19456 100644 --- a/frontend/src/components/SidebarHint.tsx +++ b/frontend/src/components/SidebarHint.tsx @@ -14,22 +14,22 @@ import { useAlbyMe } from "src/hooks/useAlbyMe"; import { useChannels } from "src/hooks/useChannels"; import { useInfo } from "src/hooks/useInfo"; import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo"; -import { backendTypeHasMnemonic } from "src/lib/utils"; import useChannelOrderStore from "src/state/ChannelOrderStore"; function SidebarHint() { const { data: channels } = useChannels(); const { data: albyBalance } = useAlbyBalance(); - const { data: info } = useInfo(); + const { data: info, hasChannelManagement, hasMnemonic } = useInfo(); const { data: albyMe } = useAlbyMe(); const { order } = useChannelOrderStore(); const location = useLocation(); const { data: nodeConnectionInfo } = useNodeConnectionInfo(); - // Don't distract with hints while opening a channel + // Don't distract with hints while opening a channel or on the settings page if ( location.pathname.endsWith("/channels/order") || - location.pathname.endsWith("/channels/new") + location.pathname.endsWith("/channels/new") || + location.pathname.startsWith("/settings") ) { return null; } @@ -49,7 +49,7 @@ function SidebarHint() { // User has funds to migrate if ( - info?.backendType === "LDK" && + hasChannelManagement && albyBalance && albyBalance.sats * (1 - ALBY_SERVICE_FEE) > ALBY_MIN_BALANCE + 50000 /* accomodate for onchain fees */ @@ -66,10 +66,7 @@ function SidebarHint() { } // User has no channels yet - if ( - (info?.backendType === "LDK" || info?.backendType === "GREENLIGHT") && - channels?.length === 0 - ) { + if (hasChannelManagement && channels?.length === 0) { return ( ); } if ( + hasMnemonic && info && - backendTypeHasMnemonic(info.backendType) && (!info.nextBackupReminder || new Date(info.nextBackupReminder).getTime() < new Date().getTime()) ) { diff --git a/frontend/src/components/UserAvatar.tsx b/frontend/src/components/UserAvatar.tsx new file mode 100644 index 000000000..909421a10 --- /dev/null +++ b/frontend/src/components/UserAvatar.tsx @@ -0,0 +1,17 @@ +import { Avatar, AvatarFallback, AvatarImage } from "src/components/ui/avatar"; +import { useAlbyMe } from "src/hooks/useAlbyMe"; + +function UserAvatar({ className }: { className?: string }) { + const { data: albyMe } = useAlbyMe(); + + return ( + + + + {(albyMe?.name || albyMe?.email || "SN").substring(0, 2).toUpperCase()} + + + ); +} + +export default UserAvatar; diff --git a/frontend/src/components/icons/Breez.tsx b/frontend/src/components/icons/Breez.tsx new file mode 100644 index 000000000..7f026776e --- /dev/null +++ b/frontend/src/components/icons/Breez.tsx @@ -0,0 +1,10 @@ +export const BreezIcon = () => { + return ( + + + + ); +}; diff --git a/frontend/src/components/icons/Greenlight.tsx b/frontend/src/components/icons/Greenlight.tsx new file mode 100644 index 000000000..0d963a342 --- /dev/null +++ b/frontend/src/components/icons/Greenlight.tsx @@ -0,0 +1,9 @@ +export const GreenlightIcon = () => { + return ( + + + + + + ); +}; diff --git a/frontend/src/components/icons/LDK.tsx b/frontend/src/components/icons/LDK.tsx new file mode 100644 index 000000000..2770bfc08 --- /dev/null +++ b/frontend/src/components/icons/LDK.tsx @@ -0,0 +1,9 @@ +export const LDKIcon = () => { + return ( + + + + + + ); +}; diff --git a/frontend/src/components/icons/Phoenixd.tsx b/frontend/src/components/icons/Phoenixd.tsx new file mode 100644 index 000000000..8aa12dab3 --- /dev/null +++ b/frontend/src/components/icons/Phoenixd.tsx @@ -0,0 +1,10 @@ +export const PhoenixdIcon = () => { + return ( + + + + ); +}; diff --git a/frontend/src/components/layouts/AppLayout.tsx b/frontend/src/components/layouts/AppLayout.tsx index deae87ab6..260a06e8b 100644 --- a/frontend/src/components/layouts/AppLayout.tsx +++ b/frontend/src/components/layouts/AppLayout.tsx @@ -20,7 +20,7 @@ import { useNavigate, } from "react-router-dom"; import SidebarHint from "src/components/SidebarHint"; -import { Avatar, AvatarFallback, AvatarImage } from "src/components/ui/avatar"; +import UserAvatar from "src/components/UserAvatar"; import { Button } from "src/components/ui/button"; import { DropdownMenu, @@ -119,14 +119,13 @@ export default function AppLayout() { } function MainNavSecondary() { - const { data: info } = useInfo(); + const { hasChannelManagement } = useInfo(); return (
- - + + + + + + - {!info?.backendType && ( - - - - )}
By continuing, you agree to our
diff --git a/frontend/src/screens/apps/AppList.tsx b/frontend/src/screens/apps/AppList.tsx index e1aafacce..de7d4eaa8 100644 --- a/frontend/src/screens/apps/AppList.tsx +++ b/frontend/src/screens/apps/AppList.tsx @@ -1,5 +1,6 @@ import { Cable, CirclePlus } from "lucide-react"; import { Link } from "react-router-dom"; +import AlbyConnectionCard from "src/components/AlbyConnectionCard"; import AppCard from "src/components/AppCard"; import AppHeader from "src/components/AppHeader"; import EmptyState from "src/components/EmptyState"; @@ -8,6 +9,8 @@ import { Button } from "src/components/ui/button"; import { useApps } from "src/hooks/useApps"; import { useInfo } from "src/hooks/useInfo"; +const albyConnectionName = "getalby.com"; + function AppList() { const { data: apps } = useApps(); const { data: info } = useInfo(); @@ -16,6 +19,11 @@ function AppList() { return ; } + const albyConnection = apps.find((x) => x.name === albyConnectionName); + const otherApps = apps.filter( + (x) => x.nostrPubkey !== albyConnection?.nostrPubkey + ); + return ( <> - {!apps.length && ( + + + {!otherApps.length && ( )} - {apps.length > 0 && ( -
- {apps.map((app, index) => ( + {otherApps.length > 0 && ( +
+ {otherApps.map((app, index) => ( ))}
diff --git a/frontend/src/screens/channels/Channels.tsx b/frontend/src/screens/channels/Channels.tsx index ab63e0922..ae6c40f52 100644 --- a/frontend/src/screens/channels/Channels.tsx +++ b/frontend/src/screens/channels/Channels.tsx @@ -64,7 +64,7 @@ export default function Channels() { const { data: balances } = useBalances(); const { data: albyBalance } = useAlbyBalance(); const [nodes, setNodes] = React.useState([]); - const { data: info, mutate: reloadInfo } = useInfo(); + const { mutate: reloadInfo } = useInfo(); const { data: csrf } = useCSRF(); const redeemOnchainFunds = useRedeemOnchainFunds(); @@ -238,8 +238,11 @@ export default function Channels() { - - On-Chain Address + + Deposit Bitcoin {(balances?.onchain.spendable || 0) > ONCHAIN_DUST_SATS && ( @@ -253,30 +256,26 @@ export default function Channels() { )} - {info?.backendType === "LDK" && ( - <> - - - Management - - - Connected Peers - - - - - Sign Message - - - - Clear Routing Data - - - - )} + + + Management + + + Connected Peers + + + + + Sign Message + + + + Clear Routing Data + + @@ -336,8 +335,11 @@ export default function Channels() { )}
- - + + + + + diff --git a/frontend/src/screens/channels/CurrentChannelOrder.tsx b/frontend/src/screens/channels/CurrentChannelOrder.tsx index 5169b6b2a..c6f20a7b2 100644 --- a/frontend/src/screens/channels/CurrentChannelOrder.tsx +++ b/frontend/src/screens/channels/CurrentChannelOrder.tsx @@ -1,9 +1,7 @@ import React from "react"; -import { localStorageKeys } from "src/constants"; import { Channel, ConnectPeerRequest, - GetOnchainAddressResponse, NewChannelOrder, Node, OpenChannelRequest, @@ -14,6 +12,7 @@ import { Payment, init } from "@getalby/bitcoin-connect-react"; import { Copy, QrCode, RefreshCw } from "lucide-react"; import { Link } from "react-router-dom"; import AppHeader from "src/components/AppHeader"; +import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; import QRCode from "src/components/QRCode"; import { Button } from "src/components/ui/button"; @@ -48,6 +47,7 @@ import { useBalances } from "src/hooks/useBalances"; import { useCSRF } from "src/hooks/useCSRF"; import { useChannels } from "src/hooks/useChannels"; import { useMempoolApi } from "src/hooks/useMempoolApi"; +import { useOnchainAddress } from "src/hooks/useOnchainAddress"; import { usePeers } from "src/hooks/usePeers"; import { useSyncWallet } from "src/hooks/useSyncWallet"; import { copyToClipboard } from "src/lib/clipboard"; @@ -205,56 +205,20 @@ function PayBitcoinChannelOrderTopup({ order }: { order: NewChannelOrder }) { } const { data: channels } = useChannels(); - const { data: csrf } = useCSRF(); + const { data: balances } = useBalances(); - const [onchainAddress, setOnchainAddress] = React.useState(); - const [isLoading, setLoading] = React.useState(false); + const { + data: onchainAddress, + getNewAddress, + loadingAddress, + } = useOnchainAddress(); + const { data: mempoolAddressUtxos } = useMempoolApi<{ value: number }[]>( onchainAddress ? `/address/${onchainAddress}/utxo` : undefined, true ); const estimatedTransactionFee = useEstimatedTransactionFee(); - const getNewAddress = React.useCallback(async () => { - if (!csrf) { - return; - } - setLoading(true); - try { - const response = await request( - "/api/wallet/new-address", - { - method: "POST", - headers: { - "X-CSRF-Token": csrf, - "Content-Type": "application/json", - }, - //body: JSON.stringify({}), - } - ); - if (!response?.address) { - throw new Error("No address in response"); - } - localStorage.setItem(localStorageKeys.onchainAddress, response.address); - setOnchainAddress(response.address); - } catch (error) { - alert("Failed to request a new address: " + error); - } finally { - setLoading(false); - } - }, [csrf]); - - React.useEffect(() => { - const existingAddress = localStorage.getItem( - localStorageKeys.onchainAddress - ); - if (existingAddress) { - setOnchainAddress(existingAddress); - return; - } - getNewAddress(); - }, [getNewAddress]); - if (!onchainAddress || !balances || !estimatedTransactionFee) { return (
@@ -282,6 +246,15 @@ function PayBitcoinChannelOrderTopup({ order }: { order: NewChannelOrder }) { 0 ); + const missingAmount = + +order.amount + + estimatedTransactionFee + + estimatedAnchorReserve - + balances.onchain.total; + + const recommendedAmount = Math.ceil(missingAmount / 10000) * 10000; + const topupLink = `https://getalby.com/topup?address=${onchainAddress}&receive_amount=${recommendedAmount}`; + return (
+

+ You currently have{" "} + {new Intl.NumberFormat().format(balances.onchain.total)} sats. We + recommend to deposit another{" "} + {new Intl.NumberFormat().format(recommendedAmount)} sats to open a + channel.{" "} +

- You currently have {balances.onchain.total} sats. You need to - deposit at least another{" "} - {+order.amount + - estimatedTransactionFee + - estimatedAnchorReserve - - balances.onchain.total}{" "} - sats to cover the cost of opening the channel, including onchain - fees and potential onchain channel reserves. + ~{new Intl.NumberFormat().format(+missingAmount)} sats are missing + to cover the cost of opening the channel, including onchain fees and + potential onchain channel reserves.

- + {!loadingAddress && } Generate a new address @@ -369,6 +345,12 @@ function PayBitcoinChannelOrderTopup({ order }: { order: NewChannelOrder }) { {unspentAmount} sats deposited )} + + + +
); @@ -586,10 +568,12 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) { if (!order.lsp) { throw new Error("no lsp selected"); } - const newJITChannelRequest: NewInstantChannelInvoiceRequest = { - lsp: order.lsp, - amount: parseInt(order.amount), - }; + const newInstantChannelInvoiceRequest: NewInstantChannelInvoiceRequest = + { + lsp: order.lsp, + amount: parseInt(order.amount), + public: order.isPublic, + }; const response = await request( "/api/instant-channel-invoices", { @@ -598,7 +582,7 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) { "X-CSRF-Token": csrf, "Content-Type": "application/json", }, - body: JSON.stringify(newJITChannelRequest), + body: JSON.stringify(newInstantChannelInvoiceRequest), } ); if (!response?.invoice) { @@ -612,7 +596,7 @@ function PayLightningChannelOrder({ order }: { order: NewChannelOrder }) { } return true; }); - }, [channels, csrf, order.amount, order.lsp]); + }, [channels, csrf, order.amount, order.isPublic, order.lsp]); return (
diff --git a/frontend/src/screens/channels/NewChannel.tsx b/frontend/src/screens/channels/NewChannel.tsx index 98b74c313..bcec51635 100644 --- a/frontend/src/screens/channels/NewChannel.tsx +++ b/frontend/src/screens/channels/NewChannel.tsx @@ -4,14 +4,6 @@ import { Link, useNavigate } from "react-router-dom"; import AppHeader from "src/components/AppHeader"; import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "src/components/ui/breadcrumb"; import { Button } from "src/components/ui/button"; import { Checkbox } from "src/components/ui/checkbox"; import { Input } from "src/components/ui/input"; @@ -51,7 +43,7 @@ export default function NewChannel() { } function NewChannelInternal({ network }: { network: Network }) { - const { data: channelPeerSuggestions } = useChannelPeerSuggestions(); + const { data: _channelPeerSuggestions } = useChannelPeerSuggestions(); const navigate = useNavigate(); const [order, setOrder] = React.useState>({ @@ -63,11 +55,33 @@ function NewChannelInternal({ network }: { network: Network }) { RecommendedChannelPeer | undefined >(); + const channelPeerSuggestions = React.useMemo(() => { + const customOption: RecommendedChannelPeer = { + name: "Custom", + network, + paymentMethod: "onchain", + minimumChannelSize: 0, + pubkey: "", + host: "", + image: "", + }; + return _channelPeerSuggestions + ? [..._channelPeerSuggestions, customOption] + : undefined; + }, [_channelPeerSuggestions, network]); + function setPaymentMethod(paymentMethod: "onchain" | "lightning") { - setOrder({ - ...order, + setOrder((current) => ({ + ...current, paymentMethod, - }); + })); + } + + function setPublic(isPublic: boolean) { + setOrder((current) => ({ + ...current, + isPublic, + })); } const setAmount = React.useCallback((amount: string) => { @@ -129,19 +143,6 @@ function NewChannelInternal({ network }: { network: Network }) { return ( <> - - - - - Liquidity - - - - - Open Channel - - - -
+
{peer.name !== "Custom" && ( )}
@@ -301,6 +302,24 @@ function NewChannelInternal({ network }: { network: Network }) { {order.paymentMethod === "lightning" && ( )} + +
+ setPublic(!order.isPublic)} + className="mr-2" + /> +
+ +

+ Enable if you want to receive keysend payments. (e.g. podcasting) +

+
+
+ @@ -332,7 +351,7 @@ function NewChannelOnchain(props: NewChannelOnchainProps) { if (props.order.paymentMethod !== "onchain") { throw new Error("unexpected payment method"); } - const { pubkey, host, isPublic } = props.order; + const { pubkey, host } = props.order; const { setOrder } = props; const isAlreadyPeered = pubkey && peers?.some((peer) => peer.nodeId === pubkey); @@ -354,13 +373,6 @@ function NewChannelOnchain(props: NewChannelOnchainProps) { }, [setOrder] ); - function setPublic(isPublic: boolean) { - props.setOrder((current) => ({ - ...current, - paymentMethod: "onchain", - isPublic, - })); - } const fetchNodeDetails = React.useCallback(async () => { if (!pubkey) { @@ -437,23 +449,6 @@ function NewChannelOnchain(props: NewChannelOnchainProps) { )} )} - -
- setPublic(!isPublic)} - className="mr-2" - /> -
- -

- Enable if you want to receive keysend payments. (e.g. podcasting) -

-
-
); diff --git a/frontend/src/screens/onboarding/MigrateAlbyFunds.tsx b/frontend/src/screens/onboarding/MigrateAlbyFunds.tsx index c2aebb8fa..5f5c9948b 100644 --- a/frontend/src/screens/onboarding/MigrateAlbyFunds.tsx +++ b/frontend/src/screens/onboarding/MigrateAlbyFunds.tsx @@ -51,10 +51,12 @@ export default function MigrateAlbyFunds() { if (!csrf) { throw new Error("csrf not loaded"); } - const newJITChannelRequest: NewInstantChannelInvoiceRequest = { - lsp: "ALBY", - amount, - }; + const newInstantChannelInvoiceRequest: NewInstantChannelInvoiceRequest = + { + lsp: "ALBY", + amount, + public: false, + }; const response = await request( "/api/instant-channel-invoices", { @@ -63,7 +65,7 @@ export default function MigrateAlbyFunds() { "X-CSRF-Token": csrf, "Content-Type": "application/json", }, - body: JSON.stringify(newJITChannelRequest), + body: JSON.stringify(newInstantChannelInvoiceRequest), } ); if (!response?.invoice) { diff --git a/frontend/src/screens/onchain/BuyBitcoin.tsx b/frontend/src/screens/onchain/BuyBitcoin.tsx new file mode 100644 index 000000000..768e1b716 --- /dev/null +++ b/frontend/src/screens/onchain/BuyBitcoin.tsx @@ -0,0 +1,221 @@ +import React from "react"; +import AppHeader from "src/components/AppHeader"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "src/components/ui/select"; +import { useOnchainAddress } from "src/hooks/useOnchainAddress"; +import { openLink } from "src/utils/openLink"; + +const SUPPORTED_CURRENCIES = [ + { + value: "usd", + label: "USD - US Dollar", + }, + { + value: "ars", + label: "ARS - Argentine Peso", + }, + { + value: "aud", + label: "AUD - Australian Dollar", + }, + { + value: "bgn", + label: "BGN - Bulgarian Lev", + }, + { + value: "brl", + label: "BRL - Brazilian Real", + }, + { + value: "cad", + label: "CAD - Canadian Dollar", + }, + { + value: "chf", + label: "CHF - Swiss Franc", + }, + { + value: "cop", + label: "COP - Colombian Peso", + }, + { + value: "czk", + label: "CZK - Czech Koruna", + }, + { + value: "dkk", + label: "DKK - Danish Krone", + }, + { + value: "dop", + label: "DOP - Dominican Peso", + }, + { + value: "egp", + label: "EGP - Egyptian Pound", + }, + { + value: "eur", + label: "EUR - Euro", + }, + { + value: "gbp", + label: "GBP - Pound Sterling", + }, + { + value: "hkd", + label: "HKD - Hong Kong Dollar", + }, + { + value: "idr", + label: "IDR - Indonesian Rupiah", + }, + { + value: "ils", + label: "ILS - Israeli New Shekel", + }, + { + value: "jod", + label: "JOD - Jordanian Dollar", + }, + { + value: "kes", + label: "KES - Kenyan Shilling", + }, + { + value: "kwd", + label: "KWD - Kuwaiti Dinar", + }, + { + value: "lkr", + label: "LKR - Sri Lankan Rupee", + }, + { + value: "mxn", + label: "MXN - Mexican Peso", + }, + { + value: "ngn", + label: "NGN - Nigerian Naira", + }, + { + value: "nok", + label: "NOK - Norwegian Krone", + }, + { + value: "nzd", + label: "NZD - New Zealand Dollar", + }, + { + value: "omr", + label: "OMR - Omani Rial", + }, + { + value: "pen", + label: "PEN - Peruvian Sol", + }, + { + value: "pln", + label: "PLN - Polish ZÅ‚oty", + }, + { + value: "ron", + label: "RON - Romanian Leu", + }, + { + value: "sek", + label: "SEK - Swedish Krona", + }, + { + value: "thb", + label: "THB - Thai Baht", + }, + { + value: "try", + label: "TRY - Turkish Lira", + }, + { + value: "twd", + label: "TWD - Taiwan Dollar", + }, + { + value: "vnd", + label: "VND - Vietnamese Dong", + }, + { + value: "zar", + label: "ZAR - South African Rand", + }, +]; + +export default function BuyBitcoin() { + const [currency, setCurrency] = React.useState("usd"); + const [amount, setAmount] = React.useState("250"); + const { data: onchainAddress } = useOnchainAddress(); + + async function launch() { + const url = `https://getalby.com/topup?address=${onchainAddress}&amount=${amount}¤cy=${currency}`; + openLink(url); + } + + return ( +
+ +
+
+
+

+ How much bitcoin would you like to buy? +

+
+ + setAmount(e.target.value)} + value={amount} + type="text" + placeholder="amount" + /> +
+ +
+ + +
+ + + Next + +
+
+
+
+ ); +} diff --git a/frontend/src/screens/onchain/DepositBitcoin.tsx b/frontend/src/screens/onchain/DepositBitcoin.tsx new file mode 100644 index 000000000..ee6f00487 --- /dev/null +++ b/frontend/src/screens/onchain/DepositBitcoin.tsx @@ -0,0 +1,90 @@ +import { Copy, CreditCard, RefreshCw } from "lucide-react"; +import { Link } from "react-router-dom"; +import AppHeader from "src/components/AppHeader"; +import Loading from "src/components/Loading"; +import QRCode from "src/components/QRCode"; +import { Button } from "src/components/ui/button"; +import { Card, CardContent } from "src/components/ui/card"; +import { LoadingButton } from "src/components/ui/loading-button"; +import { toast } from "src/components/ui/use-toast"; +import { useOnchainAddress } from "src/hooks/useOnchainAddress"; +import { copyToClipboard } from "src/lib/clipboard"; + +export default function DepositBitcoin() { + const { + data: onchainAddress, + getNewAddress, + loadingAddress, + } = useOnchainAddress(); + + if (!onchainAddress) { + return ( +
+ +
+ ); + } + + return ( +
+ + + + } + /> +
+ + + + + + +
+ {onchainAddress.match(/.{1,4}/g)?.map((word, index) => { + if (index % 2 === 0) { + return {word} ; + } else { + return {word} ; + } + })} +
+ +
+ + {!loadingAddress && } + Change + + +
+
+
+
+
+ ); +} diff --git a/frontend/src/screens/onchain/NewAddress.tsx b/frontend/src/screens/onchain/NewAddress.tsx deleted file mode 100644 index f1875d5f1..000000000 --- a/frontend/src/screens/onchain/NewAddress.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@radix-ui/react-tooltip"; -import { Copy, QrCode, RefreshCw } from "lucide-react"; -import React from "react"; -import QRCode from "react-qr-code"; -import { Link } from "react-router-dom"; -import AppHeader from "src/components/AppHeader"; -import Loading from "src/components/Loading"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "src/components/ui/breadcrumb"; -import { Button } from "src/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "src/components/ui/dialog"; -import { Input } from "src/components/ui/input"; -import { Label } from "src/components/ui/label"; -import { LoadingButton } from "src/components/ui/loading-button"; -import { localStorageKeys } from "src/constants"; -import { useCSRF } from "src/hooks/useCSRF"; -import { copyToClipboard } from "src/lib/clipboard"; -import { GetOnchainAddressResponse } from "src/types"; -import { request } from "src/utils/request"; - -export default function NewOnchainAddress() { - const { data: csrf } = useCSRF(); - const [onchainAddress, setOnchainAddress] = React.useState(); - const [isLoading, setLoading] = React.useState(false); - - const getNewAddress = React.useCallback(async () => { - if (!csrf) { - return; - } - setLoading(true); - try { - const response = await request( - "/api/wallet/new-address", - { - method: "POST", - headers: { - "X-CSRF-Token": csrf, - "Content-Type": "application/json", - }, - //body: JSON.stringify({}), - } - ); - if (!response?.address) { - throw new Error("No address in response"); - } - localStorage.setItem(localStorageKeys.onchainAddress, response.address); - setOnchainAddress(response.address); - } catch (error) { - alert("Failed to request a new address: " + error); - } finally { - setLoading(false); - } - }, [csrf]); - - React.useEffect(() => { - const existingAddress = localStorage.getItem( - localStorageKeys.onchainAddress - ); - if (existingAddress) { - setOnchainAddress(existingAddress); - return; - } - getNewAddress(); - }, [getNewAddress]); - - if (!onchainAddress) { - return ( -
- -
- ); - } - - return ( -
- - - - - Liquidity - - - - - On-Chain Address - - - - -
- -

- Funds will show up after one confirmation in your savings balance on - the liquidity page. -

-
- - - - - - - - - - Deposit bitcoin - - Scan this QR code with your wallet to send funds. - - -
- - - -
-
-
- - - - - - - - Generate a new address - - -
-
-
- ); -} diff --git a/frontend/src/screens/peers/ConnectPeer.tsx b/frontend/src/screens/peers/ConnectPeer.tsx index 2c8860c26..e09469621 100644 --- a/frontend/src/screens/peers/ConnectPeer.tsx +++ b/frontend/src/screens/peers/ConnectPeer.tsx @@ -31,7 +31,7 @@ export default function ConnectPeer() { if (!pubkey || !address || !port) { throw new Error("connection details missing"); } - console.log(`🔌 Peering with ${pubkey}`); + console.info(`🔌 Peering with ${pubkey}`); const connectPeerRequest: ConnectPeerRequest = { pubkey, address, diff --git a/frontend/src/screens/peers/Peers.tsx b/frontend/src/screens/peers/Peers.tsx index bcaf4c045..cf6682254 100644 --- a/frontend/src/screens/peers/Peers.tsx +++ b/frontend/src/screens/peers/Peers.tsx @@ -79,7 +79,7 @@ export default function Peers() { ) { return; } - console.log(`Disconnecting from ${peerId}`); + console.info(`Disconnecting from ${peerId}`); await request(`/api/peers/${peerId}`, { method: "DELETE", diff --git a/frontend/src/screens/settings/DebugTools.tsx b/frontend/src/screens/settings/DebugTools.tsx index 0ee4d4dfc..088c5e648 100644 --- a/frontend/src/screens/settings/DebugTools.tsx +++ b/frontend/src/screens/settings/DebugTools.tsx @@ -58,11 +58,12 @@ export default function DebugTools() { const amount = window.prompt("Enter amount in sats:"); if (amount) { const nodeId = window.prompt("Enter node pubkey:"); - if (nodeId) + if (nodeId) { apiRequest("/api/send-spontaneous-payment-probes", "POST", { amount: parseInt(amount) * 1000, nodeId, }); + } } }} > @@ -78,7 +79,9 @@ export default function DebugTools() { onClick={() => { const maxLen = window.prompt("Enter max length (in characters):"); - if (maxLen) apiRequest(`/api/log/app?maxLen=${maxLen}`, "GET"); + if (maxLen) { + apiRequest(`/api/log/app?maxLen=${maxLen}`, "GET"); + } }} > Get App Logs @@ -87,7 +90,9 @@ export default function DebugTools() { onClick={() => { const maxLen = window.prompt("Enter max length (in characters):"); - if (maxLen) apiRequest(`/api/log/node?maxLen=${maxLen}`, "GET"); + if (maxLen) { + apiRequest(`/api/log/node?maxLen=${maxLen}`, "GET"); + } }} > Get Node Logs diff --git a/frontend/src/screens/settings/Settings.tsx b/frontend/src/screens/settings/Settings.tsx index dc88c1142..b60742bae 100644 --- a/frontend/src/screens/settings/Settings.tsx +++ b/frontend/src/screens/settings/Settings.tsx @@ -1,114 +1,12 @@ -import { CircleCheck, Link2Off } from "lucide-react"; -import { useEffect, useState } from "react"; -import Loading from "src/components/Loading"; import SettingsHeader from "src/components/SettingsHeader"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "src/components/ui/card"; -import { LoadingButton } from "src/components/ui/loading-button"; -import { toast } from "src/components/ui/use-toast"; -import { useAlbyMe } from "src/hooks/useAlbyMe"; -import { useCSRF } from "src/hooks/useCSRF"; -import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo"; -import { request } from "src/utils/request"; function Settings() { - const { data: csrf } = useCSRF(); - const { data: me } = useAlbyMe(); - const { data: nodeConnectionInfo } = useNodeConnectionInfo(); - const [loading, setLoading] = useState(false); - const [loadingInfo, setLoadingInfo] = useState(true); - const [linked, setLinked] = useState(false); - - useEffect(() => { - if (me && nodeConnectionInfo) { - setLinked(me?.keysend_pubkey === nodeConnectionInfo.pubkey); - setLoadingInfo(false); - } - }, [me, nodeConnectionInfo]); - - async function linkAccount() { - try { - setLoading(true); - if (!csrf) { - throw new Error("csrf not loaded"); - } - await request("/api/alby/link-account", { - method: "POST", - headers: { - "X-CSRF-Token": csrf, - "Content-Type": "application/json", - }, - }); - setLinked(true); - toast({ - title: - "Your Alby Hub has successfully been linked to your Alby Account", - }); - } catch (e) { - toast({ - title: "Your Alby Hub couldn't be linked to your Alby Account", - description: "Did you already link another Alby Hub?", - }); - } finally { - setLoading(false); - } - } - return ( <> -
- - - Alby Account - - Link your lightning address & other apps to this hub. - - - -
Status
- {loadingInfo && } - {!loadingInfo && ( -
- {linked && ( - <> - -

Linked

- - )} - {!linked && me?.shared_node && ( - <> - -

Not Linked

- - )} - {!linked && !me?.shared_node && ( - <> - -

Linked to a different wallet

- - )} -
- )} -
- {!loadingInfo && !linked && me?.shared_node && ( - - - Link now - - - )} -
-
); } diff --git a/frontend/src/screens/setup/ImportMnemonic.tsx b/frontend/src/screens/setup/ImportMnemonic.tsx index 8146842e0..2b111fc7c 100644 --- a/frontend/src/screens/setup/ImportMnemonic.tsx +++ b/frontend/src/screens/setup/ImportMnemonic.tsx @@ -1,7 +1,7 @@ import * as bip39 from "@scure/bip39"; import { wordlist } from "@scure/bip39/wordlists/english"; import { LifeBuoy, ShieldCheck } from "lucide-react"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; import MnemonicInputs from "src/components/MnemonicInputs"; @@ -16,6 +16,12 @@ export function ImportMnemonic() { const navigate = useNavigate(); const setupStore = useSetupStore(); + useEffect(() => { + // in case the user presses back, remove their last-saved mnemonic + useSetupStore.getState().updateNodeInfo({ + mnemonic: undefined, + }); + }, []); const [mnemonic, setMnemonic] = useState(""); async function onSubmit(e: React.FormEvent) { @@ -40,7 +46,8 @@ export function ImportMnemonic() { mnemonic, nextBackupReminder: sixMonthsLater.toISOString(), }); - navigate(`/setup/finish`); + + navigate(`/setup/node`); } return ( @@ -77,7 +84,7 @@ export function ImportMnemonic() { - + ); diff --git a/frontend/src/screens/setup/RestoreNode.tsx b/frontend/src/screens/setup/RestoreNode.tsx index e06346796..08bd4ce8f 100644 --- a/frontend/src/screens/setup/RestoreNode.tsx +++ b/frontend/src/screens/setup/RestoreNode.tsx @@ -98,7 +98,9 @@ export function RestoreNode() { const handleChangeFile = (e: ChangeEvent) => { const files = e.currentTarget.files; - if (files) setFile(files[0]); + if (files) { + setFile(files[0]); + } }; return ( @@ -108,8 +110,8 @@ export function RestoreNode() { className="flex flex-col gap-5 mx-auto max-w-2xl text-sm" >
@@ -135,7 +137,7 @@ export function RestoreNode() { />
)} - Restore Node + Import Wallet ); diff --git a/frontend/src/screens/setup/SetupAdvanced.tsx b/frontend/src/screens/setup/SetupAdvanced.tsx new file mode 100644 index 000000000..fba99fadf --- /dev/null +++ b/frontend/src/screens/setup/SetupAdvanced.tsx @@ -0,0 +1,35 @@ +import { Link } from "react-router-dom"; + +import Container from "src/components/Container"; +import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; +import { Button } from "src/components/ui/button"; + +export function SetupAdvanced() { + return ( + <> + +
+ +
+ + + + + + + + + +
+
+
+ + ); +} diff --git a/frontend/src/screens/setup/SetupNode.tsx b/frontend/src/screens/setup/SetupNode.tsx index c54fff61a..1e224a022 100644 --- a/frontend/src/screens/setup/SetupNode.tsx +++ b/frontend/src/screens/setup/SetupNode.tsx @@ -1,343 +1,109 @@ -import React from "react"; +import React, { ReactElement } from "react"; import { useNavigate } from "react-router-dom"; import Container from "src/components/Container"; -import ExternalLink from "src/components/ExternalLink"; import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; +import { BreezIcon } from "src/components/icons/Breez"; +import { GreenlightIcon } from "src/components/icons/Greenlight"; +import { LDKIcon } from "src/components/icons/LDK"; +import { PhoenixdIcon } from "src/components/icons/Phoenixd"; import { Button } from "src/components/ui/button"; -import { Input } from "src/components/ui/input"; -import { Label } from "src/components/ui/label"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "src/components/ui/select"; -import { useToast } from "src/components/ui/use-toast"; -import useSetupStore from "src/state/SetupStore"; +import { cn } from "src/lib/utils"; import { BackendType } from "src/types"; -export function SetupNode() { - const setupStore = useSetupStore(); - const [backendType, setBackendType] = React.useState( - setupStore.nodeInfo.backendType || "LDK" - ); - const navigate = useNavigate(); - - async function handleSubmit(data: object) { - setupStore.updateNodeInfo({ - backendType, - ...data, - }); - navigate( - backendType === "BREEZ" || - backendType === "GREENLIGHT" || - backendType === "LDK" - ? `/setup/import-mnemonic` - : `/setup/finish` - ); - } - - return ( - <> - - -
- - - {backendType === "BREEZ" && } - {backendType === "GREENLIGHT" && ( - - )} - {backendType === "LDK" && } - {backendType === "LND" && } - {backendType === "PHOENIX" && ( - - )} - {backendType === "CASHU" && } -
-
- - ); -} +import cashu from "src/assets/images/node/cashu.png"; +import lnd from "src/assets/images/node/lnd.png"; +import { backendTypeConfigs } from "src/lib/backendType"; +import useSetupStore from "src/state/SetupStore"; -type SetupFormProps = { - handleSubmit(data: unknown): void; +type BackendTypeDisplayConfig = { + title: string; + icon: ReactElement; }; -function CashuForm({ handleSubmit }: SetupFormProps) { - const [cashuMintUrl, setCashuMintUrl] = React.useState(""); - - function onSubmit(e: React.FormEvent) { - e.preventDefault(); - handleSubmit({ cashuMintUrl }); - } - - return ( -
-
- - setCashuMintUrl(e.target.value)} - value={cashuMintUrl} - id="cashu-mint-url" - placeholder="https://8333.space:3338" - /> -
- - -
- ); -} - -function BreezForm({ handleSubmit }: SetupFormProps) { - const { toast } = useToast(); - const setupStore = useSetupStore(); - const [greenlightInviteCode, setGreenlightInviteCode] = - React.useState(setupStore.nodeInfo.greenlightInviteCode || ""); - const [breezApiKey, setBreezApiKey] = React.useState( - setupStore.nodeInfo.breezApiKey || "" - ); - - function onSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!greenlightInviteCode || !breezApiKey) { - toast({ - title: "Please fill out all fields", - variant: "destructive", - }); - return; - } - handleSubmit({ - greenlightInviteCode, - breezApiKey, - }); - } - - return ( -
-
- - setGreenlightInviteCode(e.target.value)} - value={greenlightInviteCode} - type="text" - id="greenlight-invite-code" - placeholder="XXXX-YYYY" - /> -
-
- - setBreezApiKey(e.target.value)} - value={breezApiKey} - autoComplete="off" - type="text" - id="breez-api-key" - /> -
- -
- ); -} - -function GreenlightForm({ handleSubmit }: SetupFormProps) { - const setupStore = useSetupStore(); - const { toast } = useToast(); - const [greenlightInviteCode, setGreenlightInviteCode] = - React.useState(setupStore.nodeInfo.greenlightInviteCode || ""); - - function onSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!greenlightInviteCode) { - toast({ - title: "Please fill out all fields", - variant: "destructive", - }); - return; - } - handleSubmit({ - greenlightInviteCode, - }); - } +const backendTypeDisplayConfigs: Record = + { + LDK: { + title: "LDK", + icon: , + }, + PHOENIX: { + title: "phoenixd", + icon: , + }, + BREEZ: { + title: "Breez SDK", + icon: , + }, + GREENLIGHT: { + title: "Greenlight", + icon: , + }, + LND: { + title: "LND", + icon: , + }, + CASHU: { + title: "Cashu Mint", + icon: , + }, + }; + +const backendTypeDisplayConfigList = Object.entries( + backendTypeDisplayConfigs +).map((entry) => ({ + ...entry[1], + backendType: entry[0] as BackendType, +})); - return ( -
-
- - setGreenlightInviteCode(e.target.value)} - value={greenlightInviteCode} - type="text" - id="greenlight-invite-code" - placeholder="XXXX-YYYY" - /> -
- -
- ); -} - -function LDKForm({ handleSubmit }: SetupFormProps) { - function onSubmit(e: React.FormEvent) { - e.preventDefault(); - handleSubmit({}); - } - - return ( -
- -
- ); -} - -function LNDForm({ handleSubmit }: SetupFormProps) { - const { toast } = useToast(); +export function SetupNode() { + const navigate = useNavigate(); const setupStore = useSetupStore(); - const [lndAddress, setLndAddress] = React.useState( - setupStore.nodeInfo.lndAddress || "" - ); - const [lndCertHex, setLndCertHex] = React.useState( - setupStore.nodeInfo.lndCertHex || "" - ); - const [lndMacaroonHex, setLndMacaroonHex] = React.useState( - setupStore.nodeInfo.lndMacaroonHex || "" - ); - // TODO: proper onboarding + const [selectedBackendType, setSelectedBackupType] = + React.useState(); - function onSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!lndAddress || !lndCertHex || !lndMacaroonHex) { - toast({ - title: "Please fill out all fields", - variant: "destructive", - }); + function next() { + if (!selectedBackendType) { return; } - handleSubmit({ - lndAddress, - lndCertHex, - lndMacaroonHex, - }); + navigate(`/setup/node/${selectedBackendType.toLowerCase()}`); } - return ( -
-
- - setLndAddress(e.target.value)} - value={lndAddress} - id="lnd-address" - /> -
-
- - setLndCertHex(e.target.value)} - value={lndCertHex} - type="text" - id="lnd-cert-hex" - /> -
-
- - setLndMacaroonHex(e.target.value)} - value={lndMacaroonHex} - type="text" - id="lnd-macaroon-hex" - /> -
- -
- ); -} - -function PhoenixForm({ handleSubmit }: SetupFormProps) { - const { toast } = useToast(); - const setupStore = useSetupStore(); - const [phoenixdAddress, setPhoenixdAddress] = React.useState( - setupStore.nodeInfo.phoenixdAddress || "http://127.0.0.1:9740" - ); - const [phoenixdAuthorization, setPhoenixdAuthorization] = - React.useState(setupStore.nodeInfo.phoenixdAuthorization || ""); - - function onSubmit(e: React.FormEvent) { - e.preventDefault(); - if (!phoenixdAddress || !phoenixdAuthorization) { - toast({ - title: "Please fill out all fields", - variant: "destructive", - }); - return; - } - handleSubmit({ - phoenixdAddress, - phoenixdAuthorization, - }); - } + const hasImportedMnemonic = !!setupStore.nodeInfo.mnemonic; return ( -
-
- - setPhoenixdAddress(e.target.value)} - placeholder="http://127.0.0.1:9740" - value={phoenixdAddress} - id="phoenix-address" - /> -
-
- - setPhoenixdAuthorization(e.target.value)} - value={phoenixdAuthorization} - type="password" - id="phoenix-authorization" + <> + + -
- -
+
+
+ {backendTypeDisplayConfigList + .filter((item) => + hasImportedMnemonic + ? backendTypeConfigs[item.backendType].hasMnemonic + : true + ) + .map((item) => ( +
setSelectedBackupType(item.backendType)} + > +
{item.icon}
+ {item.title} +
+ ))} +
+ +
+ + ); } diff --git a/frontend/src/screens/setup/SetupPassword.tsx b/frontend/src/screens/setup/SetupPassword.tsx index 78c00dcd4..ddf5ccc1e 100644 --- a/frontend/src/screens/setup/SetupPassword.tsx +++ b/frontend/src/screens/setup/SetupPassword.tsx @@ -1,9 +1,7 @@ import React, { useState } from "react"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import useSetupStore from "src/state/SetupStore"; -import * as bip39 from "@scure/bip39"; -import { wordlist } from "@scure/bip39/wordlists/english"; import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; import { Button } from "src/components/ui/button"; import { Checkbox } from "src/components/ui/checkbox"; @@ -11,18 +9,19 @@ import { Input } from "src/components/ui/input"; import { Label } from "src/components/ui/label"; import { useToast } from "src/components/ui/use-toast"; import { useInfo } from "src/hooks/useInfo"; -import { backendTypeHasMnemonic } from "src/lib/utils"; export function SetupPassword() { - const { toast } = useToast(); + const navigate = useNavigate(); const store = useSetupStore(); + const { toast } = useToast(); const { data: info } = useInfo(); const [confirmPassword, setConfirmPassword] = React.useState(""); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - const wallet = searchParams.get("wallet"); const [isPasswordSecured, setIsPasswordSecured] = useState(false); + const [searchParams] = useSearchParams(); + const wallet = searchParams.get("wallet") || "new"; + const node = searchParams.get("node") || ""; + function onSubmit(e: React.FormEvent) { e.preventDefault(); if (!info) { @@ -36,24 +35,13 @@ export function SetupPassword() { return; } - if (!backendTypeHasMnemonic(info.backendType)) { - // NOTE: LND flow does not setup a mnemonic - navigate(`/setup/finish`); - return; - } - - // Import flow (All options) if (wallet === "import") { + navigate(`/setup/import-mnemonic`); + } else if (node) { + navigate(`/setup/node/${node}`); + } else { navigate(`/setup/node`); - return; } - - // Default flow (LDK) - useSetupStore.getState().updateNodeInfo({ - backendType: "LDK", - mnemonic: bip39.generateMnemonic(wordlist, 128), - }); - navigate(`/setup/finish`); } return ( @@ -66,9 +54,10 @@ export function SetupPassword() { description="Your password is used to access your wallet, and it can't be reset or recovered if you lose it." />
-
+
-
+
- - {wallet === "import" && ( -
-

or

- - - -
- )}
diff --git a/frontend/src/screens/setup/SetupWallet.tsx b/frontend/src/screens/setup/SetupWallet.tsx deleted file mode 100644 index cf15ae4a3..000000000 --- a/frontend/src/screens/setup/SetupWallet.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { ChevronRight, WalletMinimal } from "lucide-react"; -import React from "react"; -import { Link } from "react-router-dom"; - -import Container from "src/components/Container"; -import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; -import { Button } from "src/components/ui/button"; -import { - Card, - CardContent, - CardDescription, - CardTitle, -} from "src/components/ui/card"; -import { useInfo } from "src/hooks/useInfo"; - -export function SetupWallet() { - const { data: info } = useInfo(); - const [showOtherOptions, setShowOtherOptions] = React.useState(false); - return ( - <> - -
- - {info?.backendType && ( - <> - - - -
- -
- {info.backendType} Wallet - - Connect to preconfigured {info.backendType} Wallet - -
-
- -
-
-
-
- - - {!showOtherOptions && ( - - )} - - )} - - {(showOtherOptions || !info?.backendType) && ( -
- - - - - - -
- )} -
-
- - ); -} diff --git a/frontend/src/screens/setup/node/BreezForm.tsx b/frontend/src/screens/setup/node/BreezForm.tsx new file mode 100644 index 000000000..508ed5f13 --- /dev/null +++ b/frontend/src/screens/setup/node/BreezForm.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Container from "src/components/Container"; +import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; +import { Button } from "src/components/ui/button"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { useToast } from "src/components/ui/use-toast"; +import useSetupStore from "src/state/SetupStore"; + +export function BreezForm() { + const { toast } = useToast(); + const navigate = useNavigate(); + const setupStore = useSetupStore(); + const [greenlightInviteCode, setGreenlightInviteCode] = + React.useState(setupStore.nodeInfo.greenlightInviteCode || ""); + const [breezApiKey, setBreezApiKey] = React.useState( + setupStore.nodeInfo.breezApiKey || "" + ); + + async function handleSubmit(data: object) { + setupStore.updateNodeInfo({ + backendType: "BREEZ", + ...data, + }); + navigate("/setup/import-mnemonic"); + } + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!greenlightInviteCode || !breezApiKey) { + toast({ + title: "Please fill out all fields", + variant: "destructive", + }); + return; + } + handleSubmit({ + greenlightInviteCode, + breezApiKey, + }); + } + + return ( + + +
+
+ + setGreenlightInviteCode(e.target.value)} + value={greenlightInviteCode} + type="text" + id="greenlight-invite-code" + placeholder="XXXX-YYYY" + /> +
+
+ + setBreezApiKey(e.target.value)} + value={breezApiKey} + autoComplete="off" + type="text" + id="breez-api-key" + /> +
+ +
+
+ ); +} diff --git a/frontend/src/screens/setup/node/CashuForm.tsx b/frontend/src/screens/setup/node/CashuForm.tsx new file mode 100644 index 000000000..2ee446059 --- /dev/null +++ b/frontend/src/screens/setup/node/CashuForm.tsx @@ -0,0 +1,61 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Container from "src/components/Container"; +import ExternalLink from "src/components/ExternalLink"; +import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; +import { Button } from "src/components/ui/button"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import useSetupStore from "src/state/SetupStore"; + +export function CashuForm() { + const setupStore = useSetupStore(); + const navigate = useNavigate(); + const [cashuMintUrl, setCashuMintUrl] = React.useState(""); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + handleSubmit({ cashuMintUrl }); + } + + async function handleSubmit(data: object) { + setupStore.updateNodeInfo({ + backendType: "CASHU", + ...data, + }); + navigate("/setup/finish"); + } + + return ( + + +
+
+ + setCashuMintUrl(e.target.value)} + value={cashuMintUrl} + id="cashu-mint-url" + placeholder="https://8333.space:3338" + /> +
+ +
+
+ ); +} diff --git a/frontend/src/screens/setup/node/GreenlightForm.tsx b/frontend/src/screens/setup/node/GreenlightForm.tsx new file mode 100644 index 000000000..bdc928d2f --- /dev/null +++ b/frontend/src/screens/setup/node/GreenlightForm.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Container from "src/components/Container"; +import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; +import { Button } from "src/components/ui/button"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { useToast } from "src/components/ui/use-toast"; +import useSetupStore from "src/state/SetupStore"; + +export function GreenlightForm() { + const { toast } = useToast(); + const navigate = useNavigate(); + const setupStore = useSetupStore(); + const [greenlightInviteCode, setGreenlightInviteCode] = + React.useState(setupStore.nodeInfo.greenlightInviteCode || ""); + + async function handleSubmit(data: object) { + setupStore.updateNodeInfo({ + backendType: "GREENLIGHT", + ...data, + }); + navigate("/setup/import-mnemonic"); + } + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!greenlightInviteCode) { + toast({ + title: "Please fill out all fields", + variant: "destructive", + }); + return; + } + handleSubmit({ + greenlightInviteCode, + }); + } + + return ( + + +
+
+ + setGreenlightInviteCode(e.target.value)} + value={greenlightInviteCode} + type="text" + id="greenlight-invite-code" + placeholder="XXXX-YYYY" + /> +
+ +
+
+ ); +} diff --git a/frontend/src/screens/setup/node/LDKForm.tsx b/frontend/src/screens/setup/node/LDKForm.tsx new file mode 100644 index 000000000..ff471d895 --- /dev/null +++ b/frontend/src/screens/setup/node/LDKForm.tsx @@ -0,0 +1,28 @@ +import { wordlist } from "@scure/bip39/wordlists/english"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import useSetupStore from "src/state/SetupStore"; + +import * as bip39 from "@scure/bip39"; +import Loading from "src/components/Loading"; + +export function LDKForm() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + + // No configuration needed, automatically proceed with the next step + useEffect(() => { + // only generate a mnemonic if one is not already imported + if (!useSetupStore.getState().nodeInfo.mnemonic) { + useSetupStore.getState().updateNodeInfo({ + mnemonic: bip39.generateMnemonic(wordlist, 128), + }); + } + useSetupStore.getState().updateNodeInfo({ + backendType: "LDK", + }); + navigate("/setup/finish"); + }, [navigate, searchParams]); + + return ; +} diff --git a/frontend/src/screens/setup/node/LNDForm.tsx b/frontend/src/screens/setup/node/LNDForm.tsx new file mode 100644 index 000000000..3b595527a --- /dev/null +++ b/frontend/src/screens/setup/node/LNDForm.tsx @@ -0,0 +1,90 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Container from "src/components/Container"; +import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; +import { Button } from "src/components/ui/button"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { useToast } from "src/components/ui/use-toast"; +import useSetupStore from "src/state/SetupStore"; + +export function LNDForm() { + const { toast } = useToast(); + const navigate = useNavigate(); + const setupStore = useSetupStore(); + const [lndAddress, setLndAddress] = React.useState( + setupStore.nodeInfo.lndAddress || "" + ); + const [lndCertHex, setLndCertHex] = React.useState( + setupStore.nodeInfo.lndCertHex || "" + ); + const [lndMacaroonHex, setLndMacaroonHex] = React.useState( + setupStore.nodeInfo.lndMacaroonHex || "" + ); + + // TODO: proper onboarding + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!lndAddress || !lndCertHex || !lndMacaroonHex) { + toast({ + title: "Please fill out all fields", + variant: "destructive", + }); + return; + } + handleSubmit({ + lndAddress, + lndCertHex, + lndMacaroonHex, + }); + } + + async function handleSubmit(data: object) { + setupStore.updateNodeInfo({ + backendType: "LND", + ...data, + }); + navigate("/setup/finish"); + } + + return ( + + +
+
+ + setLndAddress(e.target.value)} + value={lndAddress} + id="lnd-address" + /> +
+
+ + setLndCertHex(e.target.value)} + value={lndCertHex} + type="text" + id="lnd-cert-hex" + /> +
+
+ + setLndMacaroonHex(e.target.value)} + value={lndMacaroonHex} + type="text" + id="lnd-macaroon-hex" + /> +
+ +
+
+ ); +} diff --git a/frontend/src/screens/setup/node/PhoenixdForm.tsx b/frontend/src/screens/setup/node/PhoenixdForm.tsx new file mode 100644 index 000000000..d00b8358b --- /dev/null +++ b/frontend/src/screens/setup/node/PhoenixdForm.tsx @@ -0,0 +1,75 @@ +import React from "react"; +import { useNavigate } from "react-router-dom"; +import Container from "src/components/Container"; +import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader"; +import { Button } from "src/components/ui/button"; +import { Input } from "src/components/ui/input"; +import { Label } from "src/components/ui/label"; +import { useToast } from "src/components/ui/use-toast"; +import useSetupStore from "src/state/SetupStore"; + +export function PhoenixdForm() { + const { toast } = useToast(); + const navigate = useNavigate(); + const setupStore = useSetupStore(); + const [phoenixdAddress, setPhoenixdAddress] = React.useState( + setupStore.nodeInfo.phoenixdAddress || "http://127.0.0.1:9740" + ); + const [phoenixdAuthorization, setPhoenixdAuthorization] = + React.useState(setupStore.nodeInfo.phoenixdAuthorization || ""); + + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!phoenixdAddress || !phoenixdAuthorization) { + toast({ + title: "Please fill out all fields", + variant: "destructive", + }); + return; + } + handleSubmit({ + phoenixdAddress, + phoenixdAuthorization, + }); + } + + async function handleSubmit(data: object) { + setupStore.updateNodeInfo({ + backendType: "PHOENIX", + ...data, + }); + navigate("/setup/finish"); + } + + return ( + + +
+
+ + setPhoenixdAddress(e.target.value)} + placeholder="http://127.0.0.1:9740" + value={phoenixdAddress} + id="phoenix-address" + /> +
+
+ + setPhoenixdAuthorization(e.target.value)} + value={phoenixdAuthorization} + type="password" + id="phoenix-authorization" + /> +
+ +
+
+ ); +} diff --git a/frontend/src/screens/setup/node/PresetNodeForm.tsx b/frontend/src/screens/setup/node/PresetNodeForm.tsx new file mode 100644 index 000000000..3c72bf850 --- /dev/null +++ b/frontend/src/screens/setup/node/PresetNodeForm.tsx @@ -0,0 +1,31 @@ +import { wordlist } from "@scure/bip39/wordlists/english"; +import { useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import useSetupStore from "src/state/SetupStore"; + +import * as bip39 from "@scure/bip39"; +import Loading from "src/components/Loading"; +import { useInfo } from "src/hooks/useInfo"; +import { backendTypeConfigs } from "src/lib/backendType"; + +export function PresetNodeForm() { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { data: info } = useInfo(); + + // No configuration needed, automatically proceed with the next step + useEffect(() => { + if (!info) { + return; + } + if (backendTypeConfigs[info.backendType].hasMnemonic) { + useSetupStore.getState().updateNodeInfo({ + mnemonic: bip39.generateMnemonic(wordlist, 128), + }); + } + + navigate("/setup/finish"); + }, [info, navigate, searchParams]); + + return ; +} diff --git a/frontend/src/screens/wallet/OnboardingChecklist.tsx b/frontend/src/screens/wallet/OnboardingChecklist.tsx new file mode 100644 index 000000000..3af1fb638 --- /dev/null +++ b/frontend/src/screens/wallet/OnboardingChecklist.tsx @@ -0,0 +1,174 @@ +import { ChevronRight, Circle, CircleCheck } from "lucide-react"; +import { Link } from "react-router-dom"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "src/components/ui/card"; +import { useAlbyMe } from "src/hooks/useAlbyMe"; +import { useApps } from "src/hooks/useApps"; +import { useChannels } from "src/hooks/useChannels"; +import { useInfo } from "src/hooks/useInfo"; +import { useNodeConnectionInfo } from "src/hooks/useNodeConnectionInfo"; +import { cn } from "src/lib/utils"; + +function OnboardingChecklist() { + // const { data: albyBalance } = useAlbyBalance(); + const { data: albyMe } = useAlbyMe(); + const { data: apps } = useApps(); + const { data: channels } = useChannels(); + const { data: info, hasChannelManagement, hasMnemonic } = useInfo(); + const { data: nodeConnectionInfo } = useNodeConnectionInfo(); + + const isLoading = + !albyMe || !apps || !channels || !info || !nodeConnectionInfo; + + if (isLoading) { + return; + } + + /*const hasAlbyBalance = + hasChannelManagement && + albyBalance && + albyBalance.sats * (1 - ALBY_SERVICE_FEE) > + ALBY_MIN_BALANCE + 50000; // accommodate for on-chain fees + */ + + const isLinked = + albyMe && + nodeConnectionInfo && + albyMe?.keysend_pubkey === nodeConnectionInfo?.pubkey; + const hasChannel = hasChannelManagement && channels && channels?.length > 0; + const hasBackedUp = + hasMnemonic && + info && + info.nextBackupReminder && + new Date(info.nextBackupReminder).getTime() > new Date().getTime(); + const hasCustomApp = + apps && apps.find((x) => x.name !== "getalby.com") !== undefined; + + if (isLinked && hasChannel && (!hasMnemonic || hasBackedUp) && hasCustomApp) { + return; + } + + const checklistItems = [ + { + title: "Open your first channel", + description: + "Establish a new Lightning channel to enable fast and low-fee Bitcoin transactions.", + checked: hasChannel, + to: "/channels/new", + }, + { + title: "Link your Alby Account", + description: "Link your lightning address & other apps to this hub.", + checked: isLinked, + to: "/apps", + }, + // TODO: enable when we can always migrate funds + /*{ + title: "Migrate your balance to your Alby Hub", + description: "Move your existing funds into self-custody.", + checked: !hasAlbyBalance, + to: "/onboarding/lightning/migrate-alby", + },*/ + { + title: "Connect your first app", + description: + "Seamlessly connect apps and integrate your wallet with other apps from your hub.", + checked: hasCustomApp, + to: "/appstore", + }, + ...(hasMnemonic + ? [ + { + title: "Backup your keys", + description: + "Secure your keys by creating a backup to ensure you don't lose access.", + checked: hasBackedUp, + to: "/settings/key-backup", + }, + ] + : []), + ]; + + const sortedChecklistItems = checklistItems.sort( + (a, b) => (b && b.checked ? 1 : 0) - (a && a.checked ? 1 : 0) + ); + + return ( + + + Get started with your Alby Hub + + Follow these initial steps to set up and make the most of your Alby + Hub. + + + + {sortedChecklistItems.map((item) => ( + + ))} + + + ); +} + +type ChecklistItemProps = { + title: string; + checked: boolean; + description: string; + to: string; +}; + +function ChecklistItem({ + title, + checked = false, + description, + to, +}: ChecklistItemProps) { + const content = ( +
+ {!checked && ( +
+ +
+ )} +
+ {checked ? ( + + ) : ( + + )} +
+ {title} +
+
+ {!checked && ( +
{description}
+ )} +
+ ); + + return checked ? content : {content}; +} + +export default OnboardingChecklist; diff --git a/frontend/src/screens/wallet/index.tsx b/frontend/src/screens/wallet/index.tsx index e057a16f6..565b22cf8 100644 --- a/frontend/src/screens/wallet/index.tsx +++ b/frontend/src/screens/wallet/index.tsx @@ -1,8 +1,8 @@ -import { ExternalLink } from "lucide-react"; -import { Link } from "react-router-dom"; +import { ExternalLinkIcon } from "lucide-react"; import AlbyHead from "src/assets/images/alby-head.svg"; import AppHeader from "src/components/AppHeader"; import BreezRedeem from "src/components/BreezRedeem"; +import ExternalLink from "src/components/ExternalLink"; import Loading from "src/components/Loading"; import { Button } from "src/components/ui/button"; import { @@ -14,6 +14,7 @@ import { } from "src/components/ui/card"; import { useBalances } from "src/hooks/useBalances"; import { useInfo } from "src/hooks/useInfo"; +import OnboardingChecklist from "src/screens/wallet/OnboardingChecklist"; function Wallet() { const { data: info } = useInfo(); @@ -29,7 +30,6 @@ function Wallet() { return ( <> -
{new Intl.NumberFormat().format( @@ -40,7 +40,7 @@ function Wallet() {
- +
@@ -63,13 +63,13 @@ function Wallet() { - + {!extensionInstalled && ( - +
@@ -93,14 +93,16 @@ function Wallet() { - + )}
+ + ); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index b5cd2371c..e3dec5edd 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -215,10 +215,6 @@ export type OpenChannelResponse = { // eslint-disable-next-line @typescript-eslint/ban-types export type CloseChannelResponse = {}; -export type GetOnchainAddressResponse = { - address: string; -}; - export type OnchainBalanceResponse = { spendable: number; total: number; @@ -285,6 +281,7 @@ export type AlbyBalance = { export type NewInstantChannelInvoiceRequest = { amount: number; lsp: string; + public: boolean; }; export type NewInstantChannelInvoiceResponse = { diff --git a/frontend/src/utils/handleRequestError.ts b/frontend/src/utils/handleRequestError.ts index d96c0512d..aa9f382af 100644 --- a/frontend/src/utils/handleRequestError.ts +++ b/frontend/src/utils/handleRequestError.ts @@ -1,5 +1,13 @@ +import { Toast, ToasterToast } from "src/components/ui/use-toast"; + +type ToastSignature = (props: Toast) => { + id: string; + dismiss: () => void; + update: (props: ToasterToast) => void; +}; + export function handleRequestError( - toast: any, + toast: ToastSignature, message: string, error: unknown ) { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 3781a8d32..7c74ebbce 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -57,6 +57,10 @@ module.exports = { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, + positive: { + DEFAULT: "hsl(var(--positive))", + foreground: "hsl(var(--positive-foreground))", + }, }, borderRadius: { lg: "var(--radius)", diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 7a728d460..be1ef6afc 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -320,25 +320,44 @@ resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz" integrity sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA== -"@eslint/eslintrc@^2.1.4": - version "2.1.4" - resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz" - integrity sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ== +"@eslint/compat@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-1.0.3.tgz#6be44cf553a14a2f68fafb304818f7d824a7248f" + integrity sha512-9RaroPQaU2+SDcWav1YfuipwqnHccoiXZdUsicRQsQ/vH2wkEmRVcj344GapG/FnCeZRtqj0n6PshI+s9xkkAQ== + +"@eslint/config-array@^0.15.1": + version "0.15.1" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.15.1.tgz#1fa78b422d98f4e7979f2211a1fde137e26c7d61" + integrity sha512-K4gzNq+yymn/EVsXYmf+SBcBro8MTf+aXJZUphM96CdzUEr+ClGDvAbpmaEK+cGVigVXIgs9gNmvHAlrzzY5JQ== + dependencies: + "@eslint/object-schema" "^2.1.3" + debug "^4.3.1" + minimatch "^3.0.5" + +"@eslint/eslintrc@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" + integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.6.0" - globals "^13.19.0" + espree "^10.0.1" + globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" js-yaml "^4.1.0" minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.55.0": - version "8.55.0" - resolved "https://registry.npmjs.org/@eslint/js/-/js-8.55.0.tgz" - integrity sha512-qQfo2mxH5yVom1kacMtZZJFVdW+E70mqHMJvVg6WTLo+VBuQJ4TojZlfWBjK0ve5BdEeNAVxOsl/nvNMpJOaJA== +"@eslint/js@9.4.0", "@eslint/js@^9.4.0": + version "9.4.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.4.0.tgz#96a2edd37ec0551ce5f9540705be23951c008a0c" + integrity sha512-fdI7VJjP3Rvc70lC4xkFXHB0fiPeojiL1PxVG6t1ZvXQrarj893PweuBTujxDUFk0Fxj4R7PIIAZ/aiiyZPZcg== + +"@eslint/object-schema@^2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.3.tgz#e65ae80ee2927b4fd8c5c26b15ecacc2b2a6cc2a" + integrity sha512-HAbhAYKfsAC2EkTqve00ibWIZlaU74Z1EHwAjYr4PXF0YU2VEA1zSIKSSpKszRLRWwHzzRZXvK632u+uXzvsvw== "@floating-ui/core@^1.0.0": version "1.6.0" @@ -398,24 +417,15 @@ eventemitter3 "^5.0.1" nostr-tools "^1.17.0" -"@humanwhocodes/config-array@^0.11.13": - version "0.11.13" - resolved "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.13.tgz" - integrity sha512-JSBDMiDKSzQVngfRjOdFXgFfklaXI4K9nLF49Auh21lmBWRLIK3+xTErTWD4KU54pb6coM6ESE7Awz/FNU3zgQ== - dependencies: - "@humanwhocodes/object-schema" "^2.0.1" - debug "^4.1.1" - minimatch "^3.0.5" - "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/object-schema@^2.0.1": - version "2.0.1" - resolved "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.1.tgz" - integrity sha512-dvuCeX5fC9dXgJn9t+X5atfmgQAzUOWqS1254Gh0m6i8wKd10ebXkfNKiRK+1GWi/yTvvLDHpoxLr0xxxeslWw== +"@humanwhocodes/retry@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.0.tgz#6d86b8cb322660f03d3f0aa94b99bdd8e172d570" + integrity sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew== "@jridgewell/gen-mapping@^0.3.2": version "0.3.3" @@ -1235,11 +1245,6 @@ "@typescript-eslint/types" "7.11.0" eslint-visitor-keys "^3.4.3" -"@ungap/structured-clone@^1.2.0": - version "1.2.0" - resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz" - integrity sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ== - "@vitejs/plugin-react-swc@^3.3.2": version "3.5.0" resolved "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.5.0.tgz" @@ -1260,10 +1265,10 @@ acorn-jsx@^5.3.2: resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.9.0: - version "8.11.2" - resolved "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz" - integrity sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w== +acorn@^8.11.3: + version "8.11.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" + integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== ajv@^6.12.4: version "6.12.6" @@ -1675,6 +1680,13 @@ debug@^4.1.1, debug@^4.3.2, debug@^4.3.4, debug@~4.3.4: dependencies: ms "2.1.2" +debug@^4.3.1: + version "4.3.5" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" + integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== + dependencies: + ms "2.1.2" + deep-is@^0.1.3: version "0.1.4" resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" @@ -1702,13 +1714,6 @@ dlv@^1.1.3: resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz" integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== -doctrine@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz" - integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== - dependencies: - esutils "^2.0.2" - dot-prop@^5.1.0: version "5.3.0" resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" @@ -1819,54 +1824,55 @@ eslint-plugin-react-refresh@^0.4.3: resolved "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.5.tgz" integrity sha512-D53FYKJa+fDmZMtriODxvhwrO+IOqrxoEo21gMA0sjHdU6dPVH4OhyFip9ypl8HOF5RV5KdTo+rBQLvnY2cO8w== -eslint-scope@^7.2.2: - version "7.2.2" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz" - integrity sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg== +eslint-scope@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.0.1.tgz#a9601e4b81a0b9171657c343fb13111688963cfc" + integrity sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" -eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4.3: +eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.3: version "3.4.3" resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint@^8.45.0: - version "8.55.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-8.55.0.tgz" - integrity sha512-iyUUAM0PCKj5QpwGfmCAG9XXbZCWsqP/eWAWrG/W0umvjuLRBECwSFdt+rCntju0xEH7teIABPwXpahftIaTdA== +eslint-visitor-keys@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz#e3adc021aa038a2a8e0b2f8b0ce8f66b9483b1fb" + integrity sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw== + +eslint@^9.4.0: + version "9.4.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.4.0.tgz#79150c3610ae606eb131f1d648d5f43b3d45f3cd" + integrity sha512-sjc7Y8cUD1IlwYcTS9qPSvGjAC8Ne9LctpxKKu3x/1IC9bnOg98Zy6GxEJUfr1NojMgVPlyANXYns8oE2c1TAA== dependencies: "@eslint-community/eslint-utils" "^4.2.0" "@eslint-community/regexpp" "^4.6.1" - "@eslint/eslintrc" "^2.1.4" - "@eslint/js" "8.55.0" - "@humanwhocodes/config-array" "^0.11.13" + "@eslint/config-array" "^0.15.1" + "@eslint/eslintrc" "^3.1.0" + "@eslint/js" "9.4.0" "@humanwhocodes/module-importer" "^1.0.1" + "@humanwhocodes/retry" "^0.3.0" "@nodelib/fs.walk" "^1.2.8" - "@ungap/structured-clone" "^1.2.0" ajv "^6.12.4" chalk "^4.0.0" cross-spawn "^7.0.2" debug "^4.3.2" - doctrine "^3.0.0" escape-string-regexp "^4.0.0" - eslint-scope "^7.2.2" - eslint-visitor-keys "^3.4.3" - espree "^9.6.1" + eslint-scope "^8.0.1" + eslint-visitor-keys "^4.0.0" + espree "^10.0.1" esquery "^1.4.2" esutils "^2.0.2" fast-deep-equal "^3.1.3" - file-entry-cache "^6.0.1" + file-entry-cache "^8.0.0" find-up "^5.0.0" glob-parent "^6.0.2" - globals "^13.19.0" - graphemer "^1.4.0" ignore "^5.2.0" imurmurhash "^0.1.4" is-glob "^4.0.0" is-path-inside "^3.0.3" - js-yaml "^4.1.0" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" @@ -1876,14 +1882,14 @@ eslint@^8.45.0: strip-ansi "^6.0.1" text-table "^0.2.0" -espree@^9.6.0, espree@^9.6.1: - version "9.6.1" - resolved "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz" - integrity sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ== +espree@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.0.1.tgz#600e60404157412751ba4a6f3a2ee1a42433139f" + integrity sha512-MWkrWZbJsL2UwnjxTX3gG8FneachS/Mwg7tdGXce011sJd5b0JG54vat5KHnfSBODZ3Wvzd2WnjxyzsRoVv+ww== dependencies: - acorn "^8.9.0" + acorn "^8.11.3" acorn-jsx "^5.3.2" - eslint-visitor-keys "^3.4.1" + eslint-visitor-keys "^4.0.0" esquery@^1.4.2: version "1.5.0" @@ -1967,12 +1973,12 @@ fflate@^0.4.8: resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae" integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA== -file-entry-cache@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz" - integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== +file-entry-cache@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" + integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== dependencies: - flat-cache "^3.0.4" + flat-cache "^4.0.0" fill-range@^7.0.1: version "7.0.1" @@ -2005,14 +2011,13 @@ find-up@^7.0.0: path-exists "^5.0.0" unicorn-magic "^0.1.0" -flat-cache@^3.0.4: - version "3.2.0" - resolved "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz" - integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== +flat-cache@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" + integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== dependencies: flatted "^3.2.9" - keyv "^4.5.3" - rimraf "^3.0.2" + keyv "^4.5.4" flatted@^3.2.9: version "3.2.9" @@ -2094,9 +2099,9 @@ glob@7.1.6: once "^1.3.0" path-is-absolute "^1.0.0" -glob@^7.0.0, glob@^7.1.3: +glob@^7.0.0: version "7.2.3" - resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" @@ -2113,12 +2118,15 @@ global-directory@^4.0.1: dependencies: ini "4.1.1" -globals@^13.19.0: - version "13.24.0" - resolved "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz" - integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== - dependencies: - type-fest "^0.20.2" +globals@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" + integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== + +globals@^15.4.0: + version "15.4.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-15.4.0.tgz#3e36ea6e4d9ddcf1cb42d92f5c4a145a8a2ddc1c" + integrity sha512-unnwvMZpv0eDUyjNyh9DH/yxUaRYrEjW/qK4QcdrHg3oO11igUQrCSgODHEqxlKg8v2CD2Sd7UkqqEBoz5U7TQ== globby@^11.1.0: version "11.1.0" @@ -2373,9 +2381,9 @@ jsonparse@^1.2.0: resolved "https://registry.yarnpkg.com/jsonparse/-/jsonparse-1.3.1.tgz#3f4dae4a91fac315f71062f8521cc239f1366280" integrity sha512-POQXvpdL69+CluYsillJ7SUhKvytYjW9vG/GKpnf+xP8UWgYEM/RaMzHHofbALDiKbbP1W8UEYmgGl39WkPZsg== -keyv@^4.5.3: +keyv@^4.5.4: version "4.5.4" - resolved "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz" + resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== dependencies: json-buffer "3.0.1" @@ -3069,13 +3077,6 @@ rfdc@^1.3.1: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f" integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - rollup@^3.27.1: version "3.29.4" resolved "https://registry.npmjs.org/rollup/-/rollup-3.29.4.tgz" @@ -3367,11 +3368,6 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - typescript@^5.0.2: version "5.3.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz" diff --git a/go.mod b/go.mod index 516112255..91436de42 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/davrux/echo-logrus/v4 v4.0.3 github.com/elnosh/gonuts v0.1.1-0.20240602162005-49da741613e4 github.com/getAlby/glalby-go v0.0.0-20240416174357-e6e2faa2fbd8 - github.com/getAlby/ldk-node-go v0.0.0-20240603135137-7fe56c466ffd + github.com/getAlby/ldk-node-go v0.0.0-20240614062656-d4de573a1996 github.com/go-gormigrate/gormigrate/v2 v2.1.1 github.com/gorilla/sessions v1.2.2 github.com/labstack/echo-contrib v0.14.1 @@ -227,7 +227,7 @@ require ( require ( github.com/btcsuite/btcd/btcec/v2 v2.3.3 // indirect - github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect + github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 github.com/decred/dcrd/crypto/blake256 v1.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect github.com/glebarez/sqlite v1.11.0 diff --git a/go.sum b/go.sum index 4aceb24b3..09aa7378c 100644 --- a/go.sum +++ b/go.sum @@ -188,8 +188,8 @@ github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwV github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/getAlby/glalby-go v0.0.0-20240416174357-e6e2faa2fbd8 h1:mJsdhUb8hmSSSLR2GQFw9BGtnJP7xmKB/XQxDt3DvAo= github.com/getAlby/glalby-go v0.0.0-20240416174357-e6e2faa2fbd8/go.mod h1:ViyJvjlvv0GCesTJ7mb3fBo4G+/qsujDAFN90xZ7a9U= -github.com/getAlby/ldk-node-go v0.0.0-20240603135137-7fe56c466ffd h1:pYqFkK0TuKG8WFjHb80qod5zc0jvHSwT55rb5/W3u4s= -github.com/getAlby/ldk-node-go v0.0.0-20240603135137-7fe56c466ffd/go.mod h1:8BRjtKcz8E0RyYTPEbMS8VIdgredcGSLne8vHDtcRLg= +github.com/getAlby/ldk-node-go v0.0.0-20240614062656-d4de573a1996 h1:UULF8HX3z0kxgppzDX67oG/7t1Es+tpZogqtsYsguX0= +github.com/getAlby/ldk-node-go v0.0.0-20240614062656-d4de573a1996/go.mod h1:8BRjtKcz8E0RyYTPEbMS8VIdgredcGSLne8vHDtcRLg= github.com/getsentry/raven-go v0.2.0 h1:no+xWJRb5ZI7eE8TWgIq1jLulQiIoLG0IfYxv5JYMGs= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= diff --git a/http/http_service.go b/http/http_service.go index e32b7d821..905d3dbeb 100644 --- a/http/http_service.go +++ b/http/http_service.go @@ -41,7 +41,6 @@ const ( ) func NewHttpService(svc service.Service, logger *logrus.Logger, db *gorm.DB, eventPublisher events.EventPublisher) *HttpService { - return &HttpService{ api: api.NewAPI(svc, logger, db), albyHttpSvc: alby.NewAlbyHttpService(svc.GetAlbyOAuthSvc(), logger, svc.GetConfig().GetEnv()), @@ -104,6 +103,7 @@ func (httpSvc *HttpService) RegisterSharedRoutes(e *echo.Echo) { e.POST("/api/peers", httpSvc.connectPeerHandler, authMiddleware) e.DELETE("/api/peers/:peerId", httpSvc.disconnectPeerHandler, authMiddleware) e.DELETE("/api/peers/:peerId/channels/:channelId", httpSvc.closeChannelHandler, authMiddleware) + e.GET("/api/wallet/address", httpSvc.onchainAddressHandler, authMiddleware) e.POST("/api/wallet/new-address", httpSvc.newOnchainAddressHandler, authMiddleware) e.POST("/api/wallet/redeem-onchain-funds", httpSvc.redeemOnchainFundsHandler, authMiddleware) e.POST("/api/wallet/sign-message", httpSvc.signMessageHandler, authMiddleware) @@ -522,10 +522,24 @@ func (httpSvc *HttpService) newInstantChannelInvoiceHandler(c echo.Context) erro return c.JSON(http.StatusOK, newWrappedInvoiceResponse) } +func (httpSvc *HttpService) onchainAddressHandler(c echo.Context) error { + ctx := c.Request().Context() + + address, err := httpSvc.api.GetUnusedOnchainAddress(ctx) + + if err != nil { + return c.JSON(http.StatusInternalServerError, ErrorResponse{ + Message: fmt.Sprintf("Failed to request new onchain address: %s", err.Error()), + }) + } + + return c.JSON(http.StatusOK, address) +} + func (httpSvc *HttpService) newOnchainAddressHandler(c echo.Context) error { ctx := c.Request().Context() - newAddressResponse, err := httpSvc.api.GetNewOnchainAddress(ctx) + address, err := httpSvc.api.GetNewOnchainAddress(ctx) if err != nil { return c.JSON(http.StatusInternalServerError, ErrorResponse{ @@ -533,7 +547,7 @@ func (httpSvc *HttpService) newOnchainAddressHandler(c echo.Context) error { }) } - return c.JSON(http.StatusOK, newAddressResponse) + return c.JSON(http.StatusOK, address) } func (httpSvc *HttpService) redeemOnchainFundsHandler(c echo.Context) error { diff --git a/lnclient/ldk/ldk.go b/lnclient/ldk/ldk.go index 6056f2ca7..2e8f37654 100644 --- a/lnclient/ldk/ldk.go +++ b/lnclient/ldk/ldk.go @@ -17,6 +17,8 @@ import ( "github.com/getAlby/ldk-node-go/ldk_node" // "github.com/getAlby/nostr-wallet-connect/ldk_node" + b64 "encoding/base64" + decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/sirupsen/logrus" @@ -68,17 +70,24 @@ func NewLDKService(ctx context.Context, logger *logrus.Logger, cfg config.Config lsp.VoltageLSP().Pubkey, lsp.OlympusLSP().Pubkey, lsp.AlbyPlebsLSP().Pubkey, + lsp.MegalithLSP().Pubkey, + + // Mutinynet lsp.AlbyMutinynetPlebsLSP().Pubkey, lsp.OlympusMutinynetFlowLSP().Pubkey, + lsp.MegalithMutinynetLSP().Pubkey, } config.AnchorChannelsConfig.TrustedPeersNoReserve = []string{ lsp.VoltageLSP().Pubkey, lsp.OlympusLSP().Pubkey, - lsp.OlympusMutinynetLSPS1LSP().Pubkey, - lsp.OlympusMutinynetFlowLSP().Pubkey, lsp.AlbyPlebsLSP().Pubkey, - lsp.AlbyMutinynetPlebsLSP().Pubkey, + lsp.MegalithLSP().Pubkey, "0296b2db342fcf87ea94d981757fdf4d3e545bd5cef4919f58b5d38dfdd73bf5c9", // blocktank + + // Mutinynet + lsp.AlbyMutinynetPlebsLSP().Pubkey, + lsp.OlympusMutinynetFlowLSP().Pubkey, + lsp.MegalithMutinynetLSP().Pubkey, } config.ListeningAddresses = &listeningAddresses @@ -957,6 +966,7 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails) var settledAt *int64 preimage := "" paymentHash := "" + metadata := map[string]interface{}{} bolt11PaymentKind, isBolt11PaymentKind := payment.Kind.(ldk_node.PaymentKindBolt11) @@ -991,10 +1001,8 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails) spontaneousPaymentKind, isSpontaneousPaymentKind := payment.Kind.(ldk_node.PaymentKindSpontaneous) if isSpontaneousPaymentKind { // keysend payment - // currently no access to created at or the TLVs to get the description - // TODO: store these in NWC database lastUpdate := int64(payment.LastUpdate) - // TODO: use proper created at time + // TODO: use proper created at time (currently no access to created time for keysend payments) createdAt = lastUpdate if payment.Status == ldk_node.PaymentStatusSucceeded { settledAt = &lastUpdate @@ -1003,6 +1011,11 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails) if spontaneousPaymentKind.Preimage != nil { preimage = *spontaneousPaymentKind.Preimage } + customRecords := map[string]string{} + for _, tlv := range spontaneousPaymentKind.CustomTlvs { + customRecords[strconv.FormatUint(tlv.Type, 10)] = b64.StdEncoding.EncodeToString(tlv.Value) + } + metadata["custom_records"] = customRecords } var amount uint64 = 0 @@ -1027,6 +1040,7 @@ func (ls *LDKService) ldkPaymentToTransaction(payment *ldk_node.PaymentDetails) Description: description, DescriptionHash: descriptionHash, ExpiresAt: expiresAt, + Metadata: metadata, }, nil } @@ -1177,17 +1191,20 @@ func (ls *LDKService) publishChannelsBackupEvent() { ldkChannels := ls.node.ListChannels() channels := make([]events.ChannelBackupInfo, 0, len(ldkChannels)) for _, ldkChannel := range ldkChannels { - var fundingTx string + var fundingTxId string + var fundingTxVout uint32 if ldkChannel.FundingTxo != nil { - fundingTx = ldkChannel.FundingTxo.Txid + fundingTxId = ldkChannel.FundingTxo.Txid + fundingTxVout = ldkChannel.FundingTxo.Vout } channels = append(channels, events.ChannelBackupInfo{ - ChannelID: ldkChannel.ChannelId, - NodeID: ls.node.NodeId(), - PeerID: ldkChannel.CounterpartyNodeId, - ChannelSize: ldkChannel.ChannelValueSats, - FundingTxID: fundingTx, + ChannelID: ldkChannel.ChannelId, + NodeID: ls.node.NodeId(), + PeerID: ldkChannel.CounterpartyNodeId, + ChannelSize: ldkChannel.ChannelValueSats, + FundingTxID: fundingTxId, + FundingTxVout: fundingTxVout, }) } diff --git a/lnclient/lnd/lnd.go b/lnclient/lnd/lnd.go index aa04b7d28..bf82b6c20 100644 --- a/lnclient/lnd/lnd.go +++ b/lnclient/lnd/lnd.go @@ -6,10 +6,13 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "fmt" "sort" + "strconv" "strings" "time" + "github.com/btcsuite/btcd/chaincfg/chainhash" decodepay "github.com/nbd-wtf/ln-decodepay" "github.com/getAlby/nostr-wallet-connect/lnclient" @@ -139,8 +142,106 @@ func (svc *LNDService) GetInfo(ctx context.Context) (info *lnclient.NodeInfo, er }, nil } +func (svc *LNDService) parseChannelPoint(channelPointStr string) (*lnrpc.ChannelPoint, error) { + channelPointParts := strings.Split(channelPointStr, ":") + + if len(channelPointParts) == 2 { + channelPoint := &lnrpc.ChannelPoint{} + channelPoint.FundingTxid = &lnrpc.ChannelPoint_FundingTxidStr{ + FundingTxidStr: channelPointParts[0], + } + + outputIndex, err := strconv.ParseUint(channelPointParts[1], 10, 32) + if err != nil { + return nil, err + } + channelPoint.OutputIndex = uint32(outputIndex) + + return channelPoint, nil + } + + return nil, errors.New("invalid channel point") +} + func (svc *LNDService) ListChannels(ctx context.Context) ([]lnclient.Channel, error) { - channels := []lnclient.Channel{} + activeResp, err := svc.client.ListChannels(ctx, &lnrpc.ListChannelsRequest{}) + if err != nil { + return nil, err + } + pendingResp, err := svc.client.PendingChannels(ctx, &lnrpc.PendingChannelsRequest{}) + if err != nil { + return nil, err + } + + nodeInfo, err := svc.GetInfo(ctx) + if err != nil { + return nil, err + } + + // hardcoding required confirmations as there seems to be no way to get the number of required confirmations in LND + var confirmationsRequired uint32 = 6 + // get recent transactions to check how many confirmations pending channel(s) have + recentOnchainTransactions, err := svc.client.GetTransactions(ctx, &lnrpc.GetTransactionsRequest{ + StartHeight: int32(nodeInfo.BlockHeight - confirmationsRequired), + }) + if err != nil { + return nil, err + } + + channels := make([]lnclient.Channel, len(activeResp.Channels)+len(pendingResp.PendingOpenChannels)) + + for i, lndChannel := range activeResp.Channels { + channelPoint, err := svc.parseChannelPoint(lndChannel.ChannelPoint) + if err != nil { + return nil, err + } + + // first 3 bytes of the channel ID are the block height + channelOpeningBlockHeight := lndChannel.ChanId >> 40 + confirmations := nodeInfo.BlockHeight - uint32(channelOpeningBlockHeight) + + channels[i] = lnclient.Channel{ + InternalChannel: lndChannel, + LocalBalance: lndChannel.LocalBalance * 1000, + RemoteBalance: lndChannel.RemoteBalance * 1000, + RemotePubkey: lndChannel.RemotePubkey, + Id: strconv.FormatUint(lndChannel.ChanId, 10), + Active: lndChannel.Active, + Public: !lndChannel.Private, + FundingTxId: channelPoint.GetFundingTxidStr(), + Confirmations: &confirmations, + ConfirmationsRequired: &confirmationsRequired, + } + } + + for j, lndChannel := range pendingResp.PendingOpenChannels { + channelPoint, err := svc.parseChannelPoint(lndChannel.Channel.ChannelPoint) + if err != nil { + return nil, err + } + fundingTxId := channelPoint.GetFundingTxidStr() + + var confirmations *uint32 + for _, t := range recentOnchainTransactions.Transactions { + if t.TxHash == fundingTxId { + confirmations32 := uint32(t.NumConfirmations) + confirmations = &confirmations32 + } + } + + channels[j+len(activeResp.Channels)] = lnclient.Channel{ + InternalChannel: lndChannel, + LocalBalance: lndChannel.Channel.LocalBalance * 1000, + RemoteBalance: lndChannel.Channel.RemoteBalance * 1000, + RemotePubkey: lndChannel.Channel.RemoteNodePub, + Public: !lndChannel.Channel.Private, + FundingTxId: fundingTxId, + Active: false, + Confirmations: confirmations, + ConfirmationsRequired: &confirmationsRequired, + } + } + return channels, nil } @@ -199,8 +300,23 @@ func (svc *LNDService) SendPaymentSync(ctx context.Context, payReq string) (*lnc if err != nil { return nil, err } + + if resp.PaymentError != "" { + return nil, errors.New(resp.PaymentError) + } + + if resp.PaymentPreimage == nil { + return nil, errors.New("No preimage in response") + } + + var fee uint64 = 0 + if resp.PaymentRoute != nil { + fee = uint64(resp.PaymentRoute.TotalFeesMsat) + } + return &lnclient.PayInvoiceResponse{ Preimage: hex.EncodeToString(resp.PaymentPreimage), + Fee: &fee, }, nil } @@ -334,26 +450,153 @@ func (svc *LNDService) Shutdown() error { } func (svc *LNDService) GetNodeConnectionInfo(ctx context.Context) (nodeConnectionInfo *lnclient.NodeConnectionInfo, err error) { - return &lnclient.NodeConnectionInfo{}, nil + info, err := svc.client.GetInfo(ctx, &lnrpc.GetInfoRequest{}) + if err != nil { + return nil, err + } + + return &lnclient.NodeConnectionInfo{ + Pubkey: info.IdentityPubkey, + //Address: address, + //Port: port, + }, nil } func (svc *LNDService) ConnectPeer(ctx context.Context, connectPeerRequest *lnclient.ConnectPeerRequest) error { - return nil + _, err := svc.client.ConnectPeer(ctx, &lnrpc.ConnectPeerRequest{ + Addr: &lnrpc.LightningAddress{ + Pubkey: connectPeerRequest.Pubkey, + Host: connectPeerRequest.Address + ":" + strconv.Itoa(int(connectPeerRequest.Port)), + }, + }) + return err } + func (svc *LNDService) OpenChannel(ctx context.Context, openChannelRequest *lnclient.OpenChannelRequest) (*lnclient.OpenChannelResponse, error) { - return nil, nil + peers, err := svc.ListPeers(ctx) + var foundPeer *lnclient.PeerDetails + for _, peer := range peers { + if peer.NodeId == openChannelRequest.Pubkey { + + foundPeer = &peer + break + } + } + + if foundPeer == nil { + return nil, errors.New("node is not peered yet") + } + + svc.Logger.WithField("peer_id", foundPeer.NodeId).Info("Opening channel") + + nodePub, err := hex.DecodeString(openChannelRequest.Pubkey) + if err != nil { + return nil, errors.New("failed to decode pubkey") + } + + channel, err := svc.client.OpenChannelSync(ctx, &lnrpc.OpenChannelRequest{ + NodePubkey: nodePub, + Private: !openChannelRequest.Public, + LocalFundingAmount: openChannelRequest.Amount, + }) + if err != nil { + return nil, fmt.Errorf("failed to open channel with %s: %s", foundPeer.NodeId, err) + } + + fundingTxidBytes := channel.GetFundingTxidBytes() + + // we get the funding transaction id bytes in reverse + for i, j := 0, len(fundingTxidBytes)-1; i < j; i, j = i+1, j-1 { + fundingTxidBytes[i], fundingTxidBytes[j] = fundingTxidBytes[j], fundingTxidBytes[i] + } + + return &lnclient.OpenChannelResponse{ + FundingTxId: hex.EncodeToString(fundingTxidBytes), + }, err } func (svc *LNDService) CloseChannel(ctx context.Context, closeChannelRequest *lnclient.CloseChannelRequest) (*lnclient.CloseChannelResponse, error) { - return nil, nil + svc.Logger.WithFields(logrus.Fields{ + "request": closeChannelRequest, + }).Info("Closing Channel") + + resp, err := svc.client.ListChannels(ctx, &lnrpc.ListChannelsRequest{}) + if err != nil { + return nil, err + } + + var foundChannel *lnrpc.Channel + for _, channel := range resp.Channels { + if strconv.FormatUint(channel.ChanId, 10) == closeChannelRequest.ChannelId { + + foundChannel = channel + break + } + } + + if foundChannel == nil { + return nil, errors.New("no channel exists with the given id") + } + + channelPoint, err := svc.parseChannelPoint(foundChannel.ChannelPoint) + if err != nil { + return nil, err + } + + stream, err := svc.client.CloseChannel(ctx, &lnrpc.CloseChannelRequest{ + ChannelPoint: channelPoint, + Force: closeChannelRequest.Force, + }) + if err != nil { + return nil, err + } + + for { + resp, err := stream.Recv() + if err != nil { + return nil, err + } + + switch update := resp.Update.(type) { + case *lnrpc.CloseStatusUpdate_ClosePending: + closingHash := update.ClosePending.Txid + txid, err := chainhash.NewHash(closingHash) + if err != nil { + return nil, err + } + svc.Logger.WithFields(logrus.Fields{ + "closingTxid": txid.String(), + }).Info("Channel close pending") + // TODO: return the closing tx id or fire an event + return &lnclient.CloseChannelResponse{}, nil + } + } } func (svc *LNDService) GetNewOnchainAddress(ctx context.Context) (string, error) { - return "", nil + resp, err := svc.client.NewAddress(ctx, &lnrpc.NewAddressRequest{ + Type: lnrpc.AddressType_WITNESS_PUBKEY_HASH, + }) + if err != nil { + svc.Logger.WithError(err).Error("NewOnchainAddress failed") + return "", err + } + return resp.Address, nil } func (svc *LNDService) GetOnchainBalance(ctx context.Context) (*lnclient.OnchainBalanceResponse, error) { - return nil, nil + balances, err := svc.client.WalletBalance(ctx, &lnrpc.WalletBalanceRequest{}) + if err != nil { + return nil, err + } + svc.Logger.WithFields(logrus.Fields{ + "balances": balances, + }).Debug("Listed Balances") + return &lnclient.OnchainBalanceResponse{ + Spendable: int64(balances.ConfirmedBalance), + Total: int64(balances.TotalBalance - balances.ReservedBalanceAnchorChan), + Reserved: int64(balances.ReservedBalanceAnchorChan), + }, nil } func (svc *LNDService) RedeemOnchainFunds(ctx context.Context, toAddress string) (txId string, err error) { @@ -369,7 +612,17 @@ func (svc *LNDService) SendSpontaneousPaymentProbes(ctx context.Context, amountM } func (svc *LNDService) ListPeers(ctx context.Context) ([]lnclient.PeerDetails, error) { - return nil, nil + resp, err := svc.client.ListPeers(ctx, &lnrpc.ListPeersRequest{}) + ret := make([]lnclient.PeerDetails, 0, len(resp.Peers)) + for _, peer := range resp.Peers { + ret = append(ret, lnclient.PeerDetails{ + NodeId: peer.PubKey, + Address: peer.Address, + IsPersisted: true, + IsConnected: true, + }) + } + return ret, err } func (svc *LNDService) GetLogOutput(ctx context.Context, maxLen int) ([]byte, error) { @@ -386,23 +639,44 @@ func (svc *LNDService) SignMessage(ctx context.Context, message string) (string, } func (svc *LNDService) GetBalances(ctx context.Context) (*lnclient.BalancesResponse, error) { - balance, err := svc.GetBalance(ctx) + onchainBalance, err := svc.GetOnchainBalance(ctx) if err != nil { + svc.Logger.WithError(err).Error("Failed to retrieve onchain balance") return nil, err } + var totalReceivable int64 = 0 + var totalSpendable int64 = 0 + var nextMaxReceivable int64 = 0 + var nextMaxSpendable int64 = 0 + var nextMaxReceivableMPP int64 = 0 + var nextMaxSpendableMPP int64 = 0 + resp, err := svc.client.ListChannels(ctx, &lnrpc.ListChannelsRequest{}) + + for _, channel := range resp.Channels { + // Unnecessary since ListChannels only returns active channels + if channel.Active { + channelMinSpendable := channel.LocalBalance * 1000 + channelMinReceivable := channel.RemoteBalance * 1000 + + nextMaxSpendable = max(nextMaxSpendable, channelMinSpendable) + nextMaxReceivable = max(nextMaxReceivable, channelMinReceivable) + + totalSpendable += channelMinSpendable + totalReceivable += channelMinReceivable + } + } + return &lnclient.BalancesResponse{ - Onchain: lnclient.OnchainBalanceResponse{ - Spendable: 0, // TODO: implement - Total: 0, // TODO: implement - }, + Onchain: *onchainBalance, Lightning: lnclient.LightningBalanceResponse{ - TotalSpendable: balance, - TotalReceivable: 0, // TODO: implement - NextMaxSpendable: balance, // TODO: implement - NextMaxReceivable: 0, // TODO: implement - NextMaxSpendableMPP: balance, // TODO: implement - NextMaxReceivableMPP: 0, // TODO: implement + TotalSpendable: totalSpendable, + TotalReceivable: totalReceivable, + NextMaxSpendable: nextMaxSpendable, + NextMaxReceivable: nextMaxReceivable, + // TODO: return actuall MPP instead of 0 + NextMaxSpendableMPP: nextMaxSpendableMPP, + NextMaxReceivableMPP: nextMaxReceivableMPP, }, }, nil } diff --git a/lnclient/lnd/wrapper/lnd.go b/lnclient/lnd/wrapper/lnd.go index 15e14d239..e029a4bed 100644 --- a/lnclient/lnd/wrapper/lnd.go +++ b/lnclient/lnd/wrapper/lnd.go @@ -91,6 +91,14 @@ func (wrapper *LNDWrapper) ListChannels(ctx context.Context, req *lnrpc.ListChan return wrapper.client.ListChannels(ctx, req, options...) } +func (wrapper *LNDWrapper) GetTransactions(ctx context.Context, req *lnrpc.GetTransactionsRequest, options ...grpc.CallOption) (*lnrpc.TransactionDetails, error) { + return wrapper.client.GetTransactions(ctx, req, options...) +} + +func (wrapper *LNDWrapper) PendingChannels(ctx context.Context, req *lnrpc.PendingChannelsRequest, options ...grpc.CallOption) (*lnrpc.PendingChannelsResponse, error) { + return wrapper.client.PendingChannels(ctx, req, options...) +} + func (wrapper *LNDWrapper) SendPaymentSync(ctx context.Context, req *lnrpc.SendRequest, options ...grpc.CallOption) (*lnrpc.SendResponse, error) { return wrapper.client.SendPaymentSync(ctx, req, options...) } @@ -144,3 +152,27 @@ func (wrapper *LNDWrapper) GetMainPubkey() (pubkey string) { func (wrapper *LNDWrapper) SignMessage(ctx context.Context, req *lnrpc.SignMessageRequest, options ...grpc.CallOption) (*lnrpc.SignMessageResponse, error) { return wrapper.client.SignMessage(ctx, req, options...) } + +func (wrapper *LNDWrapper) ConnectPeer(ctx context.Context, req *lnrpc.ConnectPeerRequest, options ...grpc.CallOption) (*lnrpc.ConnectPeerResponse, error) { + return wrapper.client.ConnectPeer(ctx, req, options...) +} + +func (wrapper *LNDWrapper) ListPeers(ctx context.Context, req *lnrpc.ListPeersRequest, options ...grpc.CallOption) (*lnrpc.ListPeersResponse, error) { + return wrapper.client.ListPeers(ctx, req, options...) +} + +func (wrapper *LNDWrapper) OpenChannelSync(ctx context.Context, req *lnrpc.OpenChannelRequest, options ...grpc.CallOption) (*lnrpc.ChannelPoint, error) { + return wrapper.client.OpenChannelSync(ctx, req, options...) +} + +func (wrapper *LNDWrapper) CloseChannel(ctx context.Context, req *lnrpc.CloseChannelRequest, options ...grpc.CallOption) (lnrpc.Lightning_CloseChannelClient, error) { + return wrapper.client.CloseChannel(ctx, req, options...) +} + +func (wrapper *LNDWrapper) WalletBalance(ctx context.Context, req *lnrpc.WalletBalanceRequest, options ...grpc.CallOption) (*lnrpc.WalletBalanceResponse, error) { + return wrapper.client.WalletBalance(ctx, req, options...) +} + +func (wrapper *LNDWrapper) NewAddress(ctx context.Context, req *lnrpc.NewAddressRequest, options ...grpc.CallOption) (*lnrpc.NewAddressResponse, error) { + return wrapper.client.NewAddress(ctx, req, options...) +} diff --git a/lnclient/phoenixd/phoenixd.go b/lnclient/phoenixd/phoenixd.go index 340145900..49ac69dec 100644 --- a/lnclient/phoenixd/phoenixd.go +++ b/lnclient/phoenixd/phoenixd.go @@ -95,8 +95,7 @@ func (svc *PhoenixService) GetBalance(ctx context.Context) (balance int64, err e return 0, err } - balance = balanceRes.BalanceSat + balanceRes.FeeCreditSat - return balance * 1000, nil + return balanceRes.BalanceSat * 1000, nil } func (svc *PhoenixService) GetBalances(ctx context.Context) (*lnclient.BalancesResponse, error) { diff --git a/lsp/lsp_service.go b/lsp/lsp_service.go index 197ba2921..15479a5cb 100644 --- a/lsp/lsp_service.go +++ b/lsp/lsp_service.go @@ -23,10 +23,11 @@ type lspService struct { logger *logrus.Logger } -type lspConnectionInfo struct { - Pubkey string - Address string - Port uint16 +type lspInfo struct { + Pubkey string + Address string + Port uint16 + MaxChannelExpiryBlocks uint64 } func NewLSPService(svc service.Service, logger *logrus.Logger) *lspService { @@ -51,6 +52,10 @@ func (ls *lspService) NewInstantChannelInvoice(ctx context.Context, request *New selectedLsp = AlbyPlebsLSP() case "ALBY_MUTINYNET": selectedLsp = AlbyMutinynetPlebsLSP() + case "MEGALITH": + selectedLsp = MegalithLSP() + case "MEGALITH_MUTINYNET": + selectedLsp = MegalithMutinynetLSP() default: return nil, errors.New("unknown LSP") } @@ -59,9 +64,13 @@ func (ls *lspService) NewInstantChannelInvoice(ctx context.Context, request *New return nil, errors.New("LNClient not started") } + if selectedLsp.LspType != LSP_TYPE_LSPS1 && request.Public { + return nil, errors.New("This LSP option does not support public channels") + } + ls.logger.Infoln("Requesting LSP info") - var lspInfo *lspConnectionInfo + var lspInfo *lspInfo var err error switch selectedLsp.LspType { case LSP_TYPE_FLOW_2_0: @@ -112,7 +121,7 @@ func (ls *lspService) NewInstantChannelInvoice(ctx context.Context, request *New case LSP_TYPE_PMLSP: invoice, fee, err = ls.requestPMLSPInvoice(&selectedLsp, request.Amount, nodeInfo.Pubkey) case LSP_TYPE_LSPS1: - invoice, fee, err = ls.requestLSPS1Invoice(ctx, &selectedLsp, request.Amount, nodeInfo.Pubkey) + invoice, fee, err = ls.requestLSPS1Invoice(ctx, &selectedLsp, request.Amount, nodeInfo.Pubkey, request.Public, lspInfo.MaxChannelExpiryBlocks) default: return nil, fmt.Errorf("unsupported LSP type: %v", selectedLsp.LspType) @@ -134,13 +143,16 @@ func (ls *lspService) NewInstantChannelInvoice(ctx context.Context, request *New return newChannelResponse, nil } -func (ls *lspService) getLSPS1LSPInfo(url string) (*lspConnectionInfo, error) { - type LSPS1LSPInfo struct { - // TODO: implement options - Options interface{} `json:"options"` - URIs []string `json:"uris"` +func (ls *lspService) getLSPS1LSPInfo(url string) (*lspInfo, error) { + + type lsps1LSPInfoOptions struct { + MaxChannelExpiryBlocks uint64 `json:"max_channel_expiry_blocks"` + } + type lsps1LSPInfo struct { + Options lsps1LSPInfoOptions `json:"options"` + URIs []string `json:"uris"` } - var lsps1LspInfo LSPS1LSPInfo + var lsps1LspInfo lsps1LSPInfo client := http.Client{ Timeout: time.Second * 10, } @@ -196,13 +208,14 @@ func (ls *lspService) getLSPS1LSPInfo(url string) (*lspConnectionInfo, error) { return nil, err } - return &lspConnectionInfo{ - Pubkey: parts[1], - Address: parts[2], - Port: uint16(port), + return &lspInfo{ + Pubkey: parts[1], + Address: parts[2], + Port: uint16(port), + MaxChannelExpiryBlocks: lsps1LspInfo.Options.MaxChannelExpiryBlocks, }, nil } -func (ls *lspService) getFlowLSPInfo(url string) (*lspConnectionInfo, error) { +func (ls *lspService) getFlowLSPInfo(url string) (*lspInfo, error) { type FlowLSPConnectionMethod struct { Address string `json:"address"` Port uint16 `json:"port"` @@ -263,7 +276,7 @@ func (ls *lspService) getFlowLSPInfo(url string) (*lspConnectionInfo, error) { return nil, errors.New("unexpected LSP connection method") } - return &lspConnectionInfo{ + return &lspInfo{ Pubkey: flowLspInfo.Pubkey, Address: flowLspInfo.ConnectionMethods[ipIndex].Address, Port: flowLspInfo.ConnectionMethods[ipIndex].Port, @@ -512,7 +525,7 @@ func (ls *lspService) requestPMLSPInvoice(selectedLsp *LSP, amount uint64, pubke return invoice, fee, nil } -func (ls *lspService) requestLSPS1Invoice(ctx context.Context, selectedLsp *LSP, amount uint64, pubkey string) (invoice string, fee uint64, err error) { +func (ls *lspService) requestLSPS1Invoice(ctx context.Context, selectedLsp *LSP, amount uint64, pubkey string, public bool, channelExpiryBlocks uint64) (invoice string, fee uint64, err error) { client := http.Client{ Timeout: time.Second * 10, } @@ -535,16 +548,24 @@ func (ls *lspService) requestLSPS1Invoice(ctx context.Context, selectedLsp *LSP, return "", 0, err } + var requiredChannelConfirmations uint64 = 0 + + if public { + // as per BOLT-7 6 confirmations are required for the channel to be gossiped + // https://github.com/lightning/bolts/blob/master/07-routing-gossip.md#requirements + requiredChannelConfirmations = 6 + } + newLSPS1ChannelRequest := NewLSPS1ChannelRequest{ PublicKey: pubkey, LSPBalanceSat: strconv.FormatUint(amount, 10), ClientBalanceSat: "0", - RequiredChannelConfirmations: 0, + RequiredChannelConfirmations: requiredChannelConfirmations, FundingConfirmsWithinBlocks: 6, - ChannelExpiryBlocks: 13000, // TODO: this should be customizable + ChannelExpiryBlocks: channelExpiryBlocks, Token: "", RefundOnchainAddress: refundAddress, - AnnounceChannel: false, // TODO: this should be customizable + AnnounceChannel: public, } payloadBytes, err := json.Marshal(newLSPS1ChannelRequest) @@ -591,8 +612,8 @@ func (ls *lspService) requestLSPS1Invoice(ctx context.Context, selectedLsp *LSP, } type NewLSPS1ChannelPayment struct { - LightningInvoice string `json:"lightning_invoice"` - FeeTotalSat string `json:"fee_total_sat"` + Bolt11Invoice string `json:"bolt11_invoice"` + FeeTotalSat string `json:"fee_total_sat"` } type NewLSPS1ChannelResponse struct { Payment NewLSPS1ChannelPayment `json:"payment"` @@ -608,7 +629,7 @@ func (ls *lspService) requestLSPS1Invoice(ctx context.Context, selectedLsp *LSP, return "", 0, fmt.Errorf("failed to deserialize json %s %s", selectedLsp.Url, string(body)) } - invoice = newChannelResponse.Payment.LightningInvoice + invoice = newChannelResponse.Payment.Bolt11Invoice fee, err = strconv.ParseUint(newChannelResponse.Payment.FeeTotalSat, 10, 64) if err != nil { ls.logger.WithError(err).WithFields(logrus.Fields{ diff --git a/lsp/models.go b/lsp/models.go index 0dc0ae10d..a7713c0b2 100644 --- a/lsp/models.go +++ b/lsp/models.go @@ -74,6 +74,24 @@ func AlbyMutinynetPlebsLSP() LSP { return lsp } +func MegalithMutinynetLSP() LSP { + lsp := LSP{ + Pubkey: "03e30fda71887a916ef5548a4d02b06fe04aaa1a8de9e24134ce7f139cf79d7579", + Url: "https://lsp1.mutiny.megalith-node.com/api/lsps1/v1", + LspType: LSP_TYPE_LSPS1, + } + return lsp +} + +func MegalithLSP() LSP { + lsp := LSP{ + Pubkey: "038a9e56512ec98da2b5789761f7af8f280baf98a09282360cd6ff1381b5e889bf", + Url: "https://megalithic.me/api/lsps1/v1", + LspType: LSP_TYPE_LSPS1, + } + return lsp +} + type LSPService interface { NewInstantChannelInvoice(ctx context.Context, request *NewInstantChannelInvoiceRequest) (*NewInstantChannelInvoiceResponse, error) } @@ -81,6 +99,7 @@ type LSPService interface { type NewInstantChannelInvoiceRequest struct { Amount uint64 `json:"amount"` LSP string `json:"lsp"` + Public bool `json:"public"` } type NewInstantChannelInvoiceResponse struct { diff --git a/migrations/202406071726_vacuum.go b/migrations/202406071726_vacuum.go new file mode 100644 index 000000000..d405cbfdc --- /dev/null +++ b/migrations/202406071726_vacuum.go @@ -0,0 +1,27 @@ +package migrations + +import ( + _ "embed" + + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +// VACUUM to finish the update the vacuum mode to auto_vacuum +// See https://sqlite.org/pragma.html +// "The database connection can be changed between full and incremental autovacuum mode at any time. +// However, changing from "none" to "full" or "incremental" can only occur when the database is new +// (no tables have yet been created) or by running the VACUUM command." +var _202406071726_vacuum = &gormigrate.Migration{ + ID: "202406071726_vacuum", + Migrate: func(tx *gorm.DB) error { + if err := tx.Exec("VACUUM").Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + return nil + }, +} diff --git a/migrations/migrate.go b/migrations/migrate.go index dbc30ab78..276567e62 100644 --- a/migrations/migrate.go +++ b/migrations/migrate.go @@ -15,6 +15,7 @@ func Migrate(db *gorm.DB, appConfig *config.AppConfig, logger *logrus.Logger) er _202404021909_nullable_expires_at, _202405302121_store_decrypted_request, _202406061259_delete_content, + _202406071726_vacuum, }) return m.Migrate() diff --git a/service.go b/service.go index de2e7eb44..d552fa32a 100644 --- a/service.go +++ b/service.go @@ -125,8 +125,14 @@ func NewService(ctx context.Context) (*Service, error) { if err != nil { return nil, err } - // Enable foreign keys for sqlite - gormDB.Exec("PRAGMA foreign_keys=ON;") + err = gormDB.Exec("PRAGMA foreign_keys=ON;").Error + if err != nil { + return nil, err + } + err = gormDB.Exec("PRAGMA auto_vacuum=FULL;").Error + if err != nil { + return nil, err + } sqlDb, err = gormDB.DB() if err != nil { return nil, err diff --git a/wails_handlers.go b/wails_handlers.go index 4fce710bb..7829498c3 100644 --- a/wails_handlers.go +++ b/wails_handlers.go @@ -300,12 +300,18 @@ func (app *WailsApp) WailsRequestRouter(route string, method string, body string case "/api/wallet/sync": app.api.SyncWallet() return WailsRequestRouterResponse{Body: nil, Error: ""} + case "/api/wallet/address": + address, err := app.api.GetUnusedOnchainAddress(ctx) + if err != nil { + return WailsRequestRouterResponse{Body: nil, Error: err.Error()} + } + return WailsRequestRouterResponse{Body: address, Error: ""} case "/api/wallet/new-address": - newAddressResponse, err := app.api.GetNewOnchainAddress(ctx) + newAddress, err := app.api.GetNewOnchainAddress(ctx) if err != nil { return WailsRequestRouterResponse{Body: nil, Error: err.Error()} } - return WailsRequestRouterResponse{Body: *newAddressResponse, Error: ""} + return WailsRequestRouterResponse{Body: newAddress, Error: ""} case "/api/wallet/redeem-onchain-funds": redeemOnchainFundsRequest := &api.RedeemOnchainFundsRequest{}