Skip to content

About

Zunia, a self-custody IBC-native Cosmos wallet, as a browser extension for Chrome, Firefox, Edge and Safari.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Zunia

zunia-extension

Zunia browser extension for the Cosmos ecosystem: Chrome, Edge, Firefox and Safari.

CI License Website

Status

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.

Overview

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.

Browser support

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.

Getting started

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:safari

pnpm 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 pick manifest.json in .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.

Commands

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.

Connecting a dApp

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, or null) and locked go only to tabs of a connected site. A grant that expires disconnects the site on time. The zunia_keystorechange and keplr_keystorechange window events still fire for Keplr-style dApps.
  • Error codes. Every failure rejects with a ZuniaProviderError whose code is one of USER_REJECTED, NOT_CONNECTED, LOCKED, UNKNOWN_CHAIN, ORIGIN_MISMATCH, UNSUPPORTED, INVALID_PARAMS or INTERNAL. 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 with UNSUPPORTED "This transaction is too large to show in full" rather than shown in part.
  • Sign-in. A signArbitrary message 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.json holds the format's test vectors.
  • Release and features. version is the provider API version and stays "0.1.0". From 0.1.5 the provider also has extensionVersion, the installed release (the manifest version, or "" if the extension could not read it); isZunia: true, which tells the window.keplr alias from Keplr itself; and features, a frozen list of what the build signs that older ones refused or signed wrongly. A provider without extensionVersion is 0.1.4 or older. The same fields are on window.keplr while 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.

Related repositories

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

Contributing

See CONTRIBUTING.md.

Security

See SECURITY.md. Never paste a recovery phrase into an issue.

License

Apache-2.0. See LICENSE.

About

Zunia, a self-custody IBC-native Cosmos wallet, as a browser extension for Chrome, Firefox, Edge and Safari.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages