Source: https://wavelength.lightning.engineering/introduction/what-is-wavelength-sdk.md # What is the Wavelength SDK? ## What it is The Wavelength SDK is a TypeScript library for embedding a **self-custodial Bitcoin, Lightning, and Ark wallet** directly in your web or mobile app. Your users hold their own keys. You do not run a wallet server on their behalf. (Building a pure-native Kotlin or Swift app? The same embedded wallet ships as a [native mobile SDK](/native-ios-android/overview/) too.) The full wallet daemon ([wavelength](https://github.com/lightninglabs/wavelength)) is compiled to run **inside your app**: to WebAssembly in the browser on web, and into the app binary on mobile. There is no separate process, no local socket, and no backend you must operate to host the wallet logic. Your app boots the daemon and talks to it through a typed client. The Wavelength SDK ships as these packages: Package `@lightninglabs/wavelength-core` Role Shared types, the WavelengthClient interface, errors, and enums. No DOM, no transport. Package `@lightninglabs/wavelength-web` Role The browser WASM transport. Framework-agnostic: use it from vanilla JS, Vue, Svelte, or React. Re-exports core. Package `@lightninglabs/wavelength-react-native` Role The React Native transport. The daemon is compiled into the app binary. Re-exports core. Package `@lightninglabs/wavelength-react` Role React provider and hooks. Depends on core only and takes an injected engine, so the same binding can run over the web or React Native transport. **React is the primary developer experience**, but React is not required. On web, if you are not using React, install `@lightninglabs/wavelength-web`, call `createWebClient()`, and use the `WavelengthClient` methods directly. The React package is a thin binding over the same interface. Native iOS and Android apps are covered as well: the [wavelength-mobile](https://github.com/lightninglabs/wavelength-mobile) wrappers embed the same daemon behind idiomatic Kotlin and Swift APIs, documented in the [Native iOS & Android](/native-ios-android/overview/) section. ## Mental model Think of the Wavelength SDK as **your app talking to a local wallet daemon**, not your app talking to a remote wallet API. *Diagram: your app calls the typed WavelengthClient, which forwards to the embedded daemon running in your app, which reaches the backend gateways.* 1. **Your app** calls methods like `balance()`, `send()`, and `deposit()` on a `WavelengthClient`. 2. **The client** forwards those calls to the embedded daemon running in your app (on web, in a Web Worker by default, or the main thread if configured). 3. **The daemon** holds keys, tracks VTXOs, coordinates Ark rounds, and reaches out to public infrastructure when it needs chain data, mailbox relay, or Lightning swaps. The daemon connects to three backend services configured in `RuntimeConfig`: - **Ark operator + mailbox** (`arkServerAddress`) for rounds and VTXO/mailbox relay - **Swap server** (`swapServerAddress`) for Lightning↔Ark atomic swaps - **Esplora** (`walletEsploraUrl`) for chain and UTXO queries On web, the Ark and swap addresses are REST URLs. On React Native, they are `host:port` gRPC addresses. `walletEsploraUrl` is an HTTP Esplora endpoint on both platforms. Your app never calls those gateways directly. It only talks to the local daemon. **User keys never leave the device.** Seed generation, signing, and wallet state live on the device (OPFS in the browser; the app data directory on React Native). The gateways see protocol traffic, not your users’ private keys. A minimal boot sequence looks like this: ```ts import { createWebClient, defaultConfig } from '@lightninglabs/wavelength-web'; const client = createWebClient({ runtimeBaseUrl: '/wavewalletdk/' }); await client.ready(); // load WASM runtime await client.start(defaultConfig('signet')); // boot daemon, connect gateways // Show the mnemonic to the user for backup. const { mnemonic } = await client.createWallet({ password: '…' }); ``` ```ts import { createNativeClient, defaultConfig, } from '@lightninglabs/wavelength-react-native'; const client = createNativeClient(); await client.ready(); // ready the native runtime await client.start(defaultConfig('signet')); // boot daemon, connect gateways // Show the mnemonic to the user for backup. const { mnemonic } = await client.createWallet({ password: '…' }); ``` From here, use the same client for deposits, Lightning receive, sends, and activity. The [System architecture](/introduction/system-architecture/) page explains how those operations map to Ark, swaps, and on-chain exits. --- Source: https://wavelength.lightning.engineering/introduction/the-wavelength-system.md # The Wavelength system ## What Wavelength is Wavelength adds self-custodial Bitcoin and Lightning payments to any app with a handful of API calls. Your users get a real wallet that can send and receive over the Lightning Network, and you never run a Lightning node, open channels, or source liquidity to make it work. If you can call an API, you can accept Bitcoin. (Stablecoin support is on the way through Taproot Assets, over the same wallet surface.) The value is in what you do **not** have to run. Accepting Lightning payments has traditionally meant operating a node, balancing channels, sourcing liquidity, and keeping all of that healthy around the clock. Wavelength runs that machinery and gives you a small, friendly wallet surface in its place, so the capability (instant, global, low-fee payments) is available without the operational burden. > **This does not replace running your own node** > > Lightning has always offered a fully self-sovereign path where you run your own node and channels, and that path is not going anywhere. Wavelength is for everyone who would rather not run infrastructure at all, without giving up control of their funds. ## What you run vs. what is managed You run **Wavelength**, the self-custodial client (compiled from [wavelength](https://github.com/lightninglabs/wavelength)). It holds the user’s keys, tracks their balance, and builds and signs their payments. Everything the wallet talks to is managed for you. *Diagram: your app calls Wavelength, the client you run. Wavelength can exit to Bitcoin on-chain at any time, and pays BOLT 11 invoices through the Wavelength Operator, the managed coordination and liquidity service, which settles over the Lightning Network.* | Piece | Who runs it | What it does | | ----------------------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Wavelength** (the client) | You | Holds keys, tracks balances, builds and signs payments. Runs as an embedded SDK in a web or mobile app, as a standalone server, or as an MCP server for agents. | | **Coordination + settlement** | Wavelength Operator | An Ark-like layer that batches off-chain transfers instantly and cheaply. It settles between users but never takes unilateral control of anyone’s funds. | | **Liquidity via Loop** | Wavelength Operator | Deep Lightning liquidity through Loop, so payments route reliably without you sourcing, funding, or managing channels. | | **The Lightning Network** | Open network | Everything speaks BOLT 11, so you can pay and be paid by any wallet, exchange, or app already on Lightning. | ## Self-custodial, exit anytime Users hold their own keys. The coordination service settles transfers but never has unilateral control of anyone’s money, and a user can always move their balance back to the Bitcoin blockchain on their own, at any time, without anyone’s cooperation. The wallet exposes this directly through an `exit` command. You get the convenience of a managed payments experience with the trust guarantees of holding your own Bitcoin. See [Leaving Ark](/concepts/leaving-ark/) for how exits work in practice. ## One invoice for everything Every payment you send and receive is a BOLT 11 invoice, the standard Lightning format. There is a single payment format to learn, and your wallet works with the entire Lightning Network the moment you integrate it. A normal Bitcoin address only shows up at the edges, when funding a wallet or moving funds back to the chain. See [Lightning payments are swaps](/concepts/lightning-payments-are-swaps/) for how a Lightning payment maps to an Ark swap under the hood. ## Ways to integrate Wavelength meets you where you build: - **Embedded SDK** - pull the SDK into your app to put a wallet directly inside it. It runs on web (compiled to WebAssembly, in a browser tab) and on mobile (compiled into the app binary), on React Native or on native Kotlin and Swift. Start with the [Web quickstart](/web/get-started/quickstart/), the [React Native quickstart](/react-native/get-started/quickstart/), or the [Native iOS & Android quickstart](/native-ios-android/quickstart/). - **As a standalone client** - a single self-contained process driven over a gRPC and REST API. - **For agents** - run the MCP server so an AI agent can drive the wallet as typed tool calls. Wallet creation and unlock stay off the agent channel, so seeds and passwords are never exposed to a model. These docs focus on the SDK (web, React Native, and native iOS and Android). The other surfaces share the same wallet commands and are documented separately. ## Networks Wavelength runs on signet and testnet by default, and both are open to everyone. These test networks let you build and exercise the full payment flow with coins that have no real value. Mainnet access is gated to an approved allowlist and requires an explicit opt-in. See [Networks and config](/concepts/networks-and-config/) for the details. ## Where to go next - [What is the Wavelength SDK?](/introduction/what-is-wavelength-sdk/) - the client you run, and the packages it ships as. - [Web quickstart](/web/get-started/quickstart/), [React Native quickstart](/react-native/get-started/quickstart/), or [Native iOS & Android quickstart](/native-ios-android/quickstart/) - a running wallet in a few minutes. - [System architecture](/introduction/system-architecture/) - how the embedded daemon connects to the backend gateways. --- Source: https://wavelength.lightning.engineering/introduction/system-architecture.md # System architecture ## The embedded daemon The Wavelength SDK embeds [wavelength](https://github.com/lightninglabs/wavelength), the same wallet daemon on every platform, and runs it wherever your app runs. On the web it is compiled to WebAssembly and runs inside the browser; you boot it with `createWebClient()` and `client.start()`. On React Native it is compiled into your app binary and runs on device; you boot it with `createNativeClient()` and `client.start()`. Either way it stays running for the session, and you drive it through one typed `WavelengthClient`. **`WavelengthClient` is the app-facing API.** It is defined in `@lightninglabs/wavelength-core` and implemented by each transport (`@lightninglabs/wavelength-web` on the web, `@lightninglabs/wavelength-react-native` on React Native). Your code never imports Go or protobuf types. You call typed methods (`deposit`, `receive`, `send`, `balance`, and so on) and receive plain JavaScript objects. The wire protocol between the client and the embedded daemon differs by transport, and from your app’s perspective it is an implementation detail. The React Native transport uses the daemon’s native gRPC. Browsers cannot open arbitrary gRPC connections the way a native host can, so the web transport uses **REST** against the embedded daemon’s HTTP facade instead. You still hold one `WavelengthClient` and call the same methods on either. The daemon also owns local persistence (wallet database, swap state) and signing. Your app configures *where* the daemon connects on the network, not *how* it stores keys. ## The three backend gateways Once started, the daemon reaches three external backend services. Configure them in `RuntimeConfig` (or use your transport package’s `defaultConfig(network)` for the public signet and testnet presets): ```ts import { createWebClient, defaultConfig } from '@lightninglabs/wavelength-web'; const client = createWebClient(); await client.ready(); await client.start(defaultConfig('signet')); // arkServerAddress, walletEsploraUrl, swapServerAddress pre-filled for signet ``` ```ts import { createNativeClient, defaultConfig, } from '@lightninglabs/wavelength-react-native'; const client = createNativeClient(); await client.ready(); await client.start(defaultConfig('signet')); // arkServerAddress, walletEsploraUrl, swapServerAddress pre-filled for signet ``` | Field | Service | What the daemon uses it for | | ------------------- | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | `arkServerAddress` | Ark operator and mailbox edge | Ark **rounds**, VTXO lifecycle, and **mailbox relay** for out-of-round transfers. A REST URL on web; a `host:port` gRPC address on React Native. | | `swapServerAddress` | Swap server | **Lightning↔Ark atomic swaps** (Lightning receive and send). A REST URL on web; a `host:port` gRPC address on React Native. | | `walletEsploraUrl` | Esplora-compatible indexer | **Chain and UTXO queries**; an HTTP endpoint on both platforms that implements the Esplora `/address/:addr/utxo` API | **Mailbox and operator share one edge.** VTXO mailbox traffic goes through the same Ark server URL. There is no separate mailbox field in `RuntimeConfig`: you configure a single Ark gateway and mailbox traffic rides the same edge. Lightning is not a fourth gateway. Lightning send and receive are **swap operations** routed through `swapServerAddress`. On-chain visibility (boarding deposits, cooperative leaves, unilateral exits) goes through Esplora and the Ark operator as appropriate. Set `disableSwaps: true` if you only need Ark and on-chain flows without Lightning. It suppresses the preset and any override swap fields. ## How boarding, swaps, and exits connect Wavelength unifies three payment rails behind one balance and one activity stream. Here is how each rail uses the gateways above. ### On-chain in: boarding 1. Your app calls `deposit()` and gets a **boarding address** (a standard Bitcoin receive address). 2. The user sends on-chain BTC to that address. 3. Esplora lets the daemon see the UTXO arrive. 4. At the **next Ark round** (interval is operator-configured, often well under a minute), the deposit **boards** into Ark and becomes a **VTXO** you can spend instantly off-chain. Until boarding completes, funds show as pending inbound balance. ### Lightning: swaps Wavelength does not open Lightning channels in the browser. Lightning payments are **atomic swaps** between Lightning and Ark: - **Receive Lightning** (`receive()`) starts a **receive swap**: the swap server holds a Lightning invoice; when it is paid, you receive Ark balance (a VTXO). - **Send Lightning** (`send({ invoice })`) runs a **send swap**: you spend Ark balance; the swap server pays the BOLT-11 invoice on Lightning. For a quote-then-confirm flow instead of a single call, see `prepareSend()`/`sendPrepared()`. Both directions go through `swapServerAddress`. Swaps can sit in a pending state while liquidity or routing resolves; the daemon tracks them in your activity stream and can refund if a swap expires. ### On-chain out: leave and exit Spending to a normal Bitcoin address uses a **cooperative leave**: the Ark operator helps convert your VTXO into an on-chain transaction. This is the default path for `send({ onchainAddress })`. It is faster and cheaper than exiting alone. **Unilateral exit** is the emergency fallback. If the operator is unavailable or you need to recover funds without cooperation, the daemon can execute a unilateral exit from a VTXO. It is slower, more expensive, and intended for edge cases. Use `exit()` and `exitStatus()` on `WavelengthClient` for this path; see [Unilateral exit](/guides/unilateral-exit/) for integration details. Call `getExitPlan()` first to preview readiness and backing-wallet funding requirements before triggering `exit()`. *Diagram: the five payment rails. On-chain in: deposit, boarding, round, VTXO. Lightning receive: a receive swap through the swap server to a VTXO. Lightning send: a send swap through the swap server to a paid invoice. On-chain send: cooperative leave via the operator and Esplora. Emergency: unilateral exit to on-chain.* For deeper detail on each rail, see [Balances & VTXOs](/concepts/balances-and-vtxos/), [Lightning payments = swaps](/concepts/lightning-payments-are-swaps/), and [Leaving Ark](/concepts/leaving-ark/). --- Source: https://wavelength.lightning.engineering/concepts/balances-and-vtxos.md # Balances & VTXOs The sections below walk through how Wavelength represents spendable funds, how boarding turns into live balance, and why confirmed and pending numbers can differ. ## VTXOs In Ark, your spendable balance is not a single on-chain UTXO in your wallet. It is a set of **Virtual Transaction Outputs (VTXOs)**: off-chain outputs created inside the operator’s periodic **batch transactions**. Each batch builds a Virtual Transaction Tree (VTXT) whose leaves are individual VTXOs assigned to participants. A VTXO behaves like a UTXO you can spend, but it lives in Ark’s shared batch structure until you exit, refresh, or the operator sweeps expired batches. Wavelength tracks every live VTXO the daemon knows about and uses them as inputs when you send, swap, or leave. Each VTXO carries two spend paths: - **Collaborative path**: you and the Ark operator co-sign, so the output can move instantly inside Ark (for example through a cooperative leave or an out-of-round transfer). - **Unilateral exit path**: you alone can broadcast after a relative timelock (CSV delay) if the operator stops cooperating. VTXOs also have a **sweep delay** tied to their batch. Before the operator becomes eligible to sweep the batch, the VTXO should either be refreshed through a **batch swap** (getting a fresh VTXO in a newer batch) or moved to on-chain Bitcoin. This refresh is daemon-driven: Wavelength detects the approaching expiry and submits the batch swap for you, so there is no method to call yourself. Wavelength surfaces expiry pressure through balance and activity updates rather than asking you to manage raw outpoints manually. > **Note** > > `balance()` and `list({ view: 'vtxos' })` read from the embedded daemon’s view of live VTXOs. The operator’s indexer is authoritative on the network side; the daemon reconciles that view as rounds complete and swaps settle. ## Confirmed vs pending When you show balance in a UI, users usually want one “available” number. Wavelength splits the picture into confirmed and pending fields so you can explain why spendable funds lag behind what they see on-chain or in the activity feed. `balance()` returns three satoshi fields that answer different questions: - **`confirmedSat`**: funds you can treat as settled inside the wallet. These VTXOs are live, unencumbered, and available for send, swap, or leave. - **`pendingInSat`**: inbound value still moving through boarding, a Lightning swap, or a round that has not finished yet. A deposit may be visible on-chain before it becomes a VTXO; a Lightning receive stays pending until the swap server funds and you claim the **virtual HTLC (vHTLC)** (see [Lightning payments are swaps](/concepts/lightning-payments-are-swaps/)). - **`pendingOutSat`**: outbound value reserved by in-flight sends, swap funding, or leave requests. The wallet holds these amounts aside so you do not double-spend the same VTXOs while an operation is still settling. Confirmed satoshis are what you show as “available.” Pending fields explain why the total on-screen balance can differ from what a user can spend right now, and why a payment can appear in the activity feed before it affects confirmed balance. Pair `balance()` with the activity stream when you need per-operation detail: each **Entry** carries its own status (`pending`, `complete`, or `failed`) and a coarse phase (for example `waiting_for_payment`, `settling`, or `confirmed`) that describes where that specific operation sits in its pipeline. See the [wavelength-core reference](/reference/wavelength-core/) for the full **Balance** and **Entry** shapes. ## Boarding → rounds → VTXO lifecycle On-chain Bitcoin enters Ark through **boarding**: 1. `deposit()` returns a **boarding address** (and an initial deposit activity entry). Send on-chain BTC to that address like any other receive address. 2. The operator watches the chain via Esplora. Once the deposit confirms and meets the operator’s policy, the wallet submits a **boarding request** for the next **round**. 3. Rounds advance on a fixed cadence (often around one minute on public test networks; roughly 90 seconds end-to-end is a reasonable UX expectation). During request collection the operator aggregates boarding inputs, VTXO creations, leaves, and batch swaps into one batch transaction. 4. When the round completes, your boarded sats become one or more **VTXOs** in the new batch. The deposit entry moves from pending toward confirmed, and confirmed balance increases. Every VTXO you receive this way inherits the batch’s **CSV expiry** (sweep delay). As that horizon approaches, the VTXO is **refreshed** with a **batch swap**: forfeiting the old VTXO in a round in exchange for a fresh one in a newer batch, which resets the expiry clock. Wavelength handles the round interaction; you do not pick batch transaction IDs yourself. Operators can advertise a **free refresh window**: a late-lifetime span of blocks in which a pure refresh gets its fee waived. When that waiver can be reached without weakening exit safety, the daemon delays the automatic refresh into the window; the advertised width surfaces as `serverInfo.freeRefreshWindowBlocks` on [`getInfo()`](/reference/wavelength-core/#getInfo). See [Leaving Ark](/concepts/leaving-ark/) for the `exit()` / `getExitPlan()` / `sweepWallet()` APIs that apply if you choose to move funds on-chain instead of refreshing. > **Tip** > > Boarding is not instant. Show pending inbound balance and the deposit entry’s phase (`waiting_for_confirmation`, then `settling`) so users know their on-chain payment is recognized but not yet spendable inside Ark. Lightning receives and sends also end in VTXOs (via vHTLC outputs), but on-chain boarding is the path most integrators hit first: address → wait for round → live VTXO → refresh before expiry. --- Source: https://wavelength.lightning.engineering/concepts/activity-and-events.md # Activity & events ## The activity stream Every deposit, payment, and exit shows up in one ordered history. Wavelength keeps that history as a stream you can snapshot once or subscribe to for live updates as operations settle. Wallet history in Wavelength is an **activity stream**: a sequence of **Entry** objects, one per user-visible wallet operation. Deposits, Lightning receives, sends, and cooperative exits each create an entry when they start and update in place as they settle. You can read history in two ways: - `list({ view: 'activity' })` for a paginated snapshot. Filter with `pendingOnly` or `kinds`, and page with `limit` and the string list cursor: ```ts const result = await client.list({ view: 'activity', kinds: ['receive', 'deposit'], }); const { activity } = result; // activity.entries, activity.hasMore, activity.nextCursor ``` - `startActivity()` for a live stream that pushes updates until you call `stopActivity()`. For UI work, prefer the live stream. Call `subscribe()` first to register a listener, then open the stream with the entry kinds you need. Persist the last numeric `Entry.cursor` you processed and pass it back to resume after that position: ```ts // First launch: replay existing entries, then stream new ones. await client.startActivity({ includeExisting: true, kinds: ['send', 'receive'], }); // Later: resume after the last cursor you processed. await client.startActivity({ kinds: ['send', 'receive'], cursor: lastCursor, }); ``` Each update arrives as a **WavelengthEvent** with `type: 'activity'` and the changed entry as payload. Pending entries mutate (status, phase, txid, confirmation height) until they reach `complete` or `failed`. > **Note** > > `stopActivity()` closes the stream but leaves your `subscribe()` listener registered. You can call `startActivity()` again later without re-subscribing. Clients used without a `WalletEngine` also receive a terminal `activityStream` event when a stream ends or fails. The client does not retry automatically, so such a consumer chooses its own retry policy. The payload carries a `message` when the stream failed, but no cursor marking where the gap starts, so a consumer that reconnects decides for itself how far back to resume. `WalletEngine` manages recovery for UI consumers. It reconciles the snapshot with `list()`, retries from its last safe numeric stream cursor with bounded backoff, and resets that cursor with the runtime lifecycle. Replayed activity events trigger idempotent snapshot refreshes instead of direct row appends. This provides eventual snapshot consistency after reconnects without duplicating entries. The React `useWalletActivity` hook reads that engine snapshot, ordered newest-first, and re-renders as reconciliation completes. ## Event model Beyond activity rows, the client emits runtime and log events on the same subscription. All five event types arrive through the same `subscribe(listener)` callback registered once. **WavelengthEvent** is a discriminated union, so narrow on `type` before reading `payload`: | `type` | Payload | Meaning | | ------------------ | ------------------------------------------------------ | ----------------------------------------------------------------------------- | | `'activity'` | `Entry` | A wallet operation was created or updated. | | `'activityStream'` | `{ state: 'ended' }` or `{ state: 'failed', message }` | The activity stream ended or failed without a consumer-initiated close. | | `'log'` | `{ level, message }` | Daemon log line forwarded for debugging (`debug`, `info`, `warn`, `error`). | | `'runtimeReady'` | none | The embedded runtime finished loading and can accept `start()`. | | `'runtimeStopped'` | none | The runtime stopped, including an unsolicited stop surfaced by the transport. | Activity events carry the full entry shape: - **kind**: high-level category (see below). - **status**: collapsed outcome: `pending`, `complete`, or `failed`. - **amountSat** / **feeSat**: principal and fees in satoshis. `amountSat` is signed: positive for value coming into the wallet, negative for value going out. - **cursor**: the monotonic stream position of this update. It is zero for entries returned outside the subscription path. - **progress**: optional lifecycle metadata (phase, payment hash, txid, confirmation height, VTXO outpoint, and Lightning preimage). - **request**: optional echo of what the user initiated (Lightning invoice, on-chain address, etc.). - **failureReason** / **failureCode**: populated when status is `failed`. **failureCode** is one of: - **`timed_out`**: the operation exceeded its deadline before completing. - **`expired`**: the underlying invoice, address, or quote expired before it was used. - **`refunded`**: a swap or payment failed and funds were returned to the wallet. - **`needs_intervention`**: the operation stalled in a state that requires manual follow-up. - **`failed`**: a generic failure not covered by the other codes. When known, `EntryProgress.preimage` is the hex-encoded Lightning payment preimage. For a completed Lightning-backed send, it is proof of payment for the invoice. **Entry kinds** group operations for filtering and display: - **`deposit`**: on-chain boarding (`deposit()` address flow). - **`receive`**: inbound Lightning receive settled through an atomic swap into Ark. - **`send`**: outbound payment (Lightning send swap, cooperative on-chain leave, or in-Ark send). - **`exit`**: cooperative leave of VTXOs to a plain on-chain address. For a `send` entry, `entry.request?.type` (`'lightning'`, `'onchain'`, or `'ark'`) tells you which rail the payment actually used; when preparing a send, `PrepareSendResult.rail` reports the same thing ahead of time. See the [wavelength-core reference](/reference/wavelength-core/) for the full field list. **Entry phases** sit one level below status and describe *where* a pending operation is in its pipeline. They do not replace status; use both. Common phases include: - **`request_created`**: the wallet recorded the intent but nothing external has happened yet. - **`waiting_for_payment`**: waiting for a counterparty (Lightning payer, swap funder, or boarding confirmation). - **`waiting_for_confirmation`**: an on-chain tx is seen but not yet deep enough in the chain. - **`payment_detected`** / **`settling`**: value is in motion through Ark, Lightning, or swap machinery. - **`confirmed`**: terminal success from the backing subsystem’s perspective. - **`refunding`** / **`refunded`**: swap timeout or cancellation path returning funds. - **`failed`**: terminal failure. You can switch on the progress phase for structured UI states, or render the phase label directly when you want the daemon’s short label without maintaining a mapping table. Field definitions live in the [wavelength-core reference](/reference/wavelength-core/). For the React hook signatures built on this stream, see the [wavelength-react reference](/reference/wavelength-react/). For how activity and events relate to the client’s broader startup and connection state, see [Wallet lifecycle & auth](/concepts/wallet-lifecycle-and-auth/). --- Source: https://wavelength.lightning.engineering/concepts/wallet-lifecycle-and-auth.md # Wallet lifecycle & auth ## WalletState & phases Before you enable send or receive, the wallet must exist, unlock, and finish syncing with the operator. The Wavelength SDK exposes that readiness at two related layers: a daemon-reported wallet state and a broader runtime phase your UI can render. **WalletState** (on **WalletInfo**) is a lowercase string union the SDK normalizes from the daemon’s numeric wallet-state field: | Value | Meaning | | ----------- | -------------------------------------------------------------------- | | `'none'` | No wallet exists yet, or the runtime has not loaded wallet metadata. | | `'locked'` | A wallet is present but encrypted; credentials are required. | | `'syncing'` | Unlocked and catching up with the operator, indexer, and chain view. | | `'ready'` | Unlocked, synced, and safe to spend. | The SDK maps the daemon’s numeric proto enum to these strings at the boundary (`walletStateFromProto`). The wallet-ready flag is also exposed and is `true` exactly when wallet state equals `'ready'`. **RuntimePhase** is the broader lifecycle a host UI renders. It merges runtime boot/shutdown with wallet state, plus one phase the engine owns on its own: - **Runtime-owned phases**: `loading`, `runtimeReady`, `starting`, `stopping`, `stopped`, `error`. The `WalletEngine` (from `@lightninglabs/wavelength-web`’s `createWebWalletEngine`, or the equivalent React Native factory) sets these while the runtime loads, `start()` runs, or `stop()` tears down. A failed `start()` or `stop()` lands directly on `'error'`; there is no separate failed sub-phase to branch on. `WavelengthProvider` (from `@lightninglabs/wavelength-react`) owns nothing here: it just publishes whichever engine you hand it. If you are calling `wavelength-core`/`wavelength-web` directly without an engine, you own deriving this state machine yourself. - **Wallet-owned phases** (derived by `phaseFromInfo()` in core): `needsWallet`, `locked`, `syncing`, `ready`. - **Engine-owned phase**: `restoring`. Unlike the wallet-owned phases, this one is never derived from `WalletInfo`; the engine enters it itself whenever `restoreWallet()` (`useWalletRestore()`’s `restore` in React) is called, and leaves it once the restored wallet reports ready. When the call sets `recoverState: true`, the server-assisted recovery scan is additionally tracked through `snapshot.recovery` for the lifetime of the scan. See [Restore a wallet](/guides/restore-a-wallet/) for the full flow. After `createWallet()` or a successful unlock, the daemon often lands in the syncing phase while it replays local state and reconciles with the operator. The `WalletEngine` **automatically advances syncing to ready**: while its phase is `'syncing'`, it polls `refresh()` every 2000ms until the phase leaves syncing, so you do not call a separate “finish sync” API. If you are integrating `wavelength-core`/`wavelength-web` directly without an engine, you must poll `getInfo()` or subscribe to activity events yourself to detect the syncing-to-ready transition. Show a spinner on `'syncing'` (and `'restoring'`) and enable send/receive controls only on `'ready'`. > **Caution** > > Treat `'none'` and `'locked'` as non-spendable. Do not infer readiness from a successful unlock alone; wait for `'ready'` (or `walletReady === true`) before exposing payment actions. Use `getInfo()` / `useWalletInfo()` for the current wallet state, and `phaseFromInfo()` when you want a single enum for gating UI across the web and React Native transports. See the [wavelength-core reference](/reference/wavelength-core/) for **WalletEngine**, **WalletState**, and **RuntimePhase** definitions. ## Password vs passkey Both paths protect the same underlying wallet seed, but they derive the encryption key differently. Choose one at setup time; after unlock, the lifecycle is the same. **Password unlock** is the baseline flow: 1. `createWallet({ password })` generates (or imports) a mnemonic and creates the wallet database with its key material encrypted under the password. 2. `unlockWallet({ password })` opens the wallet database with that password on each session. The password never leaves your app as plaintext on the wire; the SDK encodes it for the daemon’s unlock RPC. Users must remember the password (and back up the mnemonic if you expose it). ```ts // After the runtime reaches 'runtimeReady' and start() has resolved: await client.createWallet({ password }); // On a later session, unlock the existing wallet instead: await client.unlockWallet({ password }); // Either call leaves the daemon syncing or ready; read the // current phase from getInfo(). const info = await client.getInfo(); const phase = phaseFromInfo(info); // Render UI from phase: 'syncing' shows a spinner, 'ready' unlocks send/receive. ``` In React, the same flow runs through `useWalletCreate()` and `useWalletUnlock()`, which wrap the calls above with verb-prefixed pending/error/data state (`createPending`/`createError`/`createData` and `unlockPending`/`unlockError`/`unlockData`) and refresh the engine’s snapshot afterward, so `phase` from `useWallet()` advances on its own. **Passkey unlock** replaces the password with a **WebAuthn PRF output**: 1. During setup, a platform **PasskeyCeremony** derives a deterministic secret (`prfOutput`) bound to the user’s passkey. 2. `openWalletFromPasskey({ prfOutput })` unlocks (or creates) the wallet using that secret instead of a typed password. ```tsx import { webPasskeyCeremony } from '@lightninglabs/wavelength-web'; import { useWalletPasskey } from '@lightninglabs/wavelength-react'; function PasskeyOnboard() { const { create, createPending } = useWalletPasskey(webPasskeyCeremony); // First run: register a passkey and create the wallet from its PRF output. const handleCreate = () => create('My Wallet App'); return ( ); } function PasskeyUnlock({ credentialId }: { credentialId: string }) { const { open, openPending } = useWalletPasskey(webPasskeyCeremony); // Returning session: assert the existing passkey to unlock. const unlock = () => open(credentialId); return ( ); } ``` Passkeys tie unlock to a device and biometric or PIN verification. They remove password re-entry but introduce platform constraints (browser support, secure context, cross-origin isolation on web). The Wavelength SDK folds passkey into the engine via an injected passkey ceremony; the core client only sees the PRF bytes. > **Note** > > WebAuthn registration, PRF extension availability, and credential persistence are platform mechanics. See [Use a passkey](/guides/use-a-passkey/) for browser-specific steps; this page is the shared mental model. From the wallet lifecycle perspective both paths converge: after unlock the daemon enters syncing, then ready. A restore (either path) instead passes through the engine-owned `'restoring'` phase first; recovery tracking through `snapshot.recovery` only kicks in when the call sets `recoverState: true`. Choose password for the simplest cross-device story; choose passkey when you want passwordless return visits on a trusted device. For what the seed and passwords actually are, and how backup and recovery work in each mode, see [Keys, backup & recovery](/concepts/keys-backup-and-recovery/). --- Source: https://wavelength.lightning.engineering/concepts/keys-backup-and-recovery.md # Keys, backup & recovery Every Wavelength wallet is a hierarchical-deterministic (HD) wallet rooted in a single seed. The seed’s origin depends on the auth mode (typed password or passkey), and everything else follows from it: what sits on disk, what counts as a backup, and what a user needs to get funds back on a new device. For the lifecycle states and UI phases around unlock, see [Wallet lifecycle & auth](/concepts/wallet-lifecycle-and-auth/). ## One seed, two ways to derive it A **password wallet** gets its seed from a freshly generated (or imported) 24-word mnemonic; the password protects the wallet’s key material at rest. A **passkey wallet** inverts this: the seed itself is *derived* from the passkey, so the passkey is simultaneously the unlock credential and the root secret. ```mermaid %%{init: {"theme":"base","themeVariables":{"fontFamily":"Inter, sans-serif","fontSize":"16px","primaryColor":"#2c2c33","primaryTextColor":"#f5f5f7","primaryBorderColor":"#15e0c2","secondaryColor":"#24242a","secondaryTextColor":"#f5f5f7","secondaryBorderColor":"#a78bfa","tertiaryColor":"#1c1c21","tertiaryTextColor":"#b6b6c0","tertiaryBorderColor":"#56c7f2","lineColor":"#8c8c96","textColor":"#f5f5f7","mainBkg":"#2c2c33","nodeBorder":"#303037","clusterBkg":"#1c1c21","clusterBorder":"#303037","titleColor":"#b6b6c0","edgeLabelBackground":"#24242a","edgeLabelTextColor":"#f5f5f7","nodeTextColor":"#f5f5f7","rectBorderRadius":"10px","clusterBorderRadius":"12px"},"flowchart":{"htmlLabels":false,"curve":"basis","padding":20,"nodeSpacing":55,"rankSpacing":60,"diagramPadding":12,"useMaxWidth":false}}}%% flowchart TD PK["Passkey assertion
(WebAuthn PRF output)"] --> HKDF["HKDF-SHA256"] HKDF -->|"info: wavewalletdk:seed:v1"| ENT["16-byte entropy"] HKDF -->|"info: wavewalletdk:dbpw:v1"| DBP["wallet DB password
(64-char hex)"] ENT --> AZ["aezeed mnemonic
(24 words)"] AZ --> SEED["HD wallet seed"] PW["User password"] --> DBP2["wallet DB password"] MN["Generated / imported
mnemonic"] --> SEED SEED --> DB[("wallet database
(OPFS / app sandbox)")] DBP --> DB DBP2 --> DB ``` Both paths converge on the same storage: the seed lives only inside the wallet database, encrypted under the wallet DB password and stored device-locally. On the web that is [OPFS](/web/runtime/data-and-persistence/), the browser’s origin-private file system. On React Native it is a data directory inside the app sandbox: `RuntimeConfig.dataDir`, defaulting to the platform path reported by `getDefaultDataDir()`. Either way the database never leaves the device, and no key material ever reaches the operator or any server. ## The passkey ceremony Passkey wallets are built on the WebAuthn **PRF extension**: an authenticator holds a hidden pseudo-random function per credential, and evaluating it over a fixed input yields a stable 32-byte secret. The SDK always evaluates the PRF over the same input, `SHA-256("wavewalletdk-passkey:v1")` (exported from core as `PASSKEY_PRF_SALT_HEX`), so the same passkey produces the same secret on every device and every session. That determinism is the whole design: the PRF output *is* the wallet’s root secret. On the web, `registerPasskeyWallet(appName)` creates the credential with `authenticatorAttachment: 'platform'`, `residentKey: 'required'`, and `userVerification: 'required'`, meaning a discoverable, biometric-gated passkey in the platform authenticator (Face ID, Touch ID, Windows Hello, or a synced password manager). Some browsers do not return a PRF result from the creation ceremony itself, so registration falls back to an immediate assertion scoped to the just-created credential to read the PRF value reliably. `assertPasskeyPrf(credentialId?)` runs the returning-session ceremony. With a stored `credentialId` the assertion is scoped to that one credential and the OS unlocks it directly; without one, the assertion is *discoverable*, so a passkey synced from another device can be offered even though this device has never seen the wallet. The `credentialId` is not a secret; persisting it in `localStorage` just skips the credential chooser on the next unlock. On React Native, `createNativePasskeyCeremony({ rpId })` supplies the same ceremony through the platform credential APIs: Credential Manager on Android and AuthenticationServices on iOS (iOS support is experimental and needs iOS 18 or newer). Because every platform evaluates the PRF over the same fixed input, the same passkey derives the same wallet everywhere: a wallet created in the browser opens in the app, and vice versa, provided both use the same relying-party domain. > **Caution** > > Passkeys are bound to the **relying-party ID**. On the web the SDK sets it to the page’s hostname; on React Native you pass `rpId` explicitly and must associate that domain with your app (see [Passkey setup](/react-native/get-started/passkey-setup/)). If the app later moves to a different domain, existing passkeys cannot be asserted there and passkey-based recovery breaks for every user. Plan the relying-party domain as a long-term commitment, share it between your web and mobile apps so one passkey opens both, and treat the mnemonic as the domain-independent escape hatch. ## Seed derivation The wallet runtime (the Go SDK, compiled to WASM on the web and bundled as a native library on React Native) expands the PRF output with **HKDF-SHA256** into two domain-separated secrets: | HKDF `info` | Output | Role | | ---------------------- | --------------------- | ----------------------------------- | | `wavewalletdk:seed:v1` | 16 bytes | aezeed entropy (the HD wallet seed) | | `wavewalletdk:dbpw:v1` | 32 bytes, hex-encoded | wallet DB password | The entropy is wrapped into an [aezeed](https://github.com/lightningnetwork/lnd/tree/master/aezeed) cipher seed with a pinned version and a birthday pinned to the Bitcoin genesis date, and an **empty seed passphrase**. Pinning all three means the wallet is a pure function of the PRF output: nothing else needs to be stored or remembered for the keys to be reproducible. Two invariants protect this contract: - **The PRF input never changes.** The evaluation input is fixed per namespace version. If a caller ever evaluated the PRF over a different input, the same passkey would silently derive a different seed (a different wallet), and the original funds would be unreachable until the correct input is used again. - **Short PRF outputs are rejected.** The SDK refuses PRF outputs under 32 bytes, so a platform bug or empty input cannot collapse the derivation into a low-entropy, attacker-reproducible seed. The derived DB password plays the same role a typed password plays in a password wallet: it is the private passphrase that encrypts the wallet database’s key material at rest. Because it comes out of HKDF, it carries the full 32 bytes of entropy, far stronger than any human password. And because the derivation is deterministic, every device derives the same password without storing or syncing it, which is why a passkey wallet never shows a password prompt. For a **password wallet**, the user’s typed password is used directly as the wallet database passphrase, and the seed comes from a mnemonic generated by the daemon (`createWallet({ password })`) or imported by the user (`createWallet({ password, mnemonic })`). An optional seed passphrase (`seedPassphrase`) can be layered on an imported mnemonic; passkey wallets always use an empty one. ## What is stored where | Artifact | Where | Protected by | | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | HD seed and derived keys | Wallet database in [OPFS](/web/runtime/data-and-persistence/) (web) or the app-sandbox data directory (React Native) | Encrypted under the wallet DB password (typed password, or HKDF-derived for passkey wallets) | | Swap and daemon bookkeeping | SQLite databases alongside the wallet database | Origin or app-sandbox isolation (no key material) | | Passkey private key | Platform authenticator / passkey provider | Device biometrics or PIN; provider sync encryption | | `credentialId`, wallet-kind markers | App-level storage (`localStorage` on web, by convention) | Nothing (not secret) | | Mnemonic | Wherever the user wrote it down | The user | Nothing above leaves the device except the passkey itself, which the platform’s passkey provider (iCloud Keychain, Google Password Manager, 1Password, and similar) may sync end-to-end encrypted across the user’s devices. The operator sees signed protocol messages, never keys. ## Backup A passkey wallet has two independent backup layers: 1. **The synced passkey.** If the user’s passkey provider syncs credentials, the wallet is already recoverable on any device signed into that provider: the PRF travels with the credential, and the seed is derived from it on demand. This is the primary, zero-effort layer. 2. **The mnemonic.** When a passkey wallet is created (or first imported on a device), the result includes a 24-word aezeed mnemonic. It decodes to the same entropy the passkey derives, so it recovers the same wallet *without* the passkey, on any domain, in any aezeed-compatible flow. Offer it to the user once, encourage writing it down, and do not persist it anywhere. > **Note** > > Two exports of the same passkey wallet show **different 24-word phrases**. aezeed encrypts the entropy under a fresh random salt each time it renders a mnemonic, so the words differ while deciphering to the same entropy, and therefore the same wallet. Both phrases are valid backups; a re-export that differs from the recorded phrase is expected, not a bug. A password wallet has exactly one backup layer: the mnemonic. The password cannot be recovered or reset from anywhere, and without the mnemonic a forgotten password means the funds are gone once the local database is lost. ## Recovery paths | Scenario | Path | What comes back | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | | Same device, returning session | Passkey assertion (scoped to the stored `credentialId`) + `openWalletFromPasskey()` / `unlockWallet({ password })` | Everything; the local database is intact | | New device, synced passkey | Discoverable passkey assertion + `openWalletFromPasskey()`. The wallet state is `'none'`, so the SDK derives the seed and imports it | Keys immediately; the view of funds converges in the background | | Lost passkey, has mnemonic | `createWallet({ password, mnemonic, recoverState: true })`; the wallet continues as a password wallet | Keys immediately; funds and history via server-assisted recovery | | Lost passkey and mnemonic | None | Nothing; there is no custodial reset | Recovery restores the *seed* instantly; restoring the *view* of funds takes a scan. A mnemonic restore can opt into **server-assisted recovery** with `recoverState: true`: the daemon walks the seed’s addresses and queries the operator’s indexer to rebuild boarding outputs, VTXOs, and Lightning receive history, tracked in the background through the engine’s `'restoring'` phase and `snapshot.recovery`. See [Restore a wallet](/guides/restore-a-wallet/) for the full flow. The passkey import path does not run that scan today, so an app that supports passkey recovery on a fresh device should treat the imported wallet’s view of funds as converging rather than immediate. > **Caution** > > Recovery reproduces keys, not liveness obligations. VTXOs have expiries and unilateral-exit windows; a wallet that has been offline for a long time should be brought back online with enough margin to refresh or exit its VTXOs. See [Leaving Ark](/concepts/leaving-ark/). ## Security model - **Spending requires the device.** A passkey assertion demands user verification (biometric or PIN) on an unlocked device. Anyone who can pass that check can derive the seed; there is no second factor beyond the platform’s. - **The database at rest is only as strong as its passphrase.** For passkey wallets that is 32 bytes of HKDF output, effectively unbreakable offline. For password wallets it is the user’s password; encourage real passphrases. - **The app is part of the trust boundary.** Script running on the wallet’s web origin, or code running inside the mobile app’s process, can drive a ceremony (still gated by a user-verification prompt) and can read the PRF output once a legitimate ceremony completes. XSS on the wallet origin or a compromised dependency in the app bundle is therefore equivalent to key compromise; treat content-security policy and dependency hygiene as wallet security, not routine hygiene. - **The mnemonic is the root of everything.** Anyone holding the 24 words holds the wallet, independent of passkeys, devices, or domains. --- Source: https://wavelength.lightning.engineering/concepts/lightning-payments-are-swaps.md # Lightning payments = swaps ## Receiving over Lightning Receiving Lightning does not mean opening a channel in your app. Wavelength routes inbound payments through an atomic swap that moves value **from Lightning into Ark**. You do **not** open or manage Lightning channels. Instead, `receive()` asks the **swap server** to act as a bridge. The flow, at a high level: 1. The wallet builds a BOLT-11 **invoice** whose payment hash locks a **virtual HTLC (vHTLC)** on Ark. 2. A Lightning payer routes to the swap server (via a route hint embedded in the invoice). 3. The swap server **holds** the incoming Lightning HTLC and **funds a matching vHTLC** on Ark, locked to the same payment hash. 4. The wallet observes the funded vHTLC (via the operator’s indexer), **claims** it with the preimage, and the swap server uses that preimage to **settle** the Lightning side. The vHTLC is a taproot output with the same hashlock/timelock structure as a Lightning HTLC, but settled inside Ark. Until the claim completes, the receive shows up as pending inbound balance and a receive activity entry in phases like `waiting_for_payment` and `settling`. > **Note** > > Wavelength Lightning receive is swap-backed. There is no inbound channel capacity to pre-fund, but swap server liquidity and operator liveness still matter for how quickly funding appears. ## Sending over Lightning Sending to a Lightning invoice works the same way in reverse: Ark value funds a vHTLC, and the swap server pays the invoice on Lightning once the Ark side is claimable. The swap moves value **from Ark out to Lightning**. Again, this is not a channel payment. The flow: 1. `prepareSend({ invoice })` (or `send()` in one step) selects VTXOs and constructs a swap session. 2. The wallet **funds a vHTLC** on Ark locked to the invoice’s payment hash. 3. The swap server **claims** that vHTLC with cooperation from the operator, learns the preimage, and **pays** the BOLT-11 invoice on Lightning. 4. When the Lightning payment succeeds, the Ark side is settled and the send entry’s progress phase (`entry.progress.phase`) reaches `confirmed`, and `entry.status` becomes `complete`. Outbound Lightning therefore consumes confirmed balance (and shows pending outbound balance while the swap runs). The **SendRail** on a prepared send is `'lightning'` when the invoice path is selected. On-chain sends (`onchainAddress`) and cooperative leaves are separate rails; only invoice payments use the swap server. ## Pending & refunds Swap legs can take seconds or minutes, and either side can time out. Your UI should treat activity entries as live until they reach a terminal status or refund phase. Swap operations are **long-lived and asynchronous**. An activity entry stays pending while the swap server, operator, or Lightning network has not reached a terminal state. The progress phase tells you which leg is active: waiting for a payer, detected funding, settling through Ark, and so on. Every vHTLC encodes **refund paths** with CLTV and CSV timelocks. If the swap server never claims (receive side) or never pays the invoice (send side), the wallet can unwind cooperatively first (operator-co-signed OOR refunds) and, if needed, fall back to on-chain unilateral leaves after delays elapse. Wavelength **arms automatic refund recovery** on pay sessions so stuck sends do not strand funds indefinitely. Entries may pass through `refunding` and `refunded` phases when a timeout fires; `failed` covers hard errors with failure reason text for display. When an entry fails, `entry.failureReason` holds a human-readable message and `entry.failureCode` holds one of `timed_out | expired | refunded | needs_intervention | failed` for programmatic handling. > **Caution** > > A pending Lightning send is often waiting on swap server liquidity or routing, not a bug in your integration. Surface the entry phase to users and expect auto-refund if the invoice expires or the swap times out. See [Troubleshooting](/web/support/troubleshooting/) for the “stuck send” pattern. You do not implement the refund ladder yourself for normal flows; the daemon drives cooperative refunds and escalates when deadlines pass. Your job is to show pending state clearly and avoid treating `prepareSend` quotes as final until the entry completes. --- Source: https://wavelength.lightning.engineering/concepts/networks-and-config.md # Networks & config ## Backend endpoints Starting the embedded daemon requires backend endpoints that serve the same Bitcoin network. Your app never calls them directly. Misaligned endpoints are a common cause of stuck deposits or swaps that never fund. `RuntimeConfig` uses one flat, camelCase shape on both platforms. The endpoint values differ by transport: 1. **`arkServerAddress`** is the Ark operator and mailbox edge. It handles rounds, boarding, VTXO relay, cooperative leave, and mailbox traffic. Use a REST URL on web and a `host:port` gRPC address on React Native. There is no separate mailbox field. 2. **`swapServerAddress`** is the swap server for Lightning to Ark atomic swaps. Use a REST URL on web and a `host:port` gRPC address on React Native. 3. **`walletEsploraUrl`** is an HTTP Esplora endpoint on both platforms for chain queries and UTXO lookups. It must implement the Esplora `/address/:addr/utxo` API (electrs or mempool.space compatible). All configured services must point at the same **`network`**. A mismatched Esplora endpoint or swap server is a common source of “stuck deposit” or “swap never funds” bugs. ## defaultConfig presets For public test networks, use your transport package’s `defaultConfig(network)` instead of hand-typing URLs. It returns a ready **RuntimeConfig** with canonical endpoints merged with any overrides you pass: ```ts import { createWebClient, defaultConfig } from '@lightninglabs/wavelength-web'; const client = createWebClient(); const config = defaultConfig('signet', { dataDir: 'my-app-wallet' }); await client.start(config); ``` ```ts import { createNativeClient, defaultConfig, } from '@lightninglabs/wavelength-react-native'; const client = createNativeClient(); const config = defaultConfig('signet', { dataDir: 'my-app-wallet' }); await client.start(config); ``` Presets ship for: | Network | Typical use | | ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `'signet'` | Default for docs live embeds and the demo app; public Lightning Labs test infrastructure. Get test coins from the [signet faucet](https://robinet.eldamar.icu/). | | `'testnet'` | Bitcoin testnet3 with hosted Ark/swap gateways. | Hosted test-network gateways use TLS. The web preset uses REST URLs, while the React Native preset uses matching `host:port` gRPC addresses. `defaultConfig` accepts only these preset networks. `'mainnet'` and `'regtest'` are excluded: mainnet has no public deployment yet, and regtest’s local ports vary per development environment. Build a `RuntimeConfig` for either by hand: mainnet with your own endpoints and `allowMainnet` (see below), regtest with local endpoints and the insecure-transport flags. Pass overrides as the second argument to change data directory, point at your own operator, or toggle advanced flags without losing the rest of the preset. ## allowMainnet Mainnet is deliberately gated. Set **`allowMainnet: true`** together with **`network: 'mainnet'`** and your own **`arkServerAddress`**, **`swapServerAddress`**, and **`walletEsploraUrl`**. Without **`allowMainnet`**, the SDK rejects the configuration before startup. > **Caution** > > `allowMainnet` is an explicit safety rail. Do not enable it in example code or demos that users might copy against production networks by accident. Test networks ignore **`allowMainnet`**; it exists only for mainnet runs. ## Mainnet access Signet and testnet are open to anyone. Mainnet is different: the mainnet server accepts only clients on an approved allowlist, so `allowMainnet` on its own is not enough to connect. Access is keyed to your client’s identity, its mailbox ID, which `wavecli getinfo` reports as `identity_pubkey`. To request access, initialize and unlock your wallet, run `wavecli getinfo`, and copy the `identity_pubkey` value (a 66-character hex string). Submit that value through the [mainnet access request form](https://docs.google.com/forms/d/e/1FAIpQLScX-AwTYPCRqD9WI2LOtalgL25PSOJ__I6Gf4D8xW04WyWCpA/viewform). Once approved, your client can register and connect. If you re-initialize with a new seed, the identity changes and you have to request access again for the new `identity_pubkey`. ## Complete configuration Most apps only need `defaultConfig(network)` plus an optional data directory. Do not copy this advanced example into a quickstart. It shows the flat fields available for self-hosting and specialized deployments: ```ts const config: RuntimeConfig = { network: 'signet', walletType: 'btcwallet', walletFeeUrl: 'https://fees.example.com', walletBlockHeadersSource: 'https://headers.example.com/block-headers.bin', walletFilterHeadersSource: 'https://headers.example.com/filter-headers.bin', walletRecoveryWindow: 250, maxOperatorFeeSat: 100, signingWorkers: 4, bufferSize: 64, }; ``` ### Common fields - `network`, `allowMainnet`, `dataDir`, and `debugLevel` select the network, mainnet safety gate, storage root, and daemon log verbosity. - `arkServerAddress`, `arkServerTlsCertPath`, and `arkServerInsecure` configure the Ark endpoint. On web, `arkServerAddress` is a REST URL and filesystem certificate paths for `arkServerTlsCertPath` are rejected. On React Native, it is a `host:port` gRPC address. - `swapServerAddress`, `swapServerTlsCertPath`, `swapServerInsecure`, and `swapDatabaseFileName` configure the swap service and its state. The web transport accepts `swapServerTlsCertPath` only when `disableSwaps: true`, which suppresses every swap field. - `disableSwaps: true` disables Lightning swaps and suppresses both preset and override swap fields. - `maxOperatorFeeSat`, `signingWorkers`, and `bufferSize` are numeric runtime limits. They must be nonnegative safe integers. ### Embedded wallet backend fields - `walletEsploraUrl`, `walletPasswordFile`, and `walletPollIntervalSeconds` apply only to `lwwallet`. - `walletFeeUrl`, `walletBlockHeadersSource`, and `walletFilterHeadersSource` apply only to `btcwallet`. - `walletRecoveryWindow` applies to both supported embedded backends. `lwwallet` is the default backend. The SDK rejects incompatible backend fields and invalid numeric values before it starts the daemon. Some daemon-only options (such as the LND wallet backend) are deliberately not exposed through `RuntimeConfig`. On regtest, set both insecure flags yourself because local web gateways speak plain HTTP. On hosted networks, leave them unset so connections stay TLS-verified. If you self-host Ark or swap infrastructure, replace the preset endpoints while keeping the network aligned with your Esplora instance. Full field documentation is in the [wavelength-core reference](/reference/wavelength-core/). ## Next steps Once your client is configured and started, see [Handle phases and errors](/guides/handle-phases-and-errors/) for surfacing startup and runtime failures, or [Installation](/web/get-started/installation/) if you still need to add the SDK packages to your app. --- Source: https://wavelength.lightning.engineering/concepts/leaving-ark.md # Leaving Ark ## Cooperative leave Most withdrawals should use the cooperative path: you forfeit VTXOs in an operator round and receive an on-chain payout in roughly one round cadence. Unilateral exit exists for emergencies when the operator cannot or will not co-sign. A **cooperative leave** is the normal way to move Ark balance to a plain on-chain Bitcoin address. You forfeit selected VTXOs in an operator **round**; the batch includes a **leave output** that pays your destination address on-chain. The operator co-signs, so the exit settles in roughly one round cadence instead of waiting for CSV timelocks. In Wavelength this is the default when you send to an on-chain address (`prepareSend` / `send` with `onchainAddress`) or call `exit({ outpoint, destination })` with a destination set. Activity shows up as kind **`exit`** (or a **`send`** on the on-chain rail). The path is **`cooperative`** in the exit result. Cooperative leave requires a **live, honest operator**. It is cheaper and faster than unilateral exit: one batch transaction (plus normal mining confirmation) rather than a chain of proof and checkpoint transactions you broadcast yourself. Round completion is asynchronous; the entry may stay pending through `settling` until the leave output confirms. > **Tip** > > For everyday withdrawals, cooperative leave is what you want. Direct users to a normal Bitcoin address send, not the unilateral exit APIs. ## Unilateral exit When the operator is unreachable or dishonest, you can broadcast pre-signed tree branches yourself. That path is slower, costs more in fees, and should never be the default “Send” action. A **unilateral exit** (also called unilateral unroll) is the **trustless emergency** path. You broadcast the pre-signed Virtual Transaction Tree branch for your VTXO on-chain without operator cooperation, then walk the proof and checkpoint transactions until your funds sit in plain UTXOs you control. It works even if the operator disappears, but it is **slow and expensive**: - CSV delays on VTXO scripts must mature before each step is valid. - Multiple on-chain transactions may be required (preview with `getExitPlan({ outpoints })`). - You may need to fund a backing wallet for fees before the exit can proceed unilaterally. Wavelength exposes this through the acknowledged `exit()` branch: ```ts import { FORCE_UNROLL_ACK } from '@lightninglabs/wavelength-core'; await client.exit({ outpoint, forceUnrollAck: FORCE_UNROLL_ACK, }); ``` Import `FORCE_UNROLL_ACK` from any Wavelength package. It carries the exact acknowledgement string the daemon requires and cannot be combined with a cooperative `destination`. Cooperative leave failures reject; they never start unilateral unroll. Once a unilateral job exists, the daemon drives it to completion on its own: the unroll actor broadcasts the proof and checkpoint transactions and the final sweep itself. Use `exitStatus({ outpoint })` to track progress, and once the funds have landed as claimable outputs, use `sweepWallet({ destinationAddress, broadcast: false })` to preview consolidating them before broadcasting for real. This path is not the same API as sending to an address cooperatively. Unilateral exit does not instantaneously credit a user-chosen address. It creates claimable on-chain outputs over time; `sweepWallet()` consolidates them once timelocks expire. Track progress with `exitStatus({ outpoint })`, which returns an `ExitJobStatus` that advances through `pending` (job queued), `materializing` (proof transactions being broadcast and confirmed), `csv_pending` (waiting on CSV timelocks to mature), `sweeping` (final consolidation broadcast), and `completed` (funds are plain UTXOs). A job can also land in `failed`, surfaced via `ExitStatusResult.lastError`; `exitStatus()` returns `found: false` rather than an error when no job exists yet for that outpoint. Before the job can start, `getExitPlan({ outpoints })` reports whether it is ready to go: each `ExitPlanEntry` includes a per-outpoint `fundingAddress` and `fundingShortfallSat` for topping up the backing wallet’s fees, plus a `canStart` flag (and an overall `canStart` on the `GetExitPlanResult`). Fund the reported address until the shortfall clears and `canStart` becomes `true` before calling `exit()`. ## When to use which | Situation | Prefer | | ----------------------------------------------------------------- | ----------------------------------------------------------------- | | User taps “Withdraw to my exchange address” | **Cooperative leave** (on-chain send / `exit` with `destination`) | | Operator online, routine payout | **Cooperative leave** | | Operator unresponsive or dishonest | **Unilateral exit** | | User needs funds soon and network is healthy | **Cooperative leave** | | User accepts higher fees and multi-block delays for trustlessness | **Unilateral exit** | Use cooperative leave whenever the Ark service is reachable. Reserve unilateral exit for custody emergencies, disputed operator behavior, or infrastructure outages that block rounds. > **Caution** > > Unilateral exit is irreversible once broadcast and can cost many times the fee of a cooperative leave. Preview `getExitPlan({ outpoints })`, explain delays, and never trigger it silently from a primary “Send” button. If cooperative leave fails, Wavelength surfaces the error directly; it does not silently fall back to unilateral unroll. The unilateral path requires an explicit `forceUnrollAck` on `exit()`. Read `ExitResult.path` to see which branch ran so the UI can treat unilateral exit as a deliberate, informed event rather than a silent substitution. > **Note** > > See [Unilateral exit](/guides/unilateral-exit/) for a walkthrough of building the flow end to end, or [Handle phases and errors](/guides/handle-phases-and-errors/) for surfacing `WavelengthErrorCode` failures along the way. --- Source: https://wavelength.lightning.engineering/web/get-started/quickstart.md # Quickstart (React) ## Install Install the two packages you need: `wavelength-web` for the browser wallet runtime and `wavelength-react` for the React bindings. ```bash npm install @lightninglabs/wavelength-web @lightninglabs/wavelength-react ``` Requires Node 20 or later to run locally. The runtime itself runs in Chrome 100+, Firefox 110+, and Safari 16+. No special build config is needed for Vite or Next.js. ## Wire the provider Create a [`WalletEngine`](/reference/wavelength-core/#WalletEngine) with [`createWebWalletEngine()`](/reference/wavelength-web/#createWebWalletEngine), and wrap your app with [`WavelengthProvider`](/reference/wavelength-react/#WavelengthProvider). Pass `config` and `autoStart: true` to boot the embedded daemon as soon as the runtime is ready, no boot effect needed. Every component below the provider gains access to the wallet through hooks. ```tsx import { createWebWalletEngine, defaultConfig, } from '@lightninglabs/wavelength-web'; import { WavelengthProvider, useWallet } from '@lightninglabs/wavelength-react'; const engine = createWebWalletEngine({ runtimeBaseUrl: '/wavewalletdk/', config: defaultConfig('signet'), autoStart: true, }); export function App() { return ( ); } function Balance() { const { phase } = useWallet(); return

