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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions apps/docs/src/content/docs/guides/handle-phases-and-errors.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,56 @@ function PayButton({ bolt11 }: { bolt11: string }) {
Every error the Wavelength SDK originates extends [`WavelengthError`](/reference/wavelength-core/#WavelengthError) and carries a `code`
string that identifies the failure reason at the machine level. Match on
`err.code` to show a specific message rather than a generic fallback. Named
SDK codes include `runtime_not_ready`, `asset_load_failed`, and
`worker_error`; daemon-originated errors currently use `wavelength_error`.
SDK codes include `runtime_not_ready`, `asset_load_failed`, `worker_error`,
`wallet_locked`, and `runtime_lock_unavailable` (the browser refused or
cancelled the runtime lock request itself, which says nothing about another
tab; offer a plain retry). Daemon-originated errors currently use
`wavelength_error`.

`wallet_locked` is worth handling on its own, and it belongs on the path that
starts the runtime rather than on a payment. Despite the name it has nothing to
do with the `locked` phase: that one means a wallet on this device is waiting
for its password or passkey, while this means another tab owns the runtime.
The daemon's browser storage is exclusive to one runtime per origin, so
starting the wallet while another tab already runs it fails immediately with
this code. It is an expected condition
rather than a fault: tell the user to close the other tab, and keep a retry
affordance so they can continue once they have.

```tsx title="start.tsx"
import { useState } from 'react';
import {
useWallet,
type RuntimeConfig,
type WavelengthError,
} from '@lightninglabs/wavelength-react';

function StartButton({ config }: { config: RuntimeConfig }) {
const { start } = useWallet();
const [notice, setNotice] = useState('');

const run = async () => {
try {
await start(config);
} catch (err) {
// Branch on the code property rather than instanceof: a duplicate
// bundled copy of core fails instanceof across the bundle boundary.
if ((err as WavelengthError)?.code === 'wallet_locked') {
setNotice('Already open in another tab. Close it, then try again.');
return;
}
setNotice('The wallet runtime could not start.');
}
};

return (
<>
<button onClick={run}>Start wallet</button>
{notice && <p role="alert">{notice}</p>}
</>
);
}
```

```tsx title="errors.tsx"
import { useWalletSend, WavelengthError } from '@lightninglabs/wavelength-react';
Expand Down
38 changes: 25 additions & 13 deletions apps/docs/src/content/docs/reference/wavelength-core.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,10 @@ package.

<Signature title="wavelength-core.d.ts" code={mobileConfigSig} />

Calling `client.callFacade('start', mobileConfig)` sends this lower-level shape
directly to the daemon and bypasses public `RuntimeConfig` validation. App code
should call `client.start(config)` instead.
The daemon's start verb accepts this lower-level shape; the SDK builds it from
your [`RuntimeConfig`](#RuntimeConfig) when you call `client.start(config)`. The
lifecycle verbs are not reachable through `callFacade`, which rejects `'start'`
and `'stop'` so the cross-tab runtime lock is never bypassed.

</ApiSymbol>

Expand Down Expand Up @@ -368,7 +369,9 @@ Resolves when the client is ready to `start()`. Rejects with a `WavelengthError`
<ApiSymbol name="start" kind="function">

Starts the embedded daemon with the given config and resolves with its
initial [`WalletInfo`](#WalletInfo).
initial [`WalletInfo`](#WalletInfo). Calling `start()` while a session is
already running coalesces onto it and resolves with the current info; the
passed config is ignored, so call `stop()` first to start under a different one.

<Signature title="wavelength-core.d.ts" code={startSig} />

Expand Down Expand Up @@ -509,7 +512,7 @@ The engine interface. Every method mirrors the matching `WavelengthClient` metho
{ name: 'client', type: 'WavelengthClient', desc: 'The underlying transport client, as an escape hatch.' },
{ name: 'getSnapshot()', type: '() => WalletSnapshot', desc: 'The current immutable state snapshot; see WalletSnapshot below.' },
{ name: 'subscribe(listener)', type: '(listener: () => void) => () => void', desc: 'Subscribes to snapshot changes; returns the unsubscribe function. Framework bindings call this via useSyncExternalStore.' },
{ name: 'start(config?)', type: '(config?: RuntimeConfig) => Promise<WalletInfo>', desc: "Starts the runtime. Falls back to the engine's configured config when called without an argument; throws if neither exists. A failure moves phase to 'error' and rejects. On success it best-effort calls refresh() (a locked or empty wallet can fail balance/list until bootstrap; that failure is swallowed)." },
{ name: 'start(config?)', type: '(config?: RuntimeConfig) => Promise<WalletInfo>', desc: "Starts the runtime. Falls back to the engine's configured config when called without an argument; throws if neither exists. On success it dispatches the info and best-effort calls refresh() (a locked or empty wallet can fail balance/list until bootstrap; that failure is swallowed); a failure moves phase to 'error' and rejects. Either way, a stop() the host issued while the start was in flight takes precedence: that stop governs the phase and the start leaves the snapshot to it (no info dispatch and no refresh on success, no error surfaced on failure), though the promise still resolves with the info or rejects as usual." },
{ name: 'stop()', type: '() => Promise<void>', desc: "Stops the runtime and clears the in-memory snapshot (info/balance/activity reset to null/null/[]); persisted wallet data is untouched. A failure moves phase to 'error'." },
{ name: 'refresh()', type: '() => Promise<void>', desc: 'Re-fetches info, balance, and activity concurrently.' },
{ name: 'createWallet(req)', type: '(req: CreateWalletRequest) => Promise<CreateWalletResult>', desc: 'Creates a new wallet, refetches info, and kicks a background refresh.' },
Expand Down Expand Up @@ -544,7 +547,7 @@ reimplement them.
{ name: 'Sync auto-poll', type: "phase: 'syncing'", desc: "Calls refresh() every 2000ms until the phase leaves 'syncing'. After 5 consecutive failures it stops polling and escalates to phase 'error' rather than polling forever." },
{ name: 'Restore readiness poll', type: "phase: 'restoring'", desc: 'Runs while any restoreWallet() is in flight, polling getInfo() every 1500ms until the wallet reports ready, then settles the restoreWallet() promise and kicks a refresh. When the request sets recoverState: true, the recovery scan is tracked separately through snapshot.recovery.' },
{ name: 'Background-refresh failure budget', type: 'all of the above', desc: 'Any refresh kicked after a mutation (create, unlock, restore, deposit, receive, send) or by a poll above shares one consecutive-failure counter. At 5 consecutive failures the engine sets snapshot.error and escalates phase to \'error\', so a dead daemon cannot hide behind a healthy-looking ready wallet.' },
{ name: 'Unsolicited stopped transition', type: 'on runtimeStopped', desc: "A runtimeStopped client event, whether from a clean stop() or a worker crash surfaced by the transport, moves phase to 'stopped' and wipes info, balance, and activity to null, null, and [], even if the host never called stop() itself." },
{ name: 'Unsolicited stopped transition', type: 'on runtimeStopped', desc: "A runtimeStopped client event, whether from a clean stop() or a worker crash surfaced by the transport, moves phase to 'stopped' (unless the engine is already in 'error', which is preserved so a failure that caused the stop still surfaces) and wipes info, balance, and activity to null, null, and [] regardless, even if the host never called stop() itself." },
]} />

</ApiSymbol>
Expand Down Expand Up @@ -2479,9 +2482,12 @@ as `T` (unchecked; the caller asserts the shape).
<Signature title="wavelength-core.d.ts" code={facadeMethodSig} />

The whitelist excludes the streaming `subscribe` operation and private worker
control methods such as `$ready` and `$init`. Requests use the raw mobile/wasm
shapes, while responses use the same camelCase keys and schema-aware nil
normalization as typed methods. The four scalar verbs
control methods such as `$ready` and `$init`. The lifecycle verbs `start` and
`stop` are rejected too: call the typed [`start()`](#start) / [`stop()`](#stop)
methods, which own the runtime lifecycle (and, on the web transports, the
cross-tab runtime lock). Requests use the raw mobile/wasm shapes, while
responses use the same camelCase keys and schema-aware nil normalization as
typed methods. The four scalar verbs
`confirmedBalanceSat`, `pendingInboundSat`, `walletReady`, and `isRunning` are
included for portable bridge callers.

Expand Down Expand Up @@ -2796,6 +2802,8 @@ export const errorCodeSig = `type WavelengthErrorCode =
| 'runtime_not_ready'
| 'asset_load_failed'
| 'worker_error'
| 'wallet_locked'
| 'runtime_lock_unavailable'
| 'unsupported_facade_method'
| 'invalid_cursor'
| 'invalid_config'
Expand All @@ -2819,18 +2827,22 @@ Base error class thrown by all Wavelength SDK packages. Carries a machine-readab
<ApiSymbol name="WavelengthErrorCode" kind="type">

Stable, machine-readable error classification on `WavelengthError`. Named SDK
codes are listed below; daemon-originated errors currently fall back to
`wavelength_error`. The `(string & {})` arm keeps the union open for forward
compatibility while still offering autocomplete on the known codes.
codes are listed below; most daemon-originated errors fall back to
`wavelength_error`, except recognized storage-contention messages on `start()`,
which the web transports map to `wallet_locked`. The `(string & {})` arm keeps the union
open for forward compatibility while still offering autocomplete on the known
codes.

<Signature title="wavelength-core.d.ts" code={errorCodeSig} />

| Code | Thrown when |
|---|---|
| `wavelength_error` | The generic default: any SDK error without a more specific code, and all daemon-originated failures today. |
| `wavelength_error` | The generic default: any SDK error without a more specific code, and daemon-originated failures today, except recognized storage-contention messages on `start()` that map to `wallet_locked`. |
| `runtime_not_ready` | The wasm runtime is not callable: it exited before signaling ready, or its call entry point is missing once loading has finished. |
| `asset_load_failed` | `ready()` fails to load the runtime assets (wasm binary or its supporting files). |
| `worker_error` | The worker transport's underlying Worker fails or crashes. |
| `wallet_locked` | The wallet is already running in another tab or window of the same origin. The daemon's browser storage is exclusive to one runtime, so `start()` fails immediately rather than corrupting or losing data. Unrelated to the `locked` phase, which means a wallet on this device is waiting to be unlocked. |
| `runtime_lock_unavailable` | The browser refused or dropped the runtime lock request itself (for example while the document is shutting down). Unlike `wallet_locked`, this says nothing about another tab holding the wallet. |
| `unsupported_facade_method` | A call names a method the daemon facade does not expose. |
| `invalid_cursor` | `startActivity()` is given a cursor that is not a nonnegative safe integer. |
| `invalid_config` | `RuntimeConfig` validation fails before the daemon starts. |
Expand Down
17 changes: 17 additions & 0 deletions apps/docs/src/content/docs/web/support/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,23 @@ base URL and set **`runtimeBaseUrl`** on `createWebClient()`. Open the failing
URL in the browser network tab; a 404 almost always means the base path or
version folder is wrong. See [Hosting runtime assets](/web/get-started/hosting-runtime-assets/).

### Wallet is open in another tab (`wallet_locked`)

The client throws `WavelengthError` with code **`wallet_locked`** when `start()`
runs while another tab or window of the same origin is already running the
wallet. The daemon's OPFS storage is exclusive to one runtime, so the second tab
fails immediately instead of colliding on the database. This is unrelated to the
`locked` phase, which means a wallet on this device is waiting to be unlocked.

**Fix:** This is expected, not a bug. Tell the user to close the other tab and
offer a retry (the demo shows "Already open in another tab. Close it, then try
again." with a retry button, not a wipe hatch). Handle it on the path that starts
the runtime, matching on `err.code === 'wallet_locked'`; see
[Handle phases and errors](/guides/handle-phases-and-errors/). A related code,
**`runtime_lock_unavailable`**, means the browser refused or dropped the lock
request itself (for example while the document is shutting down) and says nothing
about another tab; offer a plain retry.

### Page is not cross-origin isolated

Symptoms: `SharedArrayBuffer` is undefined, worker startup fails, or SQLite/OPFS
Expand Down
46 changes: 44 additions & 2 deletions apps/web-wallet-demo/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
useWalletActivity,
useWalletBalance,
} from "@lightninglabs/wavelength-react";
import type { WalletKind } from "@lightninglabs/wavelength-react";
import type { WalletKind, WavelengthError } from "@lightninglabs/wavelength-react";
import { AppShell } from "./components/layout/AppShell";
import { RecoveryBanner } from "./components/RecoveryBanner";
import { ExitBanner } from "./components/ExitBanner";
Expand Down Expand Up @@ -273,7 +273,48 @@ export function App() {
<StoppedScreen network={network} onStart={startRuntime} busy={runtimeBusy} />
);

case "error":
case "error": {
// Duck-typed on `code` rather than instanceof: a duplicate bundled copy
// of core (the hazard core's isPasskeyCancelled documents) would fail
// instanceof and silently downgrade these expected conditions to the
// generic screen, wipe button included.
const errorCode = (error as WavelengthError | null)?.code;

// A wallet_locked failure is an expected multi-tab condition, not a
// runtime fault: another tab of this origin holds the wallet's exclusive
// OPFS databases. Swap the raw error surface for actionable copy; the
// retry succeeds once the other tab stops the runtime or closes.
if (errorCode === "wallet_locked") {
return (
<ErrorScreen
network={network}
title="Wallet open in another tab"
sub="Only one tab can run the wallet at a time."
message="This wallet is already running in another tab or window. Close it there (or stop its runtime), then press Try again."
onRetry={startRuntime}
busy={runtimeBusy}
showWipe={false}
/>
);
}

// The browser refused the lock request itself, which says nothing about
// the wallet data. Retrying is the whole remedy, so keep the destructive
// wipe affordance out of it.
if (errorCode === "runtime_lock_unavailable") {
return (
<ErrorScreen
network={network}
title="Could not start just now"
sub="The browser would not hand over the wallet runtime lock."
message="Something interrupted the wallet as it was starting. Press Try again."
onRetry={startRuntime}
busy={runtimeBusy}
showWipe={false}
/>
);
}

return (
<ErrorScreen
network={network}
Expand All @@ -282,6 +323,7 @@ export function App() {
busy={runtimeBusy}
/>
);
}

case "ready":
default:
Expand Down
23 changes: 15 additions & 8 deletions apps/web-wallet-demo/src/screens/onboarding/ErrorScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,29 @@ import { PrimaryButton } from "../../components/ui/Button";
// ErrorScreen serves the `error` phase: the runtime failed to initialise or
// start. It surfaces the message and offers a retry, plus the wipe escape
// hatch for when stored data (a stale database, say) is what keeps the
// runtime from starting.
// runtime from starting. Expected conditions (the wallet already running in
// another tab, say) override the title/sub/message with friendlier copy and
// hide the wipe button, which cannot help there.
export function ErrorScreen({
network,
message,
onRetry,
busy,
title = "Runtime error",
sub = "Something went wrong starting the wallet runtime.",
showWipe = true,
}: {
network: string;
message: string;
onRetry: () => void;
busy: boolean;
title?: string;
sub?: string;
showWipe?: boolean;
}) {
return (
<AuthLayout network={network}>
<AuthHeader
title="Runtime error"
sub="Something went wrong starting the wallet runtime."
/>
<AuthHeader title={title} sub={sub} />
<Card className="p-6">
<div className="flex items-start gap-3">
<span
Expand All @@ -43,9 +48,11 @@ export function ErrorScreen({
<PrimaryButton icon={RefreshCw} onClick={onRetry} disabled={busy}>
{busy ? "Retrying…" : "Try again"}
</PrimaryButton>
<div className="mt-3 text-center">
<WipeDataButton />
</div>
{showWipe && (
<div className="mt-3 text-center">
<WipeDataButton />
</div>
)}
</div>
</AuthLayout>
);
Expand Down
Loading
Loading