├── .dockerignore
├── .env.example
├── .github
├── dependabot.yaml
└── workflows
│ ├── build-docker.yaml
│ ├── create-release.yaml
│ ├── http.yml
│ ├── linting.yml
│ ├── release.yaml
│ ├── test-postgres.yml
│ └── wails.yml
├── .gitignore
├── .mockery.yaml
├── .prettierignore
├── .vscode
├── extensions.json
└── settings.json
├── Dockerfile
├── LICENSE
├── Procfile
├── README.md
├── alby
├── alby_oauth_service.go
├── alby_oauth_service_test.go
├── alby_service.go
└── models.go
├── api
├── api.go
├── api_test.go
├── apps_test.go
├── backup.go
├── esplora.go
├── lsp.go
├── models.go
├── rebalance.go
└── transactions.go
├── appicon.png
├── apps
├── apps_service.go
└── tests
│ └── apps_service_test.go
├── build
├── darwin
│ ├── Info.dev.plist
│ ├── Info.plist
│ ├── dmgcover.png
│ ├── entitlements.plist
│ ├── gon-sign.json
│ └── http
│ │ └── gon-notarize.json
└── docker
│ └── copy_dylibs.sh
├── cmd
├── db_migrate
│ ├── main.go
│ └── migrate_test.go
└── http
│ └── main.go
├── config
├── aesgcm.go
├── aesgcm_test.go
├── config.go
└── models.go
├── constants
└── constants.go
├── db
├── db.go
├── migrations
│ ├── 202401191539_initial_migration.go
│ ├── 202401191539_initial_migration.sql.tmpl
│ ├── 202403171120_delete_ldk_payments.go
│ ├── 202404021909_nullable_expires_at.go
│ ├── 202405302121_store_decrypted_request.go
│ ├── 202406061259_delete_content.go
│ ├── 202406071726_vacuum.go
│ ├── 202406301207_rename_request_methods.go
│ ├── 202407012100_transactions.go
│ ├── 202407151352_autoincrement.go
│ ├── 202407201604_transactions_indexes.go
│ ├── 202407262257_remove_invalid_scopes.go
│ ├── 202408061737_add_boostagrams_and_use_json.go
│ ├── 202408191242_transaction_failure_reason.go
│ ├── 202408291715_app_metadata.go
│ ├── 202410141503_add_wallet_pubkey.go
│ ├── 202412212345_fix_types.go
│ ├── 202504231037_add_indexes.go
│ ├── 202504231037_hold_invoices.go
│ ├── 202506170342_swaps.go
│ ├── 202508041712_delete_non_cascade_deleted_records.go
│ ├── 202508041737_postgres_amount_bigint.go
│ ├── 202508041738_app_last_used.go
│ ├── 202508041739_response_events_index.go
│ ├── 202508151405_swap_xpub.go
│ ├── 202508192137_forwards.go
│ ├── 202509031250_transactions_updated_at_index.go
│ ├── README.md
│ └── migrate.go
├── models.go
├── queries
│ ├── get_budget_usage.go
│ ├── get_isolated_balance.go
│ └── get_isolated_balance_test.go
├── sqlite-wrapper
│ └── driver.go
└── test
│ └── db_test.go
├── doc
└── logo.svg
├── docker-compose.yml
├── events
├── events.go
└── models.go
├── fly.toml
├── frontend
├── .env.local.example
├── .husky
│ ├── commit-msg
│ └── pre-commit
├── .prettierignore
├── .prettierrc
├── commitlint.config.js
├── components.json
├── eslint.config.mjs
├── frontend.go
├── index.html
├── lint-staged.config.js
├── package.json
├── platform_specific
│ ├── http
│ │ └── src
│ │ │ └── utils
│ │ │ ├── openLink.ts
│ │ │ └── request.ts
│ └── wails
│ │ └── src
│ │ └── utils
│ │ ├── openLink.ts
│ │ └── request.ts
├── public
│ ├── favicon.ico
│ ├── favicon.svg
│ ├── fonts
│ │ ├── Inter-italic.var.woff2
│ │ └── Inter-roman.var.woff2
│ ├── icon-1024.png
│ ├── icon-192.png
│ ├── icon-512.png
│ ├── images
│ │ ├── illustrations
│ │ │ ├── alby-account-dark.svg
│ │ │ ├── alby-account-light.svg
│ │ │ ├── lightning-network-dark.svg
│ │ │ ├── lightning-network-light.svg
│ │ │ ├── sub-wallet-dark.svg
│ │ │ ├── sub-wallet-light.svg
│ │ │ └── tick.svg
│ │ └── quotes
│ │ │ ├── antonopoulos.svg
│ │ │ ├── back.svg
│ │ │ ├── finney.svg
│ │ │ ├── hayek.svg
│ │ │ ├── nakamoto.svg
│ │ │ ├── obama.svg
│ │ │ ├── roland.svg
│ │ │ ├── saylor.svg
│ │ │ └── wilson.svg
│ └── robots.txt
├── src
│ ├── App.tsx
│ ├── assets
│ │ ├── images
│ │ │ ├── alby-head.svg
│ │ │ ├── cloud.png
│ │ │ ├── cloud2.png
│ │ │ └── node
│ │ │ │ ├── cashu.png
│ │ │ │ └── lnd.png
│ │ ├── lotties
│ │ │ ├── loading-dark.json
│ │ │ └── loading-light.json
│ │ ├── suggested-apps
│ │ │ ├── alby-go.png
│ │ │ ├── alby-hub.png
│ │ │ ├── alby.png
│ │ │ ├── amethyst.png
│ │ │ ├── bitrefill.png
│ │ │ ├── bringin.png
│ │ │ ├── btcpay.png
│ │ │ ├── buzzpay.png
│ │ │ ├── clams.png
│ │ │ ├── claude.png
│ │ │ ├── coracle.png
│ │ │ ├── damus.png
│ │ │ ├── goose.png
│ │ │ ├── habla-news.png
│ │ │ ├── iris.png
│ │ │ ├── kiwi.png
│ │ │ ├── lightning-messageboard.png
│ │ │ ├── lnbits.png
│ │ │ ├── lume.png
│ │ │ ├── nakapay.png
│ │ │ ├── nostrcheck-server.png
│ │ │ ├── nostrudel.png
│ │ │ ├── nostter.png
│ │ │ ├── nostur.png
│ │ │ ├── paper-scissors-hodl.png
│ │ │ ├── primal.png
│ │ │ ├── pullthatupjamie.png
│ │ │ ├── simple-boost.png
│ │ │ ├── snort.png
│ │ │ ├── stacker-news.png
│ │ │ ├── tictactoe.png
│ │ │ ├── uncle-jim.png
│ │ │ ├── wave-space.png
│ │ │ ├── wavlake.png
│ │ │ ├── wherostr.png
│ │ │ ├── yakihonne.png
│ │ │ ├── zap-stream.png
│ │ │ ├── zapplanner.png
│ │ │ ├── zapple-pay.png
│ │ │ ├── zappy-bird.png
│ │ │ ├── zapstore.png
│ │ │ └── zeus.png
│ │ └── zapplanner
│ │ │ ├── bitcoinbrink.png
│ │ │ ├── hrf.png
│ │ │ └── opensats.png
│ ├── components
│ │ ├── AnchorReserveAlert.tsx
│ │ ├── AppAvatar.tsx
│ │ ├── AppHeader.tsx
│ │ ├── AppSidebar.tsx
│ │ ├── AuthCodeForm.tsx
│ │ ├── Banner.tsx
│ │ ├── Breadcrumbs.tsx
│ │ ├── BudgetAmountSelect.tsx
│ │ ├── BudgetRenewalSelect.tsx
│ │ ├── CardButton.tsx
│ │ ├── CloseChannelDialogContent.tsx
│ │ ├── CommandPalette.tsx
│ │ ├── Container.tsx
│ │ ├── CustomPagination.tsx
│ │ ├── DisconnectPeerDialogContent.tsx
│ │ ├── EmptyState.tsx
│ │ ├── ExecuteCustomNodeCommandDialogContent.tsx
│ │ ├── ExpirySelect.tsx
│ │ ├── ExternalLink.tsx
│ │ ├── FormattedFiatAmount.tsx
│ │ ├── IsolatedAppDrawDownDialog.tsx
│ │ ├── IsolatedAppTopupDialog.tsx
│ │ ├── Loading.tsx
│ │ ├── LottieLoading.tsx
│ │ ├── LowReceivingCapacityAlert.tsx
│ │ ├── MempoolAlert.tsx
│ │ ├── OnchainAddressDisplay.tsx
│ │ ├── PayLightningInvoice.tsx
│ │ ├── PaymentFailedAlert.tsx
│ │ ├── PendingPaymentAlert.tsx
│ │ ├── Permissions.tsx
│ │ ├── PodcastingInfo.tsx
│ │ ├── QRCode.tsx
│ │ ├── RebalanceChannelDialogContent.tsx
│ │ ├── ResetRoutingDataDialogContent.tsx
│ │ ├── ResponsiveButton.tsx
│ │ ├── RoutingFeeDialogContent.tsx
│ │ ├── Scopes.tsx
│ │ ├── SettingsHeader.tsx
│ │ ├── SidebarHint.tsx
│ │ ├── SpendingAlert.tsx
│ │ ├── TransactionItem.tsx
│ │ ├── TransactionsList.tsx
│ │ ├── TransactionsListMenu.tsx
│ │ ├── TwoColumnLayoutHeader.tsx
│ │ ├── UnlinkAlbyAccount.tsx
│ │ ├── UpgradeCard.tsx
│ │ ├── UpgradeDialog.tsx
│ │ ├── UserAvatar.tsx
│ │ ├── channels
│ │ │ ├── ChannelDropdownMenu.tsx
│ │ │ ├── ChannelPeerNote.tsx
│ │ │ ├── ChannelPublicPrivateAlert.tsx
│ │ │ ├── ChannelWaitingForConfirmations.tsx
│ │ │ ├── ChannelWarning.tsx
│ │ │ ├── ChannelsCards.tsx
│ │ │ ├── ChannelsTable.tsx
│ │ │ ├── DuplicateChannelAlert.tsx
│ │ │ ├── HealthcheckAlert.tsx
│ │ │ ├── LDKChannelWithoutPeerAlert.tsx
│ │ │ ├── LSPTermsDialog.tsx
│ │ │ ├── OnchainTransactionsTable.tsx
│ │ │ └── SwapAlert.tsx
│ │ ├── connections
│ │ │ ├── AboutAppCard.tsx
│ │ │ ├── AlbyConnectionCard.tsx
│ │ │ ├── AppCard.tsx
│ │ │ ├── AppCardConnectionInfo.tsx
│ │ │ ├── AppCardNotice.tsx
│ │ │ ├── AppDetailConnectedApps.tsx
│ │ │ ├── AppLinksCard.tsx
│ │ │ ├── AppStore.tsx
│ │ │ ├── AppStoreDetailHeader.tsx
│ │ │ ├── AppTransactionList.tsx
│ │ │ ├── AppUsage.tsx
│ │ │ ├── ConnectedApps.tsx
│ │ │ ├── ConnectionDetailsModal.tsx
│ │ │ ├── DisconnectApp.tsx
│ │ │ ├── InstallApp.tsx
│ │ │ ├── SuggestedAppData.tsx
│ │ │ └── SuggestedApps.tsx
│ │ ├── home
│ │ │ └── widgets
│ │ │ │ ├── AppOfTheDayWidget.tsx
│ │ │ │ ├── BlockHeightWidget.tsx
│ │ │ │ ├── ForwardsWidget.tsx
│ │ │ │ ├── LatestUsedAppsWidget.tsx
│ │ │ │ ├── LightningMessageboardWidget.tsx
│ │ │ │ ├── NodeStatusWidget.tsx
│ │ │ │ ├── OnchainFeesWidget.tsx
│ │ │ │ ├── SupportAlbyWidget.tsx
│ │ │ │ └── WhatsNewWidget.tsx
│ │ ├── icons
│ │ │ ├── Alby.tsx
│ │ │ ├── AlbyHubIcon.tsx
│ │ │ ├── AlbyHubLogo.tsx
│ │ │ ├── Apple.tsx
│ │ │ ├── Chrome.tsx
│ │ │ ├── Firefox.tsx
│ │ │ ├── LDK.tsx
│ │ │ ├── Lightning.tsx
│ │ │ ├── NostrWalletConnectIcon.tsx
│ │ │ ├── Phoenixd.tsx
│ │ │ ├── PlayStore.tsx
│ │ │ └── ZapStore.tsx
│ │ ├── images
│ │ │ └── AlbyHead.tsx
│ │ ├── layouts
│ │ │ ├── AppLayout.tsx
│ │ │ ├── ReceiveLayout.tsx
│ │ │ ├── SettingsLayout.tsx
│ │ │ └── TwoColumnFullScreenLayout.tsx
│ │ ├── mnemonic
│ │ │ ├── MnemonicDialog.tsx
│ │ │ └── MnemonicInputs.tsx
│ │ ├── password
│ │ │ ├── PasswordInput.tsx
│ │ │ └── RevealPasswordToggle.tsx
│ │ ├── redirects
│ │ │ ├── DefaultRedirect.tsx
│ │ │ ├── HomeRedirect.tsx
│ │ │ ├── SetupRedirect.tsx
│ │ │ └── StartRedirect.tsx
│ │ ├── stepper.tsx
│ │ └── ui
│ │ │ ├── accordion.tsx
│ │ │ ├── alert-dialog.tsx
│ │ │ ├── alert.tsx
│ │ │ ├── avatar.tsx
│ │ │ ├── badge.tsx
│ │ │ ├── breadcrumb.tsx
│ │ │ ├── button.tsx
│ │ │ ├── buttonVariants.tsx
│ │ │ ├── calendar.tsx
│ │ │ ├── card.tsx
│ │ │ ├── carousel.tsx
│ │ │ ├── checkbox.tsx
│ │ │ ├── command.tsx
│ │ │ ├── custom
│ │ │ ├── carousel-dots.tsx
│ │ │ ├── circle-progress.tsx
│ │ │ ├── external-link-button.tsx
│ │ │ ├── input-with-adornment.tsx
│ │ │ ├── link-button.tsx
│ │ │ ├── loading-button.tsx
│ │ │ └── useDotButton.tsx
│ │ │ ├── dialog.tsx
│ │ │ ├── dropdown-menu.tsx
│ │ │ ├── input.tsx
│ │ │ ├── label.tsx
│ │ │ ├── navigation-menu.tsx
│ │ │ ├── pagination.tsx
│ │ │ ├── popover.tsx
│ │ │ ├── progress.tsx
│ │ │ ├── radio-group.tsx
│ │ │ ├── search-input.tsx
│ │ │ ├── select.tsx
│ │ │ ├── separator.tsx
│ │ │ ├── sheet.tsx
│ │ │ ├── sidebar.tsx
│ │ │ ├── skeleton.tsx
│ │ │ ├── sonner.tsx
│ │ │ ├── switch.tsx
│ │ │ ├── table.tsx
│ │ │ ├── tabs.tsx
│ │ │ ├── textarea.tsx
│ │ │ ├── theme-provider.tsx
│ │ │ └── tooltip.tsx
│ ├── constants.ts
│ ├── contexts
│ │ └── CommandPaletteContext.tsx
│ ├── fonts.css
│ ├── hooks
│ │ ├── use-mobile.ts
│ │ ├── use-mobile.tsx
│ │ ├── useAlbyInfo.ts
│ │ ├── useAlbyMe.ts
│ │ ├── useApp.ts
│ │ ├── useApps.ts
│ │ ├── useBalances.ts
│ │ ├── useBanner.tsx
│ │ ├── useBitcoinRate.ts
│ │ ├── useCapabilities.ts
│ │ ├── useChannelPeerSuggestions.ts
│ │ ├── useChannels.ts
│ │ ├── useCommandPalette.ts
│ │ ├── useCreateLightningAddress.ts
│ │ ├── useDeleteApp.ts
│ │ ├── useDeleteLightningAddress.ts
│ │ ├── useForwards.ts
│ │ ├── useHealthCheck.ts
│ │ ├── useInfo.ts
│ │ ├── useLSPChannelOffer.tsx
│ │ ├── useLinkAccount.ts
│ │ ├── useMempoolApi.ts
│ │ ├── useMigrateLDKStorage.ts
│ │ ├── useNodeConnectionInfo.ts
│ │ ├── useNodeDetails.ts
│ │ ├── useNotifyReceivedPayments.ts
│ │ ├── useOnboardingData.ts
│ │ ├── useOnchainAddress.ts
│ │ ├── useOnchainTransactions.ts
│ │ ├── usePeers.ts
│ │ ├── useRemoveSuccessfulChannelOrder.ts
│ │ ├── useSwaps.tsx
│ │ ├── useSyncWallet.ts
│ │ ├── useTransaction.ts
│ │ ├── useTransactions.ts
│ │ └── useUnusedApps.ts
│ ├── index.css
│ ├── lib
│ │ ├── auth.ts
│ │ ├── backendType.ts
│ │ ├── clipboard.ts
│ │ └── utils.ts
│ ├── main.tsx
│ ├── requests
│ │ └── createApp.ts
│ ├── routes.tsx
│ ├── screens
│ │ ├── ConnectAlbyAccount.tsx
│ │ ├── CreateNodeMigrationFileSuccess.tsx
│ │ ├── Home.tsx
│ │ ├── Intro.tsx
│ │ ├── MigrateNode.tsx
│ │ ├── NotFound.tsx
│ │ ├── Start.tsx
│ │ ├── Unlock.tsx
│ │ ├── Welcome.tsx
│ │ ├── alby
│ │ │ ├── AlbyAuthRedirect.tsx
│ │ │ ├── AlbyReviews.tsx
│ │ │ └── SupportAlby.tsx
│ │ ├── apps
│ │ │ ├── AppDetails.tsx
│ │ │ ├── AppsCleanup.tsx
│ │ │ ├── ConnectAppCard.tsx
│ │ │ ├── Connections.tsx
│ │ │ └── NewApp.tsx
│ │ ├── appstore
│ │ │ └── AppStoreDetail.tsx
│ │ ├── channels
│ │ │ ├── Channels.tsx
│ │ │ ├── CurrentChannelOrder.tsx
│ │ │ ├── IncreaseIncomingCapacity.tsx
│ │ │ ├── IncreaseOutgoingCapacity.tsx
│ │ │ ├── auto
│ │ │ │ ├── AutoChannel.tsx
│ │ │ │ ├── OpenedAutoChannel.tsx
│ │ │ │ └── OpeningAutoChannel.tsx
│ │ │ └── first
│ │ │ │ ├── FirstChannel.tsx
│ │ │ │ ├── OpenedFirstChannel.tsx
│ │ │ │ └── OpeningFirstChannel.tsx
│ │ ├── internal-apps
│ │ │ ├── Bitrefill.tsx
│ │ │ ├── BuzzPay.tsx
│ │ │ ├── Claude.tsx
│ │ │ ├── Goose.tsx
│ │ │ ├── LightningMessageboard.tsx
│ │ │ ├── SimpleBoost.tsx
│ │ │ ├── Tictactoe.tsx
│ │ │ └── ZapPlanner.tsx
│ │ ├── onchain
│ │ │ ├── BuyBitcoin.tsx
│ │ │ └── DepositBitcoin.tsx
│ │ ├── peers
│ │ │ ├── ConnectPeer.tsx
│ │ │ └── Peers.tsx
│ │ ├── settings
│ │ │ ├── About.tsx
│ │ │ ├── AlbyAccount.tsx
│ │ │ ├── AutoUnlock.tsx
│ │ │ ├── Backup.tsx
│ │ │ ├── ChangeUnlockPassword.tsx
│ │ │ ├── DebugTools.tsx
│ │ │ ├── DeveloperSettings.tsx
│ │ │ └── Settings.tsx
│ │ ├── setup
│ │ │ ├── ImportMnemonic.tsx
│ │ │ ├── RestoreNode.tsx
│ │ │ ├── SetupAdvanced.tsx
│ │ │ ├── SetupFinish.tsx
│ │ │ ├── SetupNode.tsx
│ │ │ ├── SetupPassword.tsx
│ │ │ ├── SetupSecurity.tsx
│ │ │ └── node
│ │ │ │ ├── CashuForm.tsx
│ │ │ │ ├── LDKForm.tsx
│ │ │ │ ├── LNDForm.tsx
│ │ │ │ ├── PhoenixdForm.tsx
│ │ │ │ └── PresetNodeForm.tsx
│ │ ├── subwallets
│ │ │ ├── NewSubwallet.tsx
│ │ │ ├── SubwalletCreated.tsx
│ │ │ ├── SubwalletIntro.tsx
│ │ │ └── SubwalletList.tsx
│ │ └── wallet
│ │ │ ├── NodeAlias.tsx
│ │ │ ├── OnboardingChecklist.tsx
│ │ │ ├── Receive.tsx
│ │ │ ├── Send.tsx
│ │ │ ├── SignMessage.tsx
│ │ │ ├── WithdrawOnchainFunds.tsx
│ │ │ ├── index.tsx
│ │ │ ├── receive
│ │ │ ├── ReceiveInvoice.tsx
│ │ │ ├── ReceiveOffer.tsx
│ │ │ └── ReceiveOnchain.tsx
│ │ │ ├── send
│ │ │ ├── ConfirmPayment.tsx
│ │ │ ├── LnurlPay.tsx
│ │ │ ├── Onchain.tsx
│ │ │ ├── OnchainSuccess.tsx
│ │ │ ├── PaymentSuccess.tsx
│ │ │ └── ZeroAmount.tsx
│ │ │ └── swap
│ │ │ ├── AutoSwap.tsx
│ │ │ ├── SwapInStatus.tsx
│ │ │ ├── SwapOutStatus.tsx
│ │ │ └── index.tsx
│ ├── state
│ │ ├── ChannelOrderStore.ts
│ │ └── SetupStore.ts
│ ├── themes
│ │ ├── alby.css
│ │ ├── bitcoin.css
│ │ ├── claymorphism.css
│ │ ├── default.css
│ │ ├── ghibli.css
│ │ ├── index.css
│ │ ├── matrix.css
│ │ └── nostr.css
│ ├── types.ts
│ ├── utils
│ │ ├── handleRequestError.ts
│ │ ├── isHttpMode.ts
│ │ └── swr.ts
│ └── vite-env.d.ts
├── tsconfig.json
├── tsconfig.node.json
├── types
│ └── argon2-wasm-esm.d.ts
├── vite.config.ts
└── yarn.lock
├── go.mod
├── go.sum
├── http
├── alby_http_service.go
├── http_service.go
├── http_service_test.go
└── models.go
├── lnclient
├── cashu
│ └── cashu.go
├── ldk
│ ├── ldk.go
│ ├── ldk_event_broadcaster.go
│ ├── ldk_logger.go
│ └── ldk_test.go
├── lnd
│ ├── lnd.go
│ └── wrapper
│ │ ├── interface.go
│ │ └── lnd.go
├── models.go
└── phoenixd
│ └── phoenixd.go
├── logger
└── logger.go
├── lsp
└── models.go
├── main_wails.go
├── nip47
├── cipher
│ ├── cipher.go
│ └── cipher_test.go
├── controllers
│ ├── cancel_hold_invoice_controller.go
│ ├── cancel_hold_invoice_controller_test.go
│ ├── controllers_test.go
│ ├── create_connection_controller.go
│ ├── create_connection_controller_test.go
│ ├── decode_request.go
│ ├── get_balance_controller.go
│ ├── get_balance_controller_test.go
│ ├── get_budget_controller.go
│ ├── get_budget_controller_test.go
│ ├── get_info_controller.go
│ ├── get_info_controller_test.go
│ ├── list_transactions_controller.go
│ ├── list_transactions_controller_test.go
│ ├── lookup_invoice_controller.go
│ ├── lookup_invoice_controller_test.go
│ ├── make_hold_invoice_controller.go
│ ├── make_hold_invoice_controller_test.go
│ ├── make_invoice_controller.go
│ ├── make_invoice_controller_test.go
│ ├── map_nip47_error.go
│ ├── models.go
│ ├── multi_pay_invoice_controller.go
│ ├── multi_pay_invoice_controller_test.go
│ ├── multi_pay_keysend_controller.go
│ ├── multi_pay_keysend_controller_test.go
│ ├── nip47_controller.go
│ ├── pay_invoice_controller.go
│ ├── pay_invoice_controller_test.go
│ ├── pay_keysend_controller.go
│ ├── pay_keysend_controller_test.go
│ ├── settle_hold_invoice_controller.go
│ ├── settle_hold_invoice_controller_test.go
│ └── sign_message_controller.go
├── event_handler.go
├── event_handler_shared_wallet_pubkey_test.go
├── event_handler_test.go
├── models
│ ├── models.go
│ └── transactions.go
├── nip47_service.go
├── notifications
│ ├── models.go
│ ├── nip47_notification_queue.go
│ ├── nip47_notifier.go
│ └── nip47_notifier_test.go
├── permissions
│ ├── permissions.go
│ └── permissions_test.go
└── publish_nip47_info.go
├── nostr
└── models
│ └── models.go
├── render.yaml
├── scripts
├── caddy-subpath
│ ├── Caddyfile
│ └── README.md
├── keys
│ └── rolznz.asc
├── linux-aarch64
│ ├── Caddyfile.example
│ ├── README.md
│ ├── install.sh
│ └── update.sh
├── linux-x86_64
│ ├── Caddyfile.example
│ ├── README.md
│ ├── install.sh
│ ├── phoenixd
│ │ ├── README.md
│ │ ├── docker-compose.yml
│ │ ├── install.sh
│ │ └── render.yaml
│ └── update.sh
├── pi-aarch64
│ ├── README.md
│ ├── install.sh
│ └── update.sh
├── pi-arm
│ ├── README.md
│ ├── install.sh
│ └── update.sh
└── verify.sh
├── service
├── create_app_consumer.go
├── delete_app_consumer.go
├── keys
│ ├── keys.go
│ └── keys_test.go
├── models.go
├── payment_forwarded_consumer.go
├── profiler.go
├── service.go
├── start.go
├── stop.go
└── update_app_consumer.go
├── swaps
└── swaps_service.go
├── tests
├── create_app.go
├── create_mock_relay.go
├── db
│ ├── postgres
│ │ └── docker-compose.yml
│ └── test_db.go
├── mock_event_consumer.go
├── mock_ln_client.go
├── mocks
│ ├── AlbyOAuthService.go
│ ├── AlbyService.go
│ ├── Config.go
│ ├── EventPublisher.go
│ ├── Keys.go
│ ├── LNClient.go
│ └── Service.go
└── test_service.go
├── transactions
├── app_payments_test.go
├── check_unsettled_transaction_test.go
├── hold_invoice_self_payment_consumer.go
├── isolated_app_payments_test.go
├── keysend_test.go
├── list_transactions_test.go
├── lookup_transaction_test.go
├── make_invoice_test.go
├── notifications_test.go
├── payments_test.go
├── receive_keysend_test.go
├── self_hold_payments_test.go
├── self_payments_test.go
└── transactions_service.go
├── utils
├── utils.go
└── utils_test.go
├── version
└── version.go
├── wails.json
└── wails
├── wails_app.go
└── wails_handlers.go
/.dockerignore:
--------------------------------------------------------------------------------
1 | frontend/node_modules
2 | frontend/dist
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | # only enable event logging in production
2 | SEND_EVENTS_TO_ALBY=false
3 |
4 | # do not link your current account when you run a dev instance (so it stays pointing at your mainnet one)
5 | AUTO_LINK_ALBY_ACCOUNT=false
6 |
7 | # Optionally set LDK debug log level to get more info
8 | #LDK_LOG_LEVEL=2
9 | # Optionally set Main application debug log level to get more info
10 | #LOG_LEVEL=5
11 |
12 | # Base URL required for custom OAuth client
13 | #BASE_URL=http://localhost:8080
14 | # Development settings (yarn dev:http)
15 | FRONTEND_URL=http://localhost:5173
16 |
17 | #REBALANCE_SERVICE_URL=https://lsp1.mutiny.megalith-node.com
18 |
19 | #AUTO_UNLOCK_PASSWORD=123
20 | #WORK_DIR=.data
21 | #DATABASE_URI=nwc.db
22 | #JWT_SECRET=secretsecret
23 | #RELAY=wss://relay.getalby.com/v1
24 | #RELAY=ws://localhost:7447/v1
25 | #PORT=8080
26 |
27 | #LOG_DB_QUERIES=true
28 |
29 | # Alby OAuth configuration
30 | #ALBY_OAUTH_CLIENT_SECRET=
31 | #ALBY_OAUTH_CLIENT_ID=
32 |
33 |
34 | # Polar LND Client
35 | #LN_BACKEND_TYPE=LND
36 | #LND_CERT_FILE=/home/YOUR_USERNAME/.polar/networks/1/volumes/lnd/alice/tls.cert
37 | #LND_ADDRESS=127.0.0.1:10001
38 | #LND_MACAROON_FILE=/home/YOUR_USERNAME/.polar/networks/1/volumes/lnd/alice/data/chain/bitcoin/regtest/admin.macaroon
39 |
40 | #LDK_VSS_URL="http://localhost:8090/vss"
41 |
42 | # Boltz API
43 | #BOLTZ_API=https://api.testnet.boltz.exchange
44 | #NETWORK=testnet
--------------------------------------------------------------------------------
/.github/dependabot.yaml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: gomod
4 | directory: /
5 | schedule:
6 | interval: weekly
7 | - package-ecosystem: npm
8 | directory: /frontend
9 | schedule:
10 | interval: weekly
11 |
--------------------------------------------------------------------------------
/.github/workflows/build-docker.yaml:
--------------------------------------------------------------------------------
1 | name: Multiplatform Docker build & push
2 | on:
3 | push:
4 | jobs:
5 | build:
6 | env:
7 | REGISTRY: ghcr.io
8 | IMAGENAME: ${{ github.event.repository.name }}
9 | TAG: ${{ github.ref_name }}
10 | runs-on: ubuntu-22.04
11 | steps:
12 | - uses: actions/checkout@v4
13 | name: Check out code
14 | - name: Install Go
15 | uses: actions/setup-go@v5
16 | with:
17 | go-version-file: "./go.mod"
18 | - name: Run tests
19 | run: mkdir frontend/dist && touch frontend/dist/tmp && go test ./...
20 | - name: Docker build
21 | if: github.actor != 'dependabot[bot]'
22 | uses: mr-smithers-excellent/docker-build-push@v6
23 | id: build
24 | with:
25 | image: ${{ env.IMAGENAME }}
26 | registry: ${{ env.REGISTRY }}
27 | multiPlatform: true
28 | platform: linux/amd64,linux/arm64
29 | username: ${{ github.repository_owner }}
30 | password: ${{ secrets.GITHUB_TOKEN }}
31 | addLatest: ${{ startsWith(github.ref, 'refs/tags/v') }}
32 | buildArgs: TAG=${{ env.TAG }}
33 |
--------------------------------------------------------------------------------
/.github/workflows/create-release.yaml:
--------------------------------------------------------------------------------
1 | name: Create release
2 |
3 | on:
4 | workflow_call:
5 | secrets:
6 | repo-token:
7 | required: true
8 |
9 | jobs:
10 | release:
11 | runs-on: ubuntu-22.04
12 | steps:
13 | - name: Download server archives
14 | uses: actions/download-artifact@v4
15 | with:
16 | pattern: albyhub-Server-*
17 | path: artifacts
18 | merge-multiple: true
19 |
20 | - name: Download desktop archives
21 | uses: actions/download-artifact@v4
22 | with:
23 | pattern: albyhub-Desktop-*
24 | path: artifacts
25 | merge-multiple: true
26 |
27 | - name: Create release without tag
28 | if: github.ref_type != 'tag'
29 | env:
30 | GH_TOKEN: ${{ secrets.repo-token }}
31 | tag: ${{ github.sha }}
32 | run: |
33 | echo "Release without tag not supported"
34 | exit 1
35 |
36 | - name: Create release with tag
37 | if: github.ref_type == 'tag'
38 | env:
39 | GH_TOKEN: ${{ secrets.repo-token }}
40 | tag: ${{ github.ref_name }}
41 | run: |
42 | gh release create ${{ env.tag }} \
43 | --repo="$GITHUB_REPOSITORY" \
44 | --title="${GITHUB_REPOSITORY#*/} ${tag#v}" \
45 | --generate-notes \
46 | --draft \
47 | --verify-tag \
48 | ./artifacts/*
49 |
--------------------------------------------------------------------------------
/.github/workflows/linting.yml:
--------------------------------------------------------------------------------
1 | name: Code quality - linting and typechecking
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | types: [opened, synchronize]
9 |
10 | jobs:
11 | linting:
12 | runs-on: ubuntu-22.04
13 | defaults:
14 | run:
15 | working-directory: ./frontend
16 |
17 | steps:
18 | - uses: actions/checkout@v3
19 | - uses: actions/setup-node@v3
20 | with:
21 | node-version: 20.x
22 | cache: "yarn"
23 | cache-dependency-path: frontend/yarn.lock
24 |
25 | - run: yarn install
26 | - run: yarn prepare:http
27 |
28 | - name: Linting
29 | run: yarn lint:js
30 |
31 | - name: Prettier
32 | run: yarn format
33 |
34 | - name: Typechecking
35 | run: yarn tsc:compile
36 |
--------------------------------------------------------------------------------
/.github/workflows/release.yaml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | build-server:
8 | uses: ./.github/workflows/http.yml
9 | with:
10 | build-release: true
11 | secrets:
12 | APPLE_DEVELOPER_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
13 | APPLE_DEVELOPER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
14 | APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
15 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
16 | APPLE_USERNAME: ${{ secrets.APPLE_USERNAME }}
17 |
18 | build-wails:
19 | uses: ./.github/workflows/wails.yml
20 | with:
21 | build-release: true
22 | secrets:
23 | APPLE_DEVELOPER_CERTIFICATE_P12_BASE64: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_P12_BASE64 }}
24 | APPLE_DEVELOPER_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_DEVELOPER_CERTIFICATE_PASSWORD }}
25 | APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
26 | APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
27 | APPLE_USERNAME: ${{ secrets.APPLE_USERNAME }}
28 |
29 | release-draft:
30 | needs:
31 | - build-server
32 | - build-wails
33 | uses: ./.github/workflows/create-release.yaml
34 | secrets:
35 | repo-token: ${{ secrets.GITHUB_TOKEN }}
36 |
--------------------------------------------------------------------------------
/.github/workflows/test-postgres.yml:
--------------------------------------------------------------------------------
1 | name: Backend testing with Postgres
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | pull_request:
8 | types: [opened, synchronize]
9 |
10 | jobs:
11 | test-postgres:
12 | runs-on: ubuntu-22.04
13 |
14 | services:
15 | postgres:
16 | image: postgres:17.2
17 | ports:
18 | - 5432:5432
19 | env:
20 | POSTGRES_DB: albyhub
21 | POSTGRES_USER: alby
22 | POSTGRES_PASSWORD: albytest123
23 | options: >-
24 | --health-cmd pg_isready
25 | --health-interval 10s
26 | --health-timeout 5s
27 | --health-retries 5
28 |
29 | steps:
30 | - uses: actions/checkout@v4
31 | name: Check out code
32 |
33 | - name: Setup GoLang
34 | uses: actions/setup-go@v5
35 | with:
36 | go-version-file: "./go.mod"
37 |
38 | - name: Get dependencies
39 | run: go get -v -t -d ./...
40 |
41 | - name: Run tests
42 | env:
43 | TEST_DATABASE_URI: "postgresql://alby:albytest123@localhost:5432/albyhub"
44 | TEST_DB_MIGRATE_POSTGRES_URI: "postgresql://alby:albytest123@localhost:5432/albyhub"
45 | run: mkdir frontend/dist && touch frontend/dist/tmp && go test ./...
46 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .env
3 | .env.local
4 | *.db
5 | *.macaroon
6 | node_modules
7 | nostr-wallet-connect
8 | nwc.db
9 | .breez
10 | .data
11 | .idea
12 |
13 | frontend/dist
14 | frontend/node_modules
15 | frontend/wailsjs
16 | package.json.md5
17 |
18 | build/bin
19 |
20 | *.log
21 |
22 | # generated by platform-specific files
23 | frontend/src/utils/request.ts
24 | frontend/src/utils/openLink.ts
25 | frontend/.yarn
26 |
27 | # generated by rust go bindings for local development
28 | glalby
29 |
30 | *.db-shm
31 | *.db-wal
32 | *.db-journal
33 | albyhub-data
--------------------------------------------------------------------------------
/.mockery.yaml:
--------------------------------------------------------------------------------
1 | filename: "{{.InterfaceName}}.go"
2 | dir: tests/mocks
3 | pkgname: mocks
4 | template: testify
5 | packages:
6 | github.com/getAlby/hub/config:
7 | interfaces:
8 | Config: {}
9 | github.com/getAlby/hub/lnclient:
10 | interfaces:
11 | LNClient: {}
12 | github.com/getAlby/hub/service:
13 | interfaces:
14 | Service: {}
15 | github.com/getAlby/hub/service/keys:
16 | interfaces:
17 | Keys: {}
18 | github.com/getAlby/hub/alby:
19 | interfaces:
20 | AlbyService: {}
21 | AlbyOAuthService: {}
22 | github.com/getAlby/hub/events:
23 | interfaces:
24 | EventPublisher: {}
25 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | frontend/src/assets/lotties
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "esbenp.prettier-vscode",
4 | "dbaeumer.vscode-eslint",
5 | "wayou.vscode-todo-highlight",
6 | "golang.go",
7 | "bradlc.vscode-tailwindcss"
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.defaultFormatter": "esbenp.prettier-vscode",
3 | "[go]": {
4 | "editor.defaultFormatter": "golang.go"
5 | },
6 | "editor.formatOnSave": true,
7 | "typescript.preferences.importModuleSpecifier": "non-relative",
8 | "typescript.preferences.autoImportFileExcludePatterns": ["@radix-ui"],
9 | "editor.codeActionsOnSave": {
10 | "source.organizeImports": "always"
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: bin/main
--------------------------------------------------------------------------------
/api/apps_test.go:
--------------------------------------------------------------------------------
1 | package api
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/getAlby/hub/constants"
7 | "github.com/getAlby/hub/tests/mocks"
8 | "github.com/stretchr/testify/assert"
9 | "github.com/stretchr/testify/require"
10 | )
11 |
12 | func TestCreateApp_SuperuserScopeIncorrectPassword(t *testing.T) {
13 | cfg := mocks.NewMockConfig(t)
14 | cfg.On("CheckUnlockPassword", "").Return(false)
15 | theAPI := &api{svc: mocks.NewMockService(t), cfg: cfg}
16 | response, err := theAPI.CreateApp(&CreateAppRequest{
17 | Scopes: []string{constants.SUPERUSER_SCOPE},
18 | })
19 |
20 | assert.Nil(t, response)
21 | require.Error(t, err)
22 | assert.Equal(t, "incorrect unlock password to create app with superuser permission", err.Error())
23 | }
24 |
--------------------------------------------------------------------------------
/appicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/appicon.png
--------------------------------------------------------------------------------
/build/darwin/dmgcover.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/build/darwin/dmgcover.png
--------------------------------------------------------------------------------
/build/darwin/entitlements.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.app-sandbox
6 |
7 | com.apple.security.network.client
8 |
9 | com.apple.security.network.server
10 |
11 | com.apple.security.files.user-selected.read-write
12 |
13 | com.apple.security.files.downloads.read-write
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/build/darwin/gon-sign.json:
--------------------------------------------------------------------------------
1 | {
2 | "source": [
3 | "./build/bin/AlbyHub.app",
4 | "./build/bin/AlbyHub.app/Contents/MacOS/AlbyHub"
5 | ],
6 | "bundle_id": "com.getalby.AlbyHub",
7 | "sign": {
8 | "application_identity": "Developer ID Application: Alby Inc.",
9 | "entitlements_file": "./build/darwin/entitlements.plist"
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/build/darwin/http/gon-notarize.json:
--------------------------------------------------------------------------------
1 | {
2 | "notarize": [
3 | {
4 | "path": "./build/out/albyhub-Server-MacOS.zip",
5 | "bundle_id": "com.getalby.AlbyHub"
6 | }
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/build/docker/copy_dylibs.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | ARCH="$1"
4 |
5 | if [[ "$ARCH" == "amd64" ]]; then
6 | cp "$(go list -m -f "{{.Dir}}" github.com/getAlby/ldk-node-go)"/ldk_node/x86_64-unknown-linux-gnu/libldk_node.so ./
7 | elif [[ "$ARCH" == "arm64" ]]; then
8 | cp "$(go list -m -f "{{.Dir}}" github.com/getAlby/ldk-node-go)"/ldk_node/aarch64-unknown-linux-gnu/libldk_node.so ./
9 | else
10 | echo "Invalid ARCH value"
11 | exit 1
12 | fi
13 |
--------------------------------------------------------------------------------
/db/migrations/202401191539_initial_migration.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 | "text/template"
6 |
7 | "github.com/go-gormigrate/gormigrate/v2"
8 | "gorm.io/gorm"
9 | )
10 |
11 | //go:embed 202401191539_initial_migration.sql.tmpl
12 | var initialMigration string
13 |
14 | var initialMigrationTmpl = template.Must(template.New("initial_migration").Parse(initialMigration))
15 |
16 | // Initial migration
17 | var _202401191539_initial_migration = &gormigrate.Migration{
18 | ID: "202401191539_initial_migration",
19 | Migrate: func(tx *gorm.DB) error {
20 | return exec(tx, initialMigrationTmpl)
21 | },
22 | Rollback: func(tx *gorm.DB) error {
23 | return nil
24 | },
25 | }
26 |
--------------------------------------------------------------------------------
/db/migrations/202403171120_delete_ldk_payments.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // TODO: delete the whole migration
11 | // Delete LDK payments that were not migrated to the new LDK format (PaymentKind)
12 | // this has now been removed as it only affects old test builds that should have been updated by now
13 | // in case someone encounters it, they can run the commands on their DB to fix the issue.
14 | var _202403171120_delete_ldk_payments = &gormigrate.Migration{
15 | ID: "202403171120_delete_ldk_payments",
16 | Migrate: func(tx *gorm.DB) error {
17 |
18 | /*ldkDbPath := filepath.Join(appConfig.Workdir, "ldk", "storage", "ldk_node_data.sqlite")
19 | if _, err := os.Stat(ldkDbPath); errors.Is(err, os.ErrNotExist) {
20 | logger.Logger.Info("No LDK database, skipping migration")
21 | return nil
22 | }
23 | ldkDb, err := sql.Open("sqlite", ldkDbPath)
24 | if err != nil {
25 | return err
26 | }
27 | result, err := ldkDb.Exec(`update ldk_node_data set primary_namespace="payments_bkp" where primary_namespace == "payments"`)
28 | if err != nil {
29 | return err
30 | }
31 | rowsAffected, err := result.RowsAffected()
32 | if err != nil {
33 | return err
34 | }
35 | logger.Logger.WithFields(logrus.Fields{
36 | "rowsAffected": rowsAffected,
37 | }).Info("Removed incompatible payments from LDK database")
38 |
39 | return err*/
40 | return nil
41 | },
42 | Rollback: func(tx *gorm.DB) error {
43 | return nil
44 | },
45 | }
46 |
--------------------------------------------------------------------------------
/db/migrations/202404021909_nullable_expires_at.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // Delete LDK payments that were not migrated to the new LDK format (PaymentKind)
11 | // TODO: delete this sometime in the future (only affects current testers)
12 | var _202404021909_nullable_expires_at = &gormigrate.Migration{
13 | ID: "202404021909_nullable_expires_at",
14 | Migrate: func(tx *gorm.DB) error {
15 |
16 | err := tx.Exec(`update app_permissions set expires_at = NULL where expires_at = '0001-01-01 00:00:00+00:00'`).Error
17 |
18 | return err
19 | },
20 | Rollback: func(tx *gorm.DB) error {
21 | return nil
22 | },
23 | }
24 |
--------------------------------------------------------------------------------
/db/migrations/202405302121_store_decrypted_request.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | var _202405302121_store_decrypted_request = &gormigrate.Migration{
11 | ID: "_202405302121_store_decrypted_request",
12 | Migrate: func(tx *gorm.DB) error {
13 |
14 | err := tx.Exec(`ALTER TABLE request_events ADD COLUMN method TEXT;`).Error
15 | if err != nil {
16 | return err
17 | }
18 |
19 | err = tx.Exec(`CREATE INDEX "idx_request_events_method" ON "request_events"("method");`).Error
20 | if err != nil {
21 | return err
22 | }
23 |
24 | err = tx.Exec(`ALTER TABLE request_events ADD COLUMN content_data TEXT`).Error
25 | return err
26 | },
27 | Rollback: func(tx *gorm.DB) error {
28 | return nil
29 | },
30 | }
31 |
--------------------------------------------------------------------------------
/db/migrations/202406061259_delete_content.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // Delete the content column from both request and response events table
11 | var _202406061259_delete_content = &gormigrate.Migration{
12 | ID: "202406061259_remove_content_columns",
13 | Migrate: func(tx *gorm.DB) error {
14 | if err := tx.Exec("ALTER TABLE response_events DROP COLUMN content").Error; err != nil {
15 | return err
16 | }
17 |
18 | if err := tx.Exec("ALTER TABLE request_events DROP COLUMN content").Error; err != nil {
19 | return err
20 | }
21 |
22 | // Disabled for now: not used.
23 | // Cannot run when testing with txdb: VACUUM must be run outside of transaction.
24 | // if !testing.Testing() {
25 | // if err := tx.Exec("VACUUM").Error; err != nil {
26 | // return err
27 | // }
28 | // }
29 |
30 | return nil
31 | },
32 | Rollback: func(tx *gorm.DB) error {
33 | return nil
34 | },
35 | }
36 |
--------------------------------------------------------------------------------
/db/migrations/202406071726_vacuum.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // VACUUM to finish the update the vacuum mode to auto_vacuum
11 | // See https://sqlite.org/pragma.html
12 | // "The database connection can be changed between full and incremental autovacuum mode at any time.
13 | // However, changing from "none" to "full" or "incremental" can only occur when the database is new
14 | // (no tables have yet been created) or by running the VACUUM command."
15 | var _202406071726_vacuum = &gormigrate.Migration{
16 | ID: "202406071726_vacuum",
17 | Migrate: func(tx *gorm.DB) error {
18 | // Disabled for now: not used.
19 | // Cannot run when testing with txdb: VACUUM must be run outside of transaction.
20 | // if !testing.Testing() {
21 | // if err := tx.Exec("VACUUM").Error; err != nil {
22 | // return err
23 | // }
24 | // }
25 |
26 | return nil
27 | },
28 | Rollback: func(tx *gorm.DB) error {
29 | return nil
30 | },
31 | }
32 |
--------------------------------------------------------------------------------
/db/migrations/202406301207_rename_request_methods.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | var _202406301207_rename_request_methods = &gormigrate.Migration{
11 | ID: "202406301207_rename_request_methods",
12 | Migrate: func(tx *gorm.DB) error {
13 | if err := tx.Exec("ALTER TABLE app_permissions RENAME request_method TO scope").Error; err != nil {
14 | return err
15 | }
16 |
17 | return nil
18 | },
19 | Rollback: func(tx *gorm.DB) error {
20 | return nil
21 | },
22 | }
23 |
--------------------------------------------------------------------------------
/db/migrations/202407262257_remove_invalid_scopes.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // This migration removes old app permissions for request methods (now we use scopes)
11 | var _202407262257_remove_invalid_scopes = &gormigrate.Migration{
12 | ID: "202407262257_remove_invalid_scopes",
13 | Migrate: func(tx *gorm.DB) error {
14 |
15 | if err := tx.Exec(`
16 | delete from app_permissions where scope = 'pay_keysend';
17 | delete from app_permissions where scope = 'multi_pay_keysend';
18 | delete from app_permissions where scope = 'multi_pay_invoice';
19 | `).Error; err != nil {
20 | return err
21 | }
22 |
23 | return nil
24 | },
25 | Rollback: func(tx *gorm.DB) error {
26 | return nil
27 | },
28 | }
29 |
--------------------------------------------------------------------------------
/db/migrations/202408061737_add_boostagrams_and_use_json.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // This migration adds boostagram column to transactions
11 | var _202408061737_add_boostagrams_and_use_json = &gormigrate.Migration{
12 | ID: "202408061737_add_boostagrams_and_use_json",
13 | Migrate: func(db *gorm.DB) error {
14 | err := db.Transaction(func(tx *gorm.DB) error {
15 | return tx.Exec(`
16 | ALTER TABLE transactions ADD COLUMN boostagram JSON;
17 | ALTER TABLE transactions ADD COLUMN metadata_temp JSON;
18 | UPDATE transactions SET metadata_temp = json(metadata) where metadata != '';
19 | ALTER TABLE transactions DROP COLUMN metadata;
20 | ALTER TABLE transactions RENAME COLUMN metadata_temp TO metadata;
21 | `).Error
22 | })
23 |
24 | return err
25 | },
26 | Rollback: func(tx *gorm.DB) error {
27 | return nil
28 | },
29 | }
30 |
--------------------------------------------------------------------------------
/db/migrations/202408191242_transaction_failure_reason.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // This migration removes old app permissions for request methods (now we use scopes)
11 | var _202408191242_transaction_failure_reason = &gormigrate.Migration{
12 | ID: "202408191242_transaction_failure_reason",
13 | Migrate: func(tx *gorm.DB) error {
14 |
15 | if err := tx.Exec(`
16 | ALTER TABLE transactions ADD failure_reason text;
17 | `).Error; err != nil {
18 | return err
19 | }
20 |
21 | return nil
22 | },
23 | Rollback: func(tx *gorm.DB) error {
24 | return nil
25 | },
26 | }
27 |
--------------------------------------------------------------------------------
/db/migrations/202408291715_app_metadata.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | var _202408291715_app_metadata = &gormigrate.Migration{
11 | ID: "202408291715_app_metadata",
12 | Migrate: func(tx *gorm.DB) error {
13 |
14 | if err := tx.Exec(`
15 | ALTER TABLE apps ADD COLUMN metadata JSON;
16 | `).Error; err != nil {
17 | return err
18 | }
19 |
20 | return nil
21 | },
22 | Rollback: func(tx *gorm.DB) error {
23 | return nil
24 | },
25 | }
26 |
--------------------------------------------------------------------------------
/db/migrations/202410141503_add_wallet_pubkey.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | var _202410141503_add_wallet_pubkey = &gormigrate.Migration{
11 | ID: "202410141503_add_wallet_pubkey",
12 | Migrate: func(tx *gorm.DB) error {
13 |
14 | if err := tx.Exec(`
15 | ALTER TABLE apps ADD COLUMN wallet_pubkey TEXT;
16 | ALTER TABLE apps RENAME COLUMN nostr_pubkey TO app_pubkey;
17 | `).Error; err != nil {
18 | return err
19 | }
20 |
21 | return nil
22 | },
23 | Rollback: func(tx *gorm.DB) error {
24 | return nil
25 | },
26 | }
27 |
--------------------------------------------------------------------------------
/db/migrations/202412212345_fix_types.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | "github.com/go-gormigrate/gormigrate/v2"
5 | "gorm.io/gorm"
6 | )
7 |
8 | // This migration fixes column types for Postgres compatibility.
9 | //
10 | // First, an autoincrement sequence is created for user_configs.id (this works
11 | // in sqlite, because an integer primary key becomes "autoincrement"
12 | // automatically).
13 | //
14 | // Second, user_configs.encrypted is converted into a boolean; otherwise,
15 | // it fails to de/serialize from/to the Go model's `Encrypted bool` field.
16 | // Again, this happens to work in sqlite due to the way booleans are handled
17 | // (they're just an alias for numeric).
18 | var _202412212345_fix_types = &gormigrate.Migration{
19 | ID: "20241221234500_fix_types",
20 | Migrate: func(tx *gorm.DB) error {
21 | if tx.Dialector.Name() == "postgres" {
22 | err := tx.Exec(`
23 | CREATE SEQUENCE user_configs_id_seq;
24 | ALTER TABLE user_configs ALTER COLUMN id SET DEFAULT nextval('user_configs_id_seq');
25 | ALTER SEQUENCE user_configs_id_seq OWNED BY user_configs.id;
26 | ALTER TABLE user_configs ALTER COLUMN encrypted SET DATA TYPE BOOLEAN USING encrypted::integer::boolean;
27 | `).Error
28 | if err != nil {
29 | return err
30 | }
31 | }
32 | return nil
33 | },
34 | Rollback: func(tx *gorm.DB) error {
35 | return nil
36 | },
37 | }
38 |
--------------------------------------------------------------------------------
/db/migrations/202504231037_add_indexes.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | var _202504231037_add_indexes = &gormigrate.Migration{
11 | ID: "202504231037_add_indexes",
12 | Migrate: func(db *gorm.DB) error {
13 |
14 | if err := db.Transaction(func(tx *gorm.DB) error {
15 |
16 | if err := tx.Exec(`
17 | DROP INDEX IF EXISTS idx_transactions_request_event_id;
18 | DROP INDEX IF EXISTS idx_transactions_created_at;
19 | DROP INDEX IF EXISTS idx_transactions_settled_at;
20 | DROP INDEX IF EXISTS idx_transactions_app_id_type_state_created_at_settled_at_payment_hash;
21 | `).Error; err != nil {
22 | return err
23 | }
24 |
25 | if err := tx.Exec(`
26 | CREATE INDEX idx_transactions_state_type ON transactions(state, type);
27 | CREATE INDEX idx_transactions_state_type_updated_at ON transactions(state, type, updated_at);
28 | CREATE INDEX idx_transactions_app_id_state_type_updated_at ON transactions(app_id, state, type, updated_at);
29 | CREATE INDEX idx_transactions_payment_hash_settled_at_created_at ON transactions(payment_hash, settled_at, created_at);
30 | `).Error; err != nil {
31 | return err
32 | }
33 |
34 | return nil
35 | }); err != nil {
36 | return err
37 | }
38 |
39 | return nil
40 | },
41 | Rollback: func(tx *gorm.DB) error {
42 | return nil
43 | },
44 | }
45 |
--------------------------------------------------------------------------------
/db/migrations/202504231037_hold_invoices.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | var _202505091314_hold_invoices = &gormigrate.Migration{
11 | ID: "202505091314_hold_invoices",
12 | Migrate: func(db *gorm.DB) error {
13 |
14 | if err := db.Exec(`
15 | ALTER TABLE transactions ADD COLUMN hold BOOLEAN;
16 | ALTER TABLE transactions ADD COLUMN settle_deadline integer;
17 | `).Error; err != nil {
18 | return err
19 | }
20 |
21 | return nil
22 | },
23 | Rollback: func(tx *gorm.DB) error {
24 | return nil
25 | },
26 | }
27 |
--------------------------------------------------------------------------------
/db/migrations/202506170342_swaps.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 | "text/template"
6 |
7 | "github.com/go-gormigrate/gormigrate/v2"
8 | "gorm.io/gorm"
9 | )
10 |
11 | const swapsMigration = `
12 | CREATE TABLE swaps(
13 | id {{ .AutoincrementPrimaryKey }},
14 | swap_id text UNIQUE,
15 | type text,
16 | state text,
17 | invoice text,
18 | send_amount integer,
19 | receive_amount integer,
20 | destination_address text,
21 | refund_address text,
22 | lockup_address text,
23 | boltz_pubkey text,
24 | preimage text,
25 | payment_hash text,
26 | lockup_tx_id text,
27 | claim_tx_id text,
28 | auto_swap boolean,
29 | timeout_block_height integer,
30 | swap_tree json,
31 | created_at {{ .Timestamp }},
32 | updated_at {{ .Timestamp }}
33 | );
34 | `
35 |
36 | var swapsMigrationTmpl = template.Must(template.New("swapsMigration").Parse(swapsMigration))
37 |
38 | var _202506170342_swaps = &gormigrate.Migration{
39 | ID: "202506170342_swaps",
40 | Migrate: func(tx *gorm.DB) error {
41 |
42 | if err := exec(tx, swapsMigrationTmpl); err != nil {
43 | return err
44 | }
45 |
46 | return nil
47 | },
48 | Rollback: func(tx *gorm.DB) error {
49 | return nil
50 | },
51 | }
52 |
--------------------------------------------------------------------------------
/db/migrations/202508041712_delete_non_cascade_deleted_records.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // NOTE: This was actually 2025-07-08
11 | var _202508041712_delete_non_cascade_deleted_records = &gormigrate.Migration{
12 | ID: "202508041712_delete_non_cascade_deleted_records",
13 | Migrate: func(db *gorm.DB) error {
14 |
15 | // the following tables have ON DELETE CASCADE
16 | // which was not being applied due to PRAGMA foreign_keys = ON;
17 | // not applying to all DB connections:
18 | // - app_permissions -> apps
19 | // - request_events -> apps
20 | // - response_events -> request_events
21 | // however we will only delete app permissions.
22 | // excess request and response events will be removed in batches by a background task.
23 |
24 | if err := db.Exec(`DELETE FROM app_permissions
25 | WHERE app_id NOT IN (SELECT id FROM apps)`).Error; err != nil {
26 | return err
27 | }
28 |
29 | return nil
30 | },
31 | Rollback: func(tx *gorm.DB) error {
32 | return nil
33 | },
34 | }
35 |
--------------------------------------------------------------------------------
/db/migrations/202508041737_postgres_amount_bigint.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // NOTE: This was actually 2025-07-08
11 | var _202508041737_postgres_amount_bigint = &gormigrate.Migration{
12 | ID: "202508041737_postgres_amount_bigint",
13 | Migrate: func(db *gorm.DB) error {
14 |
15 | // sqlite works fine but postgres integers are only 4 bytes
16 | // amounts are in msats (= max ~2.1M sats)
17 | if db.Dialector.Name() != "postgres" {
18 | return nil
19 | }
20 |
21 | if err := db.Exec(`ALTER TABLE transactions
22 | ALTER COLUMN amount_msat TYPE bigint;`).Error; err != nil {
23 | return err
24 | }
25 |
26 | return nil
27 | },
28 | Rollback: func(tx *gorm.DB) error {
29 | return nil
30 | },
31 | }
32 |
--------------------------------------------------------------------------------
/db/migrations/202508041738_app_last_used.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 | "text/template"
6 |
7 | "github.com/go-gormigrate/gormigrate/v2"
8 | "gorm.io/gorm"
9 | )
10 |
11 | const appLastUsedMigration = `ALTER TABLE apps ADD COLUMN last_used_at {{ .Timestamp }};`
12 |
13 | var appLastUsedMigrationTmpl = template.Must(template.New("appLastUsedMigration").Parse(appLastUsedMigration))
14 |
15 | // NOTE: This was actually 2025-07-14
16 | var _202508041738_app_last_used = &gormigrate.Migration{
17 | ID: "202508041738_app_last_used",
18 | Migrate: func(tx *gorm.DB) error {
19 |
20 | err := exec(tx, appLastUsedMigrationTmpl)
21 | if err != nil {
22 | return err
23 | }
24 |
25 | return nil
26 | },
27 | Rollback: func(tx *gorm.DB) error {
28 | return nil
29 | },
30 | }
31 |
--------------------------------------------------------------------------------
/db/migrations/202508041739_response_events_index.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // NOTE: This was actually 2025-07-21
11 | var _202508041739_response_events_index = &gormigrate.Migration{
12 | ID: "202508041739_response_events_index",
13 | Migrate: func(tx *gorm.DB) error {
14 |
15 | err := tx.Exec("CREATE INDEX idx_response_events_request_id ON response_events(request_id);").Error
16 | if err != nil {
17 | return err
18 | }
19 |
20 | return nil
21 | },
22 | Rollback: func(tx *gorm.DB) error {
23 | return nil
24 | },
25 | }
26 |
--------------------------------------------------------------------------------
/db/migrations/202508151405_swap_xpub.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // NOTE: This was actually 2025-07-21
11 | var _202508151405_swap_xpub = &gormigrate.Migration{
12 | ID: "202508151405_swap_xpub",
13 | Migrate: func(tx *gorm.DB) error {
14 |
15 | err := tx.Exec("ALTER TABLE swaps ADD COLUMN used_xpub BOOLEAN;").Error
16 | if err != nil {
17 | return err
18 | }
19 |
20 | return nil
21 | },
22 | Rollback: func(tx *gorm.DB) error {
23 | return nil
24 | },
25 | }
26 |
--------------------------------------------------------------------------------
/db/migrations/202508192137_forwards.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 | "text/template"
6 |
7 | "github.com/go-gormigrate/gormigrate/v2"
8 | "gorm.io/gorm"
9 | )
10 |
11 | const forwardsMigration = `
12 | CREATE TABLE forwards(
13 | id {{ .AutoincrementPrimaryKey }},
14 | outbound_amount_forwarded_msat bigint,
15 | total_fee_earned_msat bigint,
16 | created_at {{ .Timestamp }},
17 | updated_at {{ .Timestamp }}
18 | );
19 | `
20 |
21 | var forwardsMigrationTmpl = template.Must(template.New("forwardsMigration").Parse(forwardsMigration))
22 |
23 | var _202508192137_forwards = &gormigrate.Migration{
24 | ID: "202508192137_forwards",
25 | Migrate: func(tx *gorm.DB) error {
26 |
27 | if err := exec(tx, forwardsMigrationTmpl); err != nil {
28 | return err
29 | }
30 |
31 | return nil
32 | },
33 | Rollback: func(tx *gorm.DB) error {
34 | return nil
35 | },
36 | }
37 |
--------------------------------------------------------------------------------
/db/migrations/202509031250_transactions_updated_at_index.go:
--------------------------------------------------------------------------------
1 | package migrations
2 |
3 | import (
4 | _ "embed"
5 |
6 | "github.com/go-gormigrate/gormigrate/v2"
7 | "gorm.io/gorm"
8 | )
9 |
10 | // speeds up this query:
11 | // SELECT * FROM transactions WHERE state = 'SETTLED' OR type = 'outgoing' ORDER BY updated_at DESC LIMIT 20;
12 | var _202509031250_transactions_updated_at_index = &gormigrate.Migration{
13 | ID: "202509031250_transactions_updated_at_index",
14 | Migrate: func(tx *gorm.DB) error {
15 |
16 | err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_transactions_updated_at ON transactions(updated_at);").Error
17 | if err != nil {
18 | return err
19 | }
20 |
21 | return nil
22 | },
23 | Rollback: func(tx *gorm.DB) error {
24 | return nil
25 | },
26 | }
27 |
--------------------------------------------------------------------------------
/db/migrations/README.md:
--------------------------------------------------------------------------------
1 | # Creating a new migration
2 |
3 | 1. Create a new file based on the current date and time (see existing migration format)
4 | 2. Copy the following code and update MY_ID_HERE and MY_COMMENT_HERE and DO_SOMETHING_HERE
5 | 3. Add the ID to the list of migrations in migrate.go
6 |
7 | ```go
8 | package migrations
9 |
10 | import (
11 | "github.com/go-gormigrate/gormigrate/v2"
12 | "gorm.io/gorm"
13 | )
14 |
15 | // MY_COMMENT_HERE
16 | var _MY_ID_HERE = &gormigrate.Migration {
17 | ID: "MY_ID_HERE",
18 | Migrate: func(tx *gorm.DB) error {
19 | return DO_SOMETHING_HERE.Error;
20 | },
21 | Rollback: func(tx *gorm.DB) error {
22 | return nil;
23 | },
24 | }
25 | ```
26 |
--------------------------------------------------------------------------------
/db/queries/get_isolated_balance.go:
--------------------------------------------------------------------------------
1 | package queries
2 |
3 | import (
4 | "github.com/getAlby/hub/constants"
5 | "gorm.io/gorm"
6 | )
7 |
8 | func GetIsolatedBalance(tx *gorm.DB, appId uint) int64 {
9 | var received struct {
10 | Sum int64
11 | }
12 | tx.
13 | Table("transactions").
14 | Select("SUM(amount_msat) as sum").
15 | Where("app_id = ? AND type = ? AND state = ?", appId, constants.TRANSACTION_TYPE_INCOMING, constants.TRANSACTION_STATE_SETTLED).Scan(&received)
16 |
17 | var spent struct {
18 | Sum int64
19 | }
20 |
21 | tx.
22 | Table("transactions").
23 | Select("SUM(amount_msat + fee_msat + fee_reserve_msat) as sum").
24 | Where("app_id = ? AND type = ? AND (state = ? OR state = ?)", appId, constants.TRANSACTION_TYPE_OUTGOING, constants.TRANSACTION_STATE_SETTLED, constants.TRANSACTION_STATE_PENDING).Scan(&spent)
25 |
26 | return received.Sum - spent.Sum
27 | }
28 |
--------------------------------------------------------------------------------
/db/sqlite-wrapper/driver.go:
--------------------------------------------------------------------------------
1 | package sqlite_wrapper
2 |
3 | import (
4 | "database/sql"
5 |
6 | "github.com/mattn/go-sqlite3"
7 | )
8 |
9 | const Sqlite3WrapperDriverName = "sqlite3_wrapper"
10 |
11 | func init() {
12 | // We need to set the temp_store setting on every connection, including
13 | // those that are implicitly opened by Go's database/sql package.
14 | // Unfortunately, this setting cannot be provided in the DSN; therefore
15 | // we execute the PRAGMA statement in the sqlite3's connection hook.
16 | sql.Register(Sqlite3WrapperDriverName, &sqlite3.SQLiteDriver{
17 | ConnectHook: func(conn *sqlite3.SQLiteConn) error {
18 | _, err := conn.Exec("PRAGMA temp_store = MEMORY", nil)
19 | return err
20 | },
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/db/test/db_test.go:
--------------------------------------------------------------------------------
1 | package test
2 |
3 | import (
4 | "strconv"
5 | "testing"
6 |
7 | "github.com/sirupsen/logrus"
8 |
9 | "github.com/getAlby/hub/logger"
10 | "github.com/getAlby/hub/tests/db"
11 |
12 | "github.com/stretchr/testify/assert"
13 | "github.com/stretchr/testify/require"
14 | )
15 |
16 | func TestTempStorePragmaIsApplied(t *testing.T) {
17 | logger.Init(strconv.Itoa(int(logrus.DebugLevel)))
18 | gormDb, err := db.NewDB(t)
19 | require.NoError(t, err)
20 | defer db.CloseDB(gormDb)
21 |
22 | if gormDb.Dialector.Name() != "sqlite" {
23 | t.Skip("Skipping non-sqlite dialector")
24 | }
25 |
26 | var result string
27 | err = gormDb.Raw("PRAGMA temp_store").Scan(&result).Error
28 | require.NoError(t, err)
29 |
30 | // PRAGMA temp_store = MEMORY
31 | // MEMORY = 2
32 | assert.Equal(t, "2", result)
33 | }
34 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | albyhub:
3 | # you can manually specify the platform if you use something other than linux/amd64,linux/arm64 e.g.
4 | #platform: linux/arm64/v8
5 | container_name: albyhub
6 | image: ghcr.io/getalby/hub:latest
7 | volumes:
8 | - ./albyhub-data:/data
9 | ports:
10 | - "8080:8080"
11 | environment:
12 | - WORK_DIR=/data/albyhub
13 | stop_grace_period: 300s
14 |
--------------------------------------------------------------------------------
/events/models.go:
--------------------------------------------------------------------------------
1 | package events
2 |
3 | import (
4 | "context"
5 | )
6 |
7 | type EventSubscriber interface {
8 | ConsumeEvent(ctx context.Context, event *Event, globalProperties map[string]interface{})
9 | }
10 |
11 | type EventPublisher interface {
12 | RegisterSubscriber(eventListener EventSubscriber)
13 | RemoveSubscriber(eventListener EventSubscriber)
14 | Publish(event *Event)
15 | PublishSync(event *Event)
16 | SetGlobalProperty(key string, value interface{})
17 | }
18 |
19 | type Event struct {
20 | Event string `json:"event"`
21 | Properties interface{} `json:"properties,omitempty"`
22 | }
23 |
24 | type StaticChannelsBackupEvent struct {
25 | NodeID string `json:"node_id"`
26 | Channels []ChannelBackup `json:"channels"`
27 | Monitors []EncodedChannelMonitorBackup `json:"monitors"`
28 | }
29 |
30 | type EncodedChannelMonitorBackup struct {
31 | Key string `json:"key"`
32 | Value string `json:"value"`
33 | }
34 |
35 | type ChannelBackup struct {
36 | ChannelID string `json:"channel_id"`
37 | PeerID string `json:"peer_id"`
38 | PeerSocketAddress string `json:"peer_socket_address"`
39 | ChannelSize uint64 `json:"channel_size"`
40 | FundingTxID string `json:"funding_tx_id"`
41 | FundingTxVout uint32 `json:"funding_tx_vout"`
42 | }
43 |
--------------------------------------------------------------------------------
/fly.toml:
--------------------------------------------------------------------------------
1 | # fly.toml app configuration file generated for nwc on 2024-01-30T12:30:13+01:00
2 | #
3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file.
4 | #
5 |
6 | app = 'nwc'
7 | primary_region = 'lax'
8 | swap_size_mb = 2048
9 | # Add a kill timeout longer than LDK node shutdown timeout to ensure Alby Hub gracefully shuts down
10 | kill_timeout = 300
11 |
12 | [build]
13 | image = 'ghcr.io/getalby/hub:latest'
14 |
15 | [env]
16 | DATABASE_URI = '/data/nwc.db'
17 | LDK_LOG_LEVEL = '3'
18 | LOG_LEVEL = '4'
19 | WORK_DIR = '/data'
20 |
21 | [[mounts]]
22 | source = 'nwc_data'
23 | destination = '/data'
24 | initial_size = '1'
25 | auto_extend_size_threshold = 80
26 | auto_extend_size_increment = "1GB"
27 | auto_extend_size_limit = "5GB"
28 |
29 | [http_service]
30 | internal_port = 8080
31 | force_https = true
32 | auto_stop_machines = false
33 | auto_start_machines = true
34 | min_machines_running = 0
35 | processes = ['app']
36 |
37 | [[vm]]
38 | cpu_kind = 'shared'
39 | cpus = 1
40 | memory = '512mb'
41 |
--------------------------------------------------------------------------------
/frontend/.env.local.example:
--------------------------------------------------------------------------------
1 | # set a custom messageboard wallet (should be a sub-wallet with only make invoice and list transactions permissions)
2 | #VITE_LIGHTNING_MESSAGEBOARD_NWC_URL="nostr+walletconnect://5f8e7c098137ccca853327be44a9b2e956cf79a8e2336e27a4f27b3fb55325b6?relay=wss://relay.getalby.com/v1&secret=ace5c4b9e08138a2ef91b4ccf1379952c77c651866b29f5872b5165134417894"
--------------------------------------------------------------------------------
/frontend/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | cd frontend && yarn commitlint --edit
--------------------------------------------------------------------------------
/frontend/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | cd frontend && yarn lint-staged
--------------------------------------------------------------------------------
/frontend/.prettierignore:
--------------------------------------------------------------------------------
1 | # Ignore artifacts:
2 | dist
3 | node_modules
4 |
--------------------------------------------------------------------------------
/frontend/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "trailingComma": "es5"
3 | }
4 |
--------------------------------------------------------------------------------
/frontend/commitlint.config.js:
--------------------------------------------------------------------------------
1 | export default { extends: ['@commitlint/config-conventional'] };
2 |
--------------------------------------------------------------------------------
/frontend/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": false,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "src/index.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "src/components",
15 | "utils": "src/lib/utils"
16 | },
17 | "iconLibrary": "lucide"
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/frontend.go:
--------------------------------------------------------------------------------
1 | package frontend
2 |
3 | import (
4 | "embed"
5 | "net/http"
6 |
7 | "github.com/labstack/echo/v4"
8 | "github.com/labstack/echo/v4/middleware"
9 | )
10 |
11 | //go:embed dist
12 | var embeddedReactAssets embed.FS
13 |
14 | func RegisterHandlers(e *echo.Echo) {
15 | e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
16 | Skipper: nil,
17 | // Root directory from where the static content is served.
18 | Root: "dist",
19 | // Enable HTML5 mode by forwarding all not-found requests to root so that
20 | // SPA (single-page application) can handle the routing.
21 | HTML5: true,
22 | Browse: false,
23 | IgnoreBase: false,
24 | Filesystem: http.FS(embeddedReactAssets),
25 | }))
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Alby Hub
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/frontend/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | "src/**/*.{ts,tsx,json}": [
3 | "eslint --fix --no-warn-ignored --max-warnings 0",
4 | "prettier --write",
5 | ],
6 | "platform_specific/**/*.ts": ["prettier --write"],
7 | "package.json": ["prettier --write"],
8 | "src/**/*.ts": () => "tsc --noEmit",
9 | };
10 |
--------------------------------------------------------------------------------
/frontend/platform_specific/http/src/utils/openLink.ts:
--------------------------------------------------------------------------------
1 | export const openLink = (url: string) => {
2 | // opens the link in a new tab
3 | window.setTimeout(() => window.open(url, "_blank"));
4 | };
5 |
--------------------------------------------------------------------------------
/frontend/platform_specific/http/src/utils/request.ts:
--------------------------------------------------------------------------------
1 | import { getAuthToken } from "src/lib/auth";
2 | import { ErrorResponse } from "src/types";
3 |
4 | export const request = async (
5 | ...args: Parameters
6 | ): Promise => {
7 | if (import.meta.env.BASE_URL !== "/") {
8 | // if running on a subpath, include the subpath in the request URL
9 | // BASE_URL is set via process.env.BASE_PATH, see https://vite.dev/guide/build#public-base-path
10 | args[0] = import.meta.env.BASE_URL + args[0];
11 | }
12 |
13 | const token = getAuthToken();
14 | if (token) {
15 | if (!args[1]) {
16 | args[1] = {};
17 | }
18 | args[1].headers = {
19 | ...args[1].headers,
20 | Authorization: `Bearer ${token}`,
21 | };
22 | }
23 |
24 | try {
25 | const fetchResponse = await fetch(...args);
26 |
27 | let body: T | undefined;
28 | if (fetchResponse.status !== 204) {
29 | try {
30 | body = await fetchResponse.json();
31 | } catch (error) {
32 | console.error(error);
33 | }
34 | }
35 |
36 | if (!fetchResponse.ok) {
37 | throw new Error(
38 | fetchResponse.status +
39 | " " +
40 | ((body as ErrorResponse)?.message || "Unknown error")
41 | );
42 | }
43 | return body;
44 | } catch (error) {
45 | console.error("Failed to fetch", error);
46 | throw error;
47 | }
48 | };
49 |
--------------------------------------------------------------------------------
/frontend/platform_specific/wails/src/utils/openLink.ts:
--------------------------------------------------------------------------------
1 | import { BrowserOpenURL } from "wailsjs/runtime/runtime";
2 |
3 | export const openLink = (url: string) => {
4 | // opens the link in the browser
5 | try {
6 | BrowserOpenURL(url);
7 | } catch (error) {
8 | console.error("Failed to open link", error);
9 | throw error;
10 | }
11 | };
12 |
--------------------------------------------------------------------------------
/frontend/platform_specific/wails/src/utils/request.ts:
--------------------------------------------------------------------------------
1 | import { WailsRequestRouter } from "wailsjs/go/wails/WailsApp";
2 |
3 | export const request = async (
4 | ...args: Parameters
5 | ): Promise => {
6 | try {
7 | const res = await WailsRequestRouter(
8 | args[0].toString(),
9 | args[1]?.method || "GET",
10 | args[1]?.body?.toString() || ""
11 | );
12 |
13 | console.info("Wails request", ...args, res);
14 | if (res.error) {
15 | throw new Error(res.error);
16 | }
17 |
18 | return res.body;
19 | } catch (error) {
20 | console.error("Failed to fetch", error);
21 | throw error;
22 | }
23 | };
24 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/fonts/Inter-italic.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/public/fonts/Inter-italic.var.woff2
--------------------------------------------------------------------------------
/frontend/public/fonts/Inter-roman.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/public/fonts/Inter-roman.var.woff2
--------------------------------------------------------------------------------
/frontend/public/icon-1024.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/public/icon-1024.png
--------------------------------------------------------------------------------
/frontend/public/icon-192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/public/icon-192.png
--------------------------------------------------------------------------------
/frontend/public/icon-512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/public/icon-512.png
--------------------------------------------------------------------------------
/frontend/public/images/illustrations/tick.svg:
--------------------------------------------------------------------------------
1 |
12 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | User-agent: *
2 | Disallow: /
3 |
--------------------------------------------------------------------------------
/frontend/src/App.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | RouterProvider,
3 | createBrowserRouter,
4 | createHashRouter,
5 | } from "react-router-dom";
6 | import { Toaster } from "src/components/ui/sonner";
7 | import { ThemeProvider } from "src/components/ui/theme-provider";
8 | import { TouchProvider } from "src/components/ui/tooltip";
9 | import { useInfo } from "src/hooks/useInfo";
10 | import routes from "src/routes.tsx";
11 | import { isHttpMode } from "src/utils/isHttpMode";
12 |
13 | const createRouterFunc = isHttpMode() ? createBrowserRouter : createHashRouter;
14 | const router = createRouterFunc(routes, {
15 | // if running on a subpath, use the subpath as the router basename
16 | // BASE_URL is set via process.env.BASE_PATH, see https://vite.dev/guide/build#public-base-path
17 | basename:
18 | import.meta.env.BASE_URL !== "/" ? import.meta.env.BASE_URL : undefined,
19 | });
20 |
21 | function App() {
22 | const { data: info } = useInfo();
23 |
24 | return (
25 | <>
26 |
27 |
32 | {info && }
33 |
34 |
35 |
36 | >
37 | );
38 | }
39 |
40 | export default App;
41 |
--------------------------------------------------------------------------------
/frontend/src/assets/images/cloud.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/images/cloud.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/cloud2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/images/cloud2.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/node/cashu.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/images/node/cashu.png
--------------------------------------------------------------------------------
/frontend/src/assets/images/node/lnd.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/images/node/lnd.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/alby-go.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/alby-go.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/alby-hub.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/alby-hub.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/alby.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/alby.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/amethyst.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/amethyst.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/bitrefill.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/bitrefill.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/bringin.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/bringin.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/btcpay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/btcpay.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/buzzpay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/buzzpay.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/clams.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/clams.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/claude.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/claude.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/coracle.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/coracle.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/damus.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/damus.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/goose.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/goose.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/habla-news.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/habla-news.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/iris.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/iris.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/kiwi.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/kiwi.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/lightning-messageboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/lightning-messageboard.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/lnbits.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/lnbits.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/lume.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/lume.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/nakapay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/nakapay.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/nostrcheck-server.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/nostrcheck-server.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/nostrudel.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/nostrudel.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/nostter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/nostter.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/nostur.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/nostur.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/paper-scissors-hodl.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/paper-scissors-hodl.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/primal.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/primal.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/pullthatupjamie.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/pullthatupjamie.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/simple-boost.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/simple-boost.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/snort.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/snort.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/stacker-news.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/stacker-news.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/tictactoe.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/tictactoe.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/uncle-jim.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/uncle-jim.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/wave-space.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/wave-space.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/wavlake.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/wavlake.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/wherostr.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/wherostr.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/yakihonne.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/yakihonne.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/zap-stream.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/zap-stream.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/zapplanner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/zapplanner.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/zapple-pay.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/zapple-pay.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/zappy-bird.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/zappy-bird.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/zapstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/zapstore.png
--------------------------------------------------------------------------------
/frontend/src/assets/suggested-apps/zeus.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/suggested-apps/zeus.png
--------------------------------------------------------------------------------
/frontend/src/assets/zapplanner/bitcoinbrink.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/zapplanner/bitcoinbrink.png
--------------------------------------------------------------------------------
/frontend/src/assets/zapplanner/hrf.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/zapplanner/hrf.png
--------------------------------------------------------------------------------
/frontend/src/assets/zapplanner/opensats.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/getAlby/hub/fd60afc349d06938e57cac5006618d5dcdc5432c/frontend/src/assets/zapplanner/opensats.png
--------------------------------------------------------------------------------
/frontend/src/components/AnchorReserveAlert.tsx:
--------------------------------------------------------------------------------
1 | import { AlertTriangleIcon } from "lucide-react";
2 | import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
3 | import { useBalances } from "src/hooks/useBalances";
4 | import { useChannels } from "src/hooks/useChannels";
5 |
6 | export function AnchorReserveAlert({
7 | amount,
8 | className,
9 | isSwap,
10 | }: {
11 | amount: number;
12 | isSwap?: boolean;
13 | className?: string;
14 | }) {
15 | const { data: balances } = useBalances();
16 | const { data: channels } = useChannels();
17 |
18 | if (!balances || !channels) {
19 | return null;
20 | }
21 |
22 | const showAlert =
23 | amount &&
24 | !!channels.length &&
25 | +amount > balances.onchain.spendable - channels.length * 25000;
26 |
27 | if (!showAlert) {
28 | return null;
29 | }
30 |
31 | return (
32 |
33 |
34 | Channel Anchor Reserves will be depleted
35 |
36 | You have channels open and by spending your entire on-chain balance
37 | including your anchor reserves may put your node at risk of unable to
38 | reclaim funds in your channel after a force-closure. To prevent this,
39 | set aside at least{" "}
40 | {new Intl.NumberFormat().format(channels.length * 25000)} sats on-chain
41 | {isSwap ? ", or pay with an external on-chain wallet." : "."}
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/frontend/src/components/AppHeader.tsx:
--------------------------------------------------------------------------------
1 | import { ReactElement } from "react";
2 | import { Separator } from "src/components/ui/separator";
3 | import { SidebarTrigger } from "src/components/ui/sidebar";
4 |
5 | type Props = {
6 | icon?: React.ReactElement;
7 | title: string | ReactElement;
8 | description?: string | ReactElement;
9 | contentRight?: React.ReactNode;
10 | breadcrumb?: boolean;
11 | addSidebarTrigger?: boolean;
12 | };
13 |
14 | function AppHeader({
15 | icon,
16 | title,
17 | description = "",
18 | contentRight,
19 | addSidebarTrigger = true,
20 | }: Props) {
21 | return (
22 | <>
23 |
24 | {addSidebarTrigger && }
25 |
26 | {icon}
27 |
28 |
29 |
30 |
{title}
31 | {description && (
32 |
33 | {description}
34 |
35 | )}
36 |
37 |
{contentRight}
38 |
39 |
40 |
41 | >
42 | );
43 | }
44 |
45 | export default AppHeader;
46 |
--------------------------------------------------------------------------------
/frontend/src/components/CardButton.tsx:
--------------------------------------------------------------------------------
1 | import { ChevronRightIcon } from "lucide-react";
2 | import { ReactElement } from "react";
3 | import { Link } from "react-router-dom";
4 | import { Card } from "src/components/ui/card";
5 |
6 | type Props = {
7 | title: string | ReactElement;
8 | description: string | ReactElement;
9 | to: string;
10 | };
11 |
12 | function CardButton({ title, description, to }: Props) {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 | {title}
20 |
21 |
{description}
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | export default CardButton;
33 |
--------------------------------------------------------------------------------
/frontend/src/components/Container.tsx:
--------------------------------------------------------------------------------
1 | export default function Container({ children }: React.PropsWithChildren) {
2 | return (
3 |
4 |
5 | {children}
6 |
7 |
8 | );
9 | }
10 |
--------------------------------------------------------------------------------
/frontend/src/components/EmptyState.tsx:
--------------------------------------------------------------------------------
1 | import { LucideIcon } from "lucide-react";
2 | import React from "react";
3 | import { Link } from "react-router-dom";
4 | import { Button } from "src/components/ui/button";
5 | import { cn } from "src/lib/utils";
6 |
7 | interface Props {
8 | icon: LucideIcon;
9 | title: string;
10 | description: string;
11 | buttonText: string;
12 | buttonLink: string;
13 | showButton?: boolean;
14 | showBorder?: boolean;
15 | }
16 |
17 | const EmptyState: React.FC = ({
18 | icon: Icon,
19 | title: message,
20 | description: subMessage,
21 | buttonText,
22 | buttonLink,
23 | showButton = true,
24 | showBorder = true,
25 | }) => {
26 | return (
27 |
33 |
34 |
35 |
{message}
36 |
{subMessage}
37 | {showButton && (
38 |
39 |
40 |
41 | )}
42 |
43 |
44 | );
45 | };
46 |
47 | export default EmptyState;
48 |
--------------------------------------------------------------------------------
/frontend/src/components/ExternalLink.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import { isHttpMode } from "src/utils/isHttpMode";
3 | import { openLink } from "src/utils/openLink";
4 |
5 | type Props = {
6 | to: string;
7 | className?: string;
8 | children?: React.ReactNode;
9 | };
10 |
11 | export default function ExternalLink({ to, className, children }: Props) {
12 | const _isHttpMode = isHttpMode();
13 |
14 | return _isHttpMode ? (
15 |
21 | {children}
22 |
23 | ) : (
24 | openLink(to)}>
25 | {children}
26 |
27 | );
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/components/FormattedFiatAmount.tsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from "src/components/ui/skeleton";
2 | import { useBitcoinRate } from "src/hooks/useBitcoinRate";
3 | import { useInfo } from "src/hooks/useInfo";
4 | import { cn } from "src/lib/utils";
5 |
6 | type FormattedFiatAmountProps = {
7 | amount: number;
8 | className?: string;
9 | showApprox?: boolean;
10 | };
11 |
12 | export default function FormattedFiatAmount({
13 | amount,
14 | className,
15 | showApprox,
16 | }: FormattedFiatAmountProps) {
17 | const { data: info } = useInfo();
18 | const { data: bitcoinRate, error: bitcoinRateError } = useBitcoinRate();
19 |
20 | if (info?.currency === "SATS" || bitcoinRateError) {
21 | return null;
22 | }
23 |
24 | return (
25 |
26 | {showApprox && bitcoinRate && "~"}
27 | {!bitcoinRate ? (
28 |
29 | ) : (
30 | new Intl.NumberFormat("en-US", {
31 | style: "currency",
32 | currency: info?.currency || "usd",
33 | }).format((amount / 100_000_000) * bitcoinRate.rate_float)
34 | )}
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/components/Loading.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2Icon, LoaderIcon } from "lucide-react";
2 | import { cn } from "src/lib/utils";
3 |
4 | function Loading({
5 | className,
6 | variant = "loader2",
7 | }: {
8 | className?: string;
9 | variant?: "loader2" | "loader";
10 | }) {
11 | const Component = variant === "loader2" ? Loader2Icon : LoaderIcon;
12 |
13 | return (
14 | <>
15 |
22 | Loading...
23 |
24 | >
25 | );
26 | }
27 |
28 | export default Loading;
29 |
--------------------------------------------------------------------------------
/frontend/src/components/LottieLoading.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import Lottie from "react-lottie";
3 | import animationDataDark from "src/assets/lotties/loading-dark.json";
4 | import animationDataLight from "src/assets/lotties/loading-light.json";
5 | import { useTheme } from "src/components/ui/theme-provider";
6 |
7 | export default function LottieLoading({ size }: { size?: number }) {
8 | const { isDarkMode } = useTheme();
9 |
10 | const options = useMemo(
11 | () => ({
12 | loop: true,
13 | autoplay: true,
14 | animationData: isDarkMode ? animationDataDark : animationDataLight,
15 | rendererSettings: { preserveAspectRatio: "xMidYMid slice" },
16 | }),
17 | [isDarkMode]
18 | );
19 |
20 | return ;
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/components/LowReceivingCapacityAlert.tsx:
--------------------------------------------------------------------------------
1 | import { AlertTriangleIcon } from "lucide-react";
2 | import { Link } from "react-router-dom";
3 | import {
4 | Alert,
5 | AlertDescription,
6 | AlertTitle,
7 | } from "src/components/ui/alert.tsx";
8 |
9 | export default function LowReceivingCapacityAlert() {
10 | return (
11 |
12 |
13 | Low receiving capacity
14 |
15 | You likely won't be able to receive payments until you{" "}
16 |
17 | spend
18 |
19 | ,{" "}
20 |
21 | swap out funds
22 |
23 | , or{" "}
24 |
25 | increase your receiving capacity.
26 |
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/components/OnchainAddressDisplay.tsx:
--------------------------------------------------------------------------------
1 | function OnchainAddressDisplay({ address }: { address: string }) {
2 | return (
3 | <>
4 | {address.match(/.{1,4}/g)?.map((word, index) => {
5 | if (index % 2 === 0) {
6 | return (
7 |
8 | {word}
9 |
10 | );
11 | } else {
12 | return (
13 |
14 | {word}
15 |
16 | );
17 | }
18 | })}
19 | >
20 | );
21 | }
22 |
23 | export default OnchainAddressDisplay;
24 |
--------------------------------------------------------------------------------
/frontend/src/components/QRCode.tsx:
--------------------------------------------------------------------------------
1 | import ReactQRCode from "react-qr-code";
2 | import { cn } from "src/lib/utils";
3 |
4 | export type Props = {
5 | value: string;
6 | size?: number;
7 | className?: string;
8 |
9 | // set the level to Q if there are overlays
10 | // Q will improve error correction (so we can add overlays covering up to 25% of the QR)
11 | // at the price of decreased information density (meaning the QR codes "pixels" have to be
12 | // smaller to encode the same information).
13 | // While that isn't that much of a problem for lightning addresses (because they are usually quite short),
14 | // for invoices that contain larger amount of data those QR codes can get "harder" to read.
15 | // (meaning you have to aim your phone very precisely and have to wait longer for the reader
16 | // to recognize the QR code)
17 | level?: "Q" | undefined;
18 | };
19 |
20 | function QRCode({ value, size, level, className }: Props) {
21 | // Do not use dark mode: some apps do not handle it well (e.g. Phoenix)
22 | // const { isDarkMode } = useTheme();
23 | const fgColor = "#242424"; // isDarkMode ? "#FFFFFF" : "#242424";
24 | const bgColor = "#FFFFFF"; // isDarkMode ? "#242424" : "#FFFFFF";
25 |
26 | return (
27 |
28 |
36 |
37 | );
38 | }
39 |
40 | export default QRCode;
41 |
--------------------------------------------------------------------------------
/frontend/src/components/ResponsiveButton.tsx:
--------------------------------------------------------------------------------
1 | import { VariantProps } from "class-variance-authority";
2 | import * as React from "react";
3 | import { Button } from "./ui/button";
4 | import { buttonVariants } from "./ui/buttonVariants";
5 |
6 | type Props = {
7 | icon: React.ComponentType;
8 | text: string;
9 | } & React.ComponentProps<"button"> &
10 | VariantProps & {
11 | asChild?: boolean;
12 | };
13 |
14 | const ResponsiveButton = ({ icon: Icon, text, variant, ...props }: Props) => {
15 | return (
16 | <>
17 |
21 |
24 | >
25 | );
26 | };
27 |
28 | export default ResponsiveButton;
29 |
--------------------------------------------------------------------------------
/frontend/src/components/SettingsHeader.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Separator } from "src/components/ui/separator";
3 |
4 | type Props = {
5 | title: string;
6 | description: string | React.ReactNode;
7 | };
8 |
9 | function SettingsHeader({ title, description }: Props) {
10 | return (
11 | <>
12 |
13 |
14 |
{title}
15 |
{description}
16 |
17 |
18 |
19 | >
20 | );
21 | }
22 |
23 | export default SettingsHeader;
24 |
--------------------------------------------------------------------------------
/frontend/src/components/TwoColumnLayoutHeader.tsx:
--------------------------------------------------------------------------------
1 | type Props = {
2 | title: string;
3 | description: string;
4 | };
5 |
6 | export default function TwoColumnLayoutHeader({ title, description }: Props) {
7 | return (
8 |
9 |
{title}
10 |
{description}
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/components/UpgradeCard.tsx:
--------------------------------------------------------------------------------
1 | import { SparklesIcon } from "lucide-react";
2 | import { Button } from "src/components/ui/button";
3 | import { UpgradeDialog } from "src/components/UpgradeDialog";
4 |
5 | interface Props {
6 | title: string;
7 | description: string;
8 | }
9 |
10 | const UpgradeCard: React.FC = ({
11 | title: message,
12 | description: subMessage,
13 | }) => {
14 | return (
15 |
16 |
17 |
18 |
{message}
19 |
{subMessage}
20 |
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default UpgradeCard;
29 |
--------------------------------------------------------------------------------
/frontend/src/components/UserAvatar.tsx:
--------------------------------------------------------------------------------
1 | import { Avatar, AvatarFallback, AvatarImage } from "src/components/ui/avatar";
2 | import { useAlbyMe } from "src/hooks/useAlbyMe";
3 | import { cn } from "src/lib/utils";
4 |
5 | function UserAvatar({ className }: { className?: string }) {
6 | const { data: albyMe } = useAlbyMe();
7 |
8 | return (
9 |
10 |
11 |
12 | {(albyMe?.name || albyMe?.email || "SN").substring(0, 2).toUpperCase()}
13 |
14 |
15 | );
16 | }
17 |
18 | export default UserAvatar;
19 |
--------------------------------------------------------------------------------
/frontend/src/components/channels/ChannelPeerNote.tsx:
--------------------------------------------------------------------------------
1 | import { AlertCircleIcon } from "lucide-react";
2 | import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
3 | import { RecommendedChannelPeer } from "src/types";
4 |
5 | type ChannelPeerNoteProps = {
6 | peer: RecommendedChannelPeer;
7 | };
8 |
9 | export function ChannelPeerNote({ peer }: ChannelPeerNoteProps) {
10 | return (
11 |
12 |
13 |
14 | Please note when opening a channel with {peer.name}
15 |
16 | {peer.note}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/components/channels/ChannelPublicPrivateAlert.tsx:
--------------------------------------------------------------------------------
1 | import { AlertCircleIcon } from "lucide-react";
2 | import ExternalLink from "src/components/ExternalLink";
3 | import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
4 |
5 | export function ChannelPublicPrivateAlert() {
6 | return (
7 |
8 |
9 | Conflicting Private / Public Channels
10 |
11 |
12 | You will not be able to receive payments on any private channels. It
13 | is recommended to only open all private or all public channels.
14 |
15 |
21 | Learn more
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/components/channels/DuplicateChannelAlert.tsx:
--------------------------------------------------------------------------------
1 | import { TriangleAlertIcon } from "lucide-react";
2 | import ExternalLink from "src/components/ExternalLink";
3 | import { Alert, AlertDescription, AlertTitle } from "src/components/ui/alert";
4 | import { useChannels } from "src/hooks/useChannels";
5 |
6 | type PeerAlertProps = {
7 | pubkey?: string;
8 | name?: string;
9 | };
10 |
11 | export function DuplicateChannelAlert({ pubkey, name }: PeerAlertProps) {
12 | const { data: channels } = useChannels();
13 |
14 | if (!pubkey) {
15 | return null;
16 | }
17 |
18 | const matchedPeer = channels?.find((p) => p.remotePubkey === pubkey);
19 |
20 | if (!matchedPeer) {
21 | return null;
22 | }
23 |
24 | return (
25 |
26 |
27 |
28 | You already have a channel with{" "}
29 | {name && name !== "Custom" ? (
30 | {name}
31 | ) : (
32 | "the selected peer"
33 | )}
34 |
35 |
36 | There are other options available rather than opening multiple channels
37 | with the same counterparty.{" "}
38 |
42 | Learn more
43 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/src/components/connections/AboutAppCard.tsx:
--------------------------------------------------------------------------------
1 | import { AppStoreApp } from "src/components/connections/SuggestedAppData";
2 | import {
3 | Card,
4 | CardContent,
5 | CardHeader,
6 | CardTitle,
7 | } from "src/components/ui/card";
8 |
9 | export function AboutAppCard({ appStoreApp }: { appStoreApp: AppStoreApp }) {
10 | return (
11 |
12 |
13 | About the App
14 |
15 |
16 |
17 | {appStoreApp.extendedDescription}
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/components/connections/AppDetailConnectedApps.tsx:
--------------------------------------------------------------------------------
1 | import AppCard from "src/components/connections/AppCard";
2 | import { AppStoreApp } from "src/components/connections/SuggestedAppData";
3 | import { useAppsForAppStoreApp } from "src/hooks/useApps";
4 |
5 | export function AppDetailConnectedApps({
6 | appStoreApp,
7 | showTitle,
8 | }: {
9 | appStoreApp: AppStoreApp;
10 | showTitle?: boolean;
11 | }) {
12 | const connectedApps = useAppsForAppStoreApp(appStoreApp);
13 |
14 | if (!connectedApps?.length) {
15 | return null;
16 | }
17 |
18 | return (
19 | <>
20 | {showTitle && (
21 | Your Connections
22 | )}
23 |
24 | {connectedApps.map((app) => (
25 |
26 | ))}
27 |
28 | >
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/frontend/src/components/connections/AppStore.tsx:
--------------------------------------------------------------------------------
1 | import { CirclePlusIcon } from "lucide-react";
2 | import ExternalLink from "src/components/ExternalLink";
3 | import ResponsiveButton from "src/components/ResponsiveButton";
4 | import SuggestedApps from "src/components/connections/SuggestedApps";
5 |
6 | function AppStore() {
7 | return (
8 | <>
9 |
10 |
11 |
12 |
App Store
13 |
14 |
15 |
16 |
21 |
22 |
23 |
24 |
25 |
26 | >
27 | );
28 | }
29 |
30 | export default AppStore;
31 |
--------------------------------------------------------------------------------
/frontend/src/components/connections/AppTransactionList.tsx:
--------------------------------------------------------------------------------
1 | import TransactionsList from "src/components/TransactionsList";
2 | import { TransactionsListMenu } from "src/components/TransactionsListMenu";
3 | import {
4 | Card,
5 | CardContent,
6 | CardHeader,
7 | CardTitle,
8 | } from "src/components/ui/card";
9 |
10 | export function AppTransactionList({ appId }: { appId: number }) {
11 | return (
12 |
13 |
14 | Transactions
15 |
16 |
17 |
18 |
19 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/components/home/widgets/BlockHeightWidget.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardHeader,
5 | CardTitle,
6 | } from "src/components/ui/card";
7 | import { useMempoolApi } from "src/hooks/useMempoolApi";
8 |
9 | export function BlockHeightWidget() {
10 | const { data: blocks } = useMempoolApi<{ height: number }[]>("/v1/blocks");
11 |
12 | if (!blocks?.length) {
13 | return null;
14 | }
15 | return (
16 |
17 |
18 | Block Height
19 |
20 |
21 | {blocks[0].height}
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/frontend/src/components/home/widgets/NodeStatusWidget.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import {
3 | Card,
4 | CardContent,
5 | CardHeader,
6 | CardTitle,
7 | } from "src/components/ui/card";
8 | import { useChannels } from "src/hooks/useChannels";
9 | import { usePeers } from "src/hooks/usePeers";
10 |
11 | export function NodeStatusWidget() {
12 | const { data: channels } = useChannels();
13 | const { data: peers } = usePeers();
14 |
15 | if (!channels || !peers) {
16 | return null;
17 | }
18 | return (
19 |
20 |
21 |
22 | Node
23 |
24 |
25 | Channels Online
26 |
27 | {channels.filter((c) => c.active).length || 0} /{" "}
28 | {channels.length || 0}
29 |
30 | Connected Peers
31 |
32 | {peers.filter((p) => p.isConnected).length || 0} /{" "}
33 | {peers.length || 0}
34 |
35 |
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/src/components/home/widgets/OnchainFeesWidget.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Card,
3 | CardContent,
4 | CardHeader,
5 | CardTitle,
6 | } from "src/components/ui/card";
7 | import { useMempoolApi } from "src/hooks/useMempoolApi";
8 |
9 | export function OnchainFeesWidget() {
10 | const { data: recommendedFees } = useMempoolApi<{
11 | fastestFee: number;
12 | halfHourFee: number;
13 | economyFee: number;
14 | minimumFee: number;
15 | }>("/v1/fees/recommended");
16 |
17 | if (!recommendedFees) {
18 | return null;
19 | }
20 |
21 | const entries = [
22 | {
23 | title: "No priority",
24 | value: recommendedFees.minimumFee,
25 | },
26 | {
27 | title: "Low priority",
28 | value: recommendedFees.economyFee,
29 | },
30 | {
31 | title: "Medium priority",
32 | value: recommendedFees.halfHourFee,
33 | },
34 | {
35 | title: "High priority",
36 | value: recommendedFees.fastestFee,
37 | },
38 | ];
39 |
40 | return (
41 |
42 |
43 | On-Chain Fees
44 |
45 |
46 | {entries.map((entry) => (
47 |
48 |
{entry.title}
49 |
{entry.value} sat/vB
50 |
51 | ))}
52 |
53 |
54 | );
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/components/home/widgets/WhatsNewWidget.tsx:
--------------------------------------------------------------------------------
1 | import { compare } from "compare-versions";
2 | import {
3 | Card,
4 | CardContent,
5 | CardDescription,
6 | CardHeader,
7 | CardTitle,
8 | } from "src/components/ui/card";
9 | import { ExternalLinkButton } from "src/components/ui/custom/external-link-button";
10 | import { useAlbyInfo } from "src/hooks/useAlbyInfo";
11 | import { useInfo } from "src/hooks/useInfo";
12 |
13 | export function WhatsNewWidget() {
14 | const { data: info } = useInfo();
15 | const { data: albyInfo } = useAlbyInfo();
16 |
17 | if (!info || !albyInfo || !albyInfo.hub.latestReleaseNotes) {
18 | return null;
19 | }
20 |
21 | const upToDate =
22 | info.version &&
23 | info.version.startsWith("v") &&
24 | compare(info.version.substring(1), albyInfo.hub.latestVersion, ">=");
25 |
26 | return (
27 |
28 |
29 | What's New in {upToDate && "your "}Alby Hub?
30 | {albyInfo.hub.latestReleaseNotes}
31 |
32 | {!upToDate && (
33 |
34 |
38 | Update Now
39 |
40 |
41 | )}
42 |
43 | );
44 | }
45 |
--------------------------------------------------------------------------------
/frontend/src/components/icons/Apple.tsx:
--------------------------------------------------------------------------------
1 | import { SVGAttributes } from "react";
2 |
3 | export function AppleIcon(props: SVGAttributes) {
4 | return (
5 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/components/icons/Chrome.tsx:
--------------------------------------------------------------------------------
1 | import { SVGAttributes } from "react";
2 |
3 | export function ChromeIcon(props: SVGAttributes) {
4 | return (
5 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/components/icons/Firefox.tsx:
--------------------------------------------------------------------------------
1 | import { SVGAttributes } from "react";
2 |
3 | export function FirefoxIcon(props: SVGAttributes) {
4 | return (
5 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/components/icons/LDK.tsx:
--------------------------------------------------------------------------------
1 | export const LDKIcon = () => {
2 | return (
3 |
8 | );
9 | };
10 |
--------------------------------------------------------------------------------
/frontend/src/components/icons/Phoenixd.tsx:
--------------------------------------------------------------------------------
1 | export const PhoenixdIcon = () => {
2 | return (
3 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/frontend/src/components/icons/PlayStore.tsx:
--------------------------------------------------------------------------------
1 | import { SVGAttributes } from "react";
2 |
3 | export function PlayStoreIcon(props: SVGAttributes) {
4 | return (
5 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/components/icons/ZapStore.tsx:
--------------------------------------------------------------------------------
1 | import { SVGAttributes } from "react";
2 |
3 | export function ZapStoreIcon(props: SVGAttributes) {
4 | return (
5 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/src/components/layouts/ReceiveLayout.tsx:
--------------------------------------------------------------------------------
1 | import { Outlet } from "react-router-dom";
2 | import AppHeader from "src/components/AppHeader";
3 | import Loading from "src/components/Loading";
4 | import { useBalances } from "src/hooks/useBalances";
5 | import { useChannels } from "src/hooks/useChannels";
6 |
7 | import { useInfo } from "src/hooks/useInfo";
8 |
9 | export default function ReceiveLayout() {
10 | const { hasChannelManagement } = useInfo();
11 | const { data: balances } = useBalances();
12 | const { data: channels } = useChannels();
13 |
14 | if (!balances || !channels) {
15 | return ;
16 | }
17 |
18 | return (
19 |
20 |
25 | Receive Limit:
26 |
27 | {new Intl.NumberFormat().format(
28 | Math.floor(balances.lightning.totalReceivable / 1000)
29 | )}{" "}
30 | sats
31 |
32 |
33 | )
34 | }
35 | />
36 |
37 |
38 | );
39 | }
40 |
--------------------------------------------------------------------------------
/frontend/src/components/password/PasswordInput.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import RevealPasswordToggle from "src/components/password/RevealPasswordToggle";
3 | import { InputWithAdornment } from "src/components/ui/custom/input-with-adornment";
4 |
5 | type PasswordInputProps = Omit<
6 | React.InputHTMLAttributes,
7 | "type" | "onChange" | "value"
8 | > & {
9 | value: string;
10 | onChange?: (value: string) => void;
11 | };
12 |
13 | export default function PasswordInput({
14 | onChange,
15 | placeholder,
16 | value,
17 | ...restProps
18 | }: PasswordInputProps) {
19 | const [passwordVisible, setPasswordVisible] = React.useState(false);
20 |
21 | return (
22 | onChange && onChange(e.target.value)}
27 | placeholder={placeholder}
28 | {...restProps}
29 | endAdornment={
30 |
34 | }
35 | />
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/components/password/RevealPasswordToggle.tsx:
--------------------------------------------------------------------------------
1 | import { EyeIcon, EyeOffIcon } from "lucide-react";
2 | import { useEffect, useState } from "react";
3 | import { cn } from "src/lib/utils";
4 |
5 | type Props = {
6 | onChange: (isRevealed: boolean) => void;
7 | isRevealed?: boolean;
8 | iconClass?: string;
9 | };
10 |
11 | export default function RevealPasswordToggle({
12 | onChange,
13 | isRevealed,
14 | iconClass,
15 | }: Props) {
16 | const [_isRevealed, setRevealed] = useState(false);
17 |
18 | // toggle the button if password view is handled by component itself
19 | useEffect(() => {
20 | if (typeof isRevealed !== "undefined") {
21 | setRevealed(isRevealed);
22 | }
23 | }, [isRevealed]);
24 |
25 | return (
26 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/frontend/src/components/redirects/DefaultRedirect.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Outlet, useLocation, useNavigate } from "react-router-dom";
3 | import Loading from "src/components/Loading";
4 | import { localStorageKeys } from "src/constants";
5 | import { useInfo } from "src/hooks/useInfo";
6 |
7 | export function DefaultRedirect() {
8 | const { data: info } = useInfo();
9 | const location = useLocation();
10 | const navigate = useNavigate();
11 |
12 | React.useEffect(() => {
13 | if (
14 | !info ||
15 | (info.running &&
16 | info.unlocked &&
17 | (info.albyAccountConnected || !info.albyUserIdentifier))
18 | ) {
19 | return;
20 | }
21 | const returnTo = location.pathname + location.search;
22 | window.localStorage.setItem(localStorageKeys.returnTo, returnTo);
23 | navigate("/");
24 | }, [info, location, navigate]);
25 |
26 | if (!info) {
27 | return ;
28 | }
29 |
30 | return ;
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/components/redirects/SetupRedirect.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Outlet, useLocation, useNavigate } from "react-router-dom";
3 | import Loading from "src/components/Loading";
4 | import { useInfo } from "src/hooks/useInfo";
5 |
6 | export function SetupRedirect() {
7 | const { data: info } = useInfo();
8 | const location = useLocation();
9 | const navigate = useNavigate();
10 |
11 | React.useEffect(() => {
12 | if (!info) {
13 | return;
14 | }
15 | if (info.setupCompleted && info.running) {
16 | navigate("/");
17 | return;
18 | }
19 | }, [info, location, navigate]);
20 |
21 | if (!info) {
22 | return ;
23 | }
24 |
25 | return ;
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/components/redirects/StartRedirect.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useLocation, useNavigate } from "react-router-dom";
3 | import Loading from "src/components/Loading";
4 | import { useInfo } from "src/hooks/useInfo";
5 |
6 | export function StartRedirect({ children }: React.PropsWithChildren) {
7 | const { data: info } = useInfo();
8 | const location = useLocation();
9 | const navigate = useNavigate();
10 |
11 | React.useEffect(() => {
12 | if (!info || (info.setupCompleted && !info.running)) {
13 | if (info && !info.albyAccountConnected && info.albyUserIdentifier) {
14 | navigate("/alby/auth");
15 | }
16 | return;
17 | }
18 |
19 | navigate("/");
20 | }, [info, location, navigate]);
21 |
22 | if (!info) {
23 | return ;
24 | }
25 |
26 | return children;
27 | }
28 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar";
5 |
6 | import { cn } from "src/lib/utils";
7 |
8 | function Avatar({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | );
22 | }
23 |
24 | function AvatarImage({
25 | className,
26 | ...props
27 | }: React.ComponentProps) {
28 | return (
29 |
34 | );
35 | }
36 |
37 | function AvatarFallback({
38 | className,
39 | ...props
40 | }: React.ComponentProps) {
41 | return (
42 |
50 | );
51 | }
52 |
53 | export { Avatar, AvatarImage, AvatarFallback };
54 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import { Slot } from "@radix-ui/react-slot";
2 | import { type VariantProps } from "class-variance-authority";
3 | import * as React from "react";
4 | import { buttonVariants } from "src/components/ui/buttonVariants";
5 |
6 | import { cn } from "src/lib/utils";
7 |
8 | function Button({
9 | className,
10 | variant,
11 | size,
12 | asChild = false,
13 | ...props
14 | }: React.ComponentProps<"button"> &
15 | VariantProps & {
16 | asChild?: boolean;
17 | }) {
18 | const Comp = asChild ? Slot : "button";
19 |
20 | return (
21 |
26 | );
27 | }
28 |
29 | export { Button };
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/checkbox.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
5 | import { CheckIcon } from "lucide-react";
6 |
7 | import { cn } from "src/lib/utils";
8 |
9 | function Checkbox({
10 | className,
11 | ...props
12 | }: React.ComponentProps) {
13 | return (
14 |
22 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | export { Checkbox };
33 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/custom/carousel-dots.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 |
5 | import { cn } from "src/lib/utils";
6 |
7 | // Dot Button Hook
8 | export type UseDotButtonType = {
9 | selectedIndex: number;
10 | scrollSnaps: number[];
11 | onDotButtonClick: (index: number) => void;
12 | };
13 |
14 | // Dot Button Component
15 | const CarouselDotButton = React.forwardRef<
16 | HTMLButtonElement,
17 | React.ComponentPropsWithRef<"button">
18 | >(({ className, ...props }, ref) => {
19 | return (
20 |
30 | );
31 | });
32 | CarouselDotButton.displayName = "CarouselDotButton";
33 |
34 | // Dots Container Component
35 | const CarouselDots = React.forwardRef<
36 | HTMLDivElement,
37 | React.HTMLAttributes
38 | >(({ className, ...props }, ref) => (
39 |
44 | ));
45 | CarouselDots.displayName = "CarouselDots";
46 |
47 | export { CarouselDotButton, CarouselDots };
48 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/custom/circle-progress.tsx:
--------------------------------------------------------------------------------
1 | import * as ProgressPrimitive from "@radix-ui/react-progress";
2 | import * as React from "react";
3 | import { cn } from "src/lib/utils";
4 |
5 | function CircleProgress({
6 | className,
7 | value,
8 | ...props
9 | }: React.ComponentProps) {
10 | return (
11 |
21 | {props.children || {`${value || 0}%`}
}
22 |
23 | );
24 | }
25 |
26 | export default CircleProgress;
27 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/custom/external-link-button.tsx:
--------------------------------------------------------------------------------
1 | import type { VariantProps } from "class-variance-authority";
2 | import * as React from "react";
3 | import ExternalLink from "src/components/ExternalLink";
4 | import { cn } from "src/lib/utils";
5 | import { buttonVariants } from "../buttonVariants";
6 |
7 | export function ExternalLinkButton({
8 | className,
9 | variant,
10 | size,
11 | to,
12 | children,
13 | ...props
14 | }: React.PropsWithChildren<
15 | VariantProps & {
16 | to: string;
17 | className?: string;
18 | }
19 | >) {
20 | return (
21 |
26 | {children}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/custom/input-with-adornment.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import { Input } from "src/components/ui/input";
3 | import { cn } from "src/lib/utils";
4 |
5 | export interface InputWithAdornmentProps extends React.ComponentProps<"input"> {
6 | endAdornment: React.ReactNode;
7 | }
8 |
9 | const InputWithAdornment = React.forwardRef<
10 | HTMLInputElement,
11 | InputWithAdornmentProps
12 | >(({ className, type, endAdornment, ...props }, ref) => {
13 | return (
14 |
15 |
24 | {endAdornment && (
25 |
26 | {endAdornment}
27 |
28 | )}
29 |
30 | );
31 | });
32 |
33 | InputWithAdornment.displayName = "InputWithAdornment";
34 |
35 | export { InputWithAdornment };
36 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/custom/link-button.tsx:
--------------------------------------------------------------------------------
1 | import type { VariantProps } from "class-variance-authority";
2 | import * as React from "react";
3 | import { Link } from "react-router-dom";
4 | import { cn } from "src/lib/utils";
5 | import { buttonVariants } from "../buttonVariants";
6 |
7 | export function LinkButton({
8 | className,
9 | variant,
10 | size,
11 | to,
12 | children,
13 | ...props
14 | }: React.PropsWithChildren<
15 | VariantProps & {
16 | to: string;
17 | className?: string;
18 | }
19 | >) {
20 | return (
21 |
26 | {children}
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/custom/loading-button.tsx:
--------------------------------------------------------------------------------
1 | import { Loader2Icon } from "lucide-react";
2 | import * as React from "react";
3 | import { Button } from "src/components/ui/button";
4 |
5 | export interface ButtonProps extends React.ComponentProps {
6 | loading?: boolean;
7 | }
8 |
9 | const LoadingButton = React.forwardRef(
10 | (
11 | {
12 | className,
13 | variant,
14 | size,
15 | asChild = false,
16 | loading,
17 | children,
18 | disabled,
19 | ...props
20 | },
21 | ref
22 | ) => {
23 | return (
24 |
36 | );
37 | }
38 | );
39 | LoadingButton.displayName = "LoadingButton";
40 |
41 | export { LoadingButton };
42 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/custom/useDotButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import type { EmblaCarouselType } from "node_modules/embla-carousel/esm/components/EmblaCarousel";
3 | import * as React from "react";
4 | import { UseDotButtonType } from "src/components/ui/custom/carousel-dots";
5 |
6 | export const useDotButton = (
7 | emblaApi: EmblaCarouselType | undefined
8 | ): UseDotButtonType => {
9 | const [selectedIndex, setSelectedIndex] = React.useState(0);
10 | const [scrollSnaps, setScrollSnaps] = React.useState([]);
11 |
12 | const onDotButtonClick = React.useCallback(
13 | (index: number) => {
14 | if (!emblaApi) {
15 | return;
16 | }
17 | emblaApi.scrollTo(index);
18 | },
19 | [emblaApi]
20 | );
21 |
22 | const onInit = React.useCallback((emblaApi: EmblaCarouselType) => {
23 | setScrollSnaps(emblaApi.scrollSnapList());
24 | }, []);
25 |
26 | const onSelect = React.useCallback((emblaApi: EmblaCarouselType) => {
27 | setSelectedIndex(emblaApi.selectedScrollSnap());
28 | }, []);
29 |
30 | React.useEffect(() => {
31 | if (!emblaApi) {
32 | return;
33 | }
34 |
35 | onInit(emblaApi);
36 | onSelect(emblaApi);
37 | emblaApi.on("reInit", onInit).on("reInit", onSelect).on("select", onSelect);
38 | }, [emblaApi, onInit, onSelect]);
39 |
40 | return {
41 | selectedIndex,
42 | scrollSnaps,
43 | onDotButtonClick,
44 | };
45 | };
46 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "src/lib/utils";
4 |
5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) {
6 | return (
7 |
18 | );
19 | }
20 |
21 | export { Input };
22 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as LabelPrimitive from "@radix-ui/react-label";
5 |
6 | import { cn } from "src/lib/utils";
7 |
8 | function Label({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 | );
22 | }
23 |
24 | export { Label };
25 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/progress.tsx:
--------------------------------------------------------------------------------
1 | import * as ProgressPrimitive from "@radix-ui/react-progress";
2 | import * as React from "react";
3 |
4 | import { cn } from "src/lib/utils";
5 |
6 | function Progress({
7 | className,
8 | value,
9 | ...props
10 | }: React.ComponentProps) {
11 | return (
12 |
20 |
25 |
26 | );
27 | }
28 |
29 | export { Progress };
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/separator.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SeparatorPrimitive from "@radix-ui/react-separator";
5 |
6 | import { cn } from "src/lib/utils";
7 |
8 | function Separator({
9 | className,
10 | orientation = "horizontal",
11 | decorative = true,
12 | ...props
13 | }: React.ComponentProps) {
14 | return (
15 |
25 | );
26 | }
27 |
28 | export { Separator };
29 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "src/lib/utils";
2 |
3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
4 | return (
5 |
10 | );
11 | }
12 |
13 | export { Skeleton };
14 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/sonner.tsx:
--------------------------------------------------------------------------------
1 | import { Toaster as Sonner, ToasterProps } from "sonner";
2 | import { useTheme } from "src/components/ui/theme-provider";
3 |
4 | const Toaster = ({ ...props }: ToasterProps) => {
5 | const { theme = "system" } = useTheme();
6 |
7 | return (
8 |
26 | );
27 | };
28 |
29 | export { Toaster };
30 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SwitchPrimitive from "@radix-ui/react-switch";
5 |
6 | import { cn } from "src/lib/utils";
7 |
8 | function Switch({
9 | className,
10 | ...props
11 | }: React.ComponentProps) {
12 | return (
13 |
21 |
27 |
28 | );
29 | }
30 |
31 | export { Switch };
32 |
--------------------------------------------------------------------------------
/frontend/src/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "src/lib/utils";
4 |
5 | function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6 | return (
7 |
15 | );
16 | }
17 |
18 | export { Textarea };
19 |
--------------------------------------------------------------------------------
/frontend/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const localStorageKeys = {
2 | returnTo: "returnTo",
3 | setupReturnTo: "setupReturnTo",
4 | channelOrder: "channelOrder",
5 | authToken: "authToken",
6 | supportAlbySidebarHintHiddenUntil: "supportAlbySidebarHintHiddenUntil",
7 | };
8 |
9 | export const ONCHAIN_DUST_SATS = 1000;
10 | export const ALBY_HIDE_HOSTED_BALANCE_BELOW = 100;
11 | export const ALBY_MIN_HOSTED_BALANCE_FOR_FIRST_CHANNEL = 10_000;
12 |
13 | export const LIST_TRANSACTIONS_LIMIT = 20;
14 | export const LIST_APPS_LIMIT = 20;
15 |
16 | export const SUPPORT_ALBY_CONNECTION_NAME = `ZapPlanner - Alby Hub`;
17 | export const SUPPORT_ALBY_LIGHTNING_ADDRESS = "hub@getalby.com";
18 |
19 | export const SUBWALLET_APPSTORE_APP_ID = "uncle-jim";
20 | export const ALBY_ACCOUNT_APP_NAME = "getalby.com";
21 |
--------------------------------------------------------------------------------
/frontend/src/contexts/CommandPaletteContext.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useCommandPalette } from "src/hooks/useCommandPalette";
3 |
4 | interface CommandPaletteContextType {
5 | open: boolean;
6 | setOpen: (open: boolean) => void;
7 | }
8 |
9 | const CommandPaletteContext =
10 | React.createContext(null);
11 |
12 | export function CommandPaletteProvider({
13 | children,
14 | }: {
15 | children: React.ReactNode;
16 | }) {
17 | const commandPalette = useCommandPalette();
18 |
19 | return (
20 |
21 | {children}
22 |
23 | );
24 | }
25 |
26 | // eslint-disable-next-line react-refresh/only-export-components
27 | export function useCommandPaletteContext() {
28 | const context = React.useContext(CommandPaletteContext);
29 | if (!context) {
30 | throw new Error(
31 | "useCommandPaletteContext must be used within CommandPaletteProvider"
32 | );
33 | }
34 | return context;
35 | }
36 |
--------------------------------------------------------------------------------
/frontend/src/fonts.css:
--------------------------------------------------------------------------------
1 | @font-face {
2 | font-family: "Inter var";
3 | font-weight: 100 900;
4 | font-display: swap;
5 | font-style: normal;
6 | font-named-instance: "Regular";
7 | src: url(/fonts/Inter-roman.var.woff2) format("woff2");
8 | }
9 |
10 | @font-face {
11 | font-family: "Inter var";
12 | font-weight: 100 900;
13 | font-display: swap;
14 | font-style: italic;
15 | font-named-instance: "Italic";
16 | src: url(/fonts/Inter-italic.var.woff2) format("woff2");
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/hooks/use-mobile.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | const MOBILE_BREAKPOINT = 768;
4 |
5 | export function useIsMobile() {
6 | const [isMobile, setIsMobile] = React.useState(
7 | undefined
8 | );
9 |
10 | React.useEffect(() => {
11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
12 | const onChange = () => {
13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
14 | };
15 | mql.addEventListener("change", onChange);
16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17 | return () => mql.removeEventListener("change", onChange);
18 | }, []);
19 |
20 | return !!isMobile;
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/hooks/use-mobile.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | const MOBILE_BREAKPOINT = 768;
4 |
5 | /**
6 | * @deprecated Don't use this method outside of shadcn components
7 | */
8 | export function useIsMobile() {
9 | const [isMobile, setIsMobile] = React.useState(
10 | undefined
11 | );
12 |
13 | React.useEffect(() => {
14 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
15 | const onChange = () => {
16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
17 | };
18 | mql.addEventListener("change", onChange);
19 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
20 | return () => mql.removeEventListener("change", onChange);
21 | }, []);
22 |
23 | return !!isMobile;
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useAlbyInfo.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { AlbyInfo } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useAlbyInfo() {
7 | return useSWR("/api/alby/info", swrFetcher, {
8 | dedupingInterval: 5 * 60 * 1000, // 5 minutes
9 | });
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useAlbyMe.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { useInfo } from "src/hooks/useInfo";
4 | import { AlbyMe } from "src/types";
5 | import { swrFetcher } from "src/utils/swr";
6 |
7 | export function useAlbyMe() {
8 | const { data: info } = useInfo();
9 |
10 | return useSWR(
11 | info?.albyAccountConnected ? "/api/alby/me" : undefined,
12 | swrFetcher,
13 | {
14 | dedupingInterval: 5 * 60 * 1000, // 5 minutes
15 | }
16 | );
17 | }
18 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useApp.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { App } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | const pollConfiguration: SWRConfiguration = {
7 | refreshInterval: 3000,
8 | };
9 |
10 | export function useApp(id: number | undefined, poll = false) {
11 | return useSWR(
12 | !!id && `/api/v2/apps/${id}`,
13 | swrFetcher,
14 | poll ? pollConfiguration : undefined
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useBalances.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { BalancesResponse } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | const pollConfiguration: SWRConfiguration = {
7 | refreshInterval: 3000,
8 | };
9 |
10 | export function useBalances(poll = false) {
11 | return useSWR(
12 | "/api/balances",
13 | swrFetcher,
14 | poll ? pollConfiguration : undefined
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useBanner.tsx:
--------------------------------------------------------------------------------
1 | import { compare } from "compare-versions";
2 | import React from "react";
3 | import { useAlbyInfo } from "src/hooks/useAlbyInfo";
4 | import { useAlbyMe } from "src/hooks/useAlbyMe";
5 | import { useInfo } from "src/hooks/useInfo";
6 |
7 | export function useBanner() {
8 | const { data: info } = useInfo();
9 | const { data: albyInfo } = useAlbyInfo();
10 | const { data: albyMe } = useAlbyMe();
11 | const [showBanner, setShowBanner] = React.useState(false);
12 |
13 | React.useEffect(() => {
14 | if (!info || !albyInfo) {
15 | setShowBanner(false);
16 | return;
17 | }
18 |
19 | // vss migration (alby cloud only)
20 | // TODO: remove after 2026-01-01
21 | const vssMigrationRequired =
22 | info.oauthRedirect &&
23 | !!albyMe?.subscription.plan_code.includes("buzz") &&
24 | info.vssSupported &&
25 | !info.ldkVssEnabled;
26 |
27 | const upToDate =
28 | Boolean(info.version) &&
29 | info.version.startsWith("v") &&
30 | compare(info.version.substring(1), albyInfo.hub.latestVersion, ">=");
31 |
32 | setShowBanner(!upToDate || vssMigrationRequired);
33 | }, [info, albyInfo, albyMe?.subscription.plan_code]);
34 |
35 | const dismissBanner = () => {
36 | setShowBanner(false);
37 | };
38 |
39 | return { showBanner, dismissBanner };
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useBitcoinRate.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { BitcoinRate } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useBitcoinRate() {
7 | return useSWR(`/api/alby/rates`, swrFetcher);
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useCapabilities.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { WalletCapabilities } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useCapabilities() {
7 | return useSWR("/api/wallet/capabilities", swrFetcher);
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useChannelPeerSuggestions.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { RecommendedChannelPeer } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useChannelPeerSuggestions() {
7 | return useSWR(
8 | "/api/channels/suggestions",
9 | swrFetcher
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useChannels.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { Channel } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | const pollConfiguration: SWRConfiguration = {
7 | refreshInterval: 3000,
8 | };
9 |
10 | export function useChannels(poll = false) {
11 | return useSWR(
12 | "/api/channels",
13 | swrFetcher,
14 | poll ? pollConfiguration : undefined
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useCommandPalette.ts:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | export function useCommandPalette() {
4 | const [open, setOpen] = React.useState(false);
5 |
6 | React.useEffect(() => {
7 | const down = (e: KeyboardEvent) => {
8 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
9 | e.preventDefault();
10 | setOpen((open) => !open);
11 | }
12 | };
13 |
14 | document.addEventListener("keydown", down);
15 | return () => document.removeEventListener("keydown", down);
16 | }, []);
17 |
18 | return {
19 | open,
20 | setOpen,
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useCreateLightningAddress.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { toast } from "sonner";
3 | import { useApp } from "src/hooks/useApp";
4 | import { request } from "src/utils/request";
5 |
6 | export function useCreateLightningAddress(appId?: number) {
7 | const { data: app, mutate: refetchApp } = useApp(appId);
8 | const [creatingLightningAddress, setCreatingLightningAddress] =
9 | React.useState(false);
10 |
11 | async function createLightningAddress(intendedLightningAddress: string) {
12 | try {
13 | if (!app) {
14 | throw new Error("app not found");
15 | }
16 | setCreatingLightningAddress(true);
17 | await request("/api/lightning-addresses", {
18 | method: "POST",
19 | headers: {
20 | "Content-Type": "application/json",
21 | },
22 | body: JSON.stringify({
23 | address: intendedLightningAddress,
24 | appId: app.id,
25 | }),
26 | });
27 | await refetchApp();
28 | toast("Successfully created lightning address");
29 | } catch (error) {
30 | toast.error("Failed to create lightning address", {
31 | description: (error as Error).message.replace(
32 | "500 ",
33 | ""
34 | ) /* remove 500 error code */,
35 | });
36 | }
37 | setCreatingLightningAddress(false);
38 | }
39 | return { createLightningAddress, creatingLightningAddress };
40 | }
41 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useDeleteApp.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { toast } from "sonner";
3 |
4 | import { handleRequestError } from "src/utils/handleRequestError";
5 | import { request } from "src/utils/request";
6 |
7 | export function useDeleteApp(onSuccess?: (appPubkey: string) => void) {
8 | const [isDeleting, setDeleting] = React.useState(false);
9 |
10 | const deleteApp = React.useCallback(
11 | async (appPubkey: string) => {
12 | setDeleting(true);
13 | try {
14 | await request(`/api/apps/${appPubkey}`, {
15 | method: "DELETE",
16 | headers: {
17 | "Content-Type": "application/json",
18 | },
19 | });
20 | toast("Connection deleted");
21 | if (onSuccess) {
22 | onSuccess(appPubkey);
23 | }
24 | } catch (error) {
25 | await handleRequestError("Failed to delete connection", error);
26 | } finally {
27 | setDeleting(false);
28 | }
29 | },
30 | [onSuccess]
31 | );
32 |
33 | return React.useMemo(
34 | () => ({ deleteApp, isDeleting }),
35 | [deleteApp, isDeleting]
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useDeleteLightningAddress.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { toast } from "sonner";
3 | import { useApp } from "src/hooks/useApp";
4 | import { request } from "src/utils/request";
5 |
6 | export function useDeleteLightningAddress(appId?: number) {
7 | const { data: app, mutate: refetchApp } = useApp(appId);
8 | const [deletingLightningAddress, setDeletingLightningAddress] =
9 | React.useState(false);
10 |
11 | async function deleteLightningAddress() {
12 | try {
13 | if (!app) {
14 | throw new Error("app not found");
15 | }
16 | setDeletingLightningAddress(true);
17 | await request(`/api/lightning-addresses/${app.id}`, {
18 | method: "DELETE",
19 | headers: {
20 | "Content-Type": "application/json",
21 | },
22 | });
23 | await refetchApp();
24 | toast("Successfully deleted lightning address");
25 | } catch (error) {
26 | toast.error("Failed to delete lightning address", {
27 | description: (error as Error).message,
28 | });
29 | }
30 | setDeletingLightningAddress(false);
31 | }
32 | return {
33 | deleteLightningAddress,
34 | deletingLightningAddress,
35 | };
36 | }
37 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useForwards.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { GetForwardsResponse } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useForwards() {
7 | return useSWR("/api/forwards", swrFetcher);
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useHealthCheck.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { HealthResponse } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | const pollConfiguration: SWRConfiguration = {
7 | refreshInterval: 5 * 60 * 1000, // 5 minutes
8 | };
9 |
10 | export function useHealthCheck(poll = true) {
11 | return useSWR(
12 | "/api/health",
13 | swrFetcher,
14 | poll ? pollConfiguration : undefined
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useInfo.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import React from "react";
4 | import { backendTypeConfigs } from "src/lib/backendType";
5 | import { InfoResponse } from "src/types";
6 | import { swrFetcher } from "src/utils/swr";
7 |
8 | const pollConfiguration: SWRConfiguration = {
9 | refreshInterval: 3000,
10 | };
11 |
12 | export function useInfo(poll = false) {
13 | const info = useSWR(
14 | "/api/info",
15 | swrFetcher,
16 | poll ? pollConfiguration : undefined
17 | );
18 |
19 | return React.useMemo(
20 | () => ({
21 | ...info,
22 | hasChannelManagement:
23 | info.data?.backendType &&
24 | backendTypeConfigs[info.data.backendType].hasChannelManagement,
25 | hasMnemonic:
26 | info.data?.backendType &&
27 | backendTypeConfigs[info.data.backendType].hasMnemonic,
28 | hasNodeBackup:
29 | info.data?.backendType &&
30 | backendTypeConfigs[info.data.backendType].hasNodeBackup,
31 | }),
32 | [info]
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useLSPChannelOffer.tsx:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { LSPChannelOffer } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useLSPChannelOffer() {
7 | return useSWR("/api/channel-offer", swrFetcher);
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useMempoolApi.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { swrFetcher } from "src/utils/swr";
4 |
5 | export function useMempoolApi(
6 | endpoint: string | undefined,
7 | poll?: boolean | number
8 | ) {
9 | const config: SWRConfiguration | undefined =
10 | typeof poll === "number"
11 | ? { refreshInterval: poll }
12 | : poll
13 | ? { refreshInterval: 10000 }
14 | : undefined;
15 |
16 | return useSWR(
17 | endpoint && `/api/mempool?endpoint=${endpoint}`,
18 | swrFetcher,
19 | config
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useMigrateLDKStorage.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { toast } from "sonner";
3 | import { useInfo } from "src/hooks/useInfo";
4 | import { request } from "src/utils/request";
5 |
6 | export function useMigrateLDKStorage() {
7 | const [isMigratingStorage, setMigratingStorage] = React.useState(false);
8 | const { mutate: reloadInfo } = useInfo();
9 |
10 | const migrateLDKStorage = async (to: "VSS") => {
11 | try {
12 | setMigratingStorage(true);
13 |
14 | await request("/api/node/migrate-storage", {
15 | method: "POST",
16 | headers: {
17 | "Content-Type": "application/json",
18 | },
19 | body: JSON.stringify({
20 | to,
21 | }),
22 | });
23 | await reloadInfo();
24 | toast("Please unlock your hub");
25 | } catch (e) {
26 | console.error(e);
27 | toast.error("Could not start hub storage migration: " + e);
28 | }
29 | setMigratingStorage(false);
30 | };
31 |
32 | return { isMigratingStorage, migrateLDKStorage };
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useNodeConnectionInfo.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 |
3 | import { swrFetcher } from "src/utils/swr";
4 | import { NodeConnectionInfo } from "src/types";
5 |
6 | export function useNodeConnectionInfo() {
7 | return useSWR("/api/node/connection-info", swrFetcher);
8 | }
9 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useNodeDetails.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { MempoolNode } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useNodeDetails(nodePubkey: string | undefined) {
7 | const config: SWRConfiguration = {
8 | dedupingInterval: 600000, // 10 minutes
9 | };
10 |
11 | return useSWR(
12 | nodePubkey && `/api/mempool?endpoint=/v1/lightning/nodes/${nodePubkey}`,
13 | swrFetcher,
14 | config
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useNotifyReceivedPayments.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { toast } from "sonner";
3 | import { useTransactions } from "src/hooks/useTransactions";
4 | import { Transaction } from "src/types";
5 |
6 | export function useNotifyReceivedPayments() {
7 | const { data: transactionsData } = useTransactions(undefined, true, 1);
8 | const [prevTransaction, setPrevTransaction] = React.useState();
9 |
10 | React.useEffect(() => {
11 | if (!transactionsData?.transactions?.length) {
12 | return;
13 | }
14 | const latestTx = transactionsData.transactions[0];
15 | if (latestTx !== prevTransaction) {
16 | if (prevTransaction && latestTx.type === "incoming") {
17 | toast("Payment received", {
18 | description: `${new Intl.NumberFormat().format(Math.floor(latestTx.amount / 1000))} sats`,
19 | });
20 | }
21 | setPrevTransaction(latestTx);
22 | }
23 | }, [prevTransaction, transactionsData]);
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useOnchainAddress.ts:
--------------------------------------------------------------------------------
1 | import useSWRImmutable from "swr/immutable";
2 |
3 | import React from "react";
4 |
5 | import { toast } from "sonner";
6 | import { request } from "src/utils/request";
7 | import { swrFetcher } from "src/utils/swr";
8 |
9 | export function useOnchainAddress() {
10 | // Use useSWRImmutable to avoid address randomly changing after deposit (e.g. on page re-focus on the channel order page)
11 | const swr = useSWRImmutable("/api/wallet/address", swrFetcher, {
12 | revalidateOnMount: true,
13 | });
14 | const [isLoading, setLoading] = React.useState(false);
15 |
16 | const getNewAddress = React.useCallback(async () => {
17 | setLoading(true);
18 | try {
19 | const address = await request("/api/wallet/new-address", {
20 | method: "POST",
21 | headers: {
22 | "Content-Type": "application/json",
23 | },
24 | });
25 | if (!address) {
26 | throw new Error("No address in response");
27 | }
28 | swr.mutate(address, false);
29 | return address;
30 | } catch (error) {
31 | toast.error("Failed to request a new address", {
32 | description: "" + error,
33 | });
34 | } finally {
35 | setLoading(false);
36 | }
37 | }, [swr]);
38 |
39 | return React.useMemo(
40 | () => ({
41 | ...swr,
42 | getNewAddress,
43 | loadingAddress: isLoading || !swr.data,
44 | }),
45 | [swr, getNewAddress, isLoading]
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useOnchainTransactions.ts:
--------------------------------------------------------------------------------
1 | import { OnchainTransaction } from "src/types";
2 | import { swrFetcher } from "src/utils/swr";
3 | import useSWR, { SWRConfiguration } from "swr";
4 |
5 | const pollConfiguration: SWRConfiguration = {
6 | refreshInterval: 30000,
7 | };
8 |
9 | export function useOnchainTransactions() {
10 | return useSWR(
11 | "/api/node/transactions",
12 | swrFetcher,
13 | pollConfiguration
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/frontend/src/hooks/usePeers.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { Peer } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | const pollConfiguration: SWRConfiguration = {
7 | refreshInterval: 3000,
8 | };
9 |
10 | export function usePeers(poll = false) {
11 | return useSWR(
12 | "/api/peers",
13 | swrFetcher,
14 | poll ? pollConfiguration : undefined
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useRemoveSuccessfulChannelOrder.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useLocation } from "react-router-dom";
3 | import useChannelOrderStore from "src/state/ChannelOrderStore";
4 |
5 | export function useRemoveSuccessfulChannelOrder() {
6 | const location = useLocation();
7 | const [prevLocation, setPrevLocation] = React.useState(location.pathname);
8 |
9 | React.useEffect(() => {
10 | if (
11 | location.pathname != "/channels/order" &&
12 | prevLocation === "/channels/order" &&
13 | useChannelOrderStore.getState().order?.status === "success"
14 | ) {
15 | useChannelOrderStore.getState().removeOrder();
16 | }
17 | setPrevLocation(location.pathname);
18 | }, [location.pathname, prevLocation]);
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useSwaps.tsx:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { AutoSwapConfig, Swap, SwapInfo } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | export function useAutoSwapsConfig() {
7 | return useSWR("/api/autoswap", swrFetcher);
8 | }
9 |
10 | export function useSwapInfo(direction: "in" | "out") {
11 | return useSWR(`/api/swaps/${direction}/info`, swrFetcher);
12 | }
13 |
14 | const pollConfiguration: SWRConfiguration = {
15 | refreshInterval: 3000,
16 | };
17 |
18 | export function useSwap(swapId: string | undefined, poll = false) {
19 | return useSWR(
20 | swapId && `/api/swaps/${swapId}`,
21 | swrFetcher,
22 | poll ? pollConfiguration : undefined
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useSyncWallet.ts:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { request } from "src/utils/request";
4 |
5 | export function useSyncWallet() {
6 | const REQUEST_WALLET_SYNC_INTERVAL = 30_000; // request a wallet sync every 30s (NOTE: it won't actually sync this often)
7 |
8 | React.useEffect(() => {
9 | const intervalId = setInterval(async () => {
10 | try {
11 | await request("/api/wallet/sync", {
12 | method: "POST",
13 | headers: {
14 | "Content-Type": "application/json",
15 | },
16 | });
17 | } catch (error) {
18 | console.error("failed to request wallet sync", error);
19 | }
20 | }, REQUEST_WALLET_SYNC_INTERVAL);
21 |
22 | return () => {
23 | clearInterval(intervalId);
24 | };
25 | }, []);
26 |
27 | return null;
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useTransaction.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { Transaction } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | const pollConfiguration: SWRConfiguration = {
7 | refreshInterval: 3000,
8 | };
9 |
10 | export function useTransaction(paymentHash: string, poll = false) {
11 | return useSWR(
12 | paymentHash && `/api/transactions/${paymentHash}`,
13 | swrFetcher,
14 | poll ? pollConfiguration : undefined
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useTransactions.ts:
--------------------------------------------------------------------------------
1 | import useSWR, { SWRConfiguration } from "swr";
2 |
3 | import { ListTransactionsResponse } from "src/types";
4 | import { swrFetcher } from "src/utils/swr";
5 |
6 | const pollConfiguration: SWRConfiguration = {
7 | refreshInterval: 3000,
8 | };
9 |
10 | export function useTransactions(
11 | appId?: number,
12 | poll = false,
13 | limit = 100,
14 | page = 1
15 | ) {
16 | const offset = (page - 1) * limit;
17 | let url = `/api/transactions?limit=${limit}&offset=${offset}`;
18 | if (appId) {
19 | url += `&appId=${appId}`;
20 | }
21 | return useSWR(
22 | url,
23 | swrFetcher,
24 | poll ? pollConfiguration : undefined
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/hooks/useUnusedApps.ts:
--------------------------------------------------------------------------------
1 | import { useApps } from "src/hooks/useApps";
2 |
3 | export function useUnusedApps(limit?: number) {
4 | const { data: unusedAppsData } = useApps(
5 | limit,
6 | undefined,
7 | {
8 | unused: true,
9 | subWallets: false,
10 | },
11 | undefined
12 | );
13 | return unusedAppsData?.apps;
14 | }
15 |
--------------------------------------------------------------------------------
/frontend/src/lib/auth.ts:
--------------------------------------------------------------------------------
1 | import { localStorageKeys } from "src/constants";
2 |
3 | export function getAuthToken() {
4 | return localStorage.getItem(localStorageKeys.authToken);
5 | }
6 |
7 | export function saveAuthToken(token: string) {
8 | localStorage.setItem(localStorageKeys.authToken, token);
9 | }
10 |
11 | export function deleteAuthToken() {
12 | localStorage.removeItem(localStorageKeys.authToken);
13 | }
14 |
--------------------------------------------------------------------------------
/frontend/src/lib/backendType.ts:
--------------------------------------------------------------------------------
1 | import { BackendType } from "src/types";
2 |
3 | type BackendTypeConfig = {
4 | hasMnemonic: boolean;
5 | hasChannelManagement: boolean;
6 | hasNodeBackup: boolean;
7 | };
8 |
9 | export const backendTypeConfigs: Record = {
10 | LND: {
11 | hasMnemonic: false,
12 | hasChannelManagement: true,
13 | hasNodeBackup: false,
14 | },
15 | LDK: {
16 | hasMnemonic: true,
17 | hasChannelManagement: true,
18 | hasNodeBackup: true,
19 | },
20 | PHOENIX: {
21 | hasMnemonic: false,
22 | hasChannelManagement: false,
23 | hasNodeBackup: false,
24 | },
25 | CASHU: {
26 | hasMnemonic: true,
27 | hasChannelManagement: false,
28 | hasNodeBackup: false,
29 | },
30 | };
31 |
--------------------------------------------------------------------------------
/frontend/src/lib/clipboard.ts:
--------------------------------------------------------------------------------
1 | import { toast } from "sonner";
2 |
3 | export async function copyToClipboard(content: string) {
4 | const copyPromise = new Promise((resolve, reject) => {
5 | if (navigator.clipboard && window.isSecureContext) {
6 | navigator.clipboard.writeText(content).then(resolve).catch(reject);
7 | } else {
8 | // Fallback for older browsers
9 | const textArea = document.createElement("textarea");
10 | textArea.value = content;
11 | textArea.style.position = "absolute";
12 | textArea.style.opacity = "0";
13 | document.body.appendChild(textArea);
14 | textArea.focus();
15 | textArea.select();
16 |
17 | if (document.execCommand("copy")) {
18 | resolve(content);
19 | } else {
20 | reject();
21 | }
22 |
23 | textArea.remove();
24 | }
25 | });
26 |
27 | try {
28 | await copyPromise;
29 | toast.success("Copied to clipboard");
30 | } catch (e) {
31 | toast.error("Failed to copy to clipboard");
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/frontend/src/main.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "src/App.tsx";
4 | import "src/fonts.css";
5 | import "src/index.css";
6 | import { isHttpMode } from "src/utils/isHttpMode";
7 |
8 | // redirect hash router links to browser router links
9 | // TODO: remove after 2026-01-01
10 | if (isHttpMode() && window.location.href.indexOf("/#/") > -1) {
11 | window.location.href = window.location.href.replace("/#/", "/");
12 | } else {
13 | ReactDOM.createRoot(document.getElementById("root")!).render(
14 |
15 |
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/frontend/src/requests/createApp.ts:
--------------------------------------------------------------------------------
1 | import { CreateAppRequest, CreateAppResponse } from "src/types";
2 | import { request } from "src/utils/request";
3 |
4 | export async function createApp(
5 | createAppRequest: CreateAppRequest
6 | ): Promise {
7 | const createAppResponse = await request("/api/apps", {
8 | method: "POST",
9 | headers: {
10 | "Content-Type": "application/json",
11 | },
12 | body: JSON.stringify(createAppRequest),
13 | });
14 |
15 | if (!createAppResponse) {
16 | throw new Error("no create app response received");
17 | }
18 | return createAppResponse;
19 | }
20 |
--------------------------------------------------------------------------------
/frontend/src/screens/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import { SearchXIcon } from "lucide-react";
2 | import { Link } from "react-router-dom";
3 | import { Button } from "src/components/ui/button";
4 | import {
5 | Card,
6 | CardContent,
7 | CardDescription,
8 | CardHeader,
9 | CardTitle,
10 | } from "src/components/ui/card";
11 |
12 | function NotFound() {
13 | return (
14 |
15 |
16 |
17 |
18 |
19 | Page Not Found
20 |
21 |
22 |
23 | The page you are looking for does not exist.
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 |
35 | export default NotFound;
36 |
--------------------------------------------------------------------------------
/frontend/src/screens/alby/AlbyAuthRedirect.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useLocation } from "react-router-dom";
3 | import AuthCodeForm from "src/components/AuthCodeForm";
4 |
5 | import Loading from "src/components/Loading";
6 | import { useInfo } from "src/hooks/useInfo";
7 |
8 | export default function AlbyAuthRedirect() {
9 | const { data: info } = useInfo();
10 | const location = useLocation();
11 | const queryParams = new URLSearchParams(location.search);
12 | const forceLogin = !!queryParams.get("force_login");
13 | const url = info?.albyAuthUrl
14 | ? (() => {
15 | const _url = new URL(info.albyAuthUrl);
16 | if (forceLogin) {
17 | _url.searchParams.append("force_login", "true");
18 | }
19 | if (info.albyUserIdentifier) {
20 | _url.searchParams.append("identifier", info.albyUserIdentifier);
21 | }
22 |
23 | return _url.toString();
24 | })()
25 | : undefined;
26 |
27 | React.useEffect(() => {
28 | if (!info || !url) {
29 | return;
30 | }
31 | if (info.oauthRedirect) {
32 | window.location.href = url;
33 | }
34 | }, [info, url]);
35 |
36 | return !info || info.oauthRedirect || !url ? (
37 |
38 | ) : (
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/frontend/src/screens/channels/auto/OpenedAutoChannel.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import ExternalLink from "src/components/ExternalLink";
3 | import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
4 | import { Button } from "src/components/ui/button";
5 |
6 | export function OpenedAutoChannel() {
7 | return (
8 |
9 |
13 |
14 |
15 | Congratulations! Your lightning channel is active and can be used to
16 | send and receive payments.
17 |
18 |
19 | To ensure you can both send and receive, make sure to balance your{" "}
20 |
24 | channel's liquidity
25 |
26 | .
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/src/screens/channels/auto/OpeningAutoChannel.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useLocation, useNavigate } from "react-router-dom";
3 | import { ChannelWaitingForConfirmations } from "src/components/channels/ChannelWaitingForConfirmations";
4 | import { useChannels } from "src/hooks/useChannels";
5 | import { useSyncWallet } from "src/hooks/useSyncWallet";
6 |
7 | export function OpeningAutoChannel() {
8 | useSyncWallet();
9 | const { data: channels } = useChannels(true);
10 | const navigate = useNavigate();
11 |
12 | const { state } = useLocation();
13 | const newChannelId = state?.newChannelId as string | undefined;
14 |
15 | const channel = channels?.find(
16 | (channel) => channel.id && channel.id === newChannelId
17 | );
18 |
19 | React.useEffect(() => {
20 | if (channel?.active) {
21 | navigate("/channels/auto/opened");
22 | }
23 | }, [channel, navigate]);
24 |
25 | return ;
26 | }
27 |
--------------------------------------------------------------------------------
/frontend/src/screens/channels/first/OpenedFirstChannel.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 | import ExternalLink from "src/components/ExternalLink";
3 | import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
4 | import { Button } from "src/components/ui/button";
5 |
6 | export function OpenedFirstChannel() {
7 | return (
8 |
9 |
13 |
14 |
15 | Congratulations! Your first lightning channel is active and can be used
16 | to send and receive payments.
17 |
18 |
19 | To ensure you can both send and receive, make sure to balance your{" "}
20 |
24 | channel's liquidity
25 |
26 | .
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
--------------------------------------------------------------------------------
/frontend/src/screens/channels/first/OpeningFirstChannel.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useNavigate } from "react-router-dom";
3 | import { ChannelWaitingForConfirmations } from "src/components/channels/ChannelWaitingForConfirmations";
4 | import { useChannels } from "src/hooks/useChannels";
5 | import { useSyncWallet } from "src/hooks/useSyncWallet";
6 |
7 | export function OpeningFirstChannel() {
8 | useSyncWallet();
9 | const { data: channels } = useChannels(true);
10 | const navigate = useNavigate();
11 |
12 | const firstChannel = channels?.[0];
13 |
14 | React.useEffect(() => {
15 | if (firstChannel?.active) {
16 | navigate("/channels/first/opened");
17 | }
18 | }, [firstChannel, navigate]);
19 |
20 | return ;
21 | }
22 |
--------------------------------------------------------------------------------
/frontend/src/screens/setup/SetupAdvanced.tsx:
--------------------------------------------------------------------------------
1 | import { Link } from "react-router-dom";
2 |
3 | import Container from "src/components/Container";
4 | import TwoColumnLayoutHeader from "src/components/TwoColumnLayoutHeader";
5 | import { Button } from "src/components/ui/button";
6 |
7 | export function SetupAdvanced() {
8 | return (
9 | <>
10 |
11 |
12 |
16 |
17 |
18 |
21 |
22 |
23 |
26 |
27 |
28 |
31 |
32 |
33 |
34 |
35 | >
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/frontend/src/screens/setup/node/LDKForm.tsx:
--------------------------------------------------------------------------------
1 | import { wordlist } from "@scure/bip39/wordlists/english";
2 | import { useEffect } from "react";
3 | import { useNavigate, useSearchParams } from "react-router-dom";
4 | import useSetupStore from "src/state/SetupStore";
5 |
6 | import * as bip39 from "@scure/bip39";
7 | import Loading from "src/components/Loading";
8 |
9 | export function LDKForm() {
10 | const navigate = useNavigate();
11 | const [searchParams] = useSearchParams();
12 |
13 | // No configuration needed, automatically proceed with the next step
14 | useEffect(() => {
15 | // only generate a mnemonic if one is not already imported
16 | if (!useSetupStore.getState().nodeInfo.mnemonic) {
17 | useSetupStore.getState().updateNodeInfo({
18 | mnemonic: bip39.generateMnemonic(wordlist, 128),
19 | });
20 | }
21 | useSetupStore.getState().updateNodeInfo({
22 | backendType: "LDK",
23 | });
24 | navigate("/setup/security");
25 | }, [navigate, searchParams]);
26 |
27 | return ;
28 | }
29 |
--------------------------------------------------------------------------------
/frontend/src/screens/setup/node/PresetNodeForm.tsx:
--------------------------------------------------------------------------------
1 | import { wordlist } from "@scure/bip39/wordlists/english";
2 | import { useEffect } from "react";
3 | import { useNavigate, useSearchParams } from "react-router-dom";
4 | import useSetupStore from "src/state/SetupStore";
5 |
6 | import * as bip39 from "@scure/bip39";
7 | import Loading from "src/components/Loading";
8 | import { useInfo } from "src/hooks/useInfo";
9 | import { backendTypeConfigs } from "src/lib/backendType";
10 |
11 | export function PresetNodeForm() {
12 | const navigate = useNavigate();
13 | const [searchParams] = useSearchParams();
14 | const { data: info } = useInfo();
15 |
16 | // No configuration needed, automatically proceed with the next step
17 | useEffect(() => {
18 | if (!info) {
19 | return;
20 | }
21 | if (backendTypeConfigs[info.backendType].hasMnemonic) {
22 | useSetupStore.getState().updateNodeInfo({
23 | mnemonic: bip39.generateMnemonic(wordlist, 128),
24 | });
25 | }
26 |
27 | navigate("/setup/security");
28 | }, [info, navigate, searchParams]);
29 |
30 | return ;
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/src/state/ChannelOrderStore.ts:
--------------------------------------------------------------------------------
1 | import { localStorageKeys } from "src/constants";
2 | import { NewChannelOrder } from "src/types";
3 | import { create } from "zustand";
4 |
5 | interface ChannelOrderStore {
6 | readonly order: NewChannelOrder | undefined;
7 | setOrder(order: NewChannelOrder): void;
8 | removeOrder(): void;
9 | updateOrder(order: Partial): void;
10 | }
11 |
12 | const savedOrderJSON = localStorage.getItem(localStorageKeys.channelOrder);
13 | const useChannelOrderStore = create((set, get) => ({
14 | order: savedOrderJSON && JSON.parse(savedOrderJSON),
15 | removeOrder() {
16 | localStorage.removeItem(localStorageKeys.channelOrder);
17 | set({ order: undefined });
18 | },
19 | updateOrder: (order) => {
20 | get().setOrder({
21 | ...get().order,
22 | ...order,
23 | } as NewChannelOrder);
24 | },
25 | setOrder: (order) => {
26 | set({ order });
27 | localStorage.setItem(localStorageKeys.channelOrder, JSON.stringify(order));
28 | },
29 | }));
30 |
31 | export default useChannelOrderStore;
32 |
--------------------------------------------------------------------------------
/frontend/src/state/SetupStore.ts:
--------------------------------------------------------------------------------
1 | import { SetupNodeInfo } from "src/types";
2 | import { create } from "zustand";
3 |
4 | interface SetupStore {
5 | readonly nodeInfo: SetupNodeInfo;
6 | readonly unlockPassword: string;
7 | updateNodeInfo(nodeInfo: SetupNodeInfo): void;
8 | setUnlockPassword(unlockPassword: string): void;
9 | }
10 |
11 | const useSetupStore = create((set) => ({
12 | nodeInfo: {},
13 | unlockPassword: "",
14 | updateNodeInfo: (nodeInfo) =>
15 | set((state) => ({
16 | nodeInfo: { ...state.nodeInfo, ...nodeInfo },
17 | })),
18 | setUnlockPassword: (unlockPassword) => set({ unlockPassword }),
19 | }));
20 |
21 | export default useSetupStore;
22 |
--------------------------------------------------------------------------------
/frontend/src/themes/alby.css:
--------------------------------------------------------------------------------
1 | .theme-alby {
2 | --primary: hsl(47 100% 72%);
3 | --primary-foreground: hsl(0 0% 2%);
4 |
5 | --secondary: hsl(0 0% 96%);
6 | --secondary-foreground: hsl(0 0% 5%);
7 |
8 | --muted: hsl(0 0% 96%);
9 | --muted-foreground: hsl(0 0% 45%);
10 |
11 | --accent: hsl(0 0% 96%);
12 | --accent-foreground: hsl(0 0% 5%);
13 |
14 | --destructive: hsl(0 84% 60%);
15 | --destructive-foreground: hsl(0 0% 98%);
16 |
17 | --border: hsl(0 0% 92%);
18 | --input: hsl(0 0% 85%);
19 | --ring: hsl(0 0% 76%);
20 |
21 | --radius: 0.5rem;
22 | }
23 |
24 | .theme-alby.dark {
25 | --primary: hsl(47 100% 72%);
26 | --primary-foreground: hsl(0 0% 2%);
27 |
28 | --secondary: hsl(0 0% 0%);
29 | --secondary-foreground: hsl(0 0% 98%);
30 |
31 | --muted: hsl(0 0% 10%);
32 | --muted-foreground: hsl(0 0% 49%);
33 |
34 | --accent: hsl(0 0% 0%);
35 | --accent-foreground: hsl(0 0% 98%);
36 |
37 | --destructive: hsl(0 84% 60%);
38 | --destructive-foreground: hsl(0 62.8% 30.6%);
39 |
40 | --border: hsl(0 0% 15%);
41 | --input: hsl(0 0% 15%);
42 | --ring: hsl(47 100% 40%);
43 | }
44 |
--------------------------------------------------------------------------------
/frontend/src/utils/handleRequestError.ts:
--------------------------------------------------------------------------------
1 | import { toast } from "sonner";
2 |
3 | export function handleRequestError(message: string, error: unknown) {
4 | console.error(message, error);
5 | toast.error(message, {
6 | description: isErrorWithMessage(error) ? error.message : undefined,
7 | });
8 | }
9 | type ErrorWithMessage = {
10 | message: string;
11 | };
12 |
13 | function isErrorWithMessage(error: unknown): error is ErrorWithMessage {
14 | return (
15 | typeof error === "object" &&
16 | error !== null &&
17 | "message" in error &&
18 | typeof (error as Record).message === "string"
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/frontend/src/utils/isHttpMode.ts:
--------------------------------------------------------------------------------
1 | export const isHttpMode = () => {
2 | return (
3 | window.location.protocol.startsWith("http") &&
4 | !window.location.hostname.startsWith("wails")
5 | );
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/src/utils/swr.ts:
--------------------------------------------------------------------------------
1 | import { request } from "./request";
2 |
3 | export const swrFetcher = async (...args: Parameters) => {
4 | // eslint-disable-next-line @typescript-eslint/no-explicit-any
5 | return request(...args) as any;
6 | };
7 |
--------------------------------------------------------------------------------
/frontend/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2020",
4 | "useDefineForClassFields": true,
5 | "lib": ["ES2021", "DOM", "DOM.Iterable", "WebWorker"],
6 | "module": "ESNext",
7 | "skipLibCheck": true,
8 |
9 | "baseUrl": ".",
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "noFallthroughCasesInSwitch": true,
24 |
25 | "paths": {
26 | "react": ["./node_modules/@types/react"]
27 | }
28 | },
29 | "include": ["src", "types"],
30 | "references": [{ "path": "./tsconfig.node.json" }]
31 | }
32 |
--------------------------------------------------------------------------------
/frontend/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "skipLibCheck": true,
5 | "module": "ESNext",
6 | "moduleResolution": "bundler",
7 | "allowSyntheticDefaultImports": true
8 | },
9 | "include": ["vite.config.ts"]
10 | }
11 |
--------------------------------------------------------------------------------
/frontend/types/argon2-wasm-esm.d.ts:
--------------------------------------------------------------------------------
1 | declare module "argon2-wasm-esm" {
2 | export interface Argon2Options {
3 | pass: string;
4 | salt: Uint8Array;
5 | time: number;
6 | mem: number;
7 | hashLen: number;
8 | parallelism: number;
9 | type: number;
10 | }
11 |
12 | export interface Argon2Result {
13 | hash: Uint8Array;
14 | hashHex: string;
15 | encoded: string;
16 | }
17 |
18 | export function hash(options: Argon2Options): Promise;
19 | }
20 |
--------------------------------------------------------------------------------
/http/models.go:
--------------------------------------------------------------------------------
1 | package http
2 |
3 | type ErrorResponse struct {
4 | Message string `json:"message"`
5 | }
6 |
--------------------------------------------------------------------------------
/lnclient/ldk/ldk_logger.go:
--------------------------------------------------------------------------------
1 | package ldk
2 |
3 | import (
4 | // "github.com/getAlby/hub/ldk_node"
5 | "github.com/getAlby/hub/logger"
6 |
7 | "github.com/getAlby/ldk-node-go/ldk_node"
8 | "github.com/sirupsen/logrus"
9 | )
10 |
11 | type ldkLogger struct {
12 | logLevel ldk_node.LogLevel
13 | }
14 |
15 | func NewLDKLogger(logLevel ldk_node.LogLevel) ldk_node.LogWriter {
16 | return &ldkLogger{
17 | logLevel: logLevel,
18 | }
19 | }
20 |
21 | func (ldkLogger *ldkLogger) Log(record ldk_node.LogRecord) {
22 | if record.Level >= ldkLogger.logLevel {
23 | logger.Logger.WithFields(logrus.Fields{
24 | "log_type": "LDK-node",
25 | "line": record.Line,
26 | "module_path": record.ModulePath,
27 | }).Log(mapLogLevel(record.Level), record.Args)
28 | }
29 | }
30 |
31 | func mapLogLevel(logLevel ldk_node.LogLevel) logrus.Level {
32 | switch logLevel {
33 | case ldk_node.LogLevelGossip:
34 | return logrus.TraceLevel
35 | case ldk_node.LogLevelTrace:
36 | return logrus.TraceLevel
37 | case ldk_node.LogLevelDebug:
38 | return logrus.DebugLevel
39 | case ldk_node.LogLevelInfo:
40 | return logrus.InfoLevel
41 | case ldk_node.LogLevelWarn:
42 | return logrus.WarnLevel
43 | case ldk_node.LogLevelError:
44 | return logrus.ErrorLevel
45 | }
46 | logger.Logger.WithField("log_level", logLevel).Error("Unknown LDK log level")
47 | return logrus.ErrorLevel
48 | }
49 |
--------------------------------------------------------------------------------
/lnclient/ldk/ldk_test.go:
--------------------------------------------------------------------------------
1 | package ldk
2 |
3 | import (
4 | "testing"
5 |
6 | "github.com/stretchr/testify/assert"
7 | "github.com/stretchr/testify/require"
8 |
9 | "github.com/getAlby/hub/tests"
10 | )
11 |
12 | func TestGetVssNodeIdentifier(t *testing.T) {
13 | mnemonic := "thought turkey ask pottery head say catalog desk pledge elbow naive mimic"
14 | expectedVssNodeIdentifier := "751636"
15 |
16 | svc, err := tests.CreateTestServiceWithMnemonic(t, mnemonic, "123")
17 | require.NoError(t, err)
18 | defer svc.Remove()
19 |
20 | vssNodeIdentifier, err := GetVssNodeIdentifier(svc.Keys)
21 | require.NoError(t, err)
22 |
23 | assert.Equal(t, expectedVssNodeIdentifier, vssNodeIdentifier)
24 | }
25 | func TestGetVssNodeIdentifier2(t *testing.T) {
26 | mnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
27 | expectedVssNodeIdentifier := "770256"
28 |
29 | svc, err := tests.CreateTestServiceWithMnemonic(t, mnemonic, "123")
30 | require.NoError(t, err)
31 | defer svc.Remove()
32 |
33 | vssNodeIdentifier, err := GetVssNodeIdentifier(svc.Keys)
34 | require.NoError(t, err)
35 |
36 | assert.Equal(t, expectedVssNodeIdentifier, vssNodeIdentifier)
37 | }
38 |
--------------------------------------------------------------------------------
/logger/logger.go:
--------------------------------------------------------------------------------
1 | package logger
2 |
3 | import (
4 | "os"
5 | "path/filepath"
6 | "strconv"
7 |
8 | "github.com/orandin/lumberjackrus"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | const (
13 | logDir = "log"
14 | logFilename = "nwc.log"
15 | )
16 |
17 | var Logger *logrus.Logger
18 | var logFilePath string
19 |
20 | func Init(logLevel string) {
21 | Logger = logrus.New()
22 | Logger.SetFormatter(&logrus.JSONFormatter{})
23 | Logger.SetOutput(os.Stdout)
24 | logrusLogLevel, err := strconv.Atoi(logLevel)
25 | if err != nil {
26 | logrusLogLevel = int(logrus.InfoLevel)
27 | }
28 | Logger.SetLevel(logrus.Level(logrusLogLevel))
29 | if logrusLogLevel >= int(logrus.DebugLevel) {
30 | Logger.ReportCaller = true
31 | Logger.Debug("Logrus report caller enabled in debug mode")
32 | }
33 | }
34 |
35 | func AddFileLogger(workdir string) error {
36 | logFilePath = filepath.Join(workdir, logDir, logFilename)
37 | fileLoggerHook, err := lumberjackrus.NewHook(
38 | &lumberjackrus.LogFile{
39 | Filename: logFilePath,
40 | MaxAge: 3,
41 | MaxBackups: 3,
42 | },
43 | logrus.InfoLevel,
44 | &logrus.JSONFormatter{},
45 | nil,
46 | )
47 | if err != nil {
48 | return err
49 | }
50 | Logger.AddHook(fileLoggerHook)
51 | return nil
52 | }
53 |
54 | func GetLogFilePath() string {
55 | return logFilePath
56 | }
57 |
--------------------------------------------------------------------------------
/lsp/models.go:
--------------------------------------------------------------------------------
1 | package lsp
2 |
3 | type LSP struct {
4 | Pubkey string
5 | }
6 |
7 | const (
8 | LSP_TYPE_LSPS1 = "LSPS1"
9 | )
10 |
11 | func OlympusMutinynetLSP() LSP {
12 | lsp := LSP{
13 | Pubkey: "032ae843e4d7d177f151d021ac8044b0636ec72b1ce3ffcde5c04748db2517ab03",
14 | }
15 | return lsp
16 | }
17 |
18 | func OlympusLSP() LSP {
19 | lsp := LSP{
20 | Pubkey: "031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581",
21 | }
22 | return lsp
23 | }
24 |
25 | func MegalithMutinynetLSP() LSP {
26 | lsp := LSP{
27 | Pubkey: "03e30fda71887a916ef5548a4d02b06fe04aaa1a8de9e24134ce7f139cf79d7579",
28 | }
29 | return lsp
30 | }
31 |
32 | func MegalithLSP() LSP {
33 | lsp := LSP{
34 | Pubkey: "038a9e56512ec98da2b5789761f7af8f280baf98a09282360cd6ff1381b5e889bf",
35 | }
36 | return lsp
37 | }
38 |
--------------------------------------------------------------------------------
/main_wails.go:
--------------------------------------------------------------------------------
1 | //go:build wails
2 | // +build wails
3 |
4 | package main
5 |
6 | import (
7 | "context"
8 | "embed"
9 | "net"
10 |
11 | "github.com/getAlby/hub/logger"
12 | "github.com/getAlby/hub/service"
13 | "github.com/getAlby/hub/wails"
14 | log "github.com/sirupsen/logrus"
15 | )
16 |
17 | //go:embed all:frontend/dist
18 | var assets embed.FS
19 |
20 | //go:embed appicon.png
21 | var appIcon []byte
22 |
23 | func main() {
24 | // Get a port lock on a rare port to prevent the app running twice
25 | listener, err := net.Listen("tcp", "0.0.0.0:21420")
26 | if err != nil {
27 | log.Println("Another instance of Alby Hub is already running.")
28 | return
29 | }
30 | defer listener.Close()
31 |
32 | log.Info("Alby Hub starting in WAILS mode")
33 | ctx, cancel := context.WithCancel(context.Background())
34 | svc, err := service.NewService(ctx)
35 | if err != nil {
36 | log.WithError(err).Fatal("Failed to create service")
37 | return
38 | }
39 |
40 | app := wails.NewApp(svc)
41 | wails.LaunchWailsApp(app, assets, appIcon)
42 | logger.Logger.Info("Wails app exited")
43 |
44 | logger.Logger.Info("Cancelling service context...")
45 | // cancel the service context
46 | cancel()
47 | svc.Shutdown()
48 | logger.Logger.Info("Service exited")
49 | logger.Logger.Info("Alby Hub needs to stay online to send and receive transactions. Channels may be closed if your hub stays offline for an extended period of time.")
50 | }
51 |
--------------------------------------------------------------------------------
/nip47/cipher/cipher_test.go:
--------------------------------------------------------------------------------
1 | package cipher
2 |
3 | import (
4 | "fmt"
5 | "testing"
6 |
7 | "github.com/getAlby/hub/constants"
8 | "github.com/nbd-wtf/go-nostr"
9 |
10 | "github.com/stretchr/testify/assert"
11 | )
12 |
13 | func TestCipher(t *testing.T) {
14 | doTestCipher(t, constants.ENCRYPTION_TYPE_NIP04)
15 | doTestCipher(t, constants.ENCRYPTION_TYPE_NIP44_V2)
16 | }
17 |
18 | func doTestCipher(t *testing.T, encryption string) {
19 | reqPrivateKey := nostr.GeneratePrivateKey()
20 | reqPubkey, err := nostr.GetPublicKey(reqPrivateKey)
21 |
22 | nip47Cipher, err := NewNip47Cipher(encryption, reqPubkey, reqPrivateKey)
23 | assert.NoError(t, err)
24 |
25 | payload := "test payload"
26 | msg, err := nip47Cipher.Encrypt(payload)
27 | assert.NoError(t, err)
28 |
29 | decrypted, err := nip47Cipher.Decrypt(msg)
30 | assert.Equal(t, payload, decrypted)
31 | }
32 |
33 | func TestCipher_UnsupportedEncrptions(t *testing.T) {
34 | doTestCipher_UnsupportedEncrptions(t, "nip44")
35 | doTestCipher_UnsupportedEncrptions(t, "nip44_v0")
36 | doTestCipher_UnsupportedEncrptions(t, "nip44_v1")
37 | doTestCipher_UnsupportedEncrptions(t, "nip44v2")
38 | doTestCipher_UnsupportedEncrptions(t, "nip-44")
39 | }
40 |
41 | func doTestCipher_UnsupportedEncrptions(t *testing.T, encryption string) {
42 | reqPrivateKey := nostr.GeneratePrivateKey()
43 | reqPubkey, err := nostr.GetPublicKey(reqPrivateKey)
44 |
45 | _, err = NewNip47Cipher(encryption, reqPubkey, reqPrivateKey)
46 | assert.Error(t, err)
47 | assert.Equal(t, fmt.Sprintf("invalid encryption: %s", encryption), err.Error())
48 | }
49 |
--------------------------------------------------------------------------------
/nip47/controllers/controllers_test.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "github.com/getAlby/hub/alby"
5 | "github.com/getAlby/hub/nip47/permissions"
6 | "github.com/getAlby/hub/tests"
7 | "github.com/getAlby/hub/transactions"
8 | )
9 |
10 | func NewTestNip47Controller(svc *tests.TestService) *nip47Controller {
11 | permissionsSvc := permissions.NewPermissionsService(svc.DB, svc.EventPublisher)
12 | transactionsSvc := transactions.NewTransactionsService(svc.DB, svc.EventPublisher)
13 | albyOAuthSvc := alby.NewAlbyOAuthService(svc.DB, svc.Cfg, svc.Keys, svc.EventPublisher)
14 | return NewNip47Controller(svc.LNClient, svc.DB, svc.EventPublisher, permissionsSvc, transactionsSvc, svc.AppsService, albyOAuthSvc)
15 | }
16 |
--------------------------------------------------------------------------------
/nip47/controllers/decode_request.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "encoding/json"
5 |
6 | "github.com/getAlby/hub/constants"
7 | "github.com/getAlby/hub/logger"
8 | "github.com/getAlby/hub/nip47/models"
9 | "github.com/sirupsen/logrus"
10 | )
11 |
12 | func decodeRequest(request *models.Request, methodParams interface{}) *models.Response {
13 | err := json.Unmarshal(request.Params, methodParams)
14 | if err != nil {
15 | logger.Logger.WithFields(logrus.Fields{
16 | "request": request,
17 | }).WithError(err).Error("Failed to decode NIP-47 request")
18 | return &models.Response{
19 | ResultType: request.Method,
20 | Error: &models.Error{
21 | Code: constants.ERROR_BAD_REQUEST,
22 | Message: err.Error(),
23 | }}
24 | }
25 | return nil
26 | }
27 |
--------------------------------------------------------------------------------
/nip47/controllers/map_nip47_error.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "errors"
5 |
6 | "github.com/getAlby/hub/constants"
7 | "github.com/getAlby/hub/nip47/models"
8 | "github.com/getAlby/hub/transactions"
9 | )
10 |
11 | func mapNip47Error(err error) *models.Error {
12 | code := constants.ERROR_INTERNAL
13 | if errors.Is(err, transactions.NewNotFoundError()) {
14 | code = constants.ERROR_NOT_FOUND
15 | }
16 | if errors.Is(err, transactions.NewInsufficientBalanceError()) {
17 | code = constants.ERROR_INSUFFICIENT_BALANCE
18 | }
19 | if errors.Is(err, transactions.NewQuotaExceededError()) {
20 | code = constants.ERROR_QUOTA_EXCEEDED
21 | }
22 |
23 | return &models.Error{
24 | Code: code,
25 | Message: err.Error(),
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/nip47/controllers/models.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "github.com/getAlby/hub/nip47/models"
5 | "github.com/nbd-wtf/go-nostr"
6 | )
7 |
8 | type publishFunc = func(*models.Response, nostr.Tags)
9 |
10 | type payResponse struct {
11 | Preimage string `json:"preimage"`
12 | FeesPaid uint64 `json:"fees_paid"`
13 | }
14 |
--------------------------------------------------------------------------------
/nip47/controllers/multi_pay_keysend_controller.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "context"
5 | "sync"
6 |
7 | "github.com/getAlby/hub/db"
8 | "github.com/getAlby/hub/nip47/models"
9 | "github.com/nbd-wtf/go-nostr"
10 | )
11 |
12 | type multiPayKeysendParams struct {
13 | Keysends []multiPayKeysendElement `json:"keysends"`
14 | }
15 |
16 | type multiPayKeysendElement struct {
17 | payKeysendParams
18 | Id string `json:"id"`
19 | }
20 |
21 | func (controller *nip47Controller) HandleMultiPayKeysendEvent(ctx context.Context, nip47Request *models.Request, requestEventId uint, app *db.App, publishResponse publishFunc) {
22 | multiPayParams := &multiPayKeysendParams{}
23 | resp := decodeRequest(nip47Request, multiPayParams)
24 | if resp != nil {
25 | publishResponse(resp, nostr.Tags{})
26 | return
27 | }
28 |
29 | var wg sync.WaitGroup
30 | wg.Add(len(multiPayParams.Keysends))
31 | for _, keysendInfo := range multiPayParams.Keysends {
32 | go func(keysendInfo multiPayKeysendElement) {
33 | defer wg.Done()
34 |
35 | keysendDTagValue := keysendInfo.Id
36 | if keysendDTagValue == "" {
37 | keysendDTagValue = keysendInfo.Pubkey
38 | }
39 | dTag := []string{"d", keysendDTagValue}
40 |
41 | controller.
42 | payKeysend(ctx, &keysendInfo.payKeysendParams, nip47Request, requestEventId, app, publishResponse, nostr.Tags{dTag})
43 | }(keysendInfo)
44 | }
45 |
46 | wg.Wait()
47 | }
48 |
--------------------------------------------------------------------------------
/nip47/controllers/nip47_controller.go:
--------------------------------------------------------------------------------
1 | package controllers
2 |
3 | import (
4 | "github.com/getAlby/hub/alby"
5 | "github.com/getAlby/hub/apps"
6 | "github.com/getAlby/hub/events"
7 | "github.com/getAlby/hub/lnclient"
8 | "github.com/getAlby/hub/nip47/permissions"
9 | "github.com/getAlby/hub/transactions"
10 | "gorm.io/gorm"
11 | )
12 |
13 | type nip47Controller struct {
14 | lnClient lnclient.LNClient
15 | db *gorm.DB
16 | eventPublisher events.EventPublisher
17 | permissionsService permissions.PermissionsService
18 | transactionsService transactions.TransactionsService
19 | appsService apps.AppsService
20 | albyOAuthService alby.AlbyOAuthService
21 | }
22 |
23 | func NewNip47Controller(
24 | lnClient lnclient.LNClient,
25 | db *gorm.DB,
26 | eventPublisher events.EventPublisher,
27 | permissionsService permissions.PermissionsService,
28 | transactionsService transactions.TransactionsService,
29 | appsService apps.AppsService,
30 | albyOAuthService alby.AlbyOAuthService) *nip47Controller {
31 | return &nip47Controller{
32 | lnClient: lnClient,
33 | db: db,
34 | eventPublisher: eventPublisher,
35 | permissionsService: permissionsService,
36 | transactionsService: transactionsService,
37 | appsService: appsService,
38 | albyOAuthService: albyOAuthService,
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/nip47/notifications/models.go:
--------------------------------------------------------------------------------
1 | package notifications
2 |
3 | import "github.com/getAlby/hub/nip47/models"
4 |
5 | type Notification struct {
6 | Notification interface{} `json:"notification,omitempty"`
7 | NotificationType string `json:"notification_type"`
8 | }
9 |
10 | const (
11 | PAYMENT_RECEIVED_NOTIFICATION = "payment_received"
12 | PAYMENT_SENT_NOTIFICATION = "payment_sent"
13 | HOLD_INVOICE_ACCEPTED_NOTIFICATION = "hold_invoice_accepted"
14 | )
15 |
16 | type PaymentSentNotification struct {
17 | models.Transaction
18 | }
19 |
20 | type PaymentReceivedNotification struct {
21 | models.Transaction
22 | }
23 |
24 | type HoldInvoiceAcceptedNotification struct {
25 | models.Transaction
26 | }
27 |
--------------------------------------------------------------------------------
/nip47/notifications/nip47_notification_queue.go:
--------------------------------------------------------------------------------
1 | package notifications
2 |
3 | import (
4 | "github.com/getAlby/hub/events"
5 | "github.com/getAlby/hub/logger"
6 | )
7 |
8 | type Nip47NotificationQueue interface {
9 | Channel() <-chan *events.Event
10 | AddToQueue(event *events.Event)
11 | }
12 |
13 | type nip47NotificationQueue struct {
14 | channel chan *events.Event
15 | }
16 |
17 | /*
18 | Queue events that will be consumed when the relay connection is online
19 | */
20 | func NewNip47NotificationQueue() *nip47NotificationQueue {
21 | return &nip47NotificationQueue{
22 | channel: make(chan *events.Event, 1000),
23 | }
24 | }
25 |
26 | func (q *nip47NotificationQueue) AddToQueue(event *events.Event) {
27 | select {
28 | case q.channel <- event: // Put in the channel unless it is full
29 | // successfully sent to channel
30 | default:
31 | // channel full
32 | logger.Logger.WithField("event", event).Error("NIP47NotificationQueue channel full. Discarding value")
33 | }
34 | }
35 |
36 | func (q *nip47NotificationQueue) Channel() <-chan *events.Event {
37 | return q.channel
38 | }
39 |
--------------------------------------------------------------------------------
/nostr/models/models.go:
--------------------------------------------------------------------------------
1 | package models
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/nbd-wtf/go-nostr"
7 | )
8 |
9 | type Relay interface {
10 | Publish(ctx context.Context, event nostr.Event) error
11 | }
12 |
--------------------------------------------------------------------------------
/render.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | - type: web
3 | runtime: image
4 | name: albyhub
5 | image:
6 | url: ghcr.io/getalby/hub:latest
7 | numInstances: 1
8 | region: frankfurt # Default: oregon
9 | plan: starter
10 | disk:
11 | name: data
12 | mountPath: /data
13 | sizeGB: 1
14 | autoDeploy: false
15 | envVars:
16 | - key: WORK_DIR
17 | value: /data/albyhub
18 | - key: LDK_ESPLORA_SERVER
19 | value: "https://electrs.getalbypro.com"
20 |
--------------------------------------------------------------------------------
/scripts/caddy-subpath/Caddyfile:
--------------------------------------------------------------------------------
1 | # FOR TESTING ONLY, do not use internal tls!
2 | https://your-domain.com {
3 | redir /example-path /example-path/ 301
4 | handle_path /example-path* {
5 | reverse_proxy localhost:8080
6 | }
7 | tls internal
8 | }
--------------------------------------------------------------------------------
/scripts/caddy-subpath/README.md:
--------------------------------------------------------------------------------
1 | # Caddy Subpath
2 |
3 | This is an example of how to run Alby Hub on a subpath using Caddy
4 |
5 | To test locally edit `sudo nano /etc/hosts` and add `127.0.0.1 your-domain.com`
6 |
7 | Use the following environment variables when building the frontend:
8 |
9 | ```bash
10 | BASE_PATH="/example-path" yarn build:http
11 | ```
12 |
13 | Then run Alby Hub as normal. (if default port is not 8080 you will need to update the Caddyfile)
14 |
15 | Then start caddy: `sudo caddy run -c ./Caddyfile`
16 |
17 | and visit `http://your-domain.com/example-path
18 |
--------------------------------------------------------------------------------
/scripts/linux-aarch64/Caddyfile.example:
--------------------------------------------------------------------------------
1 | # Example Caddyfile to run Alby Hub behind a Caddy reverse proxy
2 | # Caddy has embedded letsencrypt support and creates HTTPS certificates
3 | # learn more: https://caddyserver.com/docs/getting-started
4 |
5 | # Refer to the Caddy docs for more information:
6 | # https://caddyserver.com/docs/caddyfile
7 |
8 |
9 | :80 {
10 | # optional additional basic authentication
11 | # the password is hashed, see Caddy documentation: https://caddyserver.com/docs/caddyfile/directives/basic_auth
12 | #basicauth {
13 | # Username "Bob", password "hiccup"
14 | # Bob $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG
15 | #}
16 |
17 | # Alby Hub runs on 8029 by default
18 | reverse_proxy :8029
19 | }
20 |
--------------------------------------------------------------------------------
/scripts/linux-x86_64/Caddyfile.example:
--------------------------------------------------------------------------------
1 | # Example Caddyfile to run Alby Hub behind a Caddy reverse proxy
2 | # Caddy has embedded letsencrypt support and creates HTTPS certificates
3 | # learn more: https://caddyserver.com/docs/getting-started
4 |
5 | # Refer to the Caddy docs for more information:
6 | # https://caddyserver.com/docs/caddyfile
7 |
8 |
9 | :80 {
10 | # optional additional basic authentication
11 | # the password is hashed, see Caddy documentation: https://caddyserver.com/docs/caddyfile/directives/basic_auth
12 | #basicauth {
13 | # Username "Bob", password "hiccup"
14 | # Bob $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG
15 | #}
16 |
17 | # Alby Hub runs on 8029 by default
18 | reverse_proxy :8029
19 | }
20 |
--------------------------------------------------------------------------------
/scripts/linux-x86_64/phoenixd/docker-compose.yml:
--------------------------------------------------------------------------------
1 | services:
2 | albyhub:
3 | platform: linux/amd64
4 | container_name: albyhub
5 | image: ghcr.io/getalby/hub:latest
6 | volumes:
7 | - ./albyhub-phoenixd:/data
8 | ports:
9 | - "8080:8080"
10 | links:
11 | - "albyhub-phoenixd:albyhub-phoenixd"
12 | environment:
13 | - PHOENIXD_AUTHORIZATION=dcf0cf3501c04f97890e3bb3204f94f60d6b99d270cc8c40dfd390cced2f3c11
14 | - PHOENIXD_ADDRESS=http://albyhub-phoenixd:9740
15 | - WORK_DIR=/data/albyhub
16 | - LN_BACKEND_TYPE=PHOENIX
17 | stop_grace_period: 300s
18 |
19 | albyhub-phoenixd:
20 | platform: linux/amd64
21 | image: ghcr.io/sethforprivacy/phoenixd:latest
22 | container_name: albyhub-phoenixd
23 | volumes:
24 | - ./albyhub-phoenixd:/data
25 | environment:
26 | - PHOENIX_DATADIR=/data/phoenixd
27 | command: --agree-to-terms-of-service --http-bind-ip 0.0.0.0 --http-password=dcf0cf3501c04f97890e3bb3204f94f60d6b99d270cc8c40dfd390cced2f3c11
28 |
--------------------------------------------------------------------------------
/scripts/linux-x86_64/phoenixd/render.yaml:
--------------------------------------------------------------------------------
1 | services:
2 | - type: web
3 | runtime: image
4 | name: albyhub
5 | image:
6 | url: ghcr.io/getalby/hub:latest
7 | numInstances: 1
8 | region: frankfurt # Default: oregon
9 | plan: starter
10 | disk:
11 | name: data
12 | mountPath: /data
13 | sizeGB: 2
14 | autoDeploy: false
15 | envVars:
16 | - key: WORK_DIR
17 | value: /data/albyhub
18 | - key: LN_BACKEND_TYPE
19 | value: PHOENIX
20 | - key: PHOENIXD_AUTHORIZATION
21 | value: dcf0cf3501c04f97890e3bb3204f94f60d6b99d270cc8c40dfd390cced2f3c11
22 | - key: PHOENIXD_ADDRESS
23 | fromService:
24 | name: phoenixd
25 | type: pserv
26 | property: hostport
27 | - type: pserv
28 | runtime: image
29 | name: phoenixd
30 | image:
31 | url: ghcr.io/sethforprivacy/phoenixd:latest
32 | numInstances: 1
33 | region: frankfurt # Default: oregon
34 | plan: starter
35 | disk:
36 | name: data
37 | mountPath: /data
38 | sizeGB: 2
39 | autoDeploy: false
40 | dockerCommand: /phoenix/bin/phoenixd --agree-to-terms-of-service --http-bind-ip 0.0.0.0 --http-password=dcf0cf3501c04f97890e3bb3204f94f60d6b99d270cc8c40dfd390cced2f3c11
41 | envVars:
42 | - key: PHOENIX_DATADIR
43 | value: /data/phoenixd
44 |
--------------------------------------------------------------------------------
/scripts/pi-aarch64/README.md:
--------------------------------------------------------------------------------
1 | ### Installation on a Raspberry Pi 4/5 (aarch64)
2 |
3 | This install scripts will help you installing Alby Hub on a Raspberry Pi with Raspberry Pi OS (previously called Raspbian).
4 | You should have some basic Linux understanding to install and operate it.
5 |
6 | Have a look at our [installation guide](https://github.com/getAlby/hub/tree/master/scripts/pi-arm) for more details and inspiration.
7 |
8 | SSH into your Pi and run:
9 |
10 | ```shell
11 | /bin/bash -c "$(curl -fsSL https://getalby.com/install/hub/pi-aarch64-install.sh)"
12 | ```
13 |
14 | ### Updating a running instance
15 |
16 | SSH into your Pi and cd into `/opt/albyhub`
17 |
18 | Run `./update.sh`
19 |
--------------------------------------------------------------------------------
/scripts/pi-aarch64/update.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "🔃 Updating Alby Hub..."
4 | sudo systemctl stop albyhub
5 |
6 | # Download new artifacts
7 | cd /opt/albyhub
8 | rm -rf albyhub-backup
9 | mkdir albyhub-backup
10 | mv bin albyhub-backup
11 | mv lib albyhub-backup
12 | cp -r data albyhub-backup
13 |
14 | wget https://getalby.com/install/hub/server-linux-aarch64.tar.bz2
15 |
16 | ./verify.sh server-linux-aarch64.tar.bz2 albyhub-Server-Linux-aarch64.tar.bz2
17 | if [[ $? -ne 0 ]]; then
18 | echo "❌ Verification failed, aborting installation"
19 | exit 1
20 | fi
21 |
22 | # Extract archives
23 | tar -xvf server-linux-aarch64.tar.bz2
24 |
25 | # Cleanup
26 | rm server-linux-aarch64.tar.bz2
27 |
28 | sudo systemctl start albyhub
29 |
30 | echo "✅ Update finished! Please login again to start your wallet."
31 |
--------------------------------------------------------------------------------
/scripts/pi-arm/README.md:
--------------------------------------------------------------------------------
1 | ### Installation on a Raspberry Pi Zero (arm)
2 |
3 | This install scripts will help you installing Alby Hub on a Raspberry Pi with Raspberry Pi OS (previously called Raspbian).
4 | You should have some basic Linux understanding to install and operate it.
5 |
6 | SSH into your Pi and run:
7 |
8 | ```shell
9 | /bin/bash -c "$(curl -fsSL https://getalby.com/install/hub/pi-zero-install.sh)"
10 | ```
11 |
12 | ### Updating a running instance
13 |
14 | SSH into your Pi and cd into `/opt/albyhub`
15 |
16 | Run `./update.sh`
17 |
--------------------------------------------------------------------------------
/scripts/pi-arm/update.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | echo "🔃 Updating Alby Hub..."
4 | sudo systemctl stop albyhub
5 |
6 | # Download new artifacts
7 | cd /opt/albyhub
8 | rm -rf albyhub-backup
9 | mkdir albyhub-backup
10 | mv bin albyhub-backup
11 | mv lib albyhub-backup
12 | cp -r data albyhub-backup
13 |
14 | wget https://getalby.com/install/hub/server-linux-armv6.tar.bz2
15 |
16 | ./verify.sh server-linux-armv6.tar.bz2 albyhub-Server-Linux-armv6.tar.bz2
17 | if [[ $? -ne 0 ]]; then
18 | echo "❌ Verification failed, aborting installation"
19 | exit 1
20 | fi
21 |
22 | # Extract archives
23 | tar -xvf server-linux-armv6.tar.bz2
24 |
25 | # Cleanup
26 | rm server-linux-armv6.tar.bz2
27 |
28 | sudo systemctl start albyhub
29 |
30 | echo "✅ Update finished! Please login again to start your wallet."
31 |
--------------------------------------------------------------------------------
/service/models.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "gorm.io/gorm"
5 |
6 | "github.com/getAlby/hub/alby"
7 | "github.com/getAlby/hub/config"
8 | "github.com/getAlby/hub/events"
9 | "github.com/getAlby/hub/lnclient"
10 | "github.com/getAlby/hub/service/keys"
11 | "github.com/getAlby/hub/swaps"
12 | "github.com/getAlby/hub/transactions"
13 | )
14 |
15 | type Service interface {
16 | StartApp(encryptionKey string) error
17 | StopApp()
18 | Shutdown()
19 |
20 | // TODO: remove getters (currently used by http / wails services)
21 | GetAlbySvc() alby.AlbyService
22 | GetAlbyOAuthSvc() alby.AlbyOAuthService
23 | GetEventPublisher() events.EventPublisher
24 | GetLNClient() lnclient.LNClient
25 | GetTransactionsService() transactions.TransactionsService
26 | GetSwapsService() swaps.SwapsService
27 | GetDB() *gorm.DB
28 | GetConfig() config.Config
29 | GetKeys() keys.Keys
30 | IsRelayReady() bool
31 | GetStartupState() string
32 | }
33 |
--------------------------------------------------------------------------------
/service/payment_forwarded_consumer.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 |
6 | "gorm.io/gorm"
7 |
8 | "github.com/getAlby/hub/db"
9 | "github.com/getAlby/hub/events"
10 | "github.com/getAlby/hub/lnclient"
11 | "github.com/getAlby/hub/logger"
12 | )
13 |
14 | type paymentForwardedConsumer struct {
15 | events.EventSubscriber
16 | db *gorm.DB
17 | }
18 |
19 | // When a new app is created, subscribe to it on the relay
20 | func (c *paymentForwardedConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
21 | if event.Event != "nwc_payment_forwarded" {
22 | return
23 | }
24 |
25 | properties, ok := event.Properties.(*lnclient.PaymentForwardedEventProperties)
26 | if !ok {
27 | logger.Logger.WithField("event", event).Error("Failed to cast event.Properties to payment forwarded event properties")
28 | return
29 | }
30 | forward := &db.Forward{
31 | OutboundAmountForwardedMsat: properties.OutboundAmountForwardedMsat,
32 | TotalFeeEarnedMsat: properties.TotalFeeEarnedMsat,
33 | }
34 | err := c.db.Create(forward).Error
35 | if err != nil {
36 | logger.Logger.WithError(err).Error("failed to save forward to db")
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/service/profiler.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 |
6 | "errors"
7 | "net/http"
8 | "net/http/pprof"
9 | )
10 |
11 | func startProfiler(ctx context.Context, addr string) {
12 | mux := http.NewServeMux()
13 | mux.HandleFunc("/debug/pprof/", pprof.Index)
14 | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
15 | mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
16 | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
17 | mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
18 |
19 | server := &http.Server{
20 | Addr: addr,
21 | Handler: mux,
22 | }
23 |
24 | go func() {
25 | <-ctx.Done()
26 | err := server.Shutdown(context.Background())
27 | if err != nil {
28 | panic("pprof server shutdown failed: " + err.Error())
29 | }
30 | }()
31 |
32 | go func() {
33 | err := server.ListenAndServe()
34 | if err != nil && !errors.Is(err, http.ErrServerClosed) {
35 | panic("pprof server failed: " + err.Error())
36 | }
37 | }()
38 | }
39 |
--------------------------------------------------------------------------------
/service/stop.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/getAlby/hub/events"
7 | "github.com/getAlby/hub/logger"
8 | )
9 |
10 | func (svc *service) StopApp() {
11 | if svc.appCancelFn != nil {
12 | logger.Logger.Info("Stopping app...")
13 | svc.appCancelFn()
14 | svc.wg.Wait()
15 | logger.Logger.Info("app stopped")
16 | }
17 | }
18 |
19 | func (svc *service) stopLNClient() {
20 | defer svc.wg.Done()
21 | if svc.lnClient == nil {
22 | return
23 | }
24 | lnClient := svc.lnClient
25 | svc.lnClient = nil
26 |
27 | logger.Logger.Info("Shutting down LN client")
28 | err := lnClient.Shutdown()
29 | if err != nil {
30 | logger.Logger.WithError(err).Error("Failed to stop LN client")
31 | svc.eventPublisher.Publish(&events.Event{
32 | Event: "nwc_node_stop_failed",
33 | Properties: map[string]interface{}{
34 | "error": fmt.Sprintf("%v", err),
35 | },
36 | })
37 | return
38 | }
39 | logger.Logger.Info("Publishing node shutdown event")
40 | svc.eventPublisher.Publish(&events.Event{
41 | Event: "nwc_node_stopped",
42 | })
43 | logger.Logger.Info("LNClient stopped successfully")
44 | }
45 |
--------------------------------------------------------------------------------
/service/update_app_consumer.go:
--------------------------------------------------------------------------------
1 | package service
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/getAlby/hub/events"
7 | "github.com/getAlby/hub/logger"
8 | "github.com/nbd-wtf/go-nostr"
9 | )
10 |
11 | type updateAppConsumer struct {
12 | events.EventSubscriber
13 | svc *service
14 | relay *nostr.Relay
15 | }
16 |
17 | // When a app is updated, re-publish the nip47 info event
18 | func (s *updateAppConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
19 | if event.Event != "nwc_app_updated" {
20 | return
21 | }
22 |
23 | properties, ok := event.Properties.(map[string]interface{})
24 | if !ok {
25 | logger.Logger.WithField("event", event).Error("Failed to cast event.Properties to map")
26 | return
27 | }
28 | id, ok := properties["id"].(uint)
29 | if !ok {
30 | logger.Logger.WithField("event", event).Error("Failed to get app id")
31 | return
32 | }
33 | walletPrivKey, err := s.svc.keys.GetAppWalletKey(id)
34 | if err != nil {
35 | logger.Logger.WithError(err).Error("Failed to calculate app wallet priv key")
36 | return
37 | }
38 | walletPubKey, err := nostr.GetPublicKey(walletPrivKey)
39 | if err != nil {
40 | logger.Logger.WithError(err).Error("Failed to calculate app wallet pub key")
41 | return
42 | }
43 |
44 | if s.svc.keys.GetNostrPublicKey() != walletPubKey {
45 | // only need to re-publish the nip47 event info if it is not a legacy wallet
46 | s.svc.nip47Service.EnqueueNip47InfoPublishRequest(id, walletPubKey, walletPrivKey)
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/tests/create_mock_relay.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/getAlby/hub/logger"
7 | "github.com/nbd-wtf/go-nostr"
8 | )
9 |
10 | type mockRelay struct {
11 | PublishedEvents []*nostr.Event
12 | }
13 |
14 | func NewMockRelay() *mockRelay {
15 | return &mockRelay{}
16 | }
17 |
18 | func (relay *mockRelay) Publish(ctx context.Context, event nostr.Event) error {
19 | logger.Logger.WithField("event", event).Info("Mock Publishing event")
20 | relay.PublishedEvents = append(relay.PublishedEvents, &event)
21 | return nil
22 | }
23 |
--------------------------------------------------------------------------------
/tests/db/postgres/docker-compose.yml:
--------------------------------------------------------------------------------
1 | # docker compose up
2 | # connect with psql postgresql://postgres:password@localhost:5434
3 | # TEST_DATABASE_URI="postgresql://postgres:password@localhost:5434" go test -timeout 30s -run ^TestHandleMultiPayInvoiceEvent_IsolatedApp_ConcurrentPayments$ github.com/getAlby/hub/nip47/controllers
4 | # or
5 | # TEST_DATABASE_URI="postgresql://postgres:password@localhost:5434" go test -timeout 30s -run ^TestHandleMultiPayKeysendEvent_IsolatedApp_ConcurrentPayments$ github.com/getAlby/hub/nip47/controllers
6 | version: "3.6"
7 | services:
8 | pgtestdb:
9 | image: postgres:15
10 | environment:
11 | POSTGRES_PASSWORD: password
12 | restart: unless-stopped
13 | volumes:
14 | # Uses a tmpfs volume to make tests extremely fast. The data in test
15 | # databases is not persisted across restarts, nor does it need to be.
16 | - type: tmpfs
17 | target: /var/lib/postgresql/data/
18 | command:
19 | - "postgres"
20 | - "-c" # turn off fsync for speed
21 | - "fsync=off"
22 | - "-c" # log everything for debugging
23 | - "log_statement=all"
24 | ports:
25 | # Entirely up to you what port you want to use while testing.
26 | - "5434:5432"
27 |
--------------------------------------------------------------------------------
/tests/mock_event_consumer.go:
--------------------------------------------------------------------------------
1 | package tests
2 |
3 | import (
4 | "context"
5 | "time"
6 |
7 | "github.com/getAlby/hub/events"
8 | )
9 |
10 | type mockEventConsumer struct {
11 | consumedEvents []*events.Event
12 | }
13 |
14 | func NewMockEventConsumer() *mockEventConsumer {
15 | return &mockEventConsumer{
16 | consumedEvents: []*events.Event{},
17 | }
18 | }
19 |
20 | func (e *mockEventConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
21 | e.consumedEvents = append(e.consumedEvents, event)
22 | }
23 |
24 | func (e *mockEventConsumer) GetConsumedEvents() []*events.Event {
25 | // events are consumed async - give it a bit of time for tests
26 | time.Sleep(10 * time.Millisecond)
27 | return e.consumedEvents
28 | }
29 |
--------------------------------------------------------------------------------
/transactions/hold_invoice_self_payment_consumer.go:
--------------------------------------------------------------------------------
1 | package transactions
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/getAlby/hub/db"
7 | "github.com/getAlby/hub/events"
8 | )
9 |
10 | type holdInvoiceUpdatedConsumer struct {
11 | paymentHash string
12 | settledChannel chan<- *db.Transaction
13 | canceledChannel chan<- *db.Transaction
14 | }
15 |
16 | func newHoldInvoiceUpdatedConsumer(paymentHash string, settledChannel chan<- *db.Transaction, canceledChannel chan<- *db.Transaction) *holdInvoiceUpdatedConsumer {
17 | return &holdInvoiceUpdatedConsumer{
18 | paymentHash: paymentHash,
19 | settledChannel: settledChannel,
20 | canceledChannel: canceledChannel,
21 | }
22 | }
23 |
24 | func (consumer *holdInvoiceUpdatedConsumer) ConsumeEvent(ctx context.Context, event *events.Event, globalProperties map[string]interface{}) {
25 | if event.Event == "nwc_payment_received" && event.Properties.(*db.Transaction).PaymentHash == consumer.paymentHash {
26 | consumer.settledChannel <- event.Properties.(*db.Transaction)
27 | }
28 | if event.Event == "nwc_hold_invoice_canceled" && event.Properties.(*db.Transaction).PaymentHash == consumer.paymentHash {
29 | consumer.canceledChannel <- event.Properties.(*db.Transaction)
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/version/version.go:
--------------------------------------------------------------------------------
1 | package version
2 |
3 | var Tag string = ""
4 |
--------------------------------------------------------------------------------
/wails.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://wails.io/schemas/config.v2.json",
3 | "name": "Alby Hub",
4 | "outputfilename": "Alby Hub",
5 | "frontend:install": "yarn install",
6 | "frontend:build": "yarn build:wails",
7 | "frontend:dev:watcher": "yarn dev:wails",
8 | "frontend:dev:serverUrl": "auto",
9 | "author": {
10 | "name": "Alby Contributors",
11 | "email": "hello@getalby.com"
12 | },
13 | "info": {
14 | "companyName": "Alby Inc.",
15 | "productName": "Alby Hub"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------