From d50478e29cc81edcf86fd268bf13e33bddd62532 Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Fri, 15 May 2026 21:09:22 +0300 Subject: [PATCH 1/2] feat(wallet): implement wallet connection and session management --- web-v2/.env.example | 13 +- web-v2/.env.liquid | 5 + web-v2/.env.liquidtestnet | 8 + web-v2/src/constants/env.ts | 12 +- web-v2/src/lib/wallet-core/connector/jade.ts | 77 +++++ web-v2/src/lib/wallet-core/connector/seed.ts | 60 ++++ web-v2/src/lib/wallet-core/connector/types.ts | 13 + web-v2/src/lib/wallet-core/types.ts | 43 +++ web-v2/src/lib/wallet-core/wallet/session.ts | 10 + web-v2/src/lib/wallet-core/wallet/sync.ts | 37 +++ web-v2/src/pages/Dashboard/index.tsx | 277 ++++++++++++++++-- web-v2/src/providers/AppProviders.tsx | 4 + web-v2/src/providers/lwk/LwkProvider.tsx | 1 - web-v2/src/providers/wallet/WalletContext.ts | 9 + .../src/providers/wallet/WalletProvider.tsx | 263 +++++++++++++++++ web-v2/src/providers/wallet/types.ts | 14 + web-v2/src/providers/wallet/useWallet.ts | 12 + web-v2/src/types/web-serial.d.ts | 15 + 18 files changed, 837 insertions(+), 36 deletions(-) create mode 100644 web-v2/.env.liquid create mode 100644 web-v2/.env.liquidtestnet create mode 100644 web-v2/src/lib/wallet-core/connector/jade.ts create mode 100644 web-v2/src/lib/wallet-core/connector/seed.ts create mode 100644 web-v2/src/lib/wallet-core/connector/types.ts create mode 100644 web-v2/src/lib/wallet-core/types.ts create mode 100644 web-v2/src/lib/wallet-core/wallet/session.ts create mode 100644 web-v2/src/lib/wallet-core/wallet/sync.ts create mode 100644 web-v2/src/providers/wallet/WalletContext.ts create mode 100644 web-v2/src/providers/wallet/WalletProvider.tsx create mode 100644 web-v2/src/providers/wallet/types.ts create mode 100644 web-v2/src/providers/wallet/useWallet.ts create mode 100644 web-v2/src/types/web-serial.d.ts diff --git a/web-v2/.env.example b/web-v2/.env.example index 264806c..469f2d3 100644 --- a/web-v2/.env.example +++ b/web-v2/.env.example @@ -1,3 +1,12 @@ +# Copy to .env and fill in values. +# For liquid mainnet use .env.liquid, for testnet use .env.liquidtestnet as a starting point. + VITE_API_URL=http://localhost:80 -VITE_ESPLORA_BASE_URL=https://blockstream.info/liquidtestnet -VITE_NETWORK=liquidtestnet +VITE_NETWORK=liquid +VITE_ESPLORA_BASE_URL=https://blockstream.info/liquid/api +VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquid/api +VITE_WATERFALLS_RECIPIENT=age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p + +# Optional: BIP39 mnemonic for debug software signer (dev/testnet only). +# Omit this in production — Jade hardware wallet will be used instead. +# VITE_DEBUG_MNEMONIC=abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about diff --git a/web-v2/.env.liquid b/web-v2/.env.liquid new file mode 100644 index 0000000..ca1ddaa --- /dev/null +++ b/web-v2/.env.liquid @@ -0,0 +1,5 @@ +VITE_API_URL=http://localhost:8000 +VITE_NETWORK=liquid +VITE_ESPLORA_BASE_URL=https://blockstream.info/liquid/api +VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquid/api +VITE_WATERFALLS_RECIPIENT=age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p diff --git a/web-v2/.env.liquidtestnet b/web-v2/.env.liquidtestnet new file mode 100644 index 0000000..14a5ab3 --- /dev/null +++ b/web-v2/.env.liquidtestnet @@ -0,0 +1,8 @@ +VITE_API_URL=http://localhost:8000 +VITE_NETWORK=liquidtestnet +VITE_ESPLORA_BASE_URL=https://blockstream.info/liquidtestnet/api +VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquidtestnet/api +VITE_WATERFALLS_RECIPIENT=age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p + +# Uncomment for debug software signer (dev only): +# VITE_DEBUG_MNEMONIC=abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about diff --git a/web-v2/src/constants/env.ts b/web-v2/src/constants/env.ts index b6b61c4..c511f1d 100644 --- a/web-v2/src/constants/env.ts +++ b/web-v2/src/constants/env.ts @@ -2,10 +2,15 @@ import { z } from 'zod' const envSchema = z.object({ VITE_API_URL: z.string().url().default('http://localhost:80'), - VITE_ESPLORA_BASE_URL: z.string().url().default('https://blockstream.info/liquidtestnet'), - VITE_NETWORK: z.enum(['liquid', 'liquidtestnet', 'regtest']).default('liquidtestnet'), DEV: z.boolean().default(false), PROD: z.boolean().default(false), + VITE_ESPLORA_BASE_URL: z.string().url().default('https://blockstream.info/liquid/api'), + VITE_NETWORK: z.enum(['liquid', 'liquidtestnet', 'regtest']).default('liquid'), + VITE_WATERFALLS_URL: z.string().url(), + VITE_WATERFALLS_RECIPIENT: z + .string() + .default('age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p'), + VITE_DEBUG_MNEMONIC: z.string().optional().default(''), }) export const env = envSchema.parse({ @@ -14,6 +19,9 @@ export const env = envSchema.parse({ VITE_NETWORK: import.meta.env.VITE_NETWORK, DEV: import.meta.env.DEV, PROD: import.meta.env.PROD, + VITE_WATERFALLS_URL: import.meta.env.VITE_WATERFALLS_URL, + VITE_WATERFALLS_RECIPIENT: import.meta.env.VITE_WATERFALLS_RECIPIENT, + VITE_DEBUG_MNEMONIC: import.meta.env.VITE_DEBUG_MNEMONIC, }) export type AppEnv = z.infer diff --git a/web-v2/src/lib/wallet-core/connector/jade.ts b/web-v2/src/lib/wallet-core/connector/jade.ts new file mode 100644 index 0000000..368fa87 --- /dev/null +++ b/web-v2/src/lib/wallet-core/connector/jade.ts @@ -0,0 +1,77 @@ +import type { Jade, Network, Pset, WolletDescriptor } from 'lwk_web' + +import type { Lwk } from '@/lwk' + +import type { ConnectionStatus, JadeVersionInfo, SinglesigVariant } from '../types' +import type { WalletConnector } from './types' + +/** + * Production hardware wallet connector for Jade. + * + * Jade is a WASM-backed object — it holds a Rust memory pointer internally. + * It must NOT be stored in React state. This class owns the Jade reference + * exclusively and exposes only framework-agnostic methods. + */ +export class JadeConnector implements WalletConnector { + private jade: Jade | null = null + private busy = false + + constructor( + private readonly lwk: Lwk, + private readonly lwkNetwork: Network, + ) {} + + async connect(): Promise { + if (this.jade !== null) return + // HACK: The TS bindings declare this as a sync constructor, but wasm-bindgen + // generates an async constructor under the hood that returns a Promise. + // `await new this.lwk.Jade(...)` is intentional — not a mistake. + this.jade = await new this.lwk.Jade(this.lwkNetwork, true) + } + + async disconnect(): Promise { + if (this.jade) { + this.jade.free() + this.jade = null + } + } + + async readVersion(): Promise { + if (!this.jade) throw new Error('JadeConnector: not connected') + const raw = await this.jade.getVersion() + return { + jadeState: raw.JADE_STATE as JadeVersionInfo['jadeState'], + jadeMac: raw.EFUSEMAC as string, + jadeVersion: raw.JADE_VERSION as string, + } + } + + async getConnectionState(): Promise { + // HACK: Mutex polling and sign() share the same WebSerial port. If sign() is in + // progress (waiting for user button press), skip the poll to avoid CBOR + // frame corruption that would silently kill the signing request. + if (this.busy) throw new Error('jade:busy') + const info = await this.readVersion() + return info.jadeState === 'READY' ? 'ready' : 'locked' + } + + async getDescriptor(variant: SinglesigVariant): Promise { + if (!this.jade) throw new Error('JadeConnector: not connected') + // wpkh = elwpkh native segwit; shWpkh = nested segwit (sh-wpkh). + return variant === 'Wpkh' ? this.jade.wpkh() : this.jade.shWpkh() + } + + async signPset(pset: Pset): Promise { + if (!this.jade) throw new Error('JadeConnector: not connected') + this.busy = true + try { + return await this.jade.sign(pset) + } finally { + this.busy = false + } + } + + isConnected(): boolean { + return this.jade !== null + } +} diff --git a/web-v2/src/lib/wallet-core/connector/seed.ts b/web-v2/src/lib/wallet-core/connector/seed.ts new file mode 100644 index 0000000..61099bf --- /dev/null +++ b/web-v2/src/lib/wallet-core/connector/seed.ts @@ -0,0 +1,60 @@ +import type { Network, Pset, Signer, WolletDescriptor } from 'lwk_web' + +import type { Lwk } from '@/lwk' + +import type { SinglesigVariant } from '../types' +import type { WalletConnector } from './types' + +/** + * Software signer connector backed by a BIP39 mnemonic. + * + * Intended for dev/test only — never ship a real mnemonic in env vars. + * Gate behind VITE_DEBUG_MNEMONIC so it never runs in production builds. + * + * Signer is a WASM-backed object. It must NOT be stored in React state. + * This class owns the Signer reference exclusively. + */ +export class SeedConnector implements WalletConnector { + private signer: Signer | null = null + + constructor( + private readonly lwk: Lwk, + private readonly lwkNetwork: Network, + private readonly mnemonicStr: string, + ) { + if (!mnemonicStr) throw new Error('SeedConnector: VITE_DEBUG_MNEMONIC is not set') + } + + async connect(): Promise { + if (this.signer !== null) return + const mnemonic = new this.lwk.Mnemonic(this.mnemonicStr) + this.signer = new this.lwk.Signer(mnemonic, this.lwkNetwork) + } + + async disconnect(): Promise { + if (this.signer) { + this.signer.free() + this.signer = null + } + } + + async getDescriptor( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _variant: SinglesigVariant, + ): Promise { + if (!this.signer) throw new Error('SeedConnector: not connected') + // Signer only exposes wpkhSlip77Descriptor (native segwit + SLIP77 blinding). + // The variant param is accepted for interface compatibility but ignored here. + return this.signer.wpkhSlip77Descriptor() + } + + async signPset(pset: Pset): Promise { + if (!this.signer) throw new Error('SeedConnector: not connected') + // Signer.sign() is synchronous — wrap for interface compatibility. + return this.signer.sign(pset) + } + + isConnected(): boolean { + return this.signer !== null + } +} diff --git a/web-v2/src/lib/wallet-core/connector/types.ts b/web-v2/src/lib/wallet-core/connector/types.ts new file mode 100644 index 0000000..d8b600d --- /dev/null +++ b/web-v2/src/lib/wallet-core/connector/types.ts @@ -0,0 +1,13 @@ +import type { Pset, WolletDescriptor } from 'lwk_web' + +import type { ConnectionStatus, JadeVersionInfo, SinglesigVariant } from '../types' + +export interface WalletConnector { + connect(): Promise + disconnect(): Promise + getDescriptor(variant: SinglesigVariant): Promise + signPset(pset: Pset): Promise + isConnected(): boolean + readVersion?(): Promise + getConnectionState?(): Promise +} diff --git a/web-v2/src/lib/wallet-core/types.ts b/web-v2/src/lib/wallet-core/types.ts new file mode 100644 index 0000000..163e1dc --- /dev/null +++ b/web-v2/src/lib/wallet-core/types.ts @@ -0,0 +1,43 @@ +export type SinglesigVariant = 'Wpkh' | 'ShWpkh' + +/** Raw JADE_STATE values from getVersion() */ +export type JadeConnectionState = 'LOCKED' | 'READY' | 'UNINIT' | 'TEMP' + +export type JadeVersionInfo = { + jadeState: JadeConnectionState + /** EFUSEMAC — unique hardware identifier */ + jadeMac: string + jadeVersion: string +} + +export type ConnectionStatus = 'disconnected' | 'locked' | 'ready' + +export type SavedSession = { + efuseMac: string | null + walletType: SinglesigVariant + descriptorStr: string +} + +export type WalletState = { + connectionStatus: ConnectionStatus + jadeMac: string | null + walletType: SinglesigVariant | null + balances: Record + syncing: boolean + usbDeviceDetected: boolean + /** Last error message. Persists even after isError is cleared. */ + error: string | null + /** Whether the error should be shown to the user. Cleared on reconnect or new connect attempt. */ + isError: boolean +} + +export const INITIAL_WALLET_STATE: WalletState = { + connectionStatus: 'disconnected', + jadeMac: null, + walletType: null, + balances: {}, + syncing: false, + usbDeviceDetected: false, + error: null, + isError: false, +} diff --git a/web-v2/src/lib/wallet-core/wallet/session.ts b/web-v2/src/lib/wallet-core/wallet/session.ts new file mode 100644 index 0000000..362920f --- /dev/null +++ b/web-v2/src/lib/wallet-core/wallet/session.ts @@ -0,0 +1,10 @@ +import type { EsploraClient, Wollet, WolletDescriptor } from 'lwk_web' + +import type { WalletConnector } from '../connector/types' + +export type WalletSession = { + connector: WalletConnector + descriptor: WolletDescriptor + wollet: Wollet + esploraClient: EsploraClient +} diff --git a/web-v2/src/lib/wallet-core/wallet/sync.ts b/web-v2/src/lib/wallet-core/wallet/sync.ts new file mode 100644 index 0000000..461f914 --- /dev/null +++ b/web-v2/src/lib/wallet-core/wallet/sync.ts @@ -0,0 +1,37 @@ +import type { EsploraClient, Network, Wollet } from 'lwk_web' + +import { env } from '@/constants/env' +import type { Lwk } from '@/lwk' + +/** + * Creates an EsploraClient configured for waterfalls + utxoOnly scanning. + * Waterfalls provides fast indexed encrypted UTXO discovery vs slow sequential HD scan. + */ +export function createEsploraClient(lwk: Lwk, lwkNetwork: Network): EsploraClient { + const client = new lwk.EsploraClient( + lwkNetwork, + env.VITE_WATERFALLS_URL, + true, // waterfalls + 4, // concurrency + true, // utxoOnly + ) + if (lwkNetwork.isMainnet() || lwkNetwork.isTestnet()) { + client.setWaterfallsServerRecipient(env.VITE_WATERFALLS_RECIPIENT) + } + return client +} + +/** + * Syncs wallet state via waterfalls fullScan and applies the update. + * Returns the updated balance map (assetId -> satoshis). + */ +export async function syncWallet( + wollet: Wollet, + esploraClient: EsploraClient, +): Promise<[string, bigint][]> { + const update = await esploraClient.fullScanToIndex(wollet, 0) + if (update) { + wollet.applyUpdate(update) + } + return wollet.balance().entries() as [string, bigint][] +} diff --git a/web-v2/src/pages/Dashboard/index.tsx b/web-v2/src/pages/Dashboard/index.tsx index fab02c8..89ac1e4 100644 --- a/web-v2/src/pages/Dashboard/index.tsx +++ b/web-v2/src/pages/Dashboard/index.tsx @@ -1,21 +1,256 @@ +import { useEffect, useState } from 'react' + +import { env } from '@/constants/env' +import type { ConnectionStatus, SinglesigVariant } from '@/lib/wallet-core/types' import { useLwk } from '@/providers/lwk/useLwk' +import { useWallet } from '@/providers/wallet/useWallet' + +// FOR DEMO + +// Strip trailing /api from the esplora URL to get the web explorer base. +const EXPLORER_BASE = env.VITE_ESPLORA_BASE_URL.replace(/\/api$/, '') + +function explorerTxUrl(txid: string): string { + return `${EXPLORER_BASE}/tx/${txid}` +} +async function fetchConfirmations(txid: string): Promise { + const res = await fetch(`${env.VITE_ESPLORA_BASE_URL}/tx/${txid}/status`) + if (!res.ok) return null + const data = (await res.json()) as { confirmed: boolean; block_height?: number } + if (!data.confirmed || data.block_height === undefined) return null + const tipRes = await fetch(`${env.VITE_ESPLORA_BASE_URL}/blocks/tip/height`) + if (!tipRes.ok) return null + const tip = (await tipRes.json()) as number + return tip - data.block_height + 1 +} + +type Phase = 'no-usb' | 'usb-detected' | 'connecting' | 'locked' | 'ready' + +function resolvePhase( + connectionStatus: ConnectionStatus, + usbDeviceDetected: boolean, + syncing: boolean, +): Phase { + if (connectionStatus === 'locked') return 'locked' + if (connectionStatus === 'ready') return 'ready' + // disconnected + if (syncing) return 'connecting' + return usbDeviceDetected ? 'usb-detected' : 'no-usb' +} -// EXAMPLE OF LWK USAGE export default function DashboardPage() { - const { network, isTestnet, isMainnet, isRegtest, lwkNetwork } = useLwk() + const { network, isTestnet, isMainnet, isRegtest } = useLwk() + const { + connectionStatus, + syncing, + isError, + error, + balances, + jadeMac, + usbDeviceDetected, + connect, + sendLbtc, + getLastReceiveAddress, + } = useWallet() + + const [walletType, setWalletType] = useState('Wpkh') + const [sendAddress, setSendAddress] = useState('') + const [sendAmount, setSendAmount] = useState('') + const [sendTxid, setSendTxid] = useState(null) + const [sendError, setSendError] = useState(null) + const [sending, setSending] = useState(false) + const [txConfirmations, setTxConfirmations] = useState(null) + + // Poll Esplora directly for first confirmation after sending. + useEffect(() => { + if (!sendTxid || txConfirmations !== null) return + + const id = setInterval(() => { + fetchConfirmations(sendTxid) + .then(confs => { + if (confs !== null && confs >= 1) { + setTxConfirmations(confs) + clearInterval(id) + } + }) + .catch(console.warn) + }, 15_000) + + return () => clearInterval(id) + }, [sendTxid, txConfirmations]) + + const phase = resolvePhase(connectionStatus, usbDeviceDetected, syncing) - const info = lwkNetwork - ? { - label: lwkNetwork.toString(), - genesisBlockHash: lwkNetwork.genesisBlockHash(), - defaultExplorerUrl: lwkNetwork.defaultExplorerUrl(), - policyAsset: lwkNetwork.policyAsset().toString(), - } - : null + const handleSend = async () => { + setSendError(null) + setSendTxid(null) + setSending(true) + console.warn('[Dashboard] handleSend: start', { sendAddress, sendAmount }) + try { + const txid = await sendLbtc(sendAddress, BigInt(sendAmount)) + console.warn('[Dashboard] handleSend: txid received', txid) + setSendTxid(txid) + setSendAddress('') + setSendAmount('') + setTxConfirmations(null) + } catch (err) { + console.warn('[Dashboard] handleSend: error', err) + setSendError(err instanceof Error ? err.message : String(err)) + } finally { + setSending(false) + } + } return ( -
+

