Zunia browser extension for the Cosmos ecosystem: Chrome, Edge, Firefox and Safari.
Alpha. The extension is not published in any browser store yet. CI builds and checks all four targets on every push; the table below says what has been verified in each browser.
A non-custodial, multi-chain Cosmos wallet. The recovery phrase is sealed with the user's
password in extension storage and is only ever handled by the signing kernel,
zunia-core compiled to WebAssembly, inside the
background worker. Web pages reach the wallet through window.zunia, a Keplr-compatible
provider, and every connection and signature needs the user's approval. Chain metadata comes
from zunia-chain-registry.
Every target is Manifest V3.
| Browser | Background | Build | Output | Verified |
|---|---|---|---|---|
| Chrome, Brave, Opera | Service worker | pnpm build:chrome |
.output/chrome-mv3 |
Automated in Chromium: WASM kernel loads, provider injects under strict page CSPs, a dApp connects, signs in and signs, and hears account switches and revocation live |
| Edge | Service worker | pnpm build:edge |
.output/edge-mv3 |
Same Chromium build as Chrome |
| Firefox 140+ (desktop) | Event page | pnpm build:firefox |
.output/firefox-mv3 |
Automated: the same checks as Chrome, with the connect request approved from the toolbar popup, and the wallet staying unlocked when Firefox unloads the idle background page; addons-linter reports no errors |
| Safari (macOS, iOS) | Service worker | pnpm safari:build |
.output/safari-mv3, Xcode project in safari/ |
CI builds the macOS and iOS Simulator apps. By hand in Safari on the iOS Simulator: the provider reaches strict-CSP pages, a dApp connects, signs in and signs, and hears account switches, locks and revocation live. macOS Safari not yet run |
The automated checks are the stack/ suite in
zunia-e2e, run locally against these builds.
Firefox 140 is the floor because that is where Firefox shows its own data consent prompt,
which the manifest's data_collection_permissions relies on. Firefox for Android is not
tested. Details, store notes and the manual checklist are in
docs/browsers.md.
Requirements: Node 22, pnpm 9 (corepack enable), and for the kernel a Rust toolchain with
wasm-bindgen-cli at the version pinned in
zunia-core's Cargo.lock.
The @zunialab/* packages the extension uses are linked from sibling checkouts
(pnpm.overrides in package.json), so clone them side by side and build them first:
git clone https://github.com/Zunia-Lab/zunia-core
git clone https://github.com/Zunia-Lab/zunia-ui
git clone https://github.com/Zunia-Lab/zunia-sdk
git clone https://github.com/Zunia-Lab/zunia-extension
(cd zunia-core && ./scripts/build-wasm.sh)
(cd zunia-ui && pnpm install && pnpm --filter @zunialab/tokens build && pnpm --filter @zunialab/ui build)
(cd zunia-sdk && pnpm install && pnpm --filter @zunialab/interchain build)
cd zunia-extension
pnpm install
pnpm dev # Chrome; also dev:edge, dev:firefox, dev:safaripnpm dev keeps rebuilding .output/chrome-mv3-dev. Load it once and WXT reloads it in place:
- Chrome, Edge, Brave, Opera:
chrome://extensions, turn on Developer mode, then Load unpacked and pick.output/chrome-mv3-dev(or a production folder). - Firefox:
about:debugging#/runtime/this-firefox, then Load Temporary Add-on and pickmanifest.jsonin.output/firefox-mv3. - Safari: see docs/browsers.md.
The vault survives dev restarts because the Chromium profile lives in .wxt/chrome-data
(gitignored). You still unlock after a browser restart, since the decrypted phrase is only
kept in session storage.
| Command | What it does |
|---|---|
pnpm dev |
Watch build for Chrome (dev:edge, dev:firefox, dev:safari for the others) |
pnpm build |
Production builds for Chrome, Edge, Firefox and Safari |
pnpm check:build |
Checks the production builds (MV3, CSP, one kernel binary, permissions, per-browser keys, the provider's release and features) |
pnpm lint:firefox |
Mozilla's addons-linter on the Firefox build |
pnpm safari:build |
Safari build, then the macOS and iOS Simulator apps (macOS and Xcode only) |
pnpm safari:open |
Open the Safari app project in Xcode |
pnpm typecheck |
TypeScript |
pnpm lint |
ESLint |
pnpm test |
Unit tests (Vitest) |
pnpm zip:chrome, zip:edge, zip:firefox |
Store upload archives |
Stack: WXT, React 19, TypeScript, Tailwind CSS 4.
The provider is injected by an isolated content script into every HTTPS page and
localhost. The page script and the content script talk over a nonce-scoped
MessageChannel with origin checks on both sides; the background worker decides every
request by the page's origin. window.keplr is an opt-in alias, off by default.
await window.zunia.enable("cosmoshub-4");
const signer = window.zunia.getOfflineSigner("cosmoshub-4");
const [account] = await signer.getAccounts();What the provider adds on top of the Keplr surface:
- Session restore.
getConnectedChains()returns the chains the calling site is connected to and never opens a window;isLocked()answers connected sites only. A page can restore its session on reload without triggering the unlock prompt. - Targeted events.
accountsChanged,chainChanged({ chainIds }, the full list),disconnect({ chainIds }for the chains lost, ornull) andlockedgo only to tabs of a connected site. A grant that expires disconnects the site on time. Thezunia_keystorechangeandkeplr_keystorechangewindow events still fire for Keplr-style dApps. - Error codes. Every failure rejects with a
ZuniaProviderErrorwhosecodeis one ofUSER_REJECTED,NOT_CONNECTED,LOCKED,UNKNOWN_CHAIN,ORIGIN_MISMATCH,UNSUPPORTED,INVALID_PARAMSorINTERNAL. Messages keep Keplr's wording ("Request rejected", "Not authorized"). A signing request whose transaction the prompt could not show whole, past 4 MiB of JSON, is refused withUNSUPPORTED"This transaction is too large to show in full" rather than shown in part. - Sign-in. A
signArbitrarymessage in the CAIP-122 shape (" wants you to sign in with your Cosmos account: ...") is read back before anything is shown. It is refused outright when its domain or URI is not the requesting site, when it names another chain or account, or when its dates are out of bounds; otherwise the user sees a dedicated "Sign in to " screen.lib/__tests__/fixtures/sign-in-vectors.jsonholds the format's test vectors. - Release and features.
versionis the provider API version and stays"0.1.0". From 0.1.5 the provider also hasextensionVersion, the installed release (the manifest version, or""if the extension could not read it);isZunia: true, which tells thewindow.keplralias from Keplr itself; andfeatures, a frozen list of what the build signs that older ones refused or signed wrongly. A provider withoutextensionVersionis 0.1.4 or older. The same fields are onwindow.keplrwhile the alias is on.
features entry |
What the build does |
|---|---|
sign-direct:wasm-contract-32 |
Decodes and prompts Direct contract calls on 32-byte contract addresses, such as Osmosis's cross-chain swap contract or an NFT collection. 0.1.4 refused them as unknown messages. |
sign-direct:send-32 |
Decodes and prompts a Direct MsgSend to a 32-byte address. |
sign-direct:osmosis-poolmanager |
Decodes Osmosis poolmanager swaps that sell an exact amount, single and split routes (since 0.1.4). |
sign-direct:osmosis-exact-out |
Decodes poolmanager swaps that buy an exact amount, single and split routes. |
sign-amino:escaped |
Escapes &, <, >, U+2028 and U+2029 in Amino sign bytes the way chains rebuild them. 0.1.4 signed a memo like "rent & food" over bytes chains reject. |
sign-amino:osmosis-poolmanager |
Describes and prompts Amino poolmanager swap requests (osmosis/poolmanager/...), which 0.1.4 refused. |
To pick a sign mode, treat a build as able to sign everything in Direct mode when
features includes "sign-direct:wasm-contract-32", or, when features is absent, when
extensionVersion is 0.1.5 or later. Anything else is a legacy build (0.1.4 or older),
which refuses Direct contract calls on 32-byte contracts and signs Amino documents that
hold &, < or > over bytes chains reject.
const zunia = window.zunia;
const legacy = zunia !== undefined && zunia.extensionVersion === undefined;
const directEverything = zunia?.features?.includes("sign-direct:wasm-contract-32") ?? false;Most dApps should use the SDK instead of the raw provider:
@zunialab/sdk-web handles detection, events and
the QR fallback to the mobile app. The full API is documented at
docs.zunialab.com.
| Item | Location |
|---|---|
| Connect policy | config/connect.ts |
| Provider release and features | lib/provider-identity.ts, the list in config/connect.ts |
| Host permissions | config/hosts.ts, wxt.config.ts |
| Session and security policy | config/session.yaml, config/security.yaml |
| Provider types | types/window.d.ts |
Because the content script has to run on any site that might be a dApp, the install prompt
asks for access to all websites. The extension's own requests do not rely on it:
host_permissions cover localhost and the Zunia API hosts only, and balances and
broadcasts go to each chain's public REST endpoints, which answer cross-origin requests.
| Repository | Description |
|---|---|
| zunia-core | Signing kernel (Rust, compiled to WASM here) |
| zunia-sdk | SDKs for dApps (web, React; Flutter planned) |
| zunia-mobile | Mobile wallet |
| zunia-ui | Design tokens and components |
| zunia-chain-registry | Chain metadata |
| zunia-docs | Documentation |
See CONTRIBUTING.md.
See SECURITY.md. Never paste a recovery phrase into an issue.
Apache-2.0. See LICENSE.