From d50478e29cc81edcf86fd268bf13e33bddd62532 Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Fri, 15 May 2026 21:09:22 +0300 Subject: [PATCH 1/8] 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/8] 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 => { From 870ce3f163e3414f5b2764c2fe093ec7984d66d0 Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Mon, 18 May 2026 12:25:54 +0300 Subject: [PATCH 3/8] feat(wallet): add address verification for Jade connector and clear wasm on beforeunload --- web-v2/src/lib/wallet-core/connector/jade.ts | 18 ++++++++++++- web-v2/src/lib/wallet-core/connector/types.ts | 3 ++- web-v2/src/pages/Dashboard/index.tsx | 23 ++++++++++++++++- .../src/providers/wallet/WalletProvider.tsx | 25 +++++++++++++++++++ web-v2/src/providers/wallet/types.ts | 1 + 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/web-v2/src/lib/wallet-core/connector/jade.ts b/web-v2/src/lib/wallet-core/connector/jade.ts index 368fa87..49db608 100644 --- a/web-v2/src/lib/wallet-core/connector/jade.ts +++ b/web-v2/src/lib/wallet-core/connector/jade.ts @@ -1,4 +1,4 @@ -import type { Jade, Network, Pset, WolletDescriptor } from 'lwk_web' +import type { Jade, Network, Pset, Wollet, WolletDescriptor } from 'lwk_web' import type { Lwk } from '@/lwk' @@ -71,6 +71,22 @@ export class JadeConnector implements WalletConnector { } } + /** + * Ask Jade to display and confirm the receive address on-device. + * + * Jade shows the address on its screen and requires a button press to confirm. + * The returned string is the address as verified by the hardware — compare it + * against the software-derived address to detect substitution attacks. + */ + async getVerifiedReceiveAddress(variant: SinglesigVariant, wollet: Wollet): Promise { + if (!this.jade) throw new Error('JadeConnector: not connected') + const addrResult = wollet.address() + const index = addrResult.index() + const path = wollet.addressFullPath(index) + const singlesig = this.lwk.Singlesig.from(variant) + return await this.jade.getReceiveAddressSingle(singlesig, path) + } + isConnected(): boolean { return this.jade !== null } diff --git a/web-v2/src/lib/wallet-core/connector/types.ts b/web-v2/src/lib/wallet-core/connector/types.ts index d8b600d..cb14fc4 100644 --- a/web-v2/src/lib/wallet-core/connector/types.ts +++ b/web-v2/src/lib/wallet-core/connector/types.ts @@ -1,4 +1,4 @@ -import type { Pset, WolletDescriptor } from 'lwk_web' +import type { Pset, Wollet, WolletDescriptor } from 'lwk_web' import type { ConnectionStatus, JadeVersionInfo, SinglesigVariant } from '../types' @@ -10,4 +10,5 @@ export interface WalletConnector { isConnected(): boolean readVersion?(): Promise getConnectionState?(): Promise + getVerifiedReceiveAddress?(variant: SinglesigVariant, wollet: Wollet): Promise } diff --git a/web-v2/src/pages/Dashboard/index.tsx b/web-v2/src/pages/Dashboard/index.tsx index 89ac1e4..d73a124 100644 --- a/web-v2/src/pages/Dashboard/index.tsx +++ b/web-v2/src/pages/Dashboard/index.tsx @@ -33,7 +33,6 @@ function resolvePhase( ): Phase { if (connectionStatus === 'locked') return 'locked' if (connectionStatus === 'ready') return 'ready' - // disconnected if (syncing) return 'connecting' return usbDeviceDetected ? 'usb-detected' : 'no-usb' } @@ -51,6 +50,7 @@ export default function DashboardPage() { connect, sendLbtc, getLastReceiveAddress, + verifyReceiveAddress, } = useWallet() const [walletType, setWalletType] = useState('Wpkh') @@ -60,6 +60,7 @@ export default function DashboardPage() { const [sendError, setSendError] = useState(null) const [sending, setSending] = useState(false) const [txConfirmations, setTxConfirmations] = useState(null) + const [verifyingAddress, setVerifyingAddress] = useState(false) // Poll Esplora directly for first confirmation after sending. useEffect(() => { @@ -81,6 +82,19 @@ export default function DashboardPage() { const phase = resolvePhase(connectionStatus, usbDeviceDetected, syncing) + const handleVerifyAddress = async () => { + setVerifyingAddress(true) + console.warn('[Dashboard] handleVerifyAddress: requesting address verification on device...') + try { + const addr = await verifyReceiveAddress() + console.warn('[Dashboard] handleVerifyAddress: device confirmed address →', addr) + } catch (err) { + console.warn('[Dashboard] handleVerifyAddress: error', err) + } finally { + setVerifyingAddress(false) + } + } + const handleSend = async () => { setSendError(null) setSendTxid(null) @@ -179,6 +193,13 @@ export default function DashboardPage() {

Receive address

{getLastReceiveAddress()} +
diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx index 1a5cc01..beb86ba 100644 --- a/web-v2/src/providers/wallet/WalletProvider.tsx +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -60,6 +60,21 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { })) }, []) + // Release the WebSerial port before page unload to avoid Jade's -32003 + // (network inconsistency) error on reload. beforeunload cannot await promises, + // so we fire-and-forget — jade.free() is a synchronous WASM call under the hood. + useEffect(() => { + const handleBeforeUnload = () => { + const session = sessionRef.current + if (session) { + session.connector.disconnect().catch(console.warn) + sessionRef.current = null + } + } + window.addEventListener('beforeunload', handleBeforeUnload) + return () => window.removeEventListener('beforeunload', handleBeforeUnload) + }, []) + // Permanent Web Serial event listeners — detect USB plug/unplug. useEffect(() => { if (!('serial' in navigator)) return @@ -245,6 +260,15 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { return session.wollet.address().address().toString() }, []) + const verifyReceiveAddress = useCallback(async (): Promise => { + const session = sessionRef.current + if (!session) return null + if (!session.connector.getVerifiedReceiveAddress) + return session.wollet.address().address().toString() + + return session.connector.getVerifiedReceiveAddress(state.walletType ?? 'Wpkh', session.wollet) + }, [state.walletType]) + const sendLbtc = useCallback( async (recipientAddress: string, satoshi: bigint): Promise => { const session = sessionRef.current @@ -270,6 +294,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { signAndBroadcast, sendLbtc, getLastReceiveAddress, + verifyReceiveAddress, resumeSession, savedSession, }} diff --git a/web-v2/src/providers/wallet/types.ts b/web-v2/src/providers/wallet/types.ts index 765a89f..12b5f75 100644 --- a/web-v2/src/providers/wallet/types.ts +++ b/web-v2/src/providers/wallet/types.ts @@ -9,6 +9,7 @@ export interface WalletContextValue extends WalletState { signAndBroadcast(pset: Pset): Promise sendLbtc(recipientAddress: string, satoshi: bigint): Promise getLastReceiveAddress(): string | null + verifyReceiveAddress(): Promise resumeSession(): Promise savedSession: SavedSession | null } From d0adc78f0e16c0379927b06ddbed98ac078d65a5 Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Thu, 21 May 2026 10:37:11 +0300 Subject: [PATCH 4/8] refactor: update wallet connector and session management --- web-v2/.env.example | 4 +- web-v2/.env.liquid | 6 +- web-v2/.env.liquidtestnet | 6 +- web-v2/src/api/esplora/methods.ts | 18 ++ web-v2/src/constants/env.ts | 2 +- web-v2/src/hooks/useSessionStorage.ts | 26 ++ web-v2/src/lib/wallet-core/connector/jade.ts | 34 ++- web-v2/src/lib/wallet-core/connector/seed.ts | 19 +- web-v2/src/lib/wallet-core/connector/types.ts | 14 +- web-v2/src/lib/wallet-core/types.ts | 40 +-- web-v2/src/lib/wallet-core/wallet/session.ts | 10 - web-v2/src/lib/wallet-core/wallet/sync.ts | 35 +-- web-v2/src/lwk/index.ts | 28 +- web-v2/src/pages/Dashboard/WalletDemo.tsx | 265 +++++++++++++++++ web-v2/src/pages/Dashboard/index.tsx | 278 +----------------- .../src/providers/wallet/WalletProvider.tsx | 118 +++----- web-v2/src/providers/wallet/types.ts | 44 ++- 17 files changed, 491 insertions(+), 456 deletions(-) create mode 100644 web-v2/src/hooks/useSessionStorage.ts delete mode 100644 web-v2/src/lib/wallet-core/wallet/session.ts create mode 100644 web-v2/src/pages/Dashboard/WalletDemo.tsx diff --git a/web-v2/.env.example b/web-v2/.env.example index 469f2d3..507cdeb 100644 --- a/web-v2/.env.example +++ b/web-v2/.env.example @@ -3,8 +3,8 @@ VITE_API_URL=http://localhost:80 VITE_NETWORK=liquid -VITE_ESPLORA_BASE_URL=https://blockstream.info/liquid/api -VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquid/api +VITE_ESPLORA_BASE_URL=https://blockstream.info/liquid +VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquid VITE_WATERFALLS_RECIPIENT=age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p # Optional: BIP39 mnemonic for debug software signer (dev/testnet only). diff --git a/web-v2/.env.liquid b/web-v2/.env.liquid index ca1ddaa..b17baf2 100644 --- a/web-v2/.env.liquid +++ b/web-v2/.env.liquid @@ -1,5 +1,5 @@ -VITE_API_URL=http://localhost:8000 +VITE_API_URL=http://localhost:80 VITE_NETWORK=liquid -VITE_ESPLORA_BASE_URL=https://blockstream.info/liquid/api -VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquid/api +VITE_ESPLORA_BASE_URL=https://blockstream.info/liquid +VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquid VITE_WATERFALLS_RECIPIENT=age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p diff --git a/web-v2/.env.liquidtestnet b/web-v2/.env.liquidtestnet index 14a5ab3..03844bf 100644 --- a/web-v2/.env.liquidtestnet +++ b/web-v2/.env.liquidtestnet @@ -1,7 +1,7 @@ -VITE_API_URL=http://localhost:8000 +VITE_API_URL=http://localhost:80 VITE_NETWORK=liquidtestnet -VITE_ESPLORA_BASE_URL=https://blockstream.info/liquidtestnet/api -VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquidtestnet/api +VITE_ESPLORA_BASE_URL=https://blockstream.info/liquidtestnet +VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquidtestnet VITE_WATERFALLS_RECIPIENT=age1xxzrgrfjm3yrwh3u6a7exgrldked0pdauvr3mx870wl6xzrwm5ps8s2h0p # Uncomment for debug software signer (dev only): diff --git a/web-v2/src/api/esplora/methods.ts b/web-v2/src/api/esplora/methods.ts index 540bcbe..b55ec74 100644 --- a/web-v2/src/api/esplora/methods.ts +++ b/web-v2/src/api/esplora/methods.ts @@ -16,6 +16,8 @@ import { type ScriptHashUtxoEntry, scriptHashUtxoListSchema, txIdListSchema, + type TxStatus, + txStatusSchema, } from './schemas' function buildEsploraUrl(path: string): string { @@ -38,6 +40,22 @@ export async function fetchTx(txId: string, options: RequestParams = {}): Promis return requestJson(buildEsploraUrl(`/tx/${txId}`), esploraTxSchema, { signal: options.signal }) } +export async function fetchTxStatus(txId: string, options: RequestParams = {}): Promise { + return requestJson(buildEsploraUrl(`/tx/${txId}/status`), txStatusSchema, { + signal: options.signal, + }) +} + +export async function fetchTxConfirmations( + txId: string, + options: RequestParams = {}, +): Promise { + const status = await fetchTxStatus(txId, options) + if (!status.confirmed || status.block_height === undefined) return null + const tip = await fetchLatestBlockHeight(options) + return tip - status.block_height + 1 +} + export async function fetchTxRaw(txId: string, options: RequestParams = {}): Promise { return requestBytes(buildEsploraUrl(`/tx/${txId}/raw`), { signal: options.signal }) } diff --git a/web-v2/src/constants/env.ts b/web-v2/src/constants/env.ts index c511f1d..ee23c5a 100644 --- a/web-v2/src/constants/env.ts +++ b/web-v2/src/constants/env.ts @@ -4,7 +4,7 @@ const envSchema = z.object({ VITE_API_URL: z.string().url().default('http://localhost:80'), DEV: z.boolean().default(false), PROD: z.boolean().default(false), - VITE_ESPLORA_BASE_URL: z.string().url().default('https://blockstream.info/liquid/api'), + VITE_ESPLORA_BASE_URL: z.string().url().default('https://blockstream.info/liquid'), VITE_NETWORK: z.enum(['liquid', 'liquidtestnet', 'regtest']).default('liquid'), VITE_WATERFALLS_URL: z.string().url(), VITE_WATERFALLS_RECIPIENT: z diff --git a/web-v2/src/hooks/useSessionStorage.ts b/web-v2/src/hooks/useSessionStorage.ts new file mode 100644 index 0000000..a0f6a07 --- /dev/null +++ b/web-v2/src/hooks/useSessionStorage.ts @@ -0,0 +1,26 @@ +import { useCallback, useState } from 'react' + +export function useSessionStorage(key: string): [T | null, (value: T | null) => void] { + const [value, setValueState] = useState(() => { + try { + const raw = sessionStorage.getItem(key) + return raw ? (JSON.parse(raw) as T) : null + } catch { + return null + } + }) + + const setValue = useCallback( + (newValue: T | null) => { + if (newValue === null) { + sessionStorage.removeItem(key) + } else { + sessionStorage.setItem(key, JSON.stringify(newValue)) + } + setValueState(newValue) + }, + [key], + ) + + return [value, setValue] +} diff --git a/web-v2/src/lib/wallet-core/connector/jade.ts b/web-v2/src/lib/wallet-core/connector/jade.ts index 49db608..66cbdf0 100644 --- a/web-v2/src/lib/wallet-core/connector/jade.ts +++ b/web-v2/src/lib/wallet-core/connector/jade.ts @@ -2,7 +2,7 @@ import type { Jade, Network, Pset, Wollet, WolletDescriptor } from 'lwk_web' import type { Lwk } from '@/lwk' -import type { ConnectionStatus, JadeVersionInfo, SinglesigVariant } from '../types' +import type { ConnectionStatus, JadeVersionInfo, WalletType } from '../types' import type { WalletConnector } from './types' /** @@ -15,6 +15,7 @@ import type { WalletConnector } from './types' export class JadeConnector implements WalletConnector { private jade: Jade | null = null private busy = false + private _id: string | null = null constructor( private readonly lwk: Lwk, @@ -29,33 +30,40 @@ export class JadeConnector implements WalletConnector { this.jade = await new this.lwk.Jade(this.lwkNetwork, true) } - async disconnect(): Promise { + disconnect(): void { if (this.jade) { this.jade.free() this.jade = null } + this._id = null } - async readVersion(): Promise { + get id(): string | null { + return this._id + } + + async getVersionInfo(): 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, + const info = { + state: raw.JADE_STATE as JadeVersionInfo['state'], + efuseMac: raw.EFUSEMAC as string, + version: raw.JADE_VERSION as string, } + this._id ??= info.efuseMac + return info } - async getConnectionState(): Promise { + async getConnectionStatus(): 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' + const info = await this.getVersionInfo() + return info.state === 'READY' ? 'ready' : 'locked' } - async getDescriptor(variant: SinglesigVariant): Promise { + async getDescriptor(variant: WalletType): 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() @@ -78,7 +86,7 @@ export class JadeConnector implements WalletConnector { * The returned string is the address as verified by the hardware — compare it * against the software-derived address to detect substitution attacks. */ - async getVerifiedReceiveAddress(variant: SinglesigVariant, wollet: Wollet): Promise { + async getVerifiedReceiveAddress(variant: WalletType, wollet: Wollet): Promise { if (!this.jade) throw new Error('JadeConnector: not connected') const addrResult = wollet.address() const index = addrResult.index() @@ -87,7 +95,7 @@ export class JadeConnector implements WalletConnector { return await this.jade.getReceiveAddressSingle(singlesig, path) } - isConnected(): boolean { + get 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 index 61099bf..576fc8d 100644 --- a/web-v2/src/lib/wallet-core/connector/seed.ts +++ b/web-v2/src/lib/wallet-core/connector/seed.ts @@ -2,7 +2,7 @@ import type { Network, Pset, Signer, WolletDescriptor } from 'lwk_web' import type { Lwk } from '@/lwk' -import type { SinglesigVariant } from '../types' +import type { ConnectionStatus, WalletType } from '../types' import type { WalletConnector } from './types' /** @@ -16,6 +16,7 @@ import type { WalletConnector } from './types' */ export class SeedConnector implements WalletConnector { private signer: Signer | null = null + private _id: string | null = null constructor( private readonly lwk: Lwk, @@ -29,18 +30,24 @@ export class SeedConnector implements WalletConnector { if (this.signer !== null) return const mnemonic = new this.lwk.Mnemonic(this.mnemonicStr) this.signer = new this.lwk.Signer(mnemonic, this.lwkNetwork) + this._id = crypto.randomUUID() } - async disconnect(): Promise { + disconnect(): void { if (this.signer) { this.signer.free() this.signer = null } + this._id = null + } + + get id(): string | null { + return this._id } async getDescriptor( // eslint-disable-next-line @typescript-eslint/no-unused-vars - _variant: SinglesigVariant, + _variant: WalletType, ): Promise { if (!this.signer) throw new Error('SeedConnector: not connected') // Signer only exposes wpkhSlip77Descriptor (native segwit + SLIP77 blinding). @@ -54,7 +61,11 @@ export class SeedConnector implements WalletConnector { return this.signer.sign(pset) } - isConnected(): boolean { + async getConnectionStatus(): Promise { + return 'ready' + } + + get 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 index cb14fc4..eae6964 100644 --- a/web-v2/src/lib/wallet-core/connector/types.ts +++ b/web-v2/src/lib/wallet-core/connector/types.ts @@ -1,14 +1,14 @@ import type { Pset, Wollet, WolletDescriptor } from 'lwk_web' -import type { ConnectionStatus, JadeVersionInfo, SinglesigVariant } from '../types' +import type { ConnectionStatus, WalletType } from '../types' export interface WalletConnector { + readonly id: string | null connect(): Promise - disconnect(): Promise - getDescriptor(variant: SinglesigVariant): Promise + disconnect(): void + getDescriptor(variant: WalletType): Promise signPset(pset: Pset): Promise - isConnected(): boolean - readVersion?(): Promise - getConnectionState?(): Promise - getVerifiedReceiveAddress?(variant: SinglesigVariant, wollet: Wollet): Promise + isConnected: boolean + getConnectionStatus(): Promise + getVerifiedReceiveAddress?(variant: WalletType, wollet: Wollet): Promise } diff --git a/web-v2/src/lib/wallet-core/types.ts b/web-v2/src/lib/wallet-core/types.ts index 163e1dc..9457cfb 100644 --- a/web-v2/src/lib/wallet-core/types.ts +++ b/web-v2/src/lib/wallet-core/types.ts @@ -1,43 +1,13 @@ -export type SinglesigVariant = 'Wpkh' | 'ShWpkh' +export type WalletType = 'Wpkh' | 'ShWpkh' /** Raw JADE_STATE values from getVersion() */ export type JadeConnectionState = 'LOCKED' | 'READY' | 'UNINIT' | 'TEMP' -export type JadeVersionInfo = { - jadeState: JadeConnectionState +export interface JadeVersionInfo { + state: JadeConnectionState /** EFUSEMAC — unique hardware identifier */ - jadeMac: string - jadeVersion: string + efuseMac: string + version: 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 deleted file mode 100644 index 362920f..0000000 --- a/web-v2/src/lib/wallet-core/wallet/session.ts +++ /dev/null @@ -1,10 +0,0 @@ -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 index 461f914..415e768 100644 --- a/web-v2/src/lib/wallet-core/wallet/sync.ts +++ b/web-v2/src/lib/wallet-core/wallet/sync.ts @@ -1,37 +1,20 @@ -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 -} +import type { EsploraClient, Wollet } from 'lwk_web' /** * Syncs wallet state via waterfalls fullScan and applies the update. - * Returns the updated balance map (assetId -> satoshis). + * Returns the updated balance map (assetId -> satoshis as strings). */ -export async function syncWallet( +export async function syncBalances( wollet: Wollet, esploraClient: EsploraClient, -): Promise<[string, bigint][]> { +): Promise> { const update = await esploraClient.fullScanToIndex(wollet, 0) if (update) { wollet.applyUpdate(update) } - return wollet.balance().entries() as [string, bigint][] + const result: Record = {} + for (const [assetId, amount] of wollet.balance().entries() as [string, bigint][]) { + result[assetId] = amount.toString() + } + return result } diff --git a/web-v2/src/lwk/index.ts b/web-v2/src/lwk/index.ts index 7225c34..edb4f57 100644 --- a/web-v2/src/lwk/index.ts +++ b/web-v2/src/lwk/index.ts @@ -1,6 +1,12 @@ -import type { Network, SimplicityArguments, Transaction, XOnlyPublicKey } from 'lwk_web' +import type { + EsploraClient, + Network, + SimplicityArguments, + Transaction, + XOnlyPublicKey, +} from 'lwk_web' -import type { NetworkName } from '@/constants/env' +import { env, type NetworkName } from '@/constants/env' export type Lwk = typeof import('lwk_web') @@ -42,3 +48,21 @@ export function createP2trAddress(lwk: Lwk, params: CreateP2trAddressParams): st const address = program.createP2trAddress(params.internalKey, net) return address.toString() } + +/** + * 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}/api`, + true, // waterfalls + 4, // concurrency + true, // utxoOnly + ) + if (lwkNetwork.isMainnet() || lwkNetwork.isTestnet()) { + client.setWaterfallsServerRecipient(env.VITE_WATERFALLS_RECIPIENT) + } + return client +} diff --git a/web-v2/src/pages/Dashboard/WalletDemo.tsx b/web-v2/src/pages/Dashboard/WalletDemo.tsx new file mode 100644 index 0000000..8fa046d --- /dev/null +++ b/web-v2/src/pages/Dashboard/WalletDemo.tsx @@ -0,0 +1,265 @@ +import { useEffect, useState } from 'react' + +import { fetchTxConfirmations } from '@/api/esplora/methods' +import { getTxExplorerUrl } from '@/api/esplora/utils' +import { env } from '@/constants/env' +import type { ConnectionStatus, WalletType } from '@/lib/wallet-core/types' +import { useLwk } from '@/providers/lwk/useLwk' +import { useWallet } from '@/providers/wallet/useWallet' + +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' + if (syncing) return 'connecting' + return usbDeviceDetected ? 'usb-detected' : 'no-usb' +} + +export function WalletDemo() { + const { network, isTestnet, isMainnet, isRegtest } = useLwk() + const { + connectionStatus, + syncing, + isError, + error, + balances, + usbDeviceDetected, + connect, + sendLbtc, + getLastReceiveAddress, + verifyReceiveAddress, + efuseMac, + } = 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) + const [verifyingAddress, setVerifyingAddress] = useState(false) + + // Poll Esplora directly for first confirmation after sending. + useEffect(() => { + if (!sendTxid || txConfirmations !== null) return + + const id = setInterval(() => { + fetchTxConfirmations(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 handleVerifyAddress = async () => { + setVerifyingAddress(true) + console.warn('[Dashboard] handleVerifyAddress: requesting address verification on device...') + try { + const addr = await verifyReceiveAddress() + console.warn('[Dashboard] handleVerifyAddress: device confirmed address →', addr) + } catch (err) { + console.warn('[Dashboard] handleVerifyAddress: error', err) + } finally { + setVerifyingAddress(false) + } + } + + 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 ( +
+ {phase === 'no-usb' && ( +
+
+ +
+ {env.VITE_DEBUG_MNEMONIC && ( + <> + + + )} +
+ )} + + {phase === 'usb-detected' && ( +
+
+ Wallet type + + +
+
+ +
+
+ )} + + {phase === 'connecting' &&

Connecting to Jade...

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

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

+ {syncing &&

Loading wallet...

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

Receive address

+ {getLastReceiveAddress()} + +
+ +
+

+ Balances + {efuseMac && ({efuseMac})} +

+ {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} +

+

+ isTestnet: {isTestnet.toString()} / isMainnet: {isMainnet.toString()} / isRegtest:{' '} + {isRegtest.toString()} +

+
+ ) +} diff --git a/web-v2/src/pages/Dashboard/index.tsx b/web-v2/src/pages/Dashboard/index.tsx index d73a124..0d4bc02 100644 --- a/web-v2/src/pages/Dashboard/index.tsx +++ b/web-v2/src/pages/Dashboard/index.tsx @@ -1,284 +1,10 @@ -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' - if (syncing) return 'connecting' - return usbDeviceDetected ? 'usb-detected' : 'no-usb' -} +import { WalletDemo } from './WalletDemo' export default function DashboardPage() { - const { network, isTestnet, isMainnet, isRegtest } = useLwk() - const { - connectionStatus, - syncing, - isError, - error, - balances, - jadeMac, - usbDeviceDetected, - connect, - sendLbtc, - getLastReceiveAddress, - verifyReceiveAddress, - } = 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) - const [verifyingAddress, setVerifyingAddress] = useState(false) - - // 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 handleVerifyAddress = async () => { - setVerifyingAddress(true) - console.warn('[Dashboard] handleVerifyAddress: requesting address verification on device...') - try { - const addr = await verifyReceiveAddress() - console.warn('[Dashboard] handleVerifyAddress: device confirmed address →', addr) - } catch (err) { - console.warn('[Dashboard] handleVerifyAddress: error', err) - } finally { - setVerifyingAddress(false) - } - } - - 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} -

-

- isTestnet: {isTestnet.toString()} / isMainnet: {isMainnet.toString()} / isRegtest:{' '} - {isRegtest.toString()} -

+
) } diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx index beb86ba..25cf7f8 100644 --- a/web-v2/src/providers/wallet/WalletProvider.tsx +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -2,63 +2,51 @@ import type { Pset } from 'lwk_web' import { useCallback, useEffect, useRef, useState } from 'react' import { env } from '@/constants/env' +import { useSessionStorage } from '@/hooks/useSessionStorage' 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 type { WalletType } from '@/lib/wallet-core/types' +import { syncBalances } from '@/lib/wallet-core/wallet/sync' +import { createEsploraClient } from '@/lwk' +import { useLwk } from '@/providers/lwk/useLwk' + import { INITIAL_WALLET_STATE, type SavedSession, - type SinglesigVariant, + type WalletSession, 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' - +} from './types' 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) + const [savedSession, setSavedSession] = useSessionStorage(SESSION_STORAGE_KEY) // 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 } : {}), - })) - }, []) + const performDisconnect = useCallback( + async (error?: string) => { + const session = sessionRef.current + if (session) { + session.connector.disconnect() + sessionRef.current = null + } + setSavedSession(null) + // Do NOT preserve usbDeviceDetected — physical disconnect means the device is gone. + setState(() => ({ + ...INITIAL_WALLET_STATE, + ...(error !== undefined ? { error, isError: true } : {}), + })) + window.location.reload() + }, + [setSavedSession], + ) // Release the WebSerial port before page unload to avoid Jade's -32003 // (network inconsistency) error on reload. beforeunload cannot await promises, @@ -67,7 +55,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const handleBeforeUnload = () => { const session = sessionRef.current if (session) { - session.connector.disconnect().catch(console.warn) + session.connector.disconnect() sessionRef.current = null } } @@ -85,7 +73,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { } const handleDisconnect = () => { if (sessionRef.current) { - performDisconnect('Device disconnected').catch(console.warn) + performDisconnect('Device disconnected') } else { setState(s => ({ ...s, usbDeviceDetected: false })) } @@ -106,11 +94,12 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const id = setInterval(() => { const session = sessionRef.current - if (!session?.connector.getConnectionState) return + if (!session) return session.connector - .getConnectionState() + .getConnectionStatus() .then(status => { + if (status === 'locked') window.location.reload() // to prompt for PIN again and avoid serial port conflicts setState(s => (s.connectionStatus === status ? s : { ...s, connectionStatus: status })) }) .catch((err: unknown) => { @@ -128,9 +117,9 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { const id = setInterval(() => { const session = sessionRef.current if (!session) return - syncWallet(session.wollet, session.esploraClient) + syncBalances(session.wollet, session.esploraClient) .then(rawBalances => { - setState(s => ({ ...s, balances: serializeBalances(rawBalances) })) + setState(s => ({ ...s, balances: rawBalances })) }) .catch(console.warn) }, 60_000) @@ -139,7 +128,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { }, [state.connectionStatus]) const connect = useCallback( - async (variant: SinglesigVariant) => { + async (variant: WalletType) => { if (sessionRef.current !== null) return setState(s => ({ ...s, syncing: true, error: null, isError: false })) @@ -151,16 +140,13 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { 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' + const connectionStatus = await connector.getConnectionStatus() // Show the intermediate state (locked/ready) before PIN prompt blocks. setState(s => ({ ...s, connectionStatus, - jadeMac: versionInfo?.jadeMac ?? null, + efuseMac: connector.id, walletType: variant, })) @@ -171,14 +157,13 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { sessionRef.current = { connector, descriptor, wollet, esploraClient } const saved: SavedSession = { - efuseMac: versionInfo?.jadeMac ?? null, + efuseMac: connector.id, walletType: variant, descriptorStr: descriptor.toString(), } - persistSession(saved) setSavedSession(saved) - const rawBalances = await syncWallet(wollet, esploraClient) + const balances = await syncBalances(wollet, esploraClient) setState(s => ({ ...s, @@ -186,7 +171,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { syncing: false, error: null, isError: false, - balances: serializeBalances(rawBalances), + balances, })) } catch (err) { const error = err instanceof Error ? err.message : String(err) @@ -200,7 +185,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { })) } }, - [lwk, lwkNetwork], + [lwk, lwkNetwork, setSavedSession], ) const disconnect = useCallback(async () => { @@ -208,9 +193,8 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { }, [performDisconnect]) const resumeSession = useCallback(async () => { - const saved = savedSession - if (!saved) return - await connect(saved.walletType) + if (!savedSession) return + await connect(savedSession.walletType) }, [savedSession, connect]) const autoResumedRef = useRef(false) @@ -227,8 +211,8 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { setState(s => ({ ...s, syncing: true, error: null })) try { - const rawBalances = await syncWallet(session.wollet, session.esploraClient) - setState(s => ({ ...s, syncing: false, balances: serializeBalances(rawBalances) })) + const balances = await syncBalances(session.wollet, session.esploraClient) + setState(s => ({ ...s, syncing: false, balances })) } catch (err) { const error = err instanceof Error ? err.message : String(err) setState(s => ({ ...s, syncing: false, error, isError: true })) @@ -245,9 +229,9 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { 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) })) + syncBalances(session.wollet, session.esploraClient) + .then(balances => { + setState(s => ({ ...s, balances })) }) .catch(console.warn) @@ -303,11 +287,3 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { ) } - -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 index 12b5f75..eaba967 100644 --- a/web-v2/src/providers/wallet/types.ts +++ b/web-v2/src/providers/wallet/types.ts @@ -1,9 +1,10 @@ -import type { Pset } from 'lwk_web' +import type { EsploraClient, Pset, Wollet, WolletDescriptor } from 'lwk_web' -import type { SavedSession, SinglesigVariant, WalletState } from '@/lib/wallet-core/types' +import type { WalletConnector } from '@/lib/wallet-core/connector/types' +import type { ConnectionStatus, WalletType } from '@/lib/wallet-core/types' export interface WalletContextValue extends WalletState { - connect(variant: SinglesigVariant): Promise + connect(variant: WalletType): Promise disconnect(): Promise sync(): Promise signAndBroadcast(pset: Pset): Promise @@ -13,3 +14,40 @@ export interface WalletContextValue extends WalletState { resumeSession(): Promise savedSession: SavedSession | null } + +export interface WalletSession { + connector: WalletConnector + descriptor: WolletDescriptor + wollet: Wollet + esploraClient: EsploraClient +} + +export interface SavedSession { + efuseMac: string | null + walletType: WalletType + descriptorStr: string +} + +export interface WalletState { + connectionStatus: ConnectionStatus + efuseMac: string | null + walletType: WalletType | 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', + efuseMac: null, + walletType: null, + balances: {}, + syncing: false, + usbDeviceDetected: false, + error: null, + isError: false, +} From b0251204addc1014e8b9b340013a30d29db2725b Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Thu, 21 May 2026 17:55:04 +0300 Subject: [PATCH 5/8] feat(wallet): add getXOnlyPublicKey method --- web-v2/src/lib/wallet-core/connector/seed.ts | 7 ++++++ web-v2/src/lib/wallet-core/connector/types.ts | 1 + web-v2/src/pages/Dashboard/WalletDemo.tsx | 22 +++++++++++++++++++ .../src/providers/wallet/WalletProvider.tsx | 7 ++++++ web-v2/src/providers/wallet/types.ts | 1 + 5 files changed, 38 insertions(+) diff --git a/web-v2/src/lib/wallet-core/connector/seed.ts b/web-v2/src/lib/wallet-core/connector/seed.ts index 576fc8d..62f4e15 100644 --- a/web-v2/src/lib/wallet-core/connector/seed.ts +++ b/web-v2/src/lib/wallet-core/connector/seed.ts @@ -61,6 +61,13 @@ export class SeedConnector implements WalletConnector { return this.signer.sign(pset) } + async getXOnlyPublicKey(): Promise { + if (!this.signer) throw new Error('SeedConnector: not connected') + //github.com/BlockstreamResearch/smplx/blob/1945d11b47fff8838c3e99c210133519a9522324/crates/sdk/src/signer/core.rs#L621C1-L628C2 + const path = this.lwkNetwork.isMainnet() ? 'm/84h/1776h/0h/0/1' : 'm/84h/1h/0h/0/1' + return this.lwk.simplicityDeriveXonlyPubkey(this.signer, path).toString() + } + async getConnectionStatus(): Promise { return 'ready' } diff --git a/web-v2/src/lib/wallet-core/connector/types.ts b/web-v2/src/lib/wallet-core/connector/types.ts index eae6964..f6c0bfb 100644 --- a/web-v2/src/lib/wallet-core/connector/types.ts +++ b/web-v2/src/lib/wallet-core/connector/types.ts @@ -10,5 +10,6 @@ export interface WalletConnector { signPset(pset: Pset): Promise isConnected: boolean getConnectionStatus(): Promise + getXOnlyPublicKey?(): Promise getVerifiedReceiveAddress?(variant: WalletType, wollet: Wollet): Promise } diff --git a/web-v2/src/pages/Dashboard/WalletDemo.tsx b/web-v2/src/pages/Dashboard/WalletDemo.tsx index 8fa046d..16778d9 100644 --- a/web-v2/src/pages/Dashboard/WalletDemo.tsx +++ b/web-v2/src/pages/Dashboard/WalletDemo.tsx @@ -33,6 +33,7 @@ export function WalletDemo() { sendLbtc, getLastReceiveAddress, verifyReceiveAddress, + getXOnlyPublicKey, efuseMac, } = useWallet() @@ -44,6 +45,20 @@ export function WalletDemo() { const [sending, setSending] = useState(false) const [txConfirmations, setTxConfirmations] = useState(null) const [verifyingAddress, setVerifyingAddress] = useState(false) + const [xOnlyPubKey, setXOnlyPubKey] = useState(null) + + useEffect(() => { + if (connectionStatus !== 'ready') return + let cancelled = false + getXOnlyPublicKey() + .then(key => { + if (!cancelled) setXOnlyPubKey(key) + }) + .catch(console.warn) + return () => { + cancelled = true + } + }, [connectionStatus, getXOnlyPublicKey]) // Poll Esplora directly for first confirmation after sending. useEffect(() => { @@ -203,6 +218,13 @@ export function WalletDemo() { )}
+ {env.VITE_DEBUG_MNEMONIC && xOnlyPubKey && ( +
+

X-Only Public Key (Simplicity)

+ {xOnlyPubKey} +
+ )} +

Send Transfer

=> { + const session = sessionRef.current + if (!session) return null + return session.connector.getXOnlyPublicKey?.() ?? null + }, []) + const verifyReceiveAddress = useCallback(async (): Promise => { const session = sessionRef.current if (!session) return null @@ -279,6 +285,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { sendLbtc, getLastReceiveAddress, verifyReceiveAddress, + getXOnlyPublicKey, resumeSession, savedSession, }} diff --git a/web-v2/src/providers/wallet/types.ts b/web-v2/src/providers/wallet/types.ts index eaba967..10fb8c9 100644 --- a/web-v2/src/providers/wallet/types.ts +++ b/web-v2/src/providers/wallet/types.ts @@ -11,6 +11,7 @@ export interface WalletContextValue extends WalletState { sendLbtc(recipientAddress: string, satoshi: bigint): Promise getLastReceiveAddress(): string | null verifyReceiveAddress(): Promise + getXOnlyPublicKey(): Promise resumeSession(): Promise savedSession: SavedSession | null } From da6326fc90bc859abe026c5cdfbe253a47db490a Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Thu, 21 May 2026 17:59:46 +0300 Subject: [PATCH 6/8] refactor(wallet): rename sync to syncWallet for clarity --- web-v2/src/providers/wallet/WalletProvider.tsx | 2 +- web-v2/src/providers/wallet/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web-v2/src/providers/wallet/WalletProvider.tsx b/web-v2/src/providers/wallet/WalletProvider.tsx index cdcf541..272f489 100644 --- a/web-v2/src/providers/wallet/WalletProvider.tsx +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -280,7 +280,7 @@ export function WalletProvider({ children }: { children: React.ReactNode }) { ...state, connect, disconnect, - sync, + syncWallet: sync, signAndBroadcast, sendLbtc, getLastReceiveAddress, diff --git a/web-v2/src/providers/wallet/types.ts b/web-v2/src/providers/wallet/types.ts index 10fb8c9..7b33c76 100644 --- a/web-v2/src/providers/wallet/types.ts +++ b/web-v2/src/providers/wallet/types.ts @@ -6,7 +6,7 @@ import type { ConnectionStatus, WalletType } from '@/lib/wallet-core/types' export interface WalletContextValue extends WalletState { connect(variant: WalletType): Promise disconnect(): Promise - sync(): Promise + syncWallet(): Promise signAndBroadcast(pset: Pset): Promise sendLbtc(recipientAddress: string, satoshi: bigint): Promise getLastReceiveAddress(): string | null From 001bc581155bf884a08c3e07a889163418038d4f Mon Sep 17 00:00:00 2001 From: lilbonekit Date: Fri, 22 May 2026 13:53:26 +0300 Subject: [PATCH 7/8] refactor(wallet): update last receive address handling and state management --- web-v2/src/pages/Dashboard/WalletDemo.tsx | 22 ++++++-- .../src/providers/wallet/WalletProvider.tsx | 56 +++++++++++-------- web-v2/src/providers/wallet/types.ts | 8 +-- 3 files changed, 56 insertions(+), 30 deletions(-) diff --git a/web-v2/src/pages/Dashboard/WalletDemo.tsx b/web-v2/src/pages/Dashboard/WalletDemo.tsx index 16778d9..238537c 100644 --- a/web-v2/src/pages/Dashboard/WalletDemo.tsx +++ b/web-v2/src/pages/Dashboard/WalletDemo.tsx @@ -34,7 +34,7 @@ export function WalletDemo() { getLastReceiveAddress, verifyReceiveAddress, getXOnlyPublicKey, - efuseMac, + connectorId, } = useWallet() const [walletType, setWalletType] = useState('Wpkh') @@ -46,6 +46,7 @@ export function WalletDemo() { const [txConfirmations, setTxConfirmations] = useState(null) const [verifyingAddress, setVerifyingAddress] = useState(false) const [xOnlyPubKey, setXOnlyPubKey] = useState(null) + const [lastReceiveAddress, setLastReceiveAddress] = useState(null) useEffect(() => { if (connectionStatus !== 'ready') return @@ -60,6 +61,19 @@ export function WalletDemo() { } }, [connectionStatus, getXOnlyPublicKey]) + useEffect(() => { + if (connectionStatus !== 'ready') return + let cancelled = false + getLastReceiveAddress() + .then(addr => { + if (!cancelled) setLastReceiveAddress(addr) + }) + .catch(console.warn) + return () => { + cancelled = true + } + }, [connectionStatus, getLastReceiveAddress]) + // Poll Esplora directly for first confirmation after sending. useEffect(() => { if (!sendTxid || txConfirmations !== null) return @@ -178,7 +192,7 @@ export function WalletDemo() {

Enter PIN on device - {efuseMac && ({efuseMac})} + {connectorId && ({connectorId})}

{syncing &&

Loading wallet...

}
@@ -188,7 +202,7 @@ export function WalletDemo() {

Receive address

- {getLastReceiveAddress()} + {lastReceiveAddress}