diff --git a/nip66-cron/index.mjs b/nip66-cron/index.mjs index 1add35d0..dcd75a25 100644 --- a/nip66-cron/index.mjs +++ b/nip66-cron/index.mjs @@ -41,7 +41,6 @@ const MONITOR_BOT_TAG = ['bot', 'true'] // Deduplicated list of default relays to monitor (normalized URLs, first occurrence preserved) const DEFAULT_RELAYS_TO_MONITOR = [ 'wss://theforest.nostr1.com', - 'wss://orly-relay.imwald.eu', 'wss://nostr.land', 'wss://thecitadel.nostr1.com', 'wss://relay.nostr.watch', diff --git a/package.json b/package.json index 4dba82e8..eb69512e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.13.3", + "version": "23.13.4", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", @@ -14,7 +14,6 @@ "homepage": "https://github.com/Silberengel/jumble", "scripts": { "dev": "vite --host", - "orly:relay": "bash scripts/start-orly-cache-relay.sh", "dev:all": "bash scripts/dev-all-local.sh", "stack:remote": "bash scripts/stack-remote.sh", "dev:refresh": "rm -rf node_modules/.vite && vite --host", diff --git a/scripts/start-orly-cache-relay.sh b/scripts/start-orly-cache-relay.sh deleted file mode 100755 index 78b5671c..00000000 --- a/scripts/start-orly-cache-relay.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -# Start a local ORLY Nostr relay for Jumble dev/testing (sibling repo ../next.orly.dev). -# Default: ws://127.0.0.1:4869/ — add that URL as a cache relay in settings. -set -euo pipefail -ROOT="$(cd "$(dirname "$0")/.." && pwd)" -ORLY_ROOT="${ORLY_ROOT:-$ROOT/../next.orly.dev}" -export ORLY_PORT="${ORLY_PORT:-4869}" -export ORLY_ACL_MODE="${ORLY_ACL_MODE:-none}" -export ORLY_DATA_DIR="${ORLY_DATA_DIR:-${TMPDIR:-/tmp}/orly-jumble-relay-$ORLY_PORT}" -mkdir -p "$ORLY_DATA_DIR" -if [[ ! -d "$ORLY_ROOT/cmd/orly" ]]; then - echo "ORLY repo not found at: $ORLY_ROOT" >&2 - echo "Set ORLY_ROOT to your next.orly.dev checkout." >&2 - exit 1 -fi -echo "Orly: ws://127.0.0.1:${ORLY_PORT}/ (ORLY_DATA_DIR=$ORLY_DATA_DIR)" -cd "$ORLY_ROOT" -exec go run ./cmd/orly diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7cc51176..2037bd04 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -1370,6 +1370,9 @@ export default { "most popular": "most popular", "least popular": "least popular", "Connected to": "Connected to", + "Wallet connector": "Wallet connector", + "NWC relay": "NWC relay", + "Not an NWC connection (no relay URL in config).": "Not an NWC connection (no relay URL in config).", "Disconnect Wallet": "Disconnect Wallet", "Are you absolutely sure?": "Are you absolutely sure?", "You will not be able to send zaps to others.": "You will not be able to send zaps to others.", diff --git a/src/lib/wallet-connection-details.ts b/src/lib/wallet-connection-details.ts new file mode 100644 index 00000000..1129685b --- /dev/null +++ b/src/lib/wallet-connection-details.ts @@ -0,0 +1,32 @@ +import { getConnectorConfig } from '@getalby/bitcoin-connect-react' + +export type TWalletConnectionDetails = { + connectorName: string + connectorType: string + nwcRelayUrl: string | null +} + +/** Parse `relay=` from a nostr+walletconnect:// connection string. */ +export function parseNwcRelayUrl(nwcUrl: string): string | null { + try { + const httpLike = nwcUrl + .replace(/^nostr\+walletconnect:\/\//i, 'http://') + .replace(/^nostrwalletconnect:\/\//i, 'http://') + .replace(/^nostr\+walletconnect:/i, 'http://') + .replace(/^nostrwalletconnect:/i, 'http://') + return new URL(httpLike).searchParams.get('relay') + } catch { + return null + } +} + +/** Read the active Bitcoin Connect wallet config (from localStorage-backed store). */ +export function getBitcoinConnectWalletDetails(): TWalletConnectionDetails | null { + const config = getConnectorConfig() + if (!config) return null + return { + connectorName: config.connectorName, + connectorType: config.connectorType, + nwcRelayUrl: config.nwcUrl ? parseNwcRelayUrl(config.nwcUrl) : null + } +} diff --git a/src/lib/webln-payment.ts b/src/lib/webln-payment.ts new file mode 100644 index 00000000..21d30456 --- /dev/null +++ b/src/lib/webln-payment.ts @@ -0,0 +1,47 @@ +import type { GetInfoResponse, WebLNProvider } from '@webbtc/webln-types' + +/** NWC clients fetch wallet service info (kind 13194) before the first NIP-47 request. */ +export const NWC_WALLET_SERVICE_INFO_ERROR = 'no info event (kind 13194) returned from relay' + +export function isNwcWalletServiceInfoError(error: unknown): boolean { + return error instanceof Error && error.message.includes(NWC_WALLET_SERVICE_INFO_ERROR) +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +/** Enable WebLN and load wallet info so NWC encryption is negotiated before paying. */ +export async function prepareConnectedWebLNProvider( + provider: WebLNProvider +): Promise { + await provider.enable() + return provider.getInfo() +} + +export async function sendWebLNPaymentWithRetry( + provider: WebLNProvider, + invoice: string, + maxAttempts = 3 +): Promise<{ preimage: string }> { + let lastError: unknown + for (let attempt = 0; attempt < maxAttempts; attempt++) { + try { + if (attempt > 0) { + try { + await provider.getInfo() + } catch { + // Warm-up only; sendPayment below reports the actionable error. + } + } + return await provider.sendPayment(invoice) + } catch (error) { + lastError = error + if (!isNwcWalletServiceInfoError(error) || attempt === maxAttempts - 1) { + throw error + } + await delay(400 * (attempt + 1)) + } + } + throw lastError +} diff --git a/src/pages/secondary/WalletPage/WalletConnectionDetails.tsx b/src/pages/secondary/WalletPage/WalletConnectionDetails.tsx new file mode 100644 index 00000000..b7845353 --- /dev/null +++ b/src/pages/secondary/WalletPage/WalletConnectionDetails.tsx @@ -0,0 +1,60 @@ +import { Button } from '@/components/ui/button' +import { getBitcoinConnectWalletDetails } from '@/lib/wallet-connection-details' +import { useZap } from '@/providers/ZapProvider' +import { Copy } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' + +export default function WalletConnectionDetails() { + const { t } = useTranslation() + const { isWalletConnected } = useZap() + if (!isWalletConnected) return null + + const details = getBitcoinConnectWalletDetails() + + if (!details) return null + + const copyRelay = () => { + if (!details.nwcRelayUrl) return + navigator.clipboard.writeText(details.nwcRelayUrl) + toast.success(t('Copied to clipboard')) + } + + return ( +
+
+

+ {t('Wallet connector')} +

+

+ {details.connectorName} + ({details.connectorType}) +

+
+
+

+ {t('NWC relay')} +

+ {details.nwcRelayUrl ? ( +
+

+ {details.nwcRelayUrl} +

+ +
+ ) : ( +

{t('Not an NWC connection (no relay URL in config).')}

+ )} +
+
+ ) +} diff --git a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx index f8e6f536..78cbcf26 100644 --- a/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx +++ b/src/pages/secondary/WalletPage/WalletZapSendingSettings.tsx @@ -18,6 +18,7 @@ import DefaultZapAmountInput from './DefaultZapAmountInput' import DefaultZapCommentInput from './DefaultZapCommentInput' import QuickZapSwitch from './QuickZapSwitch' import IncludePublicZapReceiptSwitch from './IncludePublicZapReceiptSwitch' +import WalletConnectionDetails from './WalletConnectionDetails' export default function WalletZapSendingSettings() { const { t } = useTranslation() @@ -32,6 +33,7 @@ export default function WalletZapSendingSettings() { {t('Connected to')} {walletInfo.node.alias} )} + diff --git a/src/providers/ZapProvider.tsx b/src/providers/ZapProvider.tsx index f692fc8c..ffefd304 100644 --- a/src/providers/ZapProvider.tsx +++ b/src/providers/ZapProvider.tsx @@ -1,9 +1,12 @@ import { LIGHTNING_WALLET_PAY_ENABLED } from '@/constants' +import { prepareConnectedWebLNProvider } from '@/lib/webln-payment' import lightningService from '@/services/lightning.service' import storage from '@/services/local-storage.service' -import { onConnected, onDisconnected } from '@getalby/bitcoin-connect-react' +import { disconnect, onConnected, onDisconnected } from '@getalby/bitcoin-connect-react' import { GetInfoResponse, WebLNProvider } from '@webbtc/webln-types' import { createContext, useContext, useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { toast } from 'sonner' type TZapContext = { isWalletConnected: boolean @@ -32,6 +35,7 @@ export const useZap = () => { } export function ZapProvider({ children }: { children: React.ReactNode }) { + const { t } = useTranslation() const [defaultZapSats, setDefaultZapSats] = useState(storage.getDefaultZapSats()) const [defaultZapComment, setDefaultZapComment] = useState(storage.getDefaultZapComment()) const [quickZap, setQuickZap] = useState(storage.getQuickZap()) @@ -47,11 +51,22 @@ export function ZapProvider({ children }: { children: React.ReactNode }) { if (!LIGHTNING_WALLET_PAY_ENABLED) return const unSubOnConnected = onConnected((provider) => { - setIsWalletConnected(true) + setIsWalletConnected(false) setWalletInfo(null) - setProvider(provider) - lightningService.provider = provider - provider.getInfo().then(setWalletInfo) + void prepareConnectedWebLNProvider(provider) + .then((info) => { + setProvider(provider) + lightningService.provider = provider + setWalletInfo(info) + setIsWalletConnected(true) + }) + .catch((error) => { + setProvider(null) + lightningService.provider = null + setIsWalletConnected(false) + disconnect() + toast.error(`${t('Lightning payment failed')}: ${(error as Error).message}`) + }) }) const unSubOnDisconnected = onDisconnected(() => { setIsWalletConnected(false) diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index 57ce0f76..b06b56d2 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -8,6 +8,10 @@ import { import { getZapInfoFromEvent } from '@/lib/event-metadata' import { TProfile } from '@/types' import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react' +import { + isNwcWalletServiceInfoError, + sendWebLNPaymentWithRetry +} from '@/lib/webln-payment' import { Invoice } from '@getalby/lightning-tools' import { bech32 } from '@scure/base' import { WebLNProvider } from '@webbtc/webln-types' @@ -124,9 +128,15 @@ class LightningService { } if (this.provider) { - const { preimage } = await this.provider.sendPayment(pr) - closeOuterModel?.() - return { preimage, invoice: pr } + try { + const { preimage } = await sendWebLNPaymentWithRetry(this.provider, pr) + closeOuterModel?.() + return { preimage, invoice: pr } + } catch (error) { + if (!isNwcWalletServiceInfoError(error)) { + throw error + } + } } return new Promise((resolve) => { @@ -191,9 +201,15 @@ class LightningService { closeOuterModel?: () => void ): Promise<{ preimage: string; invoice: string } | null> { if (this.provider) { - const { preimage } = await this.provider.sendPayment(invoice) - closeOuterModel?.() - return { preimage, invoice: invoice } + try { + const { preimage } = await sendWebLNPaymentWithRetry(this.provider, invoice) + closeOuterModel?.() + return { preimage, invoice } + } catch (error) { + if (!isNwcWalletServiceInfoError(error)) { + throw error + } + } } return new Promise((resolve) => {