Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions web-v2/.env.example
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions web-v2/.env.liquid
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions web-v2/.env.liquidtestnet
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions web-v2/src/api/esplora/methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
type ScriptHashUtxoEntry,
scriptHashUtxoListSchema,
txIdListSchema,
type TxStatus,
txStatusSchema,
} from './schemas'

function buildEsploraUrl(path: string): string {
Expand All @@ -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<TxStatus> {
return requestJson(buildEsploraUrl(`/tx/${txId}/status`), txStatusSchema, {
signal: options.signal,
})
}

export async function fetchTxConfirmations(
txId: string,
options: RequestParams = {},
): Promise<number | null> {
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<Uint8Array> {
return requestBytes(buildEsploraUrl(`/tx/${txId}/raw`), { signal: options.signal })
}
Expand Down
12 changes: 10 additions & 2 deletions web-v2/src/constants/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(''),
Comment on lines +9 to +13
})

export const env = envSchema.parse({
Expand All @@ -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<typeof envSchema>
Expand Down
26 changes: 26 additions & 0 deletions web-v2/src/hooks/useSessionStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { useCallback, useState } from 'react'

export function useSessionStorage<T>(key: string): [T | null, (value: T | null) => void] {
const [value, setValueState] = useState<T | null>(() => {
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))
Comment on lines +15 to +18
}
setValueState(newValue)
},
[key],
)

return [value, setValue]
}
101 changes: 101 additions & 0 deletions web-v2/src/lib/wallet-core/connector/jade.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<JadeVersionInfo> {
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<ConnectionStatus> {
// 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<WolletDescriptor> {
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<Pset> {
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<string> {
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
}
}
78 changes: 78 additions & 0 deletions web-v2/src/lib/wallet-core/connector/seed.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<WolletDescriptor> {
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<Pset> {
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<string> {
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<ConnectionStatus> {
return 'ready'
}

get isConnected(): boolean {
return this.signer !== null
}
}
15 changes: 15 additions & 0 deletions web-v2/src/lib/wallet-core/connector/types.ts
Original file line number Diff line number Diff line change
@@ -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<void>
disconnect(): void
getDescriptor(variant: WalletType): Promise<WolletDescriptor>
signPset(pset: Pset): Promise<Pset>
isConnected: boolean
getConnectionStatus(): Promise<ConnectionStatus>
getXOnlyPublicKey?(): Promise<string>
getVerifiedReceiveAddress?(variant: WalletType, wollet: Wollet): Promise<string>
}
13 changes: 13 additions & 0 deletions web-v2/src/lib/wallet-core/types.ts
Original file line number Diff line number Diff line change
@@ -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'
20 changes: 20 additions & 0 deletions web-v2/src/lib/wallet-core/wallet/sync.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> {
const update = await esploraClient.fullScanToIndex(wollet, 0)
if (update) {
wollet.applyUpdate(update)
}
const result: Record<string, string> = {}
for (const [assetId, amount] of wollet.balance().entries() as [string, bigint][]) {
result[assetId] = amount.toString()
}
return result
}
28 changes: 26 additions & 2 deletions web-v2/src/lwk/index.ts
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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
}
Loading