refactor: make NWC fully reactive with notifications and graceful balance handling

Changes:
- Remove polling mechanism in favor of pure reactive notifications$ observable
- Subscribe to wallet.notifications$ for real-time balance updates
- Make balance display conditional (only show if available)
- Fix TypeScript errors (notification.type access, unused variable)
- Remove Jotai callback mechanism for balance updates
- Use use$() directly for reactive balance subscription
- Update comments to reflect reactive architecture (no polling)

The wallet now updates balance automatically when payments are sent/received
via NIP-47 notifications, with no polling overhead.
This commit is contained in:
Claude
2026-01-18 09:20:30 +00:00
parent cf90147425
commit 8e6e989ca5
3 changed files with 99 additions and 127 deletions

View File

@@ -88,7 +88,7 @@ export default function UserMenu() {
const walletServiceProfile = useProfile(nwcConnection?.service);
// Use wallet hook for real-time balance and methods
const { disconnect: disconnectWallet, refreshBalance } = useWallet();
const { disconnect: disconnectWallet, refreshBalance, balance } = useWallet();
function openProfile() {
if (!account?.pubkey) return;
@@ -111,7 +111,7 @@ export default function UserMenu() {
}
function handleDisconnectWallet() {
// Disconnect from NWC service (stops polling, clears wallet instance)
// Disconnect from NWC service (stops notifications, clears wallet instance)
disconnectWallet();
// Clear connection from state
disconnectNWC();
@@ -123,7 +123,7 @@ export default function UserMenu() {
try {
await refreshBalance();
toast.success("Balance refreshed");
} catch (error) {
} catch (_error) {
toast.error("Failed to refresh balance");
}
}
@@ -160,22 +160,27 @@ export default function UserMenu() {
<div className="space-y-4">
{/* Balance */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Balance:</span>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{formatBalance(nwcConnection.balance)}
{(balance !== undefined ||
nwcConnection.balance !== undefined) && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Balance:
</span>
<Button
size="sm"
variant="ghost"
onClick={handleRefreshBalance}
title="Refresh balance"
>
<RefreshCw className="size-3.5" />
</Button>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{formatBalance(balance ?? nwcConnection.balance)}
</span>
<Button
size="sm"
variant="ghost"
onClick={handleRefreshBalance}
title="Refresh balance"
>
<RefreshCw className="size-3.5" />
</Button>
</div>
</div>
</div>
)}
{/* Wallet Name */}
<div className="flex items-center justify-between">
@@ -279,9 +284,12 @@ export default function UserMenu() {
>
<div className="flex items-center gap-2">
<Zap className="size-4 text-yellow-500" />
<span className="text-sm">
{formatBalance(nwcConnection.balance)}
</span>
{balance !== undefined ||
nwcConnection.balance !== undefined ? (
<span className="text-sm">
{formatBalance(balance ?? nwcConnection.balance)}
</span>
) : null}
</div>
<span className="text-xs text-muted-foreground">
{getWalletName()}

View File

@@ -2,19 +2,20 @@
* useWallet Hook
*
* Provides access to the NWC wallet throughout the application.
* Handles wallet lifecycle, balance updates, and transaction methods.
* Fully reactive using observables - balance updates automatically via use$()
*
* @example
* ```tsx
* function MyComponent() {
* const { wallet, balance, payInvoice, makeInvoice, refreshBalance } = useWallet();
* const { wallet, balance, payInvoice, makeInvoice } = useWallet();
*
* async function handlePay() {
* if (!wallet) return;
* await payInvoice("lnbc...");
* // Balance automatically updates via notifications!
* }
*
* return <div>Balance: {balance} sats</div>;
* return <div>Balance: {balance ? Math.floor(balance / 1000) : 0} sats</div>;
* }
* ```
*/
@@ -26,18 +27,17 @@ import {
getWallet,
restoreWallet,
clearWallet as clearWalletService,
setBalanceUpdateCallback,
refreshBalance as refreshBalanceService,
balance$,
} from "@/services/nwc";
import type { WalletConnect } from "applesauce-wallet-connect";
export function useWallet() {
const { state, updateNWCBalance } = useGrimoire();
const { state } = useGrimoire();
const nwcConnection = state.nwcConnection;
const [wallet, setWallet] = useState<WalletConnect | null>(getWallet());
// Subscribe to balance updates from the service
// Subscribe to balance updates from observable (fully reactive!)
const balance = use$(balance$);
// Initialize wallet on mount if connection exists but no wallet instance
@@ -47,39 +47,30 @@ export function useWallet() {
const restoredWallet = restoreWallet(nwcConnection);
setWallet(restoredWallet);
// Refresh balance on restore
// Fetch initial balance
refreshBalanceService();
}
}, [nwcConnection, wallet]);
// Set up balance update callback to sync with state
useEffect(() => {
setBalanceUpdateCallback((newBalance) => {
updateNWCBalance(newBalance);
});
return () => {
setBalanceUpdateCallback(() => {});
};
}, [updateNWCBalance]);
// Update local wallet ref when connection changes
useEffect(() => {
const currentWallet = getWallet();
if (currentWallet !== wallet) {
setWallet(currentWallet);
}
}, [nwcConnection]);
}, [nwcConnection, wallet]);
/**
* Pay a BOLT11 invoice
* Balance will auto-update via notification subscription
*/
async function payInvoice(invoice: string, amount?: number) {
if (!wallet) throw new Error("No wallet connected");
const result = await wallet.payInvoice(invoice, amount);
// Refresh balance after payment
// Balance will update automatically via notifications
// But we can also refresh immediately for instant feedback
await refreshBalanceService();
return result;
@@ -138,7 +129,7 @@ export function useWallet() {
return {
/** The wallet instance (null if not connected) */
wallet,
/** Current balance in millisats (undefined if not available) */
/** Current balance in millisats (auto-updates via observable!) */
balance,
/** Whether a wallet is connected */
isConnected: !!wallet,

View File

@@ -7,101 +7,27 @@
* Features:
* - Maintains persistent wallet connection across app lifetime
* - Subscribes to NIP-47 notifications (kind 23197) for balance updates
* - Automatically updates balance when transactions occur
* - Provides hook for easy wallet access throughout the app
* - Fully reactive using RxJS observables (no polling!)
* - Components use use$() to reactively subscribe to balance changes
*/
import { WalletConnect } from "applesauce-wallet-connect";
import type { NWCConnection } from "@/types/app";
import pool from "./relay-pool";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, Subscription } from "rxjs";
// Set the pool for wallet connect to use
WalletConnect.pool = pool;
let walletInstance: WalletConnect | null = null;
let notificationSubscription: any = null;
let notificationSubscription: Subscription | null = null;
/**
* Observable for wallet balance updates
* Components can subscribe to this for real-time balance changes
* Components can subscribe to this for real-time balance changes using use$()
*/
export const balance$ = new BehaviorSubject<number | undefined>(undefined);
/**
* Callback for balance updates (set by the hook/state management)
*/
let onBalanceUpdate: ((balance: number) => void) | null = null;
/**
* Register a callback for balance updates
*/
export function setBalanceUpdateCallback(callback: (balance: number) => void) {
onBalanceUpdate = callback;
}
/**
* Subscribe to wallet notifications (NIP-47 kind 23197)
* This enables real-time balance updates when transactions occur
*/
function subscribeToNotifications(wallet: WalletConnect) {
// Clean up existing subscription
if (notificationSubscription) {
notificationSubscription.unsubscribe();
}
// Subscribe to notifications from the wallet service
// The applesauce-wallet-connect library handles this internally
// when notifications are enabled in the wallet info
console.log("[NWC] Subscribed to wallet notifications");
// Note: The actual notification subscription is handled by WalletConnect
// We can poll for balance updates periodically as a fallback
startBalancePolling(wallet);
}
/**
* Poll for balance updates every 30 seconds
* This ensures we stay in sync even if notifications aren't working
*/
function startBalancePolling(wallet: WalletConnect) {
const pollInterval = setInterval(async () => {
try {
const result = await wallet.getBalance();
const newBalance = result.balance;
// Update observable
if (balance$.value !== newBalance) {
balance$.next(newBalance);
// Trigger callback for state updates
if (onBalanceUpdate) {
onBalanceUpdate(newBalance);
}
console.log("[NWC] Balance updated:", newBalance);
}
} catch (error) {
console.error("[NWC] Failed to poll balance:", error);
}
}, 30000); // Poll every 30 seconds
// Store for cleanup
notificationSubscription = {
unsubscribe: () => clearInterval(pollInterval),
};
}
/**
* Creates a new WalletConnect instance from a connection string
* Automatically subscribes to notifications for balance updates
*/
export function createWalletFromURI(connectionString: string): WalletConnect {
walletInstance = WalletConnect.fromConnectURI(connectionString);
subscribeToNotifications(walletInstance);
return walletInstance;
}
/**
* Helper to convert hex string to Uint8Array
*/
@@ -113,6 +39,58 @@ function hexToBytes(hex: string): Uint8Array {
return bytes;
}
/**
* Subscribe to wallet notifications (NIP-47 kind 23197)
* This enables real-time balance updates when transactions occur
*/
function subscribeToNotifications(wallet: WalletConnect) {
// Clean up existing subscription
if (notificationSubscription) {
notificationSubscription.unsubscribe();
}
console.log("[NWC] Subscribing to wallet notifications");
// Subscribe to the wallet's notifications$ observable
// This receives events like payment_received, payment_sent, etc.
notificationSubscription = wallet.notifications$.subscribe({
next: (notification) => {
console.log("[NWC] Notification received:", notification);
// When we get a notification, refresh the balance
// The notification types include: payment_received, payment_sent, etc.
wallet
.getBalance()
.then((result) => {
const newBalance = result.balance;
if (balance$.value !== newBalance) {
balance$.next(newBalance);
console.log("[NWC] Balance updated from notification:", newBalance);
}
})
.catch((error) => {
console.error(
"[NWC] Failed to fetch balance after notification:",
error,
);
});
},
error: (error) => {
console.error("[NWC] Notification subscription error:", error);
},
});
}
/**
* Creates a new WalletConnect instance from a connection string
* Automatically subscribes to notifications for balance updates
*/
export function createWalletFromURI(connectionString: string): WalletConnect {
walletInstance = WalletConnect.fromConnectURI(connectionString);
subscribeToNotifications(walletInstance);
return walletInstance;
}
/**
* Restores a wallet from saved connection data
* Used on app startup to reconnect to a previously connected wallet
@@ -124,7 +102,7 @@ export function restoreWallet(connection: NWCConnection): WalletConnect {
secret: hexToBytes(connection.secret),
});
// Set initial balance
// Set initial balance from cache
if (connection.balance !== undefined) {
balance$.next(connection.balance);
}
@@ -150,11 +128,11 @@ export function clearWallet(): void {
}
walletInstance = null;
balance$.next(undefined);
onBalanceUpdate = null;
}
/**
* Manually refresh the balance from the wallet
* Useful for initial load or manual refresh button
*/
export async function refreshBalance(): Promise<number | undefined> {
if (!walletInstance) return undefined;
@@ -164,11 +142,6 @@ export async function refreshBalance(): Promise<number | undefined> {
const newBalance = result.balance;
balance$.next(newBalance);
if (onBalanceUpdate) {
onBalanceUpdate(newBalance);
}
return newBalance;
} catch (error) {
console.error("[NWC] Failed to refresh balance:", error);