diff --git a/web-v2/.env.example b/web-v2/.env.example index 264806c..507cdeb 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 +VITE_WATERFALLS_URL=https://waterfalls.liquidwebwallet.org/liquid +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..b17baf2 --- /dev/null +++ b/web-v2/.env.liquid @@ -0,0 +1,5 @@ +VITE_API_URL=http://localhost:80 +VITE_NETWORK=liquid +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 new file mode 100644 index 0000000..03844bf --- /dev/null +++ b/web-v2/.env.liquidtestnet @@ -0,0 +1,8 @@ +VITE_API_URL=http://localhost:80 +VITE_NETWORK=liquidtestnet +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): +# VITE_DEBUG_MNEMONIC=abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about 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 b6b61c4..ee23c5a 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'), + 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/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 new file mode 100644 index 0000000..66cbdf0 --- /dev/null +++ b/web-v2/src/lib/wallet-core/connector/jade.ts @@ -0,0 +1,101 @@ +import type { Jade, Network, Pset, Wollet, WolletDescriptor } from 'lwk_web' + +import type { Lwk } from '@/lwk' + +import type { ConnectionStatus, JadeVersionInfo, WalletType } 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 + private _id: string | null = null + + 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) + } + + disconnect(): void { + if (this.jade) { + this.jade.free() + this.jade = null + } + this._id = null + } + + 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() + 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 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.getVersionInfo() + return info.state === 'READY' ? 'ready' : 'locked' + } + + 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() + } + + 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 + } + } + + /** + * 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: WalletType, 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) + } + + 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 new file mode 100644 index 0000000..62f4e15 --- /dev/null +++ b/web-v2/src/lib/wallet-core/connector/seed.ts @@ -0,0 +1,78 @@ +import type { Network, Pset, Signer, WolletDescriptor } from 'lwk_web' + +import type { Lwk } from '@/lwk' + +import type { ConnectionStatus, WalletType } 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 + private _id: string | 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) + this._id = crypto.randomUUID() + } + + 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: WalletType, + ): 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) + } + + 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' + } + + 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 new file mode 100644 index 0000000..f6c0bfb --- /dev/null +++ b/web-v2/src/lib/wallet-core/connector/types.ts @@ -0,0 +1,15 @@ +import type { Pset, Wollet, WolletDescriptor } from 'lwk_web' + +import type { ConnectionStatus, WalletType } from '../types' + +export interface WalletConnector { + readonly id: string | null + connect(): Promise + disconnect(): void + getDescriptor(variant: WalletType): Promise + signPset(pset: Pset): Promise + isConnected: boolean + getConnectionStatus(): Promise + getXOnlyPublicKey?(): 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 new file mode 100644 index 0000000..9457cfb --- /dev/null +++ b/web-v2/src/lib/wallet-core/types.ts @@ -0,0 +1,13 @@ +export type WalletType = 'Wpkh' | 'ShWpkh' + +/** Raw JADE_STATE values from getVersion() */ +export type JadeConnectionState = 'LOCKED' | 'READY' | 'UNINIT' | 'TEMP' + +export interface JadeVersionInfo { + state: JadeConnectionState + /** EFUSEMAC — unique hardware identifier */ + efuseMac: string + version: string +} + +export type ConnectionStatus = 'disconnected' | 'locked' | 'ready' 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..415e768 --- /dev/null +++ b/web-v2/src/lib/wallet-core/wallet/sync.ts @@ -0,0 +1,20 @@ +import type { EsploraClient, Wollet } from 'lwk_web' + +/** + * Syncs wallet state via waterfalls fullScan and applies the update. + * Returns the updated balance map (assetId -> satoshis as strings). + */ +export async function syncBalances( + wollet: Wollet, + esploraClient: EsploraClient, +): Promise> { + const update = await esploraClient.fullScanToIndex(wollet, 0) + if (update) { + wollet.applyUpdate(update) + } + 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..238537c --- /dev/null +++ b/web-v2/src/pages/Dashboard/WalletDemo.tsx @@ -0,0 +1,301 @@ +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, + getXOnlyPublicKey, + connectorId, + } = 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) + const [xOnlyPubKey, setXOnlyPubKey] = useState(null) + const [lastReceiveAddress, setLastReceiveAddress] = 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]) + + 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 + + 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 + {connectorId && ({connectorId})} +

+ {syncing &&

Loading wallet...

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

Receive address

+ {lastReceiveAddress} + +
+ +
+

+ Balances + {connectorId && ({connectorId})} +

+ {syncing ? ( +

Syncing...

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

No balance

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

X-Only Public Key (Simplicity)

+ {xOnlyPubKey} +
+ )} + +
+

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 fab02c8..0d4bc02 100644 --- a/web-v2/src/pages/Dashboard/index.tsx +++ b/web-v2/src/pages/Dashboard/index.tsx @@ -1,48 +1,10 @@ -import { useLwk } from '@/providers/lwk/useLwk' +import { WalletDemo } from './WalletDemo' -// EXAMPLE OF LWK USAGE export default function DashboardPage() { - const { network, isTestnet, isMainnet, isRegtest, lwkNetwork } = useLwk() - - const info = lwkNetwork - ? { - label: lwkNetwork.toString(), - genesisBlockHash: lwkNetwork.genesisBlockHash(), - defaultExplorerUrl: lwkNetwork.defaultExplorerUrl(), - policyAsset: lwkNetwork.policyAsset().toString(), - } - : null - return ( -
+

Dashboard

-

- Network: {network} -

-

- 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..becd8e0 100644 --- a/web-v2/src/providers/AppProviders.tsx +++ b/web-v2/src/providers/AppProviders.tsx @@ -6,11 +6,14 @@ 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..ede4e30 --- /dev/null +++ b/web-v2/src/providers/wallet/WalletProvider.tsx @@ -0,0 +1,315 @@ +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 WalletSession, + type WalletState, +} from './types' +import { WalletContext } from './WalletContext' + +const SESSION_STORAGE_KEY = 'jade_wallet_session' +const DISCONNECT_ERROR_KEY = 'jade_disconnect_error' + +export function WalletProvider({ children }: { children: React.ReactNode }) { + const { lwk, lwkNetwork } = useLwk() + + const sessionRef = useRef(null) + const connectingRef = useRef(false) + + const [state, setState] = useState(() => { + const disconnectError = sessionStorage.getItem(DISCONNECT_ERROR_KEY) + if (disconnectError) { + sessionStorage.removeItem(DISCONNECT_ERROR_KEY) + return { ...INITIAL_WALLET_STATE, error: disconnectError, isError: true } + } + return INITIAL_WALLET_STATE + }) + const [savedSession, setSavedSession] = useSessionStorage(SESSION_STORAGE_KEY) + + const disconnect = useCallback( + async (error?: string) => { + const session = sessionRef.current + if (session) { + session.connector.disconnect() + sessionRef.current = null + } + setSavedSession(null) + // Persist the error so it survives the reload that releases the serial port. + if (error !== undefined) { + sessionStorage.setItem(DISCONNECT_ERROR_KEY, error) + } + 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, + // 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() + 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 + + 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) { + disconnect('Device disconnected') + } 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) + } + }, [disconnect]) + + // 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) return + + session.connector + .getConnectionStatus() + .then(status => { + if (status === 'locked' && state.connectionStatus !== '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) => { + // TODO: Move to a more robust error handling strategy + if (err instanceof Error && err.message === 'jade:busy') return + disconnect('Device disconnected').catch(console.warn) + }) + }, 3_000) + + return () => clearInterval(id) + }, [state.connectionStatus, disconnect]) + + useEffect(() => { + if (state.connectionStatus !== 'ready') return + + const id = setInterval(() => { + const session = sessionRef.current + if (!session) return + syncBalances(session.wollet, session.esploraClient) + .then(rawBalances => { + setState(s => ({ ...s, balances: rawBalances })) + }) + .catch(console.warn) + }, 60_000) + + return () => clearInterval(id) + }, [state.connectionStatus]) + + const connect = useCallback( + async (variant: WalletType) => { + if (sessionRef.current !== null || connectingRef.current) return + connectingRef.current = true + + setState(s => ({ ...s, syncing: true, error: null, isError: false })) + + let connector: WalletConnector | null = null + + try { + const walletType: WalletType = env.VITE_DEBUG_MNEMONIC ? 'Wpkh' : variant + + connector = env.VITE_DEBUG_MNEMONIC + ? new SeedConnector(lwk, lwkNetwork, env.VITE_DEBUG_MNEMONIC) + : new JadeConnector(lwk, lwkNetwork) + + await connector.connect() + + const connectionStatus = await connector.getConnectionStatus() + + // Show the intermediate state (locked/ready) before PIN prompt blocks. + setState(s => ({ + ...s, + connectionStatus, + connectorId: connector!.id, + walletType, + })) + + const descriptor = await connector.getDescriptor(walletType) + const wollet = new lwk.Wollet(lwkNetwork, descriptor) + const esploraClient = createEsploraClient(lwk, lwkNetwork) + + sessionRef.current = { connector, descriptor, wollet, esploraClient } + + const saved: SavedSession = { + connectorId: connector.id, + walletType, + descriptorStr: descriptor.toString(), + } + setSavedSession(saved) + + const balances = await syncBalances(wollet, esploraClient) + + setState(s => ({ + ...s, + connectionStatus: 'ready', + syncing: false, + error: null, + isError: false, + balances, + })) + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + connector?.disconnect() + sessionRef.current = null + // TODO: Move to a more robust error handling strategy + if (error.toLowerCase().includes('pin')) { + sessionStorage.setItem(DISCONNECT_ERROR_KEY, error) + window.location.reload() + } else { + setState(s => ({ + ...INITIAL_WALLET_STATE, + usbDeviceDetected: s.usbDeviceDetected, + error, + isError: true, + })) + } + } finally { + connectingRef.current = false + } + }, + [lwk, lwkNetwork, setSavedSession], + ) + + const resumeSession = useCallback(async () => { + if (!savedSession) return + await connect(savedSession.walletType) + }, [savedSession, connect]) + + const autoResumedRef = useRef(false) + useEffect(() => { + if (autoResumedRef.current || !savedSession || state.connectionStatus !== 'disconnected') return + autoResumedRef.current = true + resumeSession().catch(() => disconnect().catch(console.warn)) + }, [savedSession, state.connectionStatus, resumeSession, disconnect]) + + 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 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 })) + } + }, []) + + 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) + const txidStr = txid.toString() + + // Auto-sync balances after broadcast (fire-and-forget, errors are non-fatal). + syncBalances(session.wollet, session.esploraClient) + .then(balances => { + setState(s => ({ ...s, balances })) + }) + .catch(console.warn) + + return txidStr + }, []) + + const getLastReceiveAddress = useCallback(async (): Promise => { + const session = sessionRef.current + if (!session) return null + return session.wollet.address().address().toString() + }, []) + + const getXOnlyPublicKey = useCallback(async (): Promise => { + 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 + 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 + 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} + + ) +} diff --git a/web-v2/src/providers/wallet/types.ts b/web-v2/src/providers/wallet/types.ts new file mode 100644 index 0000000..ca626c6 --- /dev/null +++ b/web-v2/src/providers/wallet/types.ts @@ -0,0 +1,54 @@ +import type { EsploraClient, Pset, Wollet, WolletDescriptor } from 'lwk_web' + +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: WalletType): Promise + disconnect(): Promise + syncWallet(): Promise + signAndBroadcast(pset: Pset): Promise + sendLbtc(recipientAddress: string, satoshi: bigint): Promise + getLastReceiveAddress(): Promise + verifyReceiveAddress(): Promise + getXOnlyPublicKey(): Promise + resumeSession(): Promise + savedSession: SavedSession | null +} + +export interface WalletSession { + connector: WalletConnector + descriptor: WolletDescriptor + wollet: Wollet + esploraClient: EsploraClient +} + +export interface SavedSession { + connectorId: string | null + walletType: WalletType + descriptorStr: string +} + +export interface WalletState { + connectionStatus: ConnectionStatus + connectorId: 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', + connectorId: null, + walletType: null, + balances: {}, + syncing: false, + usbDeviceDetected: false, + error: null, + isError: false, +} 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 +}