Dashboard

+ + {phase === 'no-usb' && ( +
+
+ +
+ {env.VITE_DEBUG_MNEMONIC && ( + <> + + + )} +
+ )} + + {phase === 'usb-detected' && ( +
+
+ Wallet type + + +
+
+ +
+
+ )} + + {phase === 'connecting' &&

Connecting to Jade...

} + + {phase === 'locked' && ( +
+

+ Enter PIN on device + {jadeMac && ({jadeMac})} +

+ {syncing &&

Loading wallet...

} +
+ )} + + {phase === 'ready' && ( +
+
+

Receive address

+ {getLastReceiveAddress()} +
+ +
+

+ Balances + {jadeMac && ({jadeMac})} +

+ {syncing ? ( +

Syncing...

+ ) : Object.entries(balances).length === 0 ? ( +

No balance

+ ) : ( +
    + {Object.entries(balances).map(([assetId, amount]) => ( +
  • + {assetId}: {amount} +
  • + ))} +
+ )} +
+ +
+

Send Transfer

+ setSendAddress(e.target.value)} + /> + setSendAmount(e.target.value)} + /> + + {sendTxid && ( +
+

+ Sent!{' '} + + {sendTxid} + +

+

+ {txConfirmations !== null + ? `${txConfirmations} confirmation${txConfirmations === 1 ? '' : 's'}` + : 'Waiting for confirmation...'} +

+
+ )} + {sendError &&

{sendError}

} +
+
+ )} + + {isError && error &&

{error}

} +

Network: {network}

@@ -23,26 +258,6 @@ export default function DashboardPage() { isTestnet: {isTestnet.toString()} / isMainnet: {isMainnet.toString()} / isRegtest:{' '} {isRegtest.toString()}

- {info && ( -
-
LWK label
-
- {info.label} -
-
Genesis block hash
-
- {info.genesisBlockHash} -
-
Default explorer
-
- {info.defaultExplorerUrl} -
-
Policy asset
-
- {info.policyAsset} -
-
- )}
) } diff --git a/web-v2/src/providers/AppProviders.tsx b/web-v2/src/providers/AppProviders.tsx index 384af4b..e6bc680 100644 --- a/web-v2/src/providers/AppProviders.tsx +++ b/web-v2/src/providers/AppProviders.tsx @@ -6,11 +6,15 @@ import { env } from '@/constants/env' import { LwkProvider } from './lwk/LwkProvider' import { queryClient } from './queryClient' +import { WalletProvider } from './wallet/WalletProvider' export function AppProviders({ children }: PropsWithChildren) { return ( {children} + + {children} + {env.DEV && } ) diff --git a/web-v2/src/providers/lwk/LwkProvider.tsx b/web-v2/src/providers/lwk/LwkProvider.tsx index 24aa382..31b2457 100644 --- a/web-v2/src/providers/lwk/LwkProvider.tsx +++ b/web-v2/src/providers/lwk/LwkProvider.tsx @@ -9,7 +9,6 @@ const network = env.VITE_NETWORK export function LwkProvider({ children }: { children: React.ReactNode }) { const [lwk, setLwk] = useState(null) - useEffect(() => { let cancelled = false diff --git a/web-v2/src/providers/wallet/WalletContext.ts b/web-v2/src/providers/wallet/WalletContext.ts new file mode 100644 index 0000000..8dcfc6f --- /dev/null +++ b/web-v2/src/providers/wallet/WalletContext.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +import type { WalletContextValue } from './types' + +export const WALLET_CONTEXT_UNINITIALIZED = Symbol('WALLET_CONTEXT_UNINITIALIZED') + +export const WalletContext = createContext< + WalletContextValue | typeof WALLET_CONTEXT_UNINITIALIZED +>(WALLET_CONTEXT_UNINITIALIZED) diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx new file mode 100644 index 0000000..c892463 --- /dev/null +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -0,0 +1,263 @@ +import type { Pset } from 'lwk_web' +import { useCallback, useEffect, useRef, useState } from 'react' + +import { env } from '@/constants/env' +import { JadeConnector } from '@/lib/wallet-core/connector/jade' +import { SeedConnector } from '@/lib/wallet-core/connector/seed' +import type { WalletConnector } from '@/lib/wallet-core/connector/types' +import { + INITIAL_WALLET_STATE, + type SavedSession, + type SinglesigVariant, + type WalletState, +} from '@/lib/wallet-core/types' +import type { WalletSession } from '@/lib/wallet-core/wallet/session' +import { createEsploraClient, syncWallet } from '@/lib/wallet-core/wallet/sync' +import { useLwk } from '@/providers/lwk/useLwk' + +import { WalletContext } from './WalletContext' + +const SESSION_STORAGE_KEY = 'jade_wallet_session' + +function loadSavedSession(): SavedSession | null { + try { + const raw = sessionStorage.getItem(SESSION_STORAGE_KEY) + return raw ? (JSON.parse(raw) as SavedSession) : null + } catch { + return null + } +} + +function persistSession(session: SavedSession): void { + sessionStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)) +} + +function clearPersistedSession(): void { + sessionStorage.removeItem(SESSION_STORAGE_KEY) +} + +export function WalletProvider({ children }: { children: React.ReactNode }) { + const { lwk, lwkNetwork } = useLwk() + + const sessionRef = useRef(null) + + const [state, setState] = useState(INITIAL_WALLET_STATE) + const [savedSession, setSavedSession] = useState(loadSavedSession) + + // Stable disconnect used by polling, USB events, and the public disconnect action. + const performDisconnect = useCallback(async (error?: string) => { + const session = sessionRef.current + if (session) { + await session.connector.disconnect() + sessionRef.current = null + } + clearPersistedSession() + setSavedSession(null) + // Do NOT preserve usbDeviceDetected — physical disconnect means the device is gone. + setState(() => ({ + ...INITIAL_WALLET_STATE, + ...(error !== undefined ? { error, isError: true } : {}), + })) + }, []) + + // Permanent Web Serial event listeners — detect USB plug/unplug. + useEffect(() => { + if (!('serial' in navigator)) return + + const handleConnect = () => { + // Clear any prior disconnect error when the user re-plugs the device. + setState(s => ({ ...s, usbDeviceDetected: true, error: null, isError: false })) + } + const handleDisconnect = () => { + if (sessionRef.current) { + performDisconnect('Device disconnected').catch(console.warn) + } else { + setState(s => ({ ...s, usbDeviceDetected: false })) + } + } + + navigator.serial.addEventListener('connect', handleConnect) + navigator.serial.addEventListener('disconnect', handleDisconnect) + + return () => { + navigator.serial.removeEventListener('connect', handleConnect) + navigator.serial.removeEventListener('disconnect', handleDisconnect) + } + }, [performDisconnect]) + + // Poll Jade state while connected — detects PIN lock and physical disconnect. + useEffect(() => { + if (state.connectionStatus === 'disconnected') return + + const id = setInterval(() => { + const session = sessionRef.current + if (!session?.connector.getConnectionState) return + + session.connector + .getConnectionState() + .then(status => { + setState(s => (s.connectionStatus === status ? s : { ...s, connectionStatus: status })) + }) + .catch((err: unknown) => { + if (err instanceof Error && err.message === 'jade:busy') return + performDisconnect('Device disconnected').catch(console.warn) + }) + }, 3_000) + + return () => clearInterval(id) + }, [state.connectionStatus, performDisconnect]) + + const connect = useCallback( + async (variant: SinglesigVariant) => { + if (sessionRef.current !== null) return + + setState(s => ({ ...s, syncing: true, error: null, isError: false })) + + try { + const connector: WalletConnector = env.VITE_DEBUG_MNEMONIC + ? new SeedConnector(lwk, lwkNetwork, env.VITE_DEBUG_MNEMONIC) + : new JadeConnector(lwk, lwkNetwork) + + await connector.connect() + + // Hardware signers expose readVersion; software signers are always 'ready'. + const versionInfo = (await connector.readVersion?.()) ?? null + const connectionStatus = + versionInfo?.jadeState !== 'READY' && versionInfo !== null ? 'locked' : 'ready' + + // Show the intermediate state (locked/ready) before PIN prompt blocks. + setState(s => ({ + ...s, + connectionStatus, + jadeMac: versionInfo?.jadeMac ?? null, + walletType: variant, + })) + + const descriptor = await connector.getDescriptor(variant) + const wollet = new lwk.Wollet(lwkNetwork, descriptor) + const esploraClient = createEsploraClient(lwk, lwkNetwork) + + sessionRef.current = { connector, descriptor, wollet, esploraClient } + + const saved: SavedSession = { + efuseMac: versionInfo?.jadeMac ?? null, + walletType: variant, + descriptorStr: descriptor.toString(), + } + persistSession(saved) + setSavedSession(saved) + + const rawBalances = await syncWallet(wollet, esploraClient) + + setState(s => ({ + ...s, + connectionStatus: 'ready', + syncing: false, + error: null, + isError: false, + balances: serializeBalances(rawBalances), + })) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + sessionRef.current = null + // USB may still be plugged in even if connect() failed, so preserve usbDeviceDetected. + setState(s => ({ + ...INITIAL_WALLET_STATE, + usbDeviceDetected: s.usbDeviceDetected, + error, + isError: true, + })) + } + }, + [lwk, lwkNetwork], + ) + + const disconnect = useCallback(async () => { + await performDisconnect() + }, [performDisconnect]) + + const resumeSession = useCallback(async () => { + const saved = savedSession + if (!saved) return + await connect(saved.walletType) + }, [savedSession, connect]) + + const autoResumedRef = useRef(false) + useEffect(() => { + if (autoResumedRef.current || !savedSession || state.connectionStatus !== 'disconnected') return + autoResumedRef.current = true + resumeSession().catch(() => performDisconnect().catch(console.warn)) + }, [savedSession, state.connectionStatus, resumeSession, performDisconnect]) + + const sync = useCallback(async () => { + const session = sessionRef.current + if (!session) throw new Error('WalletProvider: not connected') + + setState(s => ({ ...s, syncing: true, error: null })) + + try { + const rawBalances = await syncWallet(session.wollet, session.esploraClient) + setState(s => ({ ...s, syncing: false, balances: serializeBalances(rawBalances) })) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + setState(s => ({ ...s, syncing: false, error, isError: true })) + } + }, []) + + const signAndBroadcast = useCallback(async (pset: Pset): Promise => { + const session = sessionRef.current + if (!session) throw new Error('WalletProvider: not connected') + + const signedPset = await session.connector.signPset(pset) + const finalizedPset = session.wollet.finalize(signedPset) + const txid = await session.esploraClient.broadcast(finalizedPset) + return txid.toString() + }, []) + + const getLastReceiveAddress = useCallback((): string | null => { + const session = sessionRef.current + if (!session) return null + return session.wollet.address().address().toString() + }, []) + + const sendLbtc = useCallback( + async (recipientAddress: string, satoshi: bigint): Promise => { + const session = sessionRef.current + if (!session) throw new Error('WalletProvider: not connected') + + const addr = lwk.Address.parse(recipientAddress, lwkNetwork) + const txBuilder = await new lwk.TxBuilder(lwkNetwork) + .feeRate(100) + .addLbtcRecipient(addr, satoshi) + const pset = txBuilder.finish(session.wollet) + return signAndBroadcast(pset) + }, + [lwk, lwkNetwork, signAndBroadcast], + ) + + return ( + + {children} + + ) +} + +function serializeBalances(raw: [string, bigint][]): Record { + const result: Record = {} + for (const [assetId, amount] of raw) { + result[assetId] = amount.toString() + } + return result +} diff --git a/web-v2/src/providers/wallet/types.ts b/web-v2/src/providers/wallet/types.ts new file mode 100644 index 0000000..765a89f --- /dev/null +++ b/web-v2/src/providers/wallet/types.ts @@ -0,0 +1,14 @@ +import type { Pset } from 'lwk_web' + +import type { SavedSession, SinglesigVariant, WalletState } from '@/lib/wallet-core/types' + +export interface WalletContextValue extends WalletState { + connect(variant: SinglesigVariant): Promise + disconnect(): Promise + sync(): Promise + signAndBroadcast(pset: Pset): Promise + sendLbtc(recipientAddress: string, satoshi: bigint): Promise + getLastReceiveAddress(): string | null + resumeSession(): Promise + savedSession: SavedSession | null +} diff --git a/web-v2/src/providers/wallet/useWallet.ts b/web-v2/src/providers/wallet/useWallet.ts new file mode 100644 index 0000000..35723ff --- /dev/null +++ b/web-v2/src/providers/wallet/useWallet.ts @@ -0,0 +1,12 @@ +import { useContext } from 'react' + +import type { WalletContextValue } from './types' +import { WALLET_CONTEXT_UNINITIALIZED, WalletContext } from './WalletContext' + +export function useWallet(): WalletContextValue { + const ctx = useContext(WalletContext) + if (ctx === WALLET_CONTEXT_UNINITIALIZED) { + throw new Error('useWallet() must be used within ') + } + return ctx +} diff --git a/web-v2/src/types/web-serial.d.ts b/web-v2/src/types/web-serial.d.ts new file mode 100644 index 0000000..ca527a6 --- /dev/null +++ b/web-v2/src/types/web-serial.d.ts @@ -0,0 +1,15 @@ +/** + * Minimal Web Serial API ambient type declarations. + * + * The standard TypeScript DOM lib does not include Web Serial API types. + * Only the subset used in this codebase is declared here. + */ + +interface Serial extends EventTarget { + addEventListener(type: 'connect' | 'disconnect', listener: (event: Event) => void): void + removeEventListener(type: 'connect' | 'disconnect', listener: (event: Event) => void): void +} + +interface Navigator { + readonly serial: Serial +} From 7a451a0c52abdfc2a476782eb2438b2f133c5a25 Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Fri, 15 May 2026 21:32:58 +0300 Subject: [PATCH 2/2] refactor(wallet): improve wallet sync and balance handling --- web-v2/src/providers/AppProviders.tsx | 1 - .../src/providers/wallet/WalletProvider.tsx | 27 ++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/web-v2/src/providers/AppProviders.tsx b/web-v2/src/providers/AppProviders.tsx index e6bc680..becd8e0 100644 --- a/web-v2/src/providers/AppProviders.tsx +++ b/web-v2/src/providers/AppProviders.tsx @@ -11,7 +11,6 @@ import { WalletProvider } from './wallet/WalletProvider' export function AppProviders({ children }: PropsWithChildren) { return ( - {children} {children} diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx index c892463..1a5cc01 100644 --- a/web-v2/src/providers/wallet/WalletProvider.tsx +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -107,6 +107,22 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { return () => clearInterval(id) }, [state.connectionStatus, performDisconnect]) + useEffect(() => { + if (state.connectionStatus !== 'ready') return + + const id = setInterval(() => { + const session = sessionRef.current + if (!session) return + syncWallet(session.wollet, session.esploraClient) + .then(rawBalances => { + setState(s => ({ ...s, balances: serializeBalances(rawBalances) })) + }) + .catch(console.warn) + }, 60_000) + + return () => clearInterval(id) + }, [state.connectionStatus]) + const connect = useCallback( async (variant: SinglesigVariant) => { if (sessionRef.current !== null) return @@ -211,7 +227,16 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const signedPset = await session.connector.signPset(pset) const finalizedPset = session.wollet.finalize(signedPset) const txid = await session.esploraClient.broadcast(finalizedPset) - return txid.toString() + const txidStr = txid.toString() + + // Auto-sync balances after broadcast (fire-and-forget, errors are non-fatal). + syncWallet(session.wollet, session.esploraClient) + .then(rawBalances => { + setState(s => ({ ...s, balances: serializeBalances(rawBalances) })) + }) + .catch(console.warn) + + return txidStr }, []) const getLastReceiveAddress = useCallback((): string | null => {