Wallet phase: {phase}

; } ``` `defaultConfig(network, overrides?)` returns a `RuntimeConfig` preloaded with public REST gateway endpoints for that network. Overrides are a shallow merge, so pass only top-level fields such as `dataDir`. Keep this quickstart on the default config path; see [Networks & config](/concepts/networks-and-config/) for advanced self-hosting fields. mainnet has no preset: build its `RuntimeConfig` by hand with `allowMainnet: true`, and only after you have key-backup UX in place. Mainnet access is also gated to an approved allowlist; see [Mainnet access](/concepts/networks-and-config/#mainnet-access). ## Create a wallet Call `create` from [`useWalletCreate`](/reference/wavelength-react/#useWalletCreate) to generate a new HD seed in the browser. The seed is encrypted locally and never transmitted. ```tsx import { useWalletCreate } from '@lightninglabs/wavelength-react'; function Onboard() { const { create, createPending } = useWalletCreate(); const handleCreate = async () => { const { mnemonic } = await create({ password: 'a-strong-password' }); // Back up `mnemonic` securely before the user sends funds. console.log('Wallet created'); }; return ( ); } ``` Once `create` resolves, `phase` from `useWallet` automatically advances (through `'syncing'` when the daemon needs to catch up) to `'ready'`, so any component watching `phase` updates without extra wiring. ## Try it Call `send` from [`useWalletSend`](/reference/wavelength-react/#useWalletSend) with a Lightning invoice or on-chain address. Wavelength routes Lightning payments through the swap server and settles on-chain sends through a cooperative leave with the Ark operator. A freshly created wallet has nothing to send yet, so fund it first: generate an invoice with [`useWalletReceive`](/reference/wavelength-react/#useWalletReceive) (see [Receive a Lightning payment](/guides/receive-a-lightning-payment/)) or board on-chain via [Get a deposit address](/guides/get-a-deposit-address/). ```tsx import { useWalletSend } from '@lightninglabs/wavelength-react'; function SendButton() { const { send, sendPending } = useWalletSend(); const handleSend = async () => { const result = await send({ invoice: 'lnbc1…', // or onchainAddress: 'bc1q…' }); console.log('Sent!', result.paymentHash ?? result); }; return ( ); } ``` You are all set. Explore the full guides to go deeper on individual topics. ### [Try the live demo](https://wavelength.lightning.engineering/demo/) [Run a signet wallet in your browser, no install required.](https://wavelength.lightning.engineering/demo/) [Open the demo →](https://wavelength.lightning.engineering/demo/) ### [Create a wallet](/guides/create-a-wallet/) [Full walkthrough of wallet creation, key backup, and passkey registration.](/guides/create-a-wallet/) [Read the guide →](/guides/create-a-wallet/) ### [Send a payment](/guides/send-a-payment/) [Fee estimation, prepare/confirm flow, and on-chain cooperative leave.](/guides/send-a-payment/) [Read the guide →](/guides/send-a-payment/) ### [Show balance and activity](/guides/show-balance-and-activity/) [Subscribe to balance changes and render a live activity feed.](/guides/show-balance-and-activity/) [Read the guide →](/guides/show-balance-and-activity/) --- Source: https://wavelength.lightning.engineering/web/get-started/run-the-demo-app.md # Run the demo app > **Try it live** > > A hosted signet demo runs in your browser at . No clone or local setup required. ## Clone & install The reference integration lives in the Wavelength monorepo at `apps/web-wallet-demo`. Clone the repository, install dependencies with pnpm, and build the workspace packages: ```bash git clone https://github.com/lightninglabs/wavelength-sdk.git cd wavelength-sdk pnpm install pnpm build ``` The demo depends on the workspace packages `@lightninglabs/wavelength-web` and `@lightninglabs/wavelength-react`. The runtime WASM binaries under `apps/web-wallet-demo/public/runtime/` are gitignored and ephemeral, so stage them before running the demo: ```bash pnpm --filter web-wallet-demo run wasm:local ``` See [Hosting runtime assets](/web/get-started/hosting-runtime-assets/) for details on what this stages and how to point the demo at a different asset source. ## Run Start the Vite dev server from the monorepo root. Locally built runtime assets cannot match the pinned release digests, so `dev:local` disables integrity verification for this flow (`VITE_RUNTIME_INTEGRITY=off`): ```bash pnpm --filter web-wallet-demo run dev:local ``` Open the URL Vite prints (typically `http://localhost:5173`). The demo boots a **signet** wallet out of the box: the create-wallet screen preselects signet, using the preset `endpointsForNetwork('signet')` returns in `src/lib/runtime-config.ts`, which mirrors the same public gateway URLs `defaultConfig('signet')` would produce. No local backend is required. Cross-origin isolation headers are configured in the demo’s Vite config so OPFS persistence works in every supported browser. See [Cross-origin isolation](/web/get-started/cross-origin-isolation/) for why these headers are required and how to configure them outside of Vite: ```ts const crossOriginIsolation = { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Resource-Policy': 'same-origin', }; export default defineConfig({ server: { headers: crossOriginIsolation }, preview: { headers: crossOriginIsolation }, }); ``` Use `pnpm --filter web-wallet-demo run build:local && pnpm --filter web-wallet-demo preview` to serve the production build with the same headers: locally built runtime assets cannot match the pinned release digests, so the plain `build` script would fail the integrity check the same way plain `dev` does. ## Optional (testnet) **Rebuilding runtime assets.** Re-run `wasm:local` whenever you change the daemon: it runs `make wasm-wallet` in wavelength (expecting a sibling checkout, overridable with `WAVELENGTH_DIR`) and copies the full `RUNTIME_ASSET_FILES` set into `public/runtime//`, the versioned path the demo’s `runtimeBaseUrl` points at. **Testnet.** Select testnet on the create-wallet screen to boot with the preset `endpointsForNetwork('testnet')` returns, which mirrors the same public gateway URLs `defaultConfig('testnet')` would produce. No local backend is needed. **Troubleshooting OPFS.** If wallet persistence fails in the browser, confirm COOP/COEP headers are present: ```bash curl -sI http://localhost:5173 | grep -i cross-origin ``` Both `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` must appear in the response. See [Troubleshooting / FAQ](/web/support/troubleshooting/) for more fixes, and the [web guides](/guides/create-a-wallet/) for end-to-end wallet flows built on top of this demo. --- Source: https://wavelength.lightning.engineering/web/support/demo-app.md # Demo app > **Try it live** > > A hosted signet demo runs in your browser at . No clone or local setup required. ## The reference integration The **`web-wallet-demo`** app in the Wavelength monorepo is a full React integration you can read, run, and copy patterns from. It is not a separate product; it exercises the same `@lightninglabs/wavelength-web` and `@lightninglabs/wavelength-react` packages your app would use. What it demonstrates: - **`WavelengthProvider`** with an injected `createWebWalletEngine()` engine (self-hosted runtime assets, worker mode, optional RPC debug logging). - **Multiple named wallets per origin**, tracked in a `localStorage` registry (`src/lib/walletRegistry.ts`): a name, network, and unlock method per wallet, with sequential switching between them by stopping the running engine and starting the next entry. - **Phase-based routing** from `useWallet()`: create/unlock, backup, syncing, ready, stopped, and error screens driven by `phase` and `useWalletInfo()`, with a wallet list and a create/restore flow shown before any engine starts. - **Password and passkey onboarding** via `useWalletCreate` / `useWalletUnlock` and `useWalletPasskey(...)`, with the credential id persisted on the wallet’s registry entry for scoped unlock (`src/lib/walletRegistry.ts`). The demo passes a ceremony built with `createWebPasskeyCeremony` so passkey timing lands on the same sink as its other performance samples; `webPasskeyCeremony` is the equivalent without instrumentation. - **Core wallet flows**: deposit address, Lightning receive, send (prepare and confirm), balance and activity, and settings. - **Network presets** for signet, testnet, and regtest, each wallet permanently bound to the network chosen at creation (`endpointsForNetwork()` in `runtime-config.ts` mirrors public gateway URLs and local arktest ports). - **Production-like hosting concerns**: COOP/COEP headers in `vite.config.ts`, self-hosted WASM binaries under `public/runtime//`, persistent storage request, and a local wipe path that clears OPFS and app markers (`src/lib/wipeLocalData.ts`). Treat the demo as the source of truth for wiring details that docs summarize. Key entry points: `src/main.tsx` (engine bootstrap), `src/App.tsx` (orchestration), `src/lib/walletRegistry.ts` (the wallet list), and `src/lib/runtime-config.ts` (gateway defaults). See also: [`createWebWalletEngine`](/reference/wavelength-web/#createWebWalletEngine), [`useWallet`](/reference/wavelength-react/#useWallet), and [`useWalletPasskey`](/reference/wavelength-react/#useWalletPasskey) in the API reference for details behind each of these bullets. ## How to run it Clone the monorepo, install dependencies, and start the demo dev server. Step-by-step commands (including `wasm:local` for a fresh WASM build and regtest/testnet overrides) live on the dedicated run page: **[Run the demo app](/web/get-started/run-the-demo-app/)** Out of the box the demo’s create-wallet screen offers **signet** and **testnet** against Lightning Labs public gateways; no local backend is required for either. Regtest is a dev-only option, hidden unless you open the demo with `?regtest=1`. For regtest, start [arktest](https://github.com/lightninglabs/wavelength) locally, pick regtest on the create screen, and enter the ports it prints in the collapsed “Advanced endpoints” section. Runtime assets are staged (never committed) under `public/runtime//`. Before the first `dev:local` run, and after upgrading the SDK or rebuilding WASM from `wavelength`, run `pnpm --filter web-wallet-demo run wasm:local` to stage fresh binaries; locally built assets cannot match the pinned release digests, so `dev:local` (not plain `dev`) is what disables verification for them. See [Run the demo app](/web/get-started/run-the-demo-app/) for the full command. React Native readers: see [Run the demo app](/react-native/get-started/run-the-demo-app/) for the mobile counterpart, which mirrors this demo screen for screen. --- Source: https://wavelength.lightning.engineering/web/get-started/installation.md # Installation ## The packages For web apps, the Wavelength SDK ships as three npm packages. You install one or two of them depending on your stack: | Package | What it is | | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | | `@lightninglabs/wavelength-core` | Types, the `WavelengthClient` interface, errors, and enums. Pulled in transitively; you rarely install it directly. | | `@lightninglabs/wavelength-web` | The browser WASM transport. Framework-agnostic: use it from vanilla JS, Vue, Svelte, or React. Re-exports everything from `core`. | | `@lightninglabs/wavelength-react` | React provider and hooks. Transport-agnostic: it takes an injected engine and depends only on `core`. | **React apps** need the web transport and the React bindings: ```bash npm install @lightninglabs/wavelength-web @lightninglabs/wavelength-react ``` **Vanilla JS, Vue, Svelte, or other non-React frameworks** need only the web transport: ```bash npm install @lightninglabs/wavelength-web ``` Before wiring up the code samples below, make sure the browser environment you’re deploying to meets the [requirements](/web/get-started/requirements/) and serves your app with the headers described in [Cross-origin isolation](/web/get-started/cross-origin-isolation/). The Wavelength SDK’s WebAssembly runtime will not start without them. ## Peer deps `@lightninglabs/wavelength-react` declares `react` as a peer dependency, with an accepted range of `^18.0.0 || ^19.0.0`. Your app must also provide `react-dom` (or the equivalent for your React renderer). Either major version, 18 or 19, works: ```bash npm install react react-dom ``` Build the wallet engine once near the root of your app. `createWebWalletEngine` accepts `WebWalletEngineOptions` (for example `runtimeBaseUrl` pointing at hosted WASM assets, see [Hosting runtime assets](/web/get-started/hosting-runtime-assets/)), plus `config` and `autoStart` to boot the embedded daemon as soon as the runtime is ready, no boot effect needed. ```tsx import { WavelengthProvider, useWallet, useWalletBalance, } from '@lightninglabs/wavelength-react'; import { createWebWalletEngine, defaultConfig, } from '@lightninglabs/wavelength-web'; // Configure transport options once. runtimeBaseUrl points at the hosted WASM // asset folder. const engine = createWebWalletEngine({ runtimeBaseUrl: 'https://your-host/wavewalletdk/', config: defaultConfig('signet'), autoStart: true, }); export function App() { return ( ); } function Wallet() { const { phase, error } = useWallet(); const balance = useWalletBalance(); if (phase !== 'ready') { return

Loading… ({phase})

; } return

Spendable: {balance?.confirmedSat ?? 0} sats

; } ``` `error` from `useWallet` holds the last fatal runtime-level error: set when the initial runtime load fails or when `start`/`stop` fails. Render it instead of dropping the failure on the floor. For a non-React app, skip the provider and call `start` on the client directly after `ready()` resolves: ```ts import { createWebClient, defaultConfig } from '@lightninglabs/wavelength-web'; const client = createWebClient({ runtimeBaseUrl: 'https://your-host/wavewalletdk/', }); await client.ready(); await client.start(defaultConfig('signet')); ``` ## Next steps - [Requirements](/web/get-started/requirements/): confirm the browser support matrix and backend services the Wavelength SDK needs. - [Hosting runtime assets](/web/get-started/hosting-runtime-assets/): obtain the WebAssembly runtime asset set and serve it from your own host. - [Quickstart](/web/get-started/quickstart/): wire up the provider, create a wallet, and send your first payment. --- Source: https://wavelength.lightning.engineering/web/get-started/requirements.md # Requirements ## Browser support Wavelength runs the full wallet daemon in the browser as WebAssembly. Target browsers with mature WASM, Web Worker, and storage APIs: | Browser | Minimum version | | ------------------------------------ | --------------- | | Chrome (and Chromium-based browsers) | 100+ | | Firefox | 110+ | | Safari (macOS and iOS) | 16+ | Node.js 20 or later is recommended for local development (Vite, Next.js, and similar bundlers). No special build plugins are required beyond serving the WASM runtime assets and, for worker mode, setting cross-origin isolation headers. Passkey support (WebAuthn with PRF) is optional and depends on the browser and OS. Password-based wallets work everywhere in the matrix above. ## Cross-origin isolation The default transport runs the daemon in a dedicated Web Worker and persists wallet state with OPFS-backed SQLite. Both paths rely on `SharedArrayBuffer`, which browsers only expose on **cross-origin isolated** pages. Your app must send `Cross-Origin-Opener-Policy` and `Cross-Origin-Embedder-Policy` response headers on every route that loads the wallet. See [Cross-origin isolation](/web/get-started/cross-origin-isolation/) for the exact values and a quick verification command. If you cannot isolate the page (for example, because third-party embeds block `require-corp`), you can run the runtime on the main thread with `createWebClient({ runtimeThread: 'main' })`, but OPFS persistence will not be available and the UI thread will block while the daemon is busy. See [Data & persistence](/web/runtime/data-and-persistence/) for how storage behavior differs between main-thread and worker mode. ## Backends Wavelength is self-custodial: the seed never leaves the browser. The embedded daemon still needs reachable **Ark**, **Esplora**, and (for Lightning swaps) **swap server** endpoints at runtime. **Signet (recommended for development)** works out of the box with the public preset. Pass `defaultConfig('signet')` to `start()` and the client connects to Lightning Labs hosted gateways: ```ts import { createWebClient, defaultConfig } from '@lightninglabs/wavelength-web'; const client = createWebClient(); await client.start(defaultConfig('signet')); ``` Presets also exist for `testnet`. No local backend is required for any hosted network. **Regtest (local development)** targets services you run yourself, so there is no `defaultConfig` preset for it; local ports vary per machine, and the URLs are the whole point of a preset. Start [arktest](https://github.com/lightninglabs/wavelength), note the ports it prints, and build the config by hand, including the insecure-transport flags for plain-HTTP local gateways: ```ts import type { RuntimeConfig } from '@lightninglabs/wavelength-web'; const config: RuntimeConfig = { network: 'regtest', arkServerAddress: 'http://127.0.0.1:7071', walletEsploraUrl: 'http://127.0.0.1:8501', swapServerAddress: 'http://127.0.0.1:10032', arkServerInsecure: true, swapServerInsecure: true, }; await client.start(config); ``` **Mainnet** has no public preset yet, so like regtest there is no `defaultConfig` for it. Build the config by hand with your own gateway URLs and set `allowMainnet: true` before going live: ```ts await client.start({ network: 'mainnet', arkServerAddress: 'https://your-ark-server.example.com', walletEsploraUrl: 'https://your-esplora.example.com/api', swapServerAddress: 'https://your-swap-server.example.com', allowMainnet: true, }); ``` On web, `arkServerAddress` and `swapServerAddress` must be REST URLs. `walletEsploraUrl` is an HTTP Esplora endpoint. The web transport always rejects `arkServerTlsCertPath`. It accepts `swapServerTlsCertPath` only when `disableSwaps: true`, which suppresses every swap field. Mainnet access is gated to an approved allowlist. See [Mainnet access](/concepts/networks-and-config/#mainnet-access) for how to request it with your client’s `identity_pubkey`. --- Source: https://wavelength.lightning.engineering/web/get-started/hosting-runtime-assets.md # Hosting runtime assets The web transport loads its WebAssembly runtime from a set of files that you host yourself and point `runtimeBaseUrl` at. You always provide the assets. The worker entry (`wavewalletdk-worker.js`) ships inside `@lightninglabs/wavelength-web` and is emitted by your bundler; only the daemon binaries below need to be hosted. ```ts import { createWebClient } from '@lightninglabs/wavelength-web'; const client = createWebClient({ runtimeBaseUrl: 'https://your-host/wavewalletdk//', }); ``` If `runtimeBaseUrl` is unset, assets resolve relative to the page URL in both worker and main-thread mode, so you can also serve the runtime files alongside your app. ## The asset set The runtime is a fixed set of files built from [wavelength](https://github.com/lightninglabs/wavelength). Import `RUNTIME_ASSET_FILES` to see every filename that must be served together at one base URL. `RUNTIME_MANIFEST_VERSION` identifies the daemon build the SDK is paired with: ```ts import { RUNTIME_ASSET_FILES, RUNTIME_MANIFEST_VERSION, } from '@lightninglabs/wavelength-web'; console.log(RUNTIME_ASSET_FILES); // wavewalletdk.wasm, wavewalletdk.wasm.gz, wasm_exec.js, sqlite-bridge.js, // sqlite-worker.js, sqlite3.js, sqlite3.wasm, sqlite3-opfs-async-proxy.js ``` ## Get the asset set There are two ways to obtain the files, both producing the set for the pinned `RUNTIME_MANIFEST_VERSION`: **Download from the wavelength release.** The matching asset set is attached to the paired [wavelength release](https://github.com/lightninglabs/wavelength/releases) as `Wavewalletdk.wasm.tar.gz`. It holds every file above, flat with no enclosing directory, so it unpacks straight into the directory you serve them from: ```bash BASE=https://github.com/lightninglabs/wavelength/releases/download curl -fsSLO "$BASE//Wavewalletdk.wasm.tar.gz" mkdir -p "./public/wavewalletdk/" tar -xzf Wavewalletdk.wasm.tar.gz -C "./public/wavewalletdk/" ``` **Build from a wavelength checkout.** From a checkout of `wavelength`, build the WASM wallet target and copy the output into your app’s static folder: ```bash make -C /path/to/wavelength wasm-wallet mkdir -p "./public/wavewalletdk/" for f in wavewalletdk.wasm wavewalletdk.wasm.gz wasm_exec.js sqlite-bridge.js \ sqlite-worker.js sqlite3.js sqlite3.wasm sqlite3-opfs-async-proxy.js; do cp "/path/to/wavelength/bin/wasm/$f" "./public/wavewalletdk//" done ``` The monorepo demo wraps the same build in `pnpm --filter web-wallet-demo run wasm:local`, which builds from a sibling `wavelength` checkout and stages files into `apps/web-wallet-demo/public/runtime//`, the versioned path the demo’s `runtimeBaseUrl` points at. After `vite build`, those files land in `dist/` and are served from the app origin. A runtime built this way will not match the pinned digests described in [Integrity verification](#integrity-verification) below; point `createWebClient()` at it with `runtimeIntegrity: false` as covered there. ## Host the assets Host all of them under a single path and set `runtimeBaseUrl` to that directory. Prefer a path that includes `RUNTIME_MANIFEST_VERSION` (for example `/wavewalletdk//`): every asset set then gets a unique URL, so browsers pick up new assets on an SDK upgrade instead of serving stale cached copies, and you can cache the files aggressively. Trailing slashes are optional; the client normalizes the base URL. **Version lock.** Runtime assets are version-locked to the embedded daemon inside `@lightninglabs/wavelength-web`. When you upgrade the SDK, obtain and redeploy the matching WASM bundle. Mismatched versions can fail at load time or produce subtle runtime errors. **Compression.** The client fetches `wavewalletdk.wasm.gz` first and falls back to the uncompressed `wavewalletdk.wasm` if it cannot be used (for example when the browser has no `DecompressionStream` to inflate it). Host both files so every supported browser can load the runtime. No particular *compression* headers are required. The client reads the file’s first bytes and branches on the magic number rather than on `Content-Type` or `Content-Encoding`, so a body your host serves compressed, one it has already inflated, and one labelled `application/gzip` all load the same way. Compress it however your host makes easiest. Other headers still matter: a cross-origin `runtimeBaseUrl` needs `Cross-Origin-Resource-Policy: cross-origin` on these assets, covered in [cross-origin isolation](/web/get-started/cross-origin-isolation/). Do still set long-lived cache headers, but note that the browser will not keep a module this large in its HTTP cache regardless of what you send. The SDK therefore keeps its own copy in Cache Storage so returning visitors do not re-download it; see [Data & persistence](/web/runtime/data-and-persistence/#the-runtime-cache) for what that stores and when it is pruned. Cached bytes are re-verified against the pinned digest on every read, exactly like a fresh fetch, so a cache entry can never become a bypass for the checks below. ## Integrity verification Before executing the wasm binary and the two bootstrap scripts the transport executes (`wasm_exec.js` and `sqlite-bridge.js`), the web transport verifies their fetched bytes against SHA-256 digests pinned in the npm package for the paired `RUNTIME_MANIFEST_VERSION`. Hosting a mismatched version’s assets fails loudly with `asset_integrity_failed` instead of silently running stale or tampered code. `wavewalletdk.wasm.gz` is covered too: the client verifies its decompressed bytes against the same digest entry as `wavewalletdk.wasm`, since decompressing it yields identical content. The remaining files in the asset set (the sqlite worker chain: `sqlite-worker.js`, `sqlite3.js`, `sqlite3.wasm`, `sqlite3-opfs-async-proxy.js`) are not verified at runtime. Cover them by checking your deployed set at deploy time instead: `RUNTIME_ASSET_DIGESTS` exports the digest for every file in the set, keyed by filename in the same SRI format (`sha256-`) the client checks against. ```bash f=./public/wavewalletdk//wavewalletdk.wasm echo "sha256-$(openssl dgst -sha256 -binary "$f" | openssl base64 -A)" ``` **Escape hatch for source-built runtimes.** A runtime you build yourself from a `wavelength` checkout will not match the pinned digests, since those pin a specific published release. Set `runtimeIntegrity: false` on `createWebClient()` or `createWebWalletEngine()` to skip verification; doing so logs a one-time console warning so the switch is never silently left off in production. **Hosting requirements.** Serve the runtime assets same-origin where possible. If your app sets a Content-Security-Policy, `script-src` must include `blob:` alongside `'self'`: the SDK executes its verified bootstrap scripts from blob URLs, and a policy without `blob:` breaks the SDK outright. Hosting the assets cross-origin additionally requires CORS headers on the asset host, since the client fetches them before verifying their bytes. --- Source: https://wavelength.lightning.engineering/web/get-started/cross-origin-isolation.md # Cross-origin isolation ## Why The Wavelength SDK’s default transport runs the wallet daemon in a Web Worker and stores encrypted wallet data in OPFS-backed SQLite. Both the worker’s WASM module and the SQLite stack use `SharedArrayBuffer` for efficient memory sharing. Browsers gate `SharedArrayBuffer` behind **cross-origin isolation**: the page must be served with headers that prevent other origins from reading its memory. Without isolation, `SharedArrayBuffer` is unavailable, OPFS persistence fails, and the worker transport cannot start. Cross-origin isolation is a web-only concern. It applies to every route in your app that loads the Wavelength SDK. (The hosted demo app is served from its own origin with these headers for exactly this reason.) ## COOP/COEP headers These are response headers on your own HTML document, so only the server that serves your app can set them. The Wavelength SDK cannot set them for you: they are not something the SDK, the worker, or the runtime assets can emit on their own. Send them on wallet routes (and on your dev server while testing locally): ```http Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` `Cross-Origin-Opener-Policy: same-origin` keeps other browsing contexts from holding a reference to your page’s global object. `Cross-Origin-Embedder-Policy: require-corp` requires every subresource (script, stylesheet, image, font, WASM) to either be same-origin or explicitly opt in with `Cross-Origin-Resource-Policy: cross-origin` (or be loaded with `crossorigin` where applicable). Same-origin assets need no extra attribute. **Scope headers to wallet routes.** If only part of your site runs the wallet (for example a `/wallet/*` section), apply COOP/COEP on those paths rather than the entire domain. Hosts like Netlify and Cloudflare Pages read a `_headers` file: ```text /wallet/* Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp ``` For Vite during local development, set the same headers on the dev and preview servers: ```ts const crossOriginIsolation = { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', 'Cross-Origin-Resource-Policy': 'same-origin', }; export default defineConfig({ server: { headers: crossOriginIsolation }, preview: { headers: crossOriginIsolation }, }); ``` **Self-host third-party assets.** Cross-origin Google Fonts, analytics scripts, and similar embeds are blocked under `require-corp`. Bundle fonts locally (for example with `@fontsource`) or serve them from the same origin as your app. > **Note** > > If you set `runtimeBaseUrl` to point the daemon runtime binaries (`wavewalletdk.wasm.gz`, `wasm_exec.js`, `sqlite-*.js`) at a different origin, that origin must also send `Cross-Origin-Resource-Policy: cross-origin` on those asset responses. Otherwise `require-corp` blocks them from loading. The wasm binary and the bootstrap scripts (`wasm_exec.js`, `sqlite-bridge.js`) are always fetched directly rather than loaded via `