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/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
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: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
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: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
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/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({
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
77 changes: 77 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,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<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)
}

async disconnect(): Promise<void> {
if (this.jade) {
this.jade.free()
this.jade = null
}
}

async readVersion(): Promise<JadeVersionInfo> {
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<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.readVersion()
return info.jadeState === 'READY' ? 'ready' : 'locked'
}

async getDescriptor(variant: SinglesigVariant): 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
}
}

isConnected(): boolean {
return this.jade !== null
}
}
60 changes: 60 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,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<void> {
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<void> {
if (this.signer) {
this.signer.free()
this.signer = null
}
}

async getDescriptor(
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_variant: SinglesigVariant,
): 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)
}

isConnected(): boolean {
return this.signer !== null
}
}
13 changes: 13 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,13 @@
import type { Pset, WolletDescriptor } from 'lwk_web'

import type { ConnectionStatus, JadeVersionInfo, SinglesigVariant } from '../types'

export interface WalletConnector {
connect(): Promise<void>
disconnect(): Promise<void>
getDescriptor(variant: SinglesigVariant): Promise<WolletDescriptor>
signPset(pset: Pset): Promise<Pset>
isConnected(): boolean
readVersion?(): Promise<JadeVersionInfo>
getConnectionState?(): Promise<ConnectionStatus>
}
43 changes: 43 additions & 0 deletions web-v2/src/lib/wallet-core/types.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
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,
}
10 changes: 10 additions & 0 deletions web-v2/src/lib/wallet-core/wallet/session.ts
Original file line number Diff line number Diff line change
@@ -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
}
37 changes: 37 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,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][]
}
Loading