A self-custodial Liquid (LBTC) wallet browser extension. Apogee holds the keys
and signs; web apps connect to it as a dapp through an injected window.liquid /
window.apogee provider. The app never exposes a seed to the page.
pnpm install
pnpm dev # vite build --watch
pnpm build # production build → dist/Load it: chrome://extensions → enable Developer mode → Load unpacked →
dist. Use a Chromium browser (Chrome/Brave) — Jade pairing needs Web Serial.
CRXJS + Vite + React + Tailwind, with lwk_wasm (Blockstream's Liquid Wallet
Kit, compiled to WASM) as the wallet engine. All wasm and key material live in
the service worker / offscreen document; the side panel and connected pages only
ever receive watch-only data and signing requests.
| Surface | File | Role |
|---|---|---|
| Background SW | src/background/index.ts |
message router; brokers UI ↔ engine ↔ dapp provider; auto-lock; connected-site sessions |
| Offscreen engine | src/offscreen/ |
runs lwk_wasm — the only place wasm + signing live |
| Keystore | src/keystore/ |
seed encrypted at rest; watch-only descriptor in cleartext; MV3 session recovery |
| Side panel | src/sidepanel/ |
the wallet UI (onboarding, unlock, balance, receive, send, settings) |
| Content bridge | src/content/content.ts |
ISOLATED world; relays window.postMessage ↔ chrome.runtime |
| Page provider | src/provider/liquid-provider.ts |
MAIN world; defines window.apogee for web pages |
| Jade window | src/jade/ |
Web Serial pairing with a Blockstream Jade |
lwk_wasmengine in an offscreen document — descriptor derivation, chain sync via Esplora, balance, addresses, transaction history, and PSET build / sign / broadcast.- Encrypted keystore — BIP-39 seed encrypted at rest (PBKDF2 → AES-256-GCM); the watch-only descriptor is stored in cleartext so balances sync while locked; MV3 session recovery keeps the wallet unlocked across service-worker eviction; idle auto-lock.
- BIP84 native-SegWit derivation — the standard
ct(slip77(...),elwpkh([fp/84'/<coin>'/0']xpub/<0;1>/*))descriptor (coinis1776'on mainnet,1'on testnet). The same seed restores in Blockstream Green / Jade (verified to the same master fingerprint). A legacy flatm/*scheme was removed in favour of this standard, interoperable form. (Funds on a non-native path — e.g. BIP49 "Legacy SegWit" — won't show; see Pending.)
- Onboarding makes hardware-vs-local a one-time choice at init, and picks the network (Mainnet or Testnet) for create, restore, and Jade pairing alike: create or restore a seed, or connect a hardware wallet.
- Unlock, balance — the whole portfolio as one figure, rendered as sats / LBTC / fiat with hide-balance; defaults to sats — receive (branded address + QR), send (build → review → sign → broadcast), Received / Sent toasts, a persistent connection-status bar, and settings (network, currency, denomination, auto-lock, background animation, reveal seed, connected apps).
- Seedless wallets — a wallet is either a local seed or a Jade (watch-only descriptor + fingerprint, no seed stored).
- Pairing — choose the network, then a Jade tab connects over Web Serial, reads the device's wpkh descriptor + fingerprint, and registers a watch-only wallet. The chooser is filtered to Blockstream-chip devices with a "show all" fallback; the device fingerprint is verified before signing.
- On-device signing (E3) — a send routes its PSET to a Jade signing tab; you review the transaction summary and approve on the device, then Apogee finalizes + broadcasts. Works for both the side panel's Send and a connected dapp's send. Pairing + signing are a branded card flow (Connect → Review → Done) on a starfield background.
- Reveal-seed is hidden for Jade wallets, and Settings shows the signer type.
- A page connects via the injected provider → content bridge → service worker.
Surface:
connect,getStatus,getNewAddress,getBalance,send(the page passes address + amount; Apogee builds the PSET, reviews, signs, broadcasts),disconnect, pluson/offevents. - Approvals — connecting a new site and every send raise an approval (an overlay in the side panel when open, a popup window otherwise); nothing is granted or signed without the user's confirmation. If the wallet is locked, the approval offers an unlock step instead of forcing a reject, and a success state confirms the outcome. A Jade send then signs on-device.
- Per-site sessions — the SW tracks connected origins; every call except connect / disconnect requires an approved session, so revoking a site actually cuts it off.
- Connection status — a persistent bar with a green status light when a site is connected, plus a connected-apps list in Settings (origin + disconnect).
- Lock-aware balance — a locked wallet returns no balance (the dapp shows a locked state and recovers on unlock) instead of a misleading 0.
- Serialized engine calls so the dapp and the side panel can't
re-entrantly alias a cached
lwk_wasmWollet.
New integrations discover the Liquid browser provider through
liquid:requestProvider / liquid:announceProvider, then use its minimal
request and on interface. The older window.liquid and eip6963:* surface
remains available for compatibility but is not the standardized discovery path.
The implementation is in src/provider/liquid-provider.ts.
Developers can exercise the real injected boundary with the
Liquid provider playground.
The standard provider currently implements getBalance, getUTXOs,
getWalletDescriptor, sendTransfer, and signPset. It also exposes the
experimental experimental_getTxManifestSupport and
experimental_executeTxManifest methods for Apogee's compiled-in trusted
Simplicity Lending v3 bundle. TX Manifest execution is currently limited to
Liquid testnet software wallets and fails closed for unknown bundles, mainnet,
Jade, and watch-only wallets. Descriptor disclosure is a separate, explicit
per-origin permission: Apogee returns only a checksummed ordinary
public descriptor and never exports its SLIP-77 master blinding key.
PSET signing is also separately permissioned and individually approved. Its
wallet-scoped analyzer revalidates every
transaction effect before local or Jade signing; Apogee returns the signed PSET
by default. A caller can explicitly request broadcast: true; the approval then
states that Apogee will finalize and broadcast the transaction, and a successful
result includes both the signed PSET and its transaction id.
- Export your wallet's descriptor and public data. A new screen under
Settings hands you everything about a wallet that does not require its seed:
the account key, the watch-only descriptor that recreates it elsewhere, the
master blinding key, and a public descriptor with that key removed. Values are
grouped by what they disclose rather than listed flat, because the difference
matters: one group reveals which addresses are yours, the other reveals every
amount you hold, permanently. Nothing in either group can sign or spend, and
your seed phrase is never included. Works for a local-seed wallet, a paired
Jade, and an imported watch-only wallet, which is the case where it matters
most since there is no seed phrase to fall back on. Per-wallet text file, or
every wallet as JSON. See
docs/wallet-descriptor-export.md. - The balance behaves like an instrument, not a label. The telemetry numerals strike like a neon sign when a figure changes, on sync and when you toggle between sats, LBTC and fiat, and the whole balance folds away as the history scrolls so the list gets the room. An Animations switch in Settings turns the motion off for anyone who would rather it did not move, and the wallet already respects the system reduced-motion preference.
- Trusted Simplicity Lending transactions. Event-discovered dapps can ask whether Apogee recognizes the built-in Lending v3 manifest, then request any of its eight supported lifecycle actions. Apogee independently resolves chain state, selects wallet inputs, constructs and dry-runs the covenant transaction, shows the exact effects for approval, signs, broadcasts, and safely resumes an interrupted broadcast. Verified action hints restore human-readable lending activity from the seed. This first release is deliberately restricted to Liquid testnet software wallets; Jade, mainnet, partial repayment, and remotely installed manifests remain follow-up work.
- Your transaction history as a CSV. Every transaction with its date, txid, amounts, assets and fee, one row per asset moved so a swap reads as the two sides it actually is. Amounts appear both in base units and scaled, so a spreadsheet can add them up without losing precision, and the raw asset id is always present because a ticker can be missing or repeated. Confirmed and pending transactions both, marked as such.
- Connect a hardware wallet is a button again. It had been demoted to a text link so the onboarding chooser could look the same on Firefox, which cannot talk to a Jade at all. Firefox is gone, so pairing a Jade is a first-class path on the only browser Apogee ships to and now reads as one.
- The L-BTC row says what its number is. In the Assets list an L-BTC balance shown in sats sat under a label reading "LBTC", so 989,411 sats could be read as 989,411 LBTC. The figure now names its unit.
- A seed QR that a Jade can actually read. Exported seed QR codes were rendered too bright for the device's camera to lock onto. The export now carries a brightness control, so moving a wallet onto a Jade no longer depends on the screen you happen to be using.
- Coins survives leaving the screen. A consolidation in flight kept its state when you navigated away and back, instead of appearing to have never happened, and a startup race that could read a stale auto-lock setting is closed.
- An implausible fee estimate is refused rather than paid. A manifest transaction whose fee-rate estimate is outside a sane band is rejected before it is built, so a bad estimate cannot turn into a bad fee.
- Less kept about a manifest transaction, for less time. A completed manifest execution now stores only its txid. Everything else was reconstructible from the request being replayed, and keeping it made that table a readable record of what you had been doing for seven days.
- Smaller things — the Testnet/Regtest marker beside the logo is now a hard-edged caution placard in the console voice rather than a rounded pill; a transaction carrying a contract marker this version does not recognize reads as "Unrecognized contract" in plain text rather than an orange warning, and shows the bundle it referenced; and the Firefox build target is gone, so Apogee is Chrome-only.
- The balance is the whole portfolio, not just L-BTC. A wallet holding USDt and no L-BTC used to read "0 sats" at the top of the panel while the Tokens list right below it showed a real balance. The headline figure now folds in every USD-pegged token at the spot rate and calls itself a total, so the number you see first is the one you actually hold. Sats, LBTC and fiat are three renderings of a single figure rather than three separate sums, so they cannot drift apart from each other.
- Nothing is guessed, and nothing is quietly dropped. An asset with no price
source is left out of the total rather than counted as zero or scaled as though
it were a dollar — and the line beneath the figure says how many were left out,
so the total never silently understates what you hold. While a price is still
in flight the figure keeps the same "not final" pulse the wallet already uses
for an unconfirmed balance, and stops pulsing once the rate has definitively
failed rather than pulsing forever. A wallet holding only L-BTC sees no change
at all — same figure, same wording. Send and Swap read per-asset balances
straight from the chain, so a wrong or missing rate can misprice this figure
but can never change what leaves the wallet. See
docs/price-sources.md. - Coins: see and tidy your unspent outputs. A new screen under Settings lists every UTXO grouped by asset, and combines one asset's outputs into a single output when they've fragmented across many small pieces. The pending consolidation shows its txid with an explorer link and clears itself once the chain sees it.
- Liquid apps can do more, and each capability is its own permission. Beyond
connecting and requesting a send, an app can ask for your unspent outputs, a
public wallet descriptor, or a signature on a transaction it built — granted or
refused one at a time. A signing request is revalidated against the wallet by
its own PSET analyzer and shows the exact
inputs, recipients, asset changes, and fees before you approve; Apogee returns
the signed transaction and broadcasts only when the app asked and you agreed.
Apps discover the wallet through the standard
liquid:requestProviderevent. - Approvals show exactly what you're agreeing to. Send approvals display the recipient's full address instead of a shortened one, and the requesting site's domain stays visible even when the origin is long. Token sends mark registry-sourced names as unverified — the asset ID, not the label, is what identifies the asset.
- Your seed phrase no longer crosses the extension's broadcast channel. Restoring a wallet and scanning a seed-phrase QR travel over a private, point-to-point connection between the panel and the wallet backend, where no other extension context can observe them.
- Encrypted storage upgrades itself in place. When Apogee's encrypted storage format changes, your wallet migrates automatically the first time you unlock — no re-import needed. A vault that can't be upgraded says so immediately instead of reporting a wrong password, and unlock attempts are never wasted on one.
- Changing your password is throttled like unlocking. The progressive lockout that guards unlocking now also guards the old-password check when changing your password.
- Less of your wallet stays decrypted while unlocked. Seeds are decrypted only for the moment a wallet signs, not all at once for the whole session. A password step-up that hits the lockout now shows its countdown and offers a way back out.
- Smaller things — a one-time intro plays on first run; transactions open in liquid.network rather than blockstream.info; amounts are set in the telemetry face with tickers in the body face; a Settings drawer scrolls itself into view instead of opening off-screen.
- Under the hood — the wallet engine rides out service-worker restarts with
reconnect backoff and per-connection state; resetting the wallet also clears
the cached asset icons it displayed; CI third-party actions are pinned to
commit SHAs and the review bot only answers trusted commenters; a dependency
with a known advisory (postcss) is held to its patched version. The at-rest
and provider threat model is now documented in
SECURITY.md.
Notes for 0.6.0 and earlier live on the Releases page.
- Multi-wallet UI — adding a wallet currently requires a reset (onboarding only appears when there are none); expose add / switch.
- Legacy-path sweep — only native SegWit (BIP84) is derived, so funds on a legacy path (e.g. BIP49 "Legacy SegWit") show 0 until moved; detect + offer a sweep.
- Persistent Jade connection — each send opens a fresh signing tab (one Web Serial port at a time); keep a paired tab connected instead.
- Blockstream Liquid Wallet Kit (
lwk_wasm/ LWK), Blockstream Jade, the Liquid Network, and Blockstream's Esplora. - The vendored Simplicity Lending contract sources retain their upstream licensing and exact revision provenance in the shipped third-party notices.
- Fonts — Satoshi (Indian Type
Foundry, via Fontshare's free license) for the UI, and Apogee Telemetry
for numeric displays: our patched build of
Routed Gothic by Darren Embry,
licensed under the SIL Open Font License 1.1
and renamed per the OFL's reserved-name rule. Modifications are documented in
the license file and reproducible via
tools/patch-telemetry-font.py. The style is a nod to the telemetry readouts of 2001: A Space Odyssey. - Built by Resolvr.
- Built with CRXJS, Vite, React, and Tailwind CSS.