# Zunia Docs > How to connect a dApp to Zunia, a self-custody multi-chain Cosmos wallet (browser extension, phone app and web dashboard), and how to use the wallet itself. Every page below is also Markdown at the same address with `.md` added. All pages in one file: https://docs.zunialab.com/llms-full.txt. The same pages as JSON: https://docs.zunialab.com/docs-index.json. The JavaScript SDKs are on npm: `@zunialab/sdk-web` (browser dApps), `@zunialab/sdk-react` (React hooks and components), `@zunialab/sdk-core` (server-side sign-in checks, shared types and errors) and `@zunialab/interchain` (IBC routes, denoms, packet-forward memos). Coding assistants can search and read these docs through the Zunia MCP server at https://docs.zunialab.com/mcp (Streamable HTTP, no key). Setup: https://docs.zunialab.com/docs/get-started/ai. Built 2026-10-13T22:43:41.788Z from zunia-docs 3f6ad0c560da. 37 pages, in sidebar order. # How Zunia works URL: https://docs.zunialab.com/docs/intro Zunia is a non-custodial Cosmos wallet. Keys stay on the device that created or imported them. A dApp never sees a seed phrase. It asks the wallet for addresses, signatures, and live account events. There are two products you can install, and three ways a site can reach a wallet: | Product | What it is | Status | |---------|------------|--------| | [Browser extension](https://github.com/Zunia-Lab/zunia-extension) | Chrome, Edge, Firefox and Safari (macOS and iOS) | On the [Chrome Web Store](https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk). Safari is in App Store review. Firefox is planned. Edge builds are not published. | | [Mobile wallet](https://github.com/Zunia-Lab/zunia-mobile) | iOS and Android | In App Store and Google Play review. WalletConnect sessions are accepted; WalletConnect signing is not implemented. | The [web dashboard](https://app.zunialab.com) is not something you install, and it holds no keys. It connects the Zunia extension, Keplr, or Zunia mobile by QR code, and every transaction it prepares is signed in that wallet. A website talks to those products through the [JavaScript SDKs](https://docs.zunialab.com/docs/get-started/quickstart): 1. **Extension.** The wallet injects `window.zunia`. This is the path to use in a desktop browser when Zunia is installed. 2. **Phone, by QR code.** The site shows a code, Zunia mobile scans it, and both sides talk through a relay. Messages are encrypted end to end. Zunia runs the relay at `https://api.zunialab.com`, the one Zunia mobile trusts. Its source is [zunia-backend](https://github.com/Zunia-Lab/zunia-backend). 3. **WalletConnect v2.** For other wallets. Do not use this to reach Zunia mobile until that app signs WalletConnect requests. ```mermaid sequenceDiagram participant Dapp as dApp SDK participant Ext as Extension participant Relay as Relay participant Phone as Zunia mobile Dapp->>Ext: enable, signIn, signAmino Ext-->>Dapp: accounts and events Dapp->>Relay: POST /v1/connect/sessions Relay-->>Dapp: sessionId, dappToken, walletJoinToken Dapp->>Dapp: QR with join token and public key Phone->>Relay: WebSocket with join token Note over Dapp,Phone: X25519, same 6-digit code, then encrypted frames Phone-->>Dapp: accounts and signatures ``` ## What you can do today - Create or import a wallet in the extension or the mobile app, on the same recovery phrase if you want both. - Connect a web dApp to the extension, restore the session after a reload, and hear account switches, locks and revocations live. - Sign in with no transaction: the wallet signs a message bound to your site, and your server checks it with `verifySignIn`. - Sign Amino and Direct transactions and broadcast them yourself (CosmJS or your own client). - Pair Zunia mobile with a site by QR code, through the Zunia relay. - Send, swap, stake and vote from the extension itself, using the WebAssembly kernel. - Use the web dashboard at [app.zunialab.com](https://app.zunialab.com) with the extension, Keplr or Zunia mobile. ## What is not ready - Store listings other than the Chrome Web Store. Safari, the App Store and Google Play are in review. Firefox is planned. Edge has no listing. - A Flutter SDK for mobile dApps. The existing `zunia_sdk` package is deep links and a button. The v2 client is planned. - WalletConnect signing in Zunia mobile. - Hardware wallets (Ledger, Keystone). - An independent security audit. None has been completed. ## Next - [Connect a site in five minutes](https://docs.zunialab.com/docs/get-started/quickstart) - [Browser and mobile status](https://docs.zunialab.com/docs/get-started/compatibility) - [Use the wallet](https://docs.zunialab.com/docs/use-wallet/extension) Need help? Email [dev@zunialab.com](mailto:dev@zunialab.com) or [zunialab@gmail.com](mailto:zunialab@gmail.com). For vulnerabilities, write to dev@zunialab.com instead of opening a public issue. # FAQ URL: https://docs.zunialab.com/docs/faq ## Is Zunia in the stores? The extension is on the [Chrome Web Store](https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk). Safari, the App Store and Google Play are in review. Firefox is planned. For the others, load a build from GitHub. See [Install the extension](https://docs.zunialab.com/docs/use-wallet/extension) and [Install the mobile wallet](https://docs.zunialab.com/docs/use-wallet/mobile). ## Can I use the same wallet on desktop and phone? Yes. Import the same recovery phrase on the second device. Each device holds its own encrypted copy. Zunia does not sync keys through a server. The two apps add further accounts differently; see [More accounts](https://docs.zunialab.com/docs/use-wallet/keys#more-accounts). ## How does a website connect? The JavaScript SDK talks to the extension (`window.zunia`), or to the phone through a QR code and a relay, or to other wallets through WalletConnect. Start at the [quickstart](https://docs.zunialab.com/docs/get-started/quickstart). ## Can I sign in without sending a transaction? Yes. [Sign in with Zunia](https://docs.zunialab.com/docs/integrate/sign-in) is an ADR-036 message bound to your domain. Your server checks it with `verifySignIn`. ## I scanned a WalletConnect code with Zunia mobile and nothing signs. Expected. The app can open a WalletConnect session. It does not sign those requests yet. Use the Zunia QR (Connect with Zunia) instead. ## Where is the relay? Zunia runs it at `https://api.zunialab.com`. Its source is [zunia-backend](https://github.com/Zunia-Lab/zunia-backend), which implements `zunia.connect.v2`. See [Connect a phone by QR](https://docs.zunialab.com/docs/integrate/qr). ## Are the SDKs on npm? Yes. `@zunialab/sdk-web`, `@zunialab/sdk-react`, `@zunialab/sdk-core` and `@zunialab/interchain` are on npm. ## Which chains work? The bundled catalog is [zunia-chain-registry](https://github.com/Zunia-Lab/zunia-chain-registry). You can add a network by hand or a dApp can suggest one. Endpoints must be HTTPS. ## Does Zunia charge fees? Only on swaps made with Zunia's own **Swap** screen, in the browser extension or on the [web dashboard](https://app.zunialab.com). Such a swap carries a 0.5% commission, sent to Zunia. It is taken in the same transaction from the token you sell, and the review screen shows it before you sign. If the funds start on another chain and the swap runs on Osmosis, the commission is taken on that chain when the transfer is sent. If the swap then fails on Osmosis, the amount swapped comes back but the commission does not. A swap you make on another site or dApp and only sign with Zunia carries no Zunia commission. Sends, IBC transfers and staking carry no commission either. You pay the network fee shown before you sign, and Zunia takes no cut of staking rewards. See [Fees](https://docs.zunialab.com/docs/use-wallet/fees). ## Is there a web dashboard I can paste my phrase into? No, and there must never be. The web dashboard at [app.zunialab.com](https://app.zunialab.com) holds no keys and never asks for a phrase. It connects the Zunia extension, Keplr, or Zunia mobile by QR code, and every transaction is signed in that wallet. Signing keys stay in the extension or the phone. ## Has Zunia been audited? No. Do not claim otherwise. ## Is it open source? Yes. [github.com/Zunia-Lab](https://github.com/Zunia-Lab). ## How do I report a vulnerability? [dev@zunialab.com](mailto:dev@zunialab.com), not a public issue. ## How do I get help? [dev@zunialab.com](mailto:dev@zunialab.com), [zunialab@gmail.com](mailto:zunialab@gmail.com), or a GitHub issue on the relevant repo. # Connect a site in five minutes URL: https://docs.zunialab.com/docs/get-started/quickstart This page gets a React app talking to the Zunia extension: connect, restore after a reload, and sign in. The same session object signs transactions with CosmJS. Install from npm: ```bash pnpm add @zunialab/sdk-react @zunialab/sdk-web ``` A server that checks sign-in uses `@zunialab/sdk-core`. IBC routes and packet memos are `@zunialab/interchain`. You also need the Zunia extension. Install it from the [Chrome Web Store](https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk), or [build it](https://docs.zunialab.com/docs/use-wallet/extension) for another browser. Create a wallet there before you click Connect. ## 1. Connect ```tsx import { ConnectWithZuniaButton, useZuniaSession } from "@zunialab/sdk-react"; const options = { chains: ["cosmoshub-4"] }; export function Connect() { const zunia = useZuniaSession({ restore: options }); if (zunia.restoring) return null; if (zunia.locked) { return ; } if (zunia.connected) { return

Connected as {zunia.accounts[0]?.address}

; } return ( zunia.connect(options).catch(() => {})} /> ); } ``` `useZuniaSession({ restore: options })` reattaches after a reload without opening a window. `connect` asks the extension for those chains. The user approves in the toolbar popup (Chrome also draws an in-page prompt when it can tell the page is visible). If the wallet was locked when the page loaded, `zunia.locked` is true and `accounts` stays empty. `unlock()` opens the unlock window and keeps your site's grant. Call it from a click. ## 2. Sign in (no transaction) Your server issues a nonce. The wallet signs a message that names your domain. You check the result on the server. ```ts // Browser const { nonce } = await (await fetch("/api/nonce", { method: "POST" })).json(); const proof = await zunia.signIn({ nonce }); await fetch("/api/verify", { method: "POST", body: JSON.stringify({ nonce, ...proof }) }); ``` ```ts // Server import { createNonce, verifySignIn } from "@zunialab/sdk-core"; const nonce = createNonce(); // store nonce for 10 minutes, then consume it once const { address } = verifySignIn({ message, signature, nonce, domain: "app.example.com", // from your config, not from the request Host }); ``` The wallet refuses a message whose domain is not the site asking. See [Sign in with Zunia](https://docs.zunialab.com/docs/integrate/sign-in). ## 3. Send with CosmJS ```ts import { SigningStargateClient } from "@cosmjs/stargate"; const [account] = zunia.accounts; const messages = [ { typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: { fromAddress: account.address, toAddress: recipient, amount: [{ denom: "uatom", amount: "1000" }] }, }, ]; const signer = zunia.session.getOfflineSignerFor("cosmoshub-4", { messages, memo }); const client = await SigningStargateClient.connectWithSigner(rpcUrl, signer); await client.signAndBroadcast(account.address, messages, "auto", memo); ``` `getOfflineSignerFor` hands CosmJS the signer the connected Zunia build can sign these messages with, so contract calls also work on older extension builds. Results are `Uint8Array` and `bigint` on every transport. ## Example [`examples/dapp`](https://github.com/Zunia-Lab/zunia-sdk/tree/main/examples/dapp) in zunia-sdk is a Vite app with connect, verified sign-in, a CosmJS send and a live event log. It is what the end-to-end tests drive. ## Next - [React guide](https://docs.zunialab.com/docs/integrate/react) - [Extension without React](https://docs.zunialab.com/docs/integrate/extension) - [Pair a phone by QR](https://docs.zunialab.com/docs/integrate/qr) - [Events and session lifecycle](https://docs.zunialab.com/docs/integrate/events) # Compatibility and status URL: https://docs.zunialab.com/docs/get-started/compatibility Honest status for each surface. The extension is on the Chrome Web Store. Safari, the App Store and Google Play are in review. The JavaScript packages are on npm. ## Browsers The extension is one Manifest V3 codebase. Each browser gets its own build. Details and the test matrix live in [zunia-extension/docs/browsers.md](https://github.com/Zunia-Lab/zunia-extension/blob/main/docs/browsers.md). | | Chrome, Brave, Opera | Edge | Firefox | Safari | | --- | --- | --- | --- | --- | | Build | `.output/chrome-mv3` | `.output/edge-mv3` | `.output/firefox-mv3` | `.output/safari-mv3` plus the Xcode app | | Connect, sign-in, signature, live account switch and revoke | Automated ([zunia-e2e](https://github.com/Zunia-Lab/zunia-e2e)) | Same Chromium build | Automated | By hand on the iOS Simulator | | In-page connect prompt | Yes, when the page is visible | Yes | No. Requests wait in the toolbar popup. | No. Toolbar popup, or a tab on iOS when the popup cannot open. | | Published | [Chrome Web Store](https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk) | No | No, planned | In App Store review | The Firefox build needs Firefox 140 or later. The Safari app needs iOS 17 or macOS 13 and later. Firefox unloads an idle background page after about 30 seconds. The unlocked session lives in `storage.session` and survives that. Safari on iOS stops an idle worker after about 8 seconds; a page that uses the wallet, and the approval screen, ping the worker so requests still complete. Safari also drops `tabs.sendMessage`, so dApp events travel over a port the content script opens. See [the provider events](https://docs.zunialab.com/docs/reference/events#safari). macOS Safari has not been run end to end. It needs **Allow unsigned extensions** in Safari Developer Settings, which Safari turns off when it quits. ## Mobile | | iOS | Android | | --- | --- | --- | | App | Flutter, iOS 13+ in the current project | Flutter, Android 7.0+ (API 24) in the current project | | QR pairing (`zunia.connect.v2`) | Implemented | Implemented | | WalletConnect session | Accepted when a Cloud project id is set | Same | | WalletConnect signing | Not implemented | Not implemented | | Store | In App Store review | In Google Play review | Pair Zunia mobile with a site by QR, not by WalletConnect, until signing exists. ## SDKs | Package | Install | Status | |---------|---------|--------| | `@zunialab/sdk-web` | Browser dApps | On npm | | `@zunialab/sdk-react` | React 18 or 19 | On npm | | `@zunialab/sdk-core` | Servers and wallets | On npm | | `@zunialab/interchain` | IBC routes and memos | On npm | | `zunia_sdk` (Dart) | Flutter dApps | Deep links and a button. The v2 session client is planned. | ## Relay [zunia-backend](https://github.com/Zunia-Lab/zunia-backend) implements `zunia.connect.v2`. Zunia runs it at `https://api.zunialab.com` (WebSocket `wss://api.zunialab.com/v1/connect/ws`). Any site may open sessions there, within per-address rate limits. Zunia mobile only pairs through relays on its trusted list, which holds `wss://api.zunialab.com` by default, so pass `apiBase: "https://api.zunialab.com"`. To test against a relay of your own, `pnpm dev` in zunia-backend listens on `http://localhost:8788`. Only a debug build of Zunia mobile, or one built with that relay on its trusted list, pairs through it. ## Signing - Cosmos Amino and Direct: supported in the extension and over QR pairing. - Zunia extension 0.1.4 and older refuse, in Direct mode, a contract call to a 32-byte contract or a send to a 32-byte address. They also sign Amino documents holding `&`, `<` or `>` over bytes the chain rejects. 0.1.5 fixes both and reports `extensionVersion` and `features` on `window.zunia`. `session.getOfflineSignerFor()` picks the mode each build can sign. - Sign in with Zunia: ADR-036 message bound to the site. Supported. - `sendTx` on `window.zunia`: not supported. The dApp broadcasts. - Ethermint / coin type 60: the wallet derives those addresses. It does not expose an Ethereum provider (`eth_*`, EIP-712). Do not treat it as MetaMask. - Hardware wallets: not supported. ## Chains The bundled catalog comes from [zunia-chain-registry](https://github.com/Zunia-Lab/zunia-chain-registry). A site can [suggest a chain](https://docs.zunialab.com/docs/integrate/suggest-chain). A user can add one under **Manage networks**. Endpoints must be `https://`. # Build with AI URL: https://docs.zunialab.com/docs/get-started/ai Coding assistants can work from these docs in two ways: the Zunia MCP server, or plain-text files made for language models. Both are built from the docs each time the docs are published, so they say what this site says. ## MCP server - **Hosted endpoint:** `https://docs.zunialab.com/mcp` (Streamable HTTP, no key). - **npm package:** `@zunialab/mcp`, run over stdio with `npx -y @zunialab/mcp`. The hosted endpoint needs no install. Use the npm package when your client only starts local servers. ### Claude Code ```bash claude mcp add --transport http zunia https://docs.zunialab.com/mcp ``` ### Claude Desktop and claude.ai Add a custom connector with the URL `https://docs.zunialab.com/mcp`. ### Cursor In `.cursor/mcp.json` (this project) or `~/.cursor/mcp.json` (every project): ```json { "mcpServers": { "zunia": { "url": "https://docs.zunialab.com/mcp" } } } ``` ### VS Code In `.vscode/mcp.json`: ```json { "servers": { "zunia": { "type": "http", "url": "https://docs.zunialab.com/mcp" } } } ``` ### Clients that start a local server ```json { "mcpServers": { "zunia": { "command": "npx", "args": ["-y", "@zunialab/mcp"] } } } ``` ### What it does | Tool | What it does | |------|--------------| | `search_docs` | Searches these docs and returns the best sections with their links | | `get_doc`, `list_docs` | Reads a page, or one section of it, and lists every page | | `list_integrations`, `get_integration_guide` | The integration paths, each with its packages at their latest npm version and the install command | | `get_sdk_packages` | The `@zunialab` packages, read from npm when you ask | | `get_chain`, `check_address` | A chain's entry in the [Zunia chain registry](https://docs.zunialab.com/docs/chain-registry/overview), and whether an address belongs to a chain | | `explain_error` | What an error code means and what to do next, from the SDK's `explainZuniaError` | | `verify_sign_in` | Runs `verifySignIn` on a sign-in you captured and says which check fails | | `ibc_denom`, `build_forward_memo` | Computes an IBC denom and builds a packet-forward memo with `@zunialab/interchain` | Each docs page is also offered as a resource, and the `integrate_zunia` prompt plans an integration for the project you have open. The server is read-only. It never signs, holds no keys and needs no account. ## llms.txt | File | What it holds | |------|---------------| | [llms.txt](https://docs.zunialab.com/llms.txt) | Every page, with a one-line description | | [llms-full.txt](https://docs.zunialab.com/llms-full.txt) | Every page in one file | | `/docs/…/page.md` | Any page as Markdown: add `.md` to its address, for example [quickstart.md](https://docs.zunialab.com/docs/get-started/quickstart.md) | | [docs-index.json](https://docs.zunialab.com/docs-index.json) | The same pages as JSON. The MCP server reads this file | Store availability and other product facts are in the website's own [llms.txt](https://zunialab.com/llms.txt). ## Keep keys out of prompts Never paste a recovery phrase or a private key into an assistant, a prompt or an MCP client. Integrating Zunia never needs one, and the wallet never shows its keys to a site. # Connect from a web dApp URL: https://docs.zunialab.com/docs/integrate/extension Use `@zunialab/sdk-web` when you are not on React, or when you want the session object yourself. The extension injects `window.zunia` on `https://` pages and on localhost. ```ts import { createZuniaSession } from "@zunialab/sdk-web"; const options = { chains: ["cosmoshub-4", "osmosis-1"], metadata: { name: "My dApp", url: location.origin }, }; const session = createZuniaSession(); if (!(await session.restore(options))) { await session.connect({ ...options, prefer: "extension" }); } const [account] = session.accounts; ``` `restore` does not prompt. It calls `getConnectedChains()` on the provider and, if your site is still granted, reattaches. If the wallet is locked, status becomes `locked`, `accounts` stays empty, and later requests wait for unlock. `session.unlock()`, called from a click, opens the unlock window and keeps your grant. A CosmJS signer from the session asks for the unlock by itself when the user signs. ## Detect the extension ```ts import { getZunia, isZuniaInstalled } from "@zunialab/sdk-web"; isZuniaInstalled(); // synchronous, current window only const provider = await getZunia(); // waits for `zunia#initialized`, or undefined ``` `getZunia({ preferAlias: true })` also accepts `window.keplr`, but only when that object says it is Zunia (`isZunia`, from extension 0.1.5). Another wallet's `window.keplr` is never taken for Zunia. Prefer `window.zunia`. With the default `prefer: "auto"`, when nothing can connect, `connect` opens the install page unless you pass `openInstallIfMissing: false`, and throws `NOT_INSTALLED`. With `prefer: "extension"` it throws `NOT_INSTALLED` without opening anything. ## Approve, then use `enable` / `connect` is the only call that should open a window for a first visit. After that: | Call | Window? | |------|---------| | `getConnectedChains()` | Never | | `getKey`, `getAccounts` | Only to unlock a locked wallet. Without a grant they fail with `NOT_CONNECTED`. | | `signIn`, `signAmino`, `signDirect`, `signArbitrary` | Always, so the user can read the request | | `experimentalSuggestChain` | Only when the chain is unknown | On Chrome the first connect can appear as an in-page card. Firefox and Safari have no way to know the card is visible, so they answer from the toolbar icon. On Safari iOS, if the popup cannot open, the same screen opens in a tab and closes itself after the last answer. ## Sign and broadcast ```ts import { SigningStargateClient } from "@cosmjs/stargate"; const messages = [ { typeUrl: "/cosmos.bank.v1beta1.MsgSend", value: { fromAddress: account.address, toAddress: recipient, amount: [{ denom: "uatom", amount: "1000" }] }, }, ]; const signer = session.getOfflineSignerFor("cosmoshub-4", { messages, memo }); const client = await SigningStargateClient.connectWithSigner(rpc, signer); await client.signAndBroadcast(account.address, messages, "auto", memo); ``` CosmJS signs Direct whenever its signer can. `getOfflineSignerFor` hands it the signer the connected Zunia build can sign these messages with. Zunia 0.1.4 and older refuse a Direct contract call to a 32-byte contract, or a send to a 32-byte address, so for those it returns the Amino-only signer. From 0.1.5, and on other transports, it returns the full signer. `session.capabilities` says what the connected extension can sign (`null` on other transports). On those older builds, an Amino signature over `&`, `<` or `>` that the chain would refuse is stopped before anything is broadcast: `UNSUPPORTED` with `reason: "amino-escaping"`. `getOfflineSigner` (Direct and Amino), `getOfflineSignerOnlyAmino`, `signAmino`, `signDirect` and `signArbitrary` are also on the session. The extension never broadcasts a dApp transaction (`sendTx` is `UNSUPPORTED`). ## Raw `window.zunia` You can call the provider without the SDK. See [the provider API](https://docs.zunialab.com/docs/reference/provider). The SDK is the path that restores sessions, normalizes results and listens for events the same way on every transport. # React URL: https://docs.zunialab.com/docs/integrate/react `@zunialab/sdk-react` is a thin layer on `@zunialab/sdk-web`. Components are client components (`"use client"`), so they work in the Next.js App Router. ```bash pnpm add @zunialab/sdk-react @zunialab/sdk-web ``` ## One hook ```tsx import { ConnectPairingModal, ConnectWithZuniaButton, useZuniaSession } from "@zunialab/sdk-react"; const options = { chains: ["cosmoshub-4"], apiBase: "https://api.zunialab.com", // QR pairing with Zunia mobile; leave out to offer the extension only }; export function Wallet() { const zunia = useZuniaSession({ restore: options }); if (zunia.restoring) return null; if (zunia.connected) { return ( <>

{zunia.accounts[0]?.address}

{zunia.locked && } ); } return ( <> zunia.connect(options).catch(() => {})} /> !open && zunia.disconnect()} status={zunia.status} pairing={zunia.pairing} verificationCode={zunia.verificationCode} error={zunia.error} /> ); } ``` The hook re-renders when the wallet switches accounts, locks, unlocks or revokes your site. | Field | | |-------|--| | `status`, `transport` | See [events](https://docs.zunialab.com/docs/integrate/events). | | `accounts`, `chains` | What your site may use right now. | | `pairing`, `verificationCode` | For the QR dialog. | | `error` | Last `ZuniaConnectError`. `explainZuniaError(error)` puts it in words for your UI. | | `connected`, `locked`, `connecting`, `restoring` | Shortcuts. `connected` stays true while `locked`. After a restore that found the wallet locked, `accounts` is empty until the user unlocks. | | `connect`, `disconnect`, `restore`, `unlock`, `signIn` | Actions. `unlock` opens the wallet's unlock window and keeps your site's grant: call it from a click. | | `session` | The underlying session (`getOfflineSignerFor`, `on`, …). | Call the hook once near the root, or create `const session = createZuniaSession()` at module scope and pass `{ session }` to every caller. ## Sign in ```tsx const onSignIn = async () => { const { nonce } = await (await fetch("/api/nonce", { method: "POST" })).json(); const proof = await zunia.signIn({ nonce }); await fetch("/api/verify", { method: "POST", body: JSON.stringify({ nonce, ...proof }) }); }; ``` Verify on the server with `verifySignIn` from `@zunialab/sdk-core`. ## Components - `ConnectWithZuniaButton`: official button. Opens the install page when `installed` is false and there is no `onClick`. - `ConnectPairingModal`: QR code, then the 6-digit check. On a phone it also offers a link that opens the Zunia app. Cancel should call `disconnect()`. - `ZuniaQrCode`: SVG, rendered locally. - `ZuniaMark`: the logo. - `useZunia()`: detects `window.zunia` only. # Sign in with Zunia URL: https://docs.zunialab.com/docs/integrate/sign-in Use this when you only need to know which Cosmos account is at the keyboard. There is no transaction and no fee. The wallet shows a dedicated **Sign in to <site>** screen: your domain, the account and the expiry. A generic `signArbitrary` of the same bytes is refused if the domain does not match the caller. ## Browser ```ts const { nonce } = await (await fetch("/api/nonce", { method: "POST" })).json(); const { message, signature, address } = await session.signIn({ nonce, statement: "Sign in to My dApp.", // optional }); await fetch("/api/verify", { method: "POST", body: JSON.stringify({ nonce, message, signature }) }); ``` `session.signIn()` fills `location.host` as the domain and `location.origin` as the URI. The message expires after 10 minutes unless you pass `expirationTime`. ## Server ```ts import { createNonce, verifySignIn, ZuniaSignInError } from "@zunialab/sdk-core"; // POST /api/nonce const nonce = createNonce(); await nonces.save(nonce, { ttlSeconds: 600 }); // POST /api/verify { nonce, message, signature } if (!(await nonces.consume(nonce))) throw new Error("Unknown or used nonce"); try { const { address, chainId } = verifySignIn({ message, signature, nonce, domain: "app.example.com", chainId: ["cosmoshub-4"], // optional }); } catch (error) { if (error instanceof ZuniaSignInError) console.warn(error.code); throw error; } ``` `domain` must come from your config, not from the request `Host` header. `verifySignIn` checks, in order: the message format, the domain, that the URI is on that domain, the nonce, the chain, the address if you pass one, the dates (issued less than 10 minutes ago by default, not expired, not before `notBefore`), that the key is a compressed secp256k1 key, that it derives the signed address, and the ADR-036 signature. Storing each nonce and consuming it once is your job. That is what stops a captured signature from being replayed. ## What the user signs ```text app.example.com wants you to sign in with your Cosmos account: cosmos1fr389pmrhma7sqmmshzpvg6hyn9jzegzvx6y6x Sign in to Example. URI: https://app.example.com Version: 1 Chain ID: cosmoshub-4 Nonce: 8f1c2a9d4b7e6f30a5c1d2e3f4a5b6c7 Issued At: 2026-09-24T10:00:00.000Z Expiration Time: 2026-09-24T10:10:00.000Z ``` The wallet signs it as ADR-036 arbitrary data, so it can never be a transaction. Field rules are in [the sign-in reference](https://docs.zunialab.com/docs/reference/sign-in). ## Over QR pairing The phone binds the message to the **verified origin** the relay recorded from the page's `Origin` header, not to whatever URL the dApp put in its metadata. If those differ, the wallet warns and refuses sign-in. See [QR connection](https://docs.zunialab.com/docs/integrate/qr#verified-origin). # Events and session lifecycle URL: https://docs.zunialab.com/docs/integrate/events A connected site should treat the wallet as the source of truth. The user can switch accounts, lock, add a chain or revoke you without touching your UI. ## Status | Status | Meaning | |--------|---------| | `idle` | Nothing started. | | `connecting` | Looking for a wallet, or waiting for the user to approve in the extension. | | `awaiting_wallet` | A QR code or WalletConnect link is waiting for the wallet to scan and approve it. | | `connected` | You have accounts. | | `locked` | Still granted, the wallet is locked. Requests wait for unlock. `session.unlock()`, called from a click, opens the unlock window. | | `reconnecting` | The relay socket dropped and is coming back. | | `disconnected` | You called `disconnect()`, the user revoked you, the session expired, or a connect failed (see `error`). | | `error` | Part of the type, but the SDK does not set it. A failed connect ends in `disconnected`, with the failure in `error`. | ```ts session.on("status", (status) => setStatus(status)); session.on("accountsChanged", (accounts) => render(accounts)); session.on("chainChanged", (chains) => setChains(chains)); session.on("disconnect", (reason) => showSignedOut(reason)); ``` On React, `useZuniaSession` already subscribes. `session.subscribe` / `session.getSnapshot()` work with `useSyncExternalStore`. ## Wallet events These are what `window.zunia` emits, and what the SDK forwards. | Event | When | Payload | |-------|------|---------| | `accountsChanged` | Active account changed or was renamed, or the wallet unlocked. Connected sites only. | None on `window.zunia`: read `getKey` again. The SDK does that and emits the new accounts. | | `chainChanged` | Chains granted to your origin changed. | Remaining chain ids | | `disconnect` | Access revoked or expired. | `null` for every chain, or `{ chainIds }` for those lost | | `locked` | The wallet locked. | none | A grant in the extension lasts 7 days. When it runs out, your site gets `disconnect` and has to connect again. After a reload, call `restore` (or `getConnectedChains()`). That does not unlock the wallet and does not open a window. ## What is stored By default the SDK writes to `localStorage`: - `zunia.session.transport` - `zunia.connect.v2.session` (relay URL, session id, dApp token, session keys and counters; never the QR join token) - `zunia.walletconnect.session` Any script on your origin can read these. Keep a strict Content Security Policy. Pass `storage: null` to keep the session in memory only (users pair again after a reload), or pass your own `{ getItem, setItem, removeItem }`. ## Timeouts and drops Over QR pairing and WalletConnect, each connect and each signature waits 5 minutes by default (`timeoutMs`, `requestTimeoutMs`). In the extension, a prompt nobody answers expires after 5 minutes, and the SDK rejects with `TIMEOUT` (`reason: "prompt-expired"`). Over QR pairing the SDK pings every 20 seconds, reconnects with backoff, and resends requests the relay had not confirmed. If the relay forgot the session, you get `SESSION_EXPIRED` and the user pairs again. `disconnect()` from your page ends the session. On the extension it also revokes your site's grant for the session's chains (`window.zunia.disable`), so the next `connect` asks the user again. Over QR pairing it closes the relay session and deletes the stored one. The user can also revoke your site under **Connected dApps**. # Connect a phone by QR URL: https://docs.zunialab.com/docs/integrate/qr A page and Zunia mobile pair through `zunia.connect.v2`. The relay only forwards ciphertext. Both screens show the same 6-digit code so the user can see they are talking to each other. Zunia runs the relay at `https://api.zunialab.com`. Pass it as `apiBase`. Any site may open sessions there, within per-address rate limits. Its source is [zunia-backend](https://github.com/Zunia-Lab/zunia-backend). ```ts import { createZuniaSession, renderQrSvg } from "@zunialab/sdk-web"; const session = createZuniaSession(); session.on("pairing", ({ uri }) => { dialog.innerHTML = renderQrSvg(uri, { size: 240, label: "Scan with Zunia" }); }); session.on("verification", (code) => { dialog.textContent = `Your phone should show ${code.slice(0, 3)} ${code.slice(3)}`; }); await session.connect({ chains: ["cosmoshub-4"], prefer: "native-ws", apiBase: "https://api.zunialab.com", metadata: { name: "My dApp", url: location.origin }, }); ``` On React, `ConnectPairingModal` does this. On a phone browser it also offers a `zunia://connect?...` link so the user does not have to scan their own screen. An unscanned code expires after 10 minutes. After the wallet sends `paired`, the room lasts 24 hours. ## What travels ```mermaid sequenceDiagram participant Dapp as dApp participant Relay as Relay participant Wallet as Zunia mobile Dapp->>Relay: POST /v1/connect/sessions (Origin recorded) Relay-->>Dapp: sessionId, dappToken, walletJoinToken Dapp->>Wallet: QR: sid, joinToken, dApp public key, relay Wallet->>Relay: WS with joinToken (single use) Relay-->>Wallet: resumeToken, verifiedOrigin Wallet->>Dapp: hello with wallet public key Note over Dapp,Wallet: HKDF, ChaCha20-Poly1305, sequence numbers Dapp->>Wallet: connect_request (sealed) Wallet->>Dapp: connect_approve (sealed) Wallet->>Relay: paired ``` The QR holds a one-time `walletJoinToken`, the dApp public key and the relay address. The `dappToken` never goes in the QR. Application frames are opaque to the relay. The protocol is in [zunia.connect.v2](https://docs.zunialab.com/docs/reference/connect-v2). ## Verified origin When the page creates the session, the relay stores the browser `Origin` header as `verifiedOrigin` and gives it to the wallet. The phone shows that origin. If the dApp's metadata URL is a different site, the wallet warns ("This site is not where it says") and refuses sign-in. `verifiedOrigin` is only as trustworthy as the relay. A relay you do not control can invent one. Zunia mobile therefore only opens pairing URIs whose relay is on its **trusted list**: `wss://api.zunialab.com` by default, plus relays added at build time (`ZUNIA_CONNECT_TRUSTED_RELAYS`, `CONNECT_WS_PUBLIC_URL`) and, in debug builds, relays on the same machine or local network. A QR that names any other relay is refused. So pass `apiBase: "https://api.zunialab.com"`. A relay you run yourself only pairs with a debug build of Zunia mobile, or with one you built with that relay on its list. ## After a reload `session.restore({ apiBase, chains })` reopens the WebSocket with the stored dApp token and session keys. The wallet uses its `resumeToken`. Counters keep going, so a replayed frame is ignored. ## Deep links | Kind | Example | |------|---------| | Custom scheme | `zunia://connect?…` | | Universal / App Link | `https://zunialab.com/connect?…` or `https://link.zunialab.com/connect?…` | Those HTTPS links do not open the app yet: the `apple-app-site-association` and `assetlinks.json` files on zunialab.com and link.zunialab.com still hold placeholders for the Apple team id and the Play signing certificate. Use the `zunia://` link. # WalletConnect URL: https://docs.zunialab.com/docs/integrate/walletconnect Use WalletConnect v2 when you want wallets other than Zunia. The Zunia mobile app can **accept** a WalletConnect session when a Cloud project id is set. It does **not** sign WalletConnect requests yet. To reach Zunia mobile, use [QR pairing](https://docs.zunialab.com/docs/integrate/qr). ```ts await session.connect({ chains: ["cosmoshub-4"], prefer: "walletconnect", walletConnectProjectId: "your WalletConnect Cloud project id", loadWalletConnect: () => import("@walletconnect/sign-client"), }); ``` `@walletconnect/sign-client` is an optional peer. Loading it through the function you pass keeps it out of apps that never use it. Show `session.pairing.uri` as a QR code the same way as native pairing. The transport asks the wallet for accounts through `cosmos_getAccounts` (so CosmJS gets real public keys) and follows `session_event`, `session_update`, `session_delete` and `session_expire`. ## Same project id everywhere Create a project in the [Reown dashboard](https://dashboard.reown.com) (formerly WalletConnect Cloud) and use **one** id in: - your dApp (`walletConnectProjectId`) - `zunia-mobile` (`WALLETCONNECT_PROJECT_ID`) - `zunia-dashboard` (`NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID`). The dashboard keeps WalletConnect turned off until Zunia mobile signs WalletConnect requests. Do not commit a production project id in a public fork and then keep using it. ## Deep links (mobile) | Kind | Example | |------|---------| | WalletConnect URI | `wc:…` | | Custom scheme | `zunia://wc?…` | | Universal / App Link | `https://zunialab.com/wc?…` | Until WalletConnect signing ships in Zunia mobile, those links can open a session whose signature requests are never answered: they end in `TIMEOUT`. Prefer QR pairing for Zunia. The HTTPS link does not open the app yet either; see [Deep links](https://docs.zunialab.com/docs/integrate/qr#deep-links). # Suggest a chain URL: https://docs.zunialab.com/docs/integrate/suggest-chain If the wallet does not already know a chain, a site can propose it. The user sees **Add <name>** and the endpoints you chose. Nothing is saved until they approve. ```ts import { getZunia } from "@zunialab/sdk-web"; const chain = { chainId: "my-test-1", chainName: "My Testnet", rpc: "https://rpc.example.com", rest: "https://lcd.example.com", bip44: { coinType: 118 }, bech32Config: { bech32PrefixAccAddr: "my" }, currencies: [ { coinDenom: "TEST", coinMinimalDenom: "utest", coinDecimals: 6 }, ], feeCurrencies: [ { coinDenom: "TEST", coinMinimalDenom: "utest", coinDecimals: 6, gasPriceStep: { low: 0.01, average: 0.025, high: 0.04 }, }, ], }; // From a session connected to the extension await session.suggestChain(chain); // Or straight to the provider, before any connection const zunia = await getZunia(); await zunia?.experimentalSuggestChain?.(chain); ``` `session.suggestChain` works on the extension transport only. Over QR pairing or WalletConnect it throws `UNSUPPORTED`. Once the user has added the chain, call `connect` again with its chain id. The shape is the usual Keplr `ChainInfo`. The wallet keeps a smaller draft: name, chain id, RPC, REST, bech32 prefix, coin type, denom metadata and a gas price. Endpoints must be `https://` and must not contain credentials. If the chain is already in the bundled registry or was added earlier, the call returns without a prompt. A user can also add a network by hand: menu, **Manage networks**, then **Add chain manually**. See [Networks](https://docs.zunialab.com/docs/use-wallet/networks). Only suggest endpoints you operate or trust. The wallet will send the user's addresses to them for balances and will broadcast through them when the user sends from the wallet itself. # Flutter URL: https://docs.zunialab.com/docs/integrate/flutter Two different things share the word "SDK" here. Keep them straight. ## A Flutter **dApp** talking to Zunia The Dart package `zunia_sdk` in [zunia-sdk/flutter/zunia_sdk](https://github.com/Zunia-Lab/zunia-sdk/tree/main/flutter/zunia_sdk) is **not** a session client. It ships deep-link helpers and a Connect with Zunia button. A v2 client (QR pairing, events, CosmJS-equivalent signers) is planned. ```yaml dependencies: zunia_sdk: git: url: https://github.com/Zunia-Lab/zunia-sdk.git path: flutter/zunia_sdk ``` ```dart import 'package:zunia_sdk/zunia_sdk.dart'; ConnectWithZuniaButton( size: ConnectWithZuniaButtonSize.medium, onPressed: () { // Open your own WalletConnect or deep-link flow. }, ); ``` Until that client exists, a Flutter dApp should use WalletConnect with another wallet, or open a web view that uses `@zunialab/sdk-web`. ## The Zunia **wallet** (the mobile app) [zunia-mobile](https://github.com/Zunia-Lab/zunia-mobile) is the wallet. It already speaks `zunia.connect.v2`: it reads the pairing URI, agrees on keys, shows the 6-digit code, shows the verified origin, binds sign-in to that origin, and resumes with `resumeToken`. It accepts WalletConnect sessions when a Cloud project id is set. It does not sign WalletConnect requests yet. The app is in App Store and Google Play review. To build it yourself, see [Install the mobile wallet](https://docs.zunialab.com/docs/use-wallet/mobile): it needs zunia-core and zunia-ui checked out next to it. Same recovery phrase as the extension if you want both devices. # Troubleshooting URL: https://docs.zunialab.com/docs/integrate/troubleshooting ## `NOT_INSTALLED` The extension is not in this browser, or the content script is not allowed on your origin. - Install Zunia from the [Chrome Web Store](https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk), or load a production build (`pnpm build:chrome`, then the unpacked `.output/chrome-mv3`). - On Safari, allow Zunia on the site. On iPhone and iPad: Settings, Apps, Safari, Extensions, Zunia, **Other Websites**: Allow. On a Mac: Safari Settings, Extensions, Zunia, **Always Allow on Every Website**, with Other Websites set to Allow. The provider only appears on sites Safari allows. - On Firefox, if the user withdrew host access in `about:addons`, the provider disappears. ## The connect window never appears (Firefox, Safari) That is expected. Those browsers answer connect from the **toolbar icon**. On Safari iOS, if `action.openPopup()` fails, the same screen opens in a tab. ## `LOCKED` The wallet stayed locked: the user closed the unlock window, or nobody unlocked it in time. Do not open a second connect prompt. `restore` after a reload reports `locked` without a window. Offer a button that calls `session.unlock()` (`unlock` on `useZuniaSession`): it opens the unlock window and keeps your site's grant. ## `ORIGIN_MISMATCH` on sign-in The message named a different host than the page that asked. `session.signIn()` fills `location.host` for you. Do not build the message yourself unless you also pass it through `checkSignInBinding`. On QR pairing, the phone compares the message to the **verified origin**, not to your metadata URL. ## Sign-in says "The wallet returned no signature" You are talking to an old extension build that wrapped `signArbitrary` in an extra object. Current builds return the bare `StdSignature`, which is what Keplr, cosmos-kit, graz and the SDK expect. Update or rebuild the extension. ## Events never arrive on Safari Safari drops `tabs.sendMessage` from the worker. Current builds send events over a port the content script opens. Rebuild from a revision that includes `lib/event-port.ts`. A backgrounded Safari tab reconnects when it becomes visible and replays what it missed. ## QR pairing never completes - `apiBase` must be a relay the phone trusts: `https://api.zunialab.com`. - The phone refuses a QR whose relay is not on its trusted list. Debug builds also accept relays on the same machine or local network. - Compare the 6-digit codes. If they differ, throw the session away. - Unscanned rooms die after 10 minutes. ## `SESSION_EXPIRED` The relay forgot the room (restart without Postgres, or the 24-hour TTL). Call `connect` again. ## CosmJS cannot sign Public keys must be real `Uint8Array`s. The WalletConnect transport fetches them with `cosmos_getAccounts`. If you roll your own WC client and leave pubkeys empty, CosmJS will fail. ## `sendTx` is `UNSUPPORTED` By design. Sign with `signAmino` / `signDirect` and broadcast from the dApp. ## `UNSUPPORTED` on a contract call or a memo with `&` Check `error.reason`: - `blind-signing`: the extension could not decode the transaction, so it refused it without a prompt. Zunia 0.1.4 and older do this for a Direct contract call to a 32-byte contract. Hand CosmJS `session.getOfflineSignerFor(chainId, { messages, memo })` instead of `getOfflineSigner`, or ask the user to update Zunia. - `amino-escaping`: Zunia 0.1.4 or older signed `&`, `<` or `>` in a way the chain refuses. The SDK stopped it before broadcast. Ask the user to update Zunia, or remove those characters from the memo. `explainZuniaError(error)` gives a title and a next step for any error. ## Live balances stay off in Safari Safari never prompts for `https://*/*`. On iPhone and iPad, the user allows it under Settings, Apps, Safari, Extensions, Zunia, Other Websites, Allow. On a Mac: Safari Settings, Extensions, Zunia, **Always Allow on Every Website**, with Other Websites set to Allow. The wallet says so when the switch is flipped. ## Packages 404 on npm `@zunialab/sdk-web`, `@zunialab/sdk-react`, `@zunialab/sdk-core` and `@zunialab/interchain` are public. A 404 means the name or version does not match, or the install is using a registry other than `registry.npmjs.org`. See the [quickstart](https://docs.zunialab.com/docs/get-started/quickstart). # `window.zunia` URL: https://docs.zunialab.com/docs/reference/provider The extension injects this object on `https://` pages and localhost. Types live in `@zunialab/sdk-core` as `ZuniaProvider`. An optional `window.keplr` alias exists when the user enables it. The provider dispatches `zunia#initialized` on `window`, and `zunia_keystorechange` on account changes (`keplr_keystorechange` too while the Keplr alias is on). ## Methods | Method | | |--------|--| | `enable(chainIds)` | Ask for those chains. Prompts on first grant. | | `disable(chainIds?)` | Drop the grant for those chains, or the whole origin. | | `getConnectedChains()` | Chains this origin may use, no prompt. `[]` if none. | | `isLocked()` | Whether the wallet is locked. Connected sites only. | | `getKey(chainId)` | Keplr-shaped key for the active account. `NOT_CONNECTED` without a grant. Opens the unlock window if the wallet is locked. | | `getOfflineSigner(chainId)` | Amino + Direct signer. | | `getOfflineSignerOnlyAmino(chainId)` | Amino only. | | `getOfflineSignerAuto(chainId)` | Same as `getOfflineSigner` today. | | `signAmino(chainId, signer, signDoc)` | Amino signature. Always prompts. | | `signDirect(chainId, signer, signDoc)` | Direct signature. Always prompts. | | `signArbitrary(chainId, signer, data)` | ADR-036. Returns a bare `StdSignature`. Sign-in messages get a dedicated screen. | | `verifyArbitrary(chainId, signer, data, signature)` | Local check of an ADR-036 signature. | | `experimentalSuggestChain(chainInfo)` | Propose a chain. Prompts if unknown. | | `on` / `off` | See [events](https://docs.zunialab.com/docs/reference/events). | `sendTx` rejects with `UNSUPPORTED`. Sign, then broadcast from the dApp. `getKey` returns `{ name, algo, pubKey, address, bech32Address, isNanoLedger }`, plus `ethereumHexAddress` on chains with Ethereum-style addresses. `isNanoLedger` is always false. ## Release and features `version` is the provider API version. It stays `"0.1.0"` in every release, so Zunia 0.1.0 to 0.1.4 cannot be told apart. From extension 0.1.5 the provider also has: - `extensionVersion`: the installed release. - `isZunia: true`, which tells Zunia's `window.keplr` alias from Keplr itself. - `features`: what this build signs that older builds refused or signed wrongly. | `features` entry | Meaning | |------------------|---------| | `sign-direct:wasm-contract-32` | Direct contract calls to 32-byte contracts are decoded and prompted. | | `sign-direct:send-32` | A Direct `MsgSend` to a 32-byte address is decoded and prompted. | | `sign-direct:osmosis-poolmanager` | Direct Osmosis poolmanager swaps that sell an exact amount. | | `sign-direct:osmosis-exact-out` | Direct Osmosis poolmanager swaps that buy an exact amount. | | `sign-amino:escaped` | Amino sign bytes escape `&`, `<`, `>`, U+2028 and U+2029 the way chains rebuild them. | | `sign-amino:osmosis-poolmanager` | Amino poolmanager swap requests are described and prompted. | A provider without `extensionVersion` is 0.1.4 or older. `zuniaCapabilities(window.zunia)` from `@zunialab/sdk-core` reads these fields, and `session.getOfflineSignerFor()` in `@zunialab/sdk-web` uses them to pick Direct or Amino. ## Errors Rejected calls use `ZuniaProviderError` with a stable `code`: `USER_REJECTED`, `NOT_CONNECTED`, `LOCKED`, `UNKNOWN_CHAIN`, `ORIGIN_MISMATCH`, `UNSUPPORTED`, `INVALID_PARAMS` or `INTERNAL`. Human messages keep Keplr's wording where they overlap, so existing integrations keep working. A request whose message types the wallet cannot decode is refused with `UNSUPPORTED` "Blind signing disabled for unknown messages", unless the user turned on blind signing. ## What the page never sees The seed phrase, the password, and private keys. Content scripts may only forward provider calls and ping the worker so it stays up while a request waits (on Safari, also while the page receives events). They cannot read `storage.session`. # SDK API URL: https://docs.zunialab.com/docs/reference/sdk Four packages. The first three share one session. ESM only. ## `@zunialab/sdk-web` | Export | | |--------|--| | `createZuniaSession()` | Session that picks a transport. | | `connectWithZunia(options)` | Create, connect, return the session. | | `restoreSession(options)` | Create and restore, or `null`. | | `createExtensionSession` / `createZuniaWsSession` / `createWalletConnectSession` | Force one transport. | | `getZunia` / `getZuniaSync` / `isZuniaInstalled` / `enableZunia` | Provider helpers. | | `renderQrSvg` / `qrMatrix` | Local QR rendering. | | `createConnectWithZuniaButton` | DOM button. CSS at `@zunialab/sdk-web/connect-button.css`. | | `explainZuniaError` / `zuniaCapabilities` / `zuniaSignMode` | Re-exported from `@zunialab/sdk-core`. | `ConnectOptions`: `chains`, `metadata`, `prefer` (`auto` \| `extension` \| `native-ws` \| `walletconnect`), `apiBase` (the Zunia relay is `https://api.zunialab.com`), `wsBase`, `walletConnectProjectId`, `loadWalletConnect`, `timeoutMs`, `requestTimeoutMs`, `storage` (`null` disables persistence), `openInstallIfMissing`. A session exposes `connect`, `restore`, `disconnect`, `unlock`, `signIn`, `signAmino`, `signDirect`, `signArbitrary`, `suggestChain`, `getKey`, `getOfflineSigner`, `getOfflineSignerOnlyAmino`, `getOfflineSignerFor`, `capabilities`, `accounts`, `chains`, `status`, `transport`, `pairing`, `verificationCode`, `on` / `off`, `subscribe`, `getSnapshot`. ## `@zunialab/sdk-react` | Export | | |--------|--| | `useZuniaSession(options?)` | Hook. Options `{ restore, session }`. Returns the snapshot plus `connected`, `locked`, `connecting`, `restoring`, `connect`, `disconnect`, `restore`, `unlock`, `signIn`, `session`. | | `useZunia()` | Detect `window.zunia`. | | `ConnectWithZuniaButton` | Official button. | | `ConnectPairingModal` | QR, then 6-digit code. | | `ZuniaQrCode` / `ZuniaMark` | Presentational. | | Re-exports | `createZuniaSession`, `ZuniaConnectError`, `explainZuniaError`, `createNonce`, … | ## `@zunialab/sdk-core` | Export | | |--------|--| | `verifySignIn` / `createNonce` / `buildSignInMessage` / `parseSignInMessage` / `checkSignInBinding` | Sign-in. | | `ZuniaConnectError` / `ZuniaSignInError` / `explainZuniaError` | Errors, and words for them. | | `zuniaCapabilities` / `zuniaSignMode` / `aminoNeedsEscaping` / `ZUNIA_SIGNING_FEATURES` | What a Zunia extension can sign, and which mode to ask it for. | | `checkAminoSignature` / `serializeAminoSignDoc` | Whether an Amino signature is over the bytes the chain rebuilds. | | `ConnectCipher` / `deriveConnectKeys` / `verificationCodeFrom` / `parsePairingUri` / `buildPairingUri` | `zunia.connect.v2` crypto and URIs. | | `normalizeAminoResponse` / `normalizeDirectResponse` / `normalizeStdSignature` | Wire to CosmJS shapes. | | Types | `ZuniaProvider`, `ZuniaSession`, `ZuniaAccountInfo`, … | | Constants | `ZUNIA_CONNECT_PROTOCOL`, `ZUNIA_CONNECT_PATHS`, `ZUNIA_WALLETCONNECT`, deep links. | Test vectors: `test-vectors/sign-in-vectors.json` and `test-vectors/connect-v2-vectors.json` inside the package. ## `@zunialab/interchain` IBC channel discovery, denom unwinding, packet-forward / ibc-hooks memos, cross-chain swaps and NFTs. Used by the wallet. A dApp can use it without talking to Zunia. # Events URL: https://docs.zunialab.com/docs/reference/events ## Provider (`window.zunia`) | Event | Audience | Data | |-------|----------|------| | `accountsChanged` | Origins that are connected. Also sent when the wallet unlocks. | Read `getKey` again | | `chainChanged` | Origins that are connected | `{ chainIds }` still granted | | `disconnect` | Origins that lost access | `null` (all) or `{ chainIds }` | | `locked` | Connected origins | none | | `keplr_keystorechange` | Same as `accountsChanged` | none (Keplr alias) | For older listeners, `window` also gets `zunia_keystorechange`, `zunia_disconnect`, `zunia_locked` and `zunia_chainchanged`, plus `keplr_keystorechange` and `keplr_disconnect` while the Keplr alias is on. The worker only delivers these to tabs whose origin is granted. An origin that never connected hears nothing. ### Safari Safari accepts `tabs.sendMessage` from the worker and never hands the message to the content script. The Safari build therefore: 1. Lets the content script open a port named `zunia:provider-events` once the page has used the provider. 2. Numbers every event and keeps the last 100 in `storage.session`. 3. On reconnect (worker restarted, or the tab came back to the front), sends everything newer than the page's last number. A visible Safari tab that uses the wallet also pings the worker every 3 seconds so iOS does not stop it after ~8 seconds idle. ## SDK session | Event | Payload | |-------|---------| | `status` | `ZuniaSessionStatus` | | `accountsChanged` | `ZuniaAccountInfo[]` | | `chainChanged` | `string[]` | | `pairing` | `{ transport, uri, expiresAt }` | | `verification` | 6-digit string (QR only) | | `disconnect` | reason string, such as `"user"` (your own `disconnect()`), `"revoked"` (the extension dropped your site) or `"expired"` (the relay session ran out) | | `error` | `ZuniaConnectError` | # Errors URL: https://docs.zunialab.com/docs/reference/errors Every transport rejects with `ZuniaConnectError` and a stable `code`. `window.zunia` uses the wallet's codes from this list: `USER_REJECTED` to `INVALID_PARAMS`, and `INTERNAL`. | Code | Meaning | |------|---------| | `USER_REJECTED` | The user said no, or closed the window. | | `NOT_CONNECTED` | This origin has no grant for that chain or account. | | `LOCKED` | The wallet is locked. | | `UNKNOWN_CHAIN` | The wallet does not know the chain. Suggest it first. | | `ORIGIN_MISMATCH` | A sign-in message names another site. | | `UNSUPPORTED` | The wallet or transport cannot do this (`sendTx`, unknown methods, blind signing of unknown messages). | | `INVALID_PARAMS` | Bad arguments. | | `NOT_INSTALLED` | No extension, relay or WalletConnect setup. | | `TIMEOUT` | The wallet did not answer in time. | | `DISCONNECTED` | The session ended while waiting. | | `PAIRING_FAILED` | QR pairing could not complete. | | `SESSION_EXPIRED` | The relay no longer knows the session. | | `NETWORK` | The relay or WalletConnect could not be reached. | | `INTERNAL` | Anything else. | `error instanceof ZuniaConnectError` and `error.code` are the stable checks. Do not match on `error.message`. `error.reason` narrows the cases where the next step differs: | Reason | Code | Meaning | |--------|------|---------| | `blind-signing` | `UNSUPPORTED` | This Zunia build cannot show the transaction, so it signed nothing. | | `amino-escaping` | `UNSUPPORTED` | Zunia 0.1.4 or older signed `&`, `<` or `>` in a way the chain refuses. Stopped before broadcast. | | `prompt-expired` | `TIMEOUT` | The extension's prompt expired before anyone answered. | | `stale-page` | `DISCONNECTED` | The extension was updated or reloaded under the page. Reload the page. | `explainZuniaError(error)` turns any error into a short `title`, a `message` with the next step, `retryable`, and the wallet's own text as `detail`. ## Sign-in (`ZuniaSignInError`) Thrown by `verifySignIn` on the server: `INVALID_MESSAGE`, `DOMAIN_MISMATCH`, `URI_MISMATCH`, `NONCE_MISMATCH`, `CHAIN_MISMATCH`, `ADDRESS_MISMATCH`, `ISSUED_IN_FUTURE`, `TOO_OLD`, `EXPIRED`, `NOT_YET_VALID`, `UNSUPPORTED_KEY`, `KEY_MISMATCH`, `INVALID_SIGNATURE`. # `zunia.connect.v2` URL: https://docs.zunialab.com/docs/reference/connect-v2 Protocol between a dApp, a relay, and Zunia mobile. The TypeScript types and crypto live in `@zunialab/sdk-core`. The Dart wallet implements the same vectors. ## HTTP Zunia runs a relay at `https://api.zunialab.com`. Any page origin may create sessions there; the relay records that origin, it does not filter on it. `POST /v1/connect/sessions` with the page's `Origin`. Response (`201`): ```ts { v: "zunia.connect.v2", sessionId: string, dappToken: string, // stays with the dApp, never in the QR walletJoinToken: string, // single use, goes in the QR verifiedOrigin: string | null, expiresAt: number, wsUrl: string, } ``` `GET` and `DELETE /v1/connect/sessions/{sessionId}` with `Authorization: Bearer ` read the session's state or end it. ## WebSocket `/v1/connect/ws?sid=…&role=dapp` (or `role=wallet`). Tokens travel in `Sec-WebSocket-Protocol` (`zunia.connect.v2` plus `zunia.token.…`), not in the query string. The wallet's join token is consumed on first use and replaced by a `resumeToken`. Relay frames (`t`) are the only thing the relay reads: `welcome`, `hello`, `peer`, `msg`, `paired`, `ping` / `pong`, `error`, `closed`, and `close` from a client that ends the session. Application payloads sit inside sealed `msg` frames (`n` sequence, `c` ciphertext). Only the wallet may send `hello` (its public key) and `paired`. `paired` locks the room and extends the TTL to 24 hours. Unpaired rooms die after 10 minutes. A socket is only replaced by a holder of that role's token. ## Crypto - Key agreement: X25519 - KDF: HKDF-SHA256 over a transcript of both public keys and the session id - Sealed frames: ChaCha20-Poly1305, with the sequence number in the AAD - Sequence numbers: monotonic, reject duplicates and replays - Verification code: 6 digits derived from the shared secret, shown on both sides Test vectors: `test-vectors/connect-v2-vectors.json` in the `@zunialab/sdk-core` package. ## Pairing URI `zunia://connect?v=2&sid=…&t=…&pk=…&r=…` (also accepted as `https://zunialab.com/connect?…`). The query holds the protocol version, the session id (`sid`), the join token (`t`), the dApp public key (`pk`) and the relay's WebSocket base (`r`). It does not hold the dApp token. ## What the relay sees Session id, hashed tokens, `verifiedOrigin`, role, timestamps, ciphertext length. Not keys, not accounts, not the sign-in message, not the transaction. Tokens are stored as SHA-256 hashes and compared with `timingSafeEqual`. Sessions persist in Postgres when `DATABASE_URL` is set; otherwise they live in memory and die on restart. Run one instance: live sockets are not shared. Limits: 20 new sessions and 60 WebSocket upgrades per client address per minute, frames up to 512 KiB and 120 per 10 seconds per socket, 10,000 live sessions in all (beyond that, creation answers `503`), and a bounded offline queue. `TRUST_PROXY` must match how you terminate TLS, or rate limits attach to the wrong address. This is not a multi-instance relay. The deployment at `api.zunialab.com` is one instance. # Sign-in message URL: https://docs.zunialab.com/docs/reference/sign-in CAIP-122-style text, signed as ADR-036. Built by `buildSignInMessage` / `session.signIn()`, parsed by `parseSignInMessage`, checked by `verifySignIn`. ```text wants you to sign in with your Cosmos account:
URI: Version: 1 Chain ID: Nonce: Issued At: Expiration Time: # optional, always set by session.signIn() Not Before: # optional Request ID: # optional Resources: # optional - ``` | Field | Rule | |-------|------| | Domain | Exact host of the site that asked. The wallet compares it to the caller origin (extension) or to `verifiedOrigin` (QR). | | Address | Bech32 of the account that signs. Must derive from the signature's public key. | | URI | Must be on that domain (`https://app.example.com/…` is fine; `https://evil.example` is not). | | Version | `1` | | Chain ID | The chain the account belongs to. | | Nonce | 8 to 128 letters or digits. `createNonce()` gives 32 hex characters (128 random bits). Consume once on the server. | | Issued At | Not in the future (5 minutes of clock skew allowed). Default max age: 10 minutes (`maxAgeMs`). | | Expiration Time | Optional in the format. `session.signIn()` always sets it, by default 10 minutes after issue. Must be in the future at verify time. | `looksLikeSignIn` is what the wallet uses to decide between the sign-in screen and a generic arbitrary-data screen. `checkSignInBinding` is the domain/URI rule wallets must run before signing. Vectors: `test-vectors/sign-in-vectors.json` in the `@zunialab/sdk-core` package. # Security model URL: https://docs.zunialab.com/docs/security/model Zunia is non-custodial. The seed phrase and private keys never leave the device that holds them. There is no Zunia server that can sign, recover a phrase, or freeze a balance. No independent audit has been completed. Do not read this page as one. ## Keys - Generated or imported on the device (BIP-39, BIP-44, coin type 118 by default, coin type 60 for Ethermint-style addresses). - Encrypted at rest with the user's password. The unlocked phrase lives in `storage.session` in the extension (cleared when the browser closes) and in the platform secure store on mobile. - The extension asks `storage.session` to stay out of content-script reach (`TRUSTED_CONTEXTS`). - Hardware wallets are not supported. ## Permissions, per origin A site sees nothing until the user approves `enable` / `connect` for named chains. Grants are stored per origin and last 7 days. The user revokes them under **Connected dApps** (menu in the extension). An expired or revoked grant emits `disconnect` to that origin only. `getConnectedChains()` returns the current grant without unlocking and without opening a window. ## Approvals Every signature opens a screen: connect, sign-in, Amino, Direct, arbitrary data, or suggest-chain. Message types the wallet cannot decode are refused without a prompt (`UNSUPPORTED`), unless the user turns on **Blind signing** under **Settings & security** → **Security**. It is off by default. The user can reject or close the window (`USER_REJECTED`). Firefox and Safari answer connect from the toolbar popup. Safari on iOS may open the same screen in a tab. ## Sign-in binding A Sign in with Zunia message must name the site that asked. The extension compares domain and URI to the caller origin and refuses otherwise (`ORIGIN_MISMATCH`), without opening a window for a spoofed domain. `verifySignIn` repeats those checks on your server, plus the key, the address, the nonce and the dates. Consume each nonce once. ## QR pairing Messages between the page and the phone are X25519 + HKDF-SHA256 + ChaCha20-Poly1305, with sequence numbers. The relay sees ciphertext and routing metadata. The QR join token is single use. The dApp token never goes in the QR. `verifiedOrigin` is the `Origin` header the relay recorded when the page created the session. The phone shows it and binds sign-in to it. That value is only as good as the relay: Zunia mobile therefore refuses pairing URIs whose relay is not on its trusted list. By default that list holds only Zunia's relay, `wss://api.zunialab.com`. See [QR connection](https://docs.zunialab.com/docs/integrate/qr#verified-origin). ## What leaves the device When live balances are on, addresses go to the chain endpoints in the catalog (or the ones a user / dApp added), and the NFT screen sends a Stargaze address to Stargaze's API to list its collections. Prices come from CoinGecko and swap quotes from Osmosis's SQS service, without an address. Signed transactions go to the node that broadcasts them. The extension sends nothing to a Zunia server. Firefox's data-collection prompt is answered as `financialAndPaymentInfo` for that reason. QR pairing goes through Zunia's relay at `api.zunialab.com`, which sees ciphertext and routing metadata, as above. The web dashboard at `app.zunialab.com` reads chain data and broadcasts through its own server, so the addresses you use there and the transactions you sign there reach that server. It never receives a key or a phrase. ## Limits (relay) Session creation per client address, frame size and rate, a global room cap, hashed tokens, `timingSafeEqual`. `TRUST_PROXY` must match your edge, or the limiter attaches to the wrong address. One instance only. ## Content Security Policy The extension pages use `script-src 'self'` plus `'wasm-unsafe-eval'` for the kernel, `object-src 'self'`, and `frame-ancestors 'self' https: http://localhost:* http://127.0.0.1:*` so the connect prompt can be drawn in the page. Only that prompt (`connect.html`) is web accessible, so it is the only extension page a site can frame; the popup and every signing screen are not. The provider is injected through a web-accessible script. dApps should keep their own CSP strict: the SDK's `localStorage` keys are readable by any script on the origin. ## Supply chain Repositories are public under [github.com/Zunia-Lab](https://github.com/Zunia-Lab). Extension builds are checked in CI (MV3, WASM present, no bare specifiers). Bit-for-bit reproducible store builds are **not** claimed yet. See [Reproducible builds](https://docs.zunialab.com/docs/developers/reproducible-builds). ## Disclosure Email [dev@zunialab.com](mailto:dev@zunialab.com). Do not open a public issue for a vulnerability. See [SECURITY.md](https://github.com/Zunia-Lab/.github/blob/main/SECURITY.md). # Install the extension URL: https://docs.zunialab.com/docs/use-wallet/extension On Chrome, install Zunia from the [Chrome Web Store](https://chromewebstore.google.com/detail/zunia/ngokakoekdogobjmokipglbcclelgajk). Safari is in App Store review and Firefox is planned. Edge Add-ons has no listing. For those browsers, load a build you made. The extension links zunia-core (built to WebAssembly with Rust and `wasm-bindgen-cli`), zunia-ui and zunia-sdk from checkouts next to it, so build those first: ```bash 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 build ``` | Browser | Load | |---------|------| | Chrome, Brave, Opera | `chrome://extensions` → Developer mode → Load unpacked → `.output/chrome-mv3` | | Edge | `edge://extensions` → the same, or `.output/edge-mv3` | | Firefox 140+ | `about:debugging#/runtime/this-firefox` → Load Temporary Add-on → `.output/firefox-mv3/manifest.json` | | Safari (macOS) | `pnpm safari:build`, run the macOS scheme in Xcode, then in Safari: Develop → Developer Settings → **Allow unsigned extensions**, enable Zunia, allow it on all websites | | Safari (iOS Simulator) | `pnpm safari:build`, run the iOS scheme, then Settings → Apps → Safari → Extensions → Zunia, allow on all websites | `pnpm dev` rebuilds Chrome while you edit. Requirements: Node 22 and pnpm 9 (`corepack enable`). See the [extension README](https://github.com/Zunia-Lab/zunia-extension) for the kernel toolchain. On first open you get **Welcome**, then **Create wallet** or **Restore with phrase**. Write the recovery phrase down offline. Zunia cannot restore it. ## Daily use The toolbar icon opens the popup (a sheet on iPhone). The bottom bar has **Home**, **Earn**, **Swap** and **Activity**. The home screen has **Send**, **Deposit**, **Swap** and **Stake**. The header opens the account switcher. The menu (☰) holds: - **Manage networks** - **NFTs** - **Governance** - **Notifications** - **Address book** - **Connected dApps** (revoke a site or a chain) - **Settings & security** (accounts, networks, security, preferences, notifications) - **Lock wallet** **Settings & security** → **Security** is where you turn the Keplr-compatible alias (**Keplr alias**) on or off. ## Browser notes - **Firefox and Safari:** a dApp connect request waits in this popup. Click the toolbar icon. - **Safari on iPhone:** if the popup cannot open, the request opens in a tab. Inputs are 16px so Safari does not zoom. Approve / Reject sit under the content so they stay in the half-height sheet. - **Safari website access:** on iPhone and iPad, Settings → Apps → Safari → Extensions → Zunia → Other Websites → Allow. On a Mac, Safari → Settings → Extensions → Zunia → Always Allow on Every Website, with Other Websites set to Allow. Without that, sites do not see `window.zunia`, and live balances stay off. - Auto-lock counts time without input. Opening the popup resets it. Closing the browser clears the unlocked session. More detail: [browsers.md](https://github.com/Zunia-Lab/zunia-extension/blob/main/docs/browsers.md) in the extension repo. # Install the mobile wallet URL: https://docs.zunialab.com/docs/use-wallet/mobile The iOS and Android apps are in App Store and Google Play review. Until they are listed, build them yourself with a Flutter release that ships Dart 3.11.5 or later (the repo builds with Flutter 3.41). The app links zunia-core and zunia-ui from checkouts next to it, and needs the signing kernel's native libraries from a [zunia-core release](https://github.com/Zunia-Lab/zunia-core/releases). `scripts/fetch-native.sh` downloads and checks them; it needs the GitHub CLI (`gh`). ```bash git clone https://github.com/Zunia-Lab/zunia-core.git git clone https://github.com/Zunia-Lab/zunia-ui.git git clone https://github.com/Zunia-Lab/zunia-mobile.git cd zunia-mobile ZUNIA_CORE_TAG= ./scripts/fetch-native.sh flutter pub get flutter run ``` Release builds: ```bash flutter build apk --release flutter build appbundle --release flutter build ipa --release # needs an Apple team in Xcode ``` Create a wallet or import the same 12/24-word phrase you use in the extension. Keys stay on the phone. Zunia does not sync them through a server. ## Pair with a website 1. On the site, choose Connect with Zunia (QR). 2. Scan the code, or tap the deep link if you are already on the phone. 3. Check that the 6-digit code matches the site. 4. Read the origin the relay confirmed. If it differs from the name the site used, stop. 5. Approve the chains. Later signatures open a sheet each time. Pairing goes through Zunia's relay at `api.zunialab.com`, the only relay the app trusts by default. A code that names another relay is refused. Debug builds also accept a relay on the same machine or local network. Paired sites are listed under **Settings & security** → **Connected apps**, where you can end a session. WalletConnect QR codes can open a session. The app does not sign those requests yet. ## Same phrase, two devices Import the phrase on the second device. Each device holds its own encrypted copy. Losing both copies without the phrase means the accounts are gone. Accounts added after the first one do not carry over between the extension and the app; see [More accounts](https://docs.zunialab.com/docs/use-wallet/keys#more-accounts). # Keys and accounts URL: https://docs.zunialab.com/docs/use-wallet/keys Zunia is an HD wallet (BIP-39 / BIP-44). Each account has a bech32 address per network. - Default coin type **118** (Cosmos). A chain in the catalog can set another. - Coin type **60** for Ethermint-style addresses. That is address derivation only. Zunia is not an Ethereum wallet. ## Create Onboarding → **Create wallet** → write the phrase on paper → confirm some of its words → set a name and a password → choose networks. The phrase is shown once during setup. Screenshots are a bad idea. ## Import Onboarding → **Restore with phrase** in the extension, **I have a recovery phrase** in the mobile app. 12 or 24 words. The password only unlocks this device; it is not part of the phrase. ## More accounts The two apps add accounts differently: - **Extension:** account name (top left) → **Add account** → **Create new account** or **Restore with phrase**. Each account added this way has its own recovery phrase. The same password unlocks them all. Back up each phrase. - **Mobile app:** **Settings & security** → **Wallets** → **Add account**. It derives the next account from the same phrase, so backing up that phrase backs up all of them. So only the first account of a phrase appears in both apps when you import that phrase. Switch accounts from the account list; a connected site hears `accountsChanged`. ## Reveal the phrase **Settings & security** → **Security** → **Reveal recovery phrase** (password again). There is no way to change the password. **Forgot password?** on the extension's unlock screen removes the wallet so you can restore it from the phrase. It only helps if you still have that phrase, and the phrase of each account you added with its own. ## Backup Zunia cannot recover a lost phrase. Store it offline. Anyone with the phrase has the accounts. Ledger and Keystone are planned, not available. # Networks URL: https://docs.zunialab.com/docs/use-wallet/networks Home lists the networks you turned on. You choose them when you create or restore a wallet (**Choose networks**). The bundled catalog comes from [zunia-chain-registry](https://github.com/Zunia-Lab/zunia-chain-registry). Updates to that repo ship in the next wallet build. ## Manage networks Menu → **Manage networks**. Turn each network on or off for the current account, or add one the catalog does not have with **Add chain manually**. **Add network** has two tabs. **Registry** searches the catalog. **Manual** asks for chain name, chain ID, RPC, REST, prefix, coin type, base denom and gas price; the token gets 6 decimals. RPC and REST must be `https://`. Only add endpoints you trust: they will see your addresses and can show fake balances or refuse to broadcast. A dApp can propose a chain too ([suggest a chain](https://docs.zunialab.com/docs/integrate/suggest-chain)). You still have to approve it. ## Live balances While live balances are off, Home shows **Turn on live balances** with an **Enable** button. The switch itself is **Settings & security** → **Preferences** → **Live balances**. When it is on, the wallet reads spendable and staked amounts from each chain's public LCD. - Chrome and Firefox prompt once for host access. - Safari never prompts. On iPhone and iPad, allow **Other Websites** under Settings → Apps → Safari → Extensions → Zunia. On a Mac, Safari → Settings → Extensions → Zunia → **Always Allow on Every Website**, with Other Websites set to Allow. Then try the switch again. Turn it off and the wallet stops those requests. ## Fees Network fees go to validators, not to Zunia. Before you sign, the screen shows the fee denom, the amount and the gas. The network fee row on the Send and Swap screens opens the speed (**Low**, **Mid**, **High**) and the gas adjustment, and Zunia keeps your choice. Defaults come from the registry. A swap made with the **Swap** screen of the extension or of the [web dashboard](https://app.zunialab.com) also carries a 0.5% Zunia commission. See [Fees](https://docs.zunialab.com/docs/use-wallet/fees). # Send and IBC URL: https://docs.zunialab.com/docs/use-wallet/send-and-ibc Home → **Send**. Pick the token, the recipient and the amount. Same-chain sends stay on that network. If the recipient is on another catalog chain, the wallet builds an IBC transfer (including packet-forward when the route needs a hop). The review screen shows: - Source token and amount - Destination chain and address - Route (channels) - Fee on the source chain - Memo, if you set one Sign on the device. The wallet broadcasts from the LCD you configured for that chain. Arrival time is whatever the IBC relayers take; Zunia does not run those. **Deposit** (**Receive** in the mobile app) shows the current account's address and QR for the selected network. Check the prefix (`cosmos`, `osmo`, …) before you give it out. **Address book** (menu) stores labels so you do not paste a raw bech32 every time. **Swap** is a separate home action. It uses the same review + sign path. It needs the WASM kernel; if the kernel failed to load, Confirm stays disabled and the screen says why. A swap made with **Swap** carries a 0.5% Zunia commission, taken in the same transaction from the token you sell. The review screen shows it before you sign. Sends and IBC transfers carry no commission. [Fees](https://docs.zunialab.com/docs/use-wallet/fees) covers swaps that start on another chain. Do not paste a seed phrase into any website, including the Zunia web dashboard at `app.zunialab.com`. It never asks for one: it holds no keys and sends every transaction to your wallet to sign. # Staking and governance URL: https://docs.zunialab.com/docs/use-wallet/staking Home → **Stake** opens Earn: validators, commission, your delegations and rewards. - **Stake** with a validator from its row. - **Unstake** from a delegation (extension only). Unbonding time is the chain's (often 14 or 21 days). - **Claim rewards** from Earn. Neither app redelegates. To move stake, unstake, wait out the unbonding time, then stake again. Each action has its own review screen (amount, fee, validator). Sign on the device. Home → menu → **Governance** lists proposals on the selected network. You can vote **Yes**, **No**, **Veto** (no with veto) or **Abstain**. The wallet does not deposit for you. Staking parameters (minimum, unbonding, APR figures) come from the chain. Zunia takes no cut of rewards. # Fees URL: https://docs.zunialab.com/docs/use-wallet/fees The wallet is free to install. There is no premium tier and no token. | Action | Network fee | Zunia commission | |--------|-------------|------------------| | Send, IBC transfer | Yes | None | | Stake, unstake, claim rewards, vote | Yes | None | | Swap with the **Swap** screen of the extension or of the [web dashboard](https://app.zunialab.com) | Yes | 0.5% | | Swap on another site or dApp, signed with Zunia | Yes | None | ## Network fee Every transaction pays a network fee to the chain. It goes to validators, not to Zunia. Before you sign, the screen shows the fee denom, the amount and the gas. Gas settings are under [Networks](https://docs.zunialab.com/docs/use-wallet/networks#fees). ## Swap commission A swap made with the **Swap** screen of the Zunia browser extension, or of the web dashboard at app.zunialab.com, carries a 0.5% commission, sent to Zunia. Both charge the same rate to the same Zunia addresses. - It is taken in the same transaction as the swap. - It is paid in the token you sell. - The review screen shows it before you sign. On a chain where Zunia has no fee address, the swap carries no commission. ## Swaps that start on another chain When the funds sit on another chain, they are transferred to Osmosis and swapped there. The commission is taken on the starting chain, when that transfer is sent. If the swap then fails on Osmosis, the amount swapped comes back. The commission does not. Some pairs cannot be swapped in one step from another chain. The **Swap** screen then asks you to move the tokens to Osmosis first: with Send in the extension, with Bridge on the web dashboard. That transfer carries no commission. The swap that follows starts on Osmosis, and its commission is taken there, in the same transaction. ## No commission Sends, IBC transfers and staking carry no Zunia commission. You pay only the network fee. Zunia takes no cut of staking rewards. A swap you make on another site or dApp and only sign with Zunia carries no Zunia commission. The commission shown next to a validator in Earn is set by that validator. It is not Zunia's. # Chain registry overview URL: https://docs.zunialab.com/docs/chain-registry/overview The [zunia-chain-registry](https://github.com/Zunia-Lab/zunia-chain-registry) repository holds JSON metadata for Cosmos-SDK, EVM, and SVM chains. The extension, the mobile app and the web dashboard bundle the Cosmos entries when they are built, so a registry change reaches users with their next release. Only Cosmos chains are signed for: EVM and SVM entries are metadata, and no Zunia app signs EVM or Solana transactions. Each entry, and its logo, is also served from: ``` https://raw.githubusercontent.com/Zunia-Lab/zunia-chain-registry/main/cosmos/{identifier}.json https://raw.githubusercontent.com/Zunia-Lab/zunia-chain-registry/main/images/{identifier}/chain.png ``` The schema is compatible with the wider Cosmos suggest-chain ecosystem. Upstream reference: [keplr-chain-registry](https://github.com/chainapsis/keplr-chain-registry). Curation and branding are maintained by Zunia Lab. # Contribute a chain URL: https://docs.zunialab.com/docs/chain-registry/contribute 1. Fork [zunia-chain-registry](https://github.com/Zunia-Lab/zunia-chain-registry) 2. Add `{chain-identifier}.json` under `cosmos/`, `evm/`, or `svm/` 3. Add `images/{chain-identifier}/chain.png` (256x256) 4. Run `yarn validate path/to/file.json` 5. Open a pull request See the repository README for field requirements and the `features` array reference. # Brand assets URL: https://docs.zunialab.com/docs/brand/assets Official logos, colors, and typography live in [zunia-brand](https://github.com/Zunia-Lab/zunia-brand). ## Colors | Name | Hex | |------|-----| | Accent red | `#FF1B0C` | | Vermilion | `#FF4E12` | | Amber | `#FF8A17` | | Gold | `#FFBE14` | | Ink | `#111111` | | Paper | `#F1F0EE` | | Void | `#0B0A09` | ## Fonts - Space Grotesk (UI, headings) - JetBrains Mono (addresses, chain IDs) ## Docs site assets This site ships the docs subbrand lockups and mark under `/img/`: - `zunia-docs-white.svg` / `zunia-docs-black.svg` (navbar) - `zunia-mark.svg` (`currentColor` mark) - `favicon.ico`, `zunia-social-card.png` ## Usage Do not modify the mark, apply gradients to the mark, or change wordmark spacing. See the brand README for full guidelines. Questions: [zunialab@gmail.com](mailto:zunialab@gmail.com) or [dev@zunialab.com](mailto:dev@zunialab.com) # Terms of Service URL: https://docs.zunialab.com/docs/legal/terms **Status: DRAFT — not legal advice. Not in force until counsel review and publication.** Last updated: 14 October 2026 (draft) ## 1. Parties These draft terms would govern use of Zunia wallets, websites, and related services operated by Zunia Lab (name TBD). ## 2. Non-custodial software Zunia provides non-custodial wallet software. You control your keys. We cannot recover seed phrases, reverse transactions, or freeze on-chain balances. ## 3. Eligibility / acceptable use Draft placeholders: age requirements, prohibited illicit use, sanctions compliance, no reverse engineering of security controls beyond research disclosure policy. ## 4. Risks Blockchain transactions are irreversible. Smart contract, bridge, and third-party dApp risk sits with you. ## 5. Fees The wallet is free to install, with no premium tier and no token. Network fees are paid to validators / chains, not to Zunia. Sends, IBC transfers and staking carry no Zunia commission: you pay only the network fee. Swaps made with the Swap screen of the Zunia browser extension or of the Zunia web dashboard (app.zunialab.com) carry a 0.5% commission, sent to Zunia. It is taken in the same transaction as the swap, in the token you sell, and shown before you sign. For a cross-chain swap (funds on another chain, swapped on Osmosis), the commission is taken on the starting chain when the transfer is sent. If the swap then fails on Osmosis, the amount swapped comes back; the commission does not. Swaps made on another website or dApp and only signed with Zunia carry no Zunia commission. ## 6. Disclaimers Software provided "as is" while in beta / pre-release. Limit liability clauses TBD by counsel. ## 7. Contact dev@zunialab.com # Privacy Policy URL: https://docs.zunialab.com/docs/legal/privacy **Status: DRAFT — not legal advice. Not in force until counsel review and publication.** Last updated: 2026-08-31 (draft) ## Summary Zunia is designed so **seed phrases and private keys never leave your device** and are never uploaded to Zunia servers. The web dashboard does not accept signing keys in the browser. ## Data we may process (when features ship) | Category | Examples | Purpose | |----------|----------|---------| | Device / push | Web Push subscription endpoints, FCM tokens | Deliver tx / security alerts | | Platform session | Address + ADR-36 device binding proof | Authorize push and light history | | Usage (optional) | Crash reports with scrubbing | Reliability | | Support | Email you send us | Respond to requests | ## What we do not collect by design - Seed phrases, private keys, mnemonics - Full wallet unlock passwords - Unnecessary chain RPC traffic from the browser (dashboard proxies server-side) ## Retention / deletion TBD — push tokens deleted on unsubscribe / device revoke; support mail per ordinary retention. ## GDPR / rights See [GDPR data map](https://docs.zunialab.com/docs/legal/gdpr-data-map). Contact: dev@zunialab.com # GDPR data map (draft) URL: https://docs.zunialab.com/docs/legal/gdpr-data-map **Status: DRAFT.** Map systems before appointing a DPO / completing RoPA. | Processing | Personal data | Lawful basis (draft) | Systems | Retention (draft) | |------------|---------------|----------------------|---------|-------------------| | Device binding | Bech32 address, pubkey, challenge metadata | Legitimate interest / contract | `zunia-backend` | Until revoke + short audit window | | Push delivery | Push endpoint, device id | Consent (notification permission) | Backend + FCM/APNs/Web Push | Until unsubscribe | | Tx history cache | Address, tx hashes, summaries | Legitimate interest (service) | `zunia-indexer` | Cap per wallet; TTL TBD | | Support email | Email, message content | Consent / legitimate interest | Inbox | Ordinary business retention | | Crash / Sentry | Device model, stack (scrubbed) | Legitimate interest | Sentry | Per Sentry project policy | | Analytics | **Prefer none / privacy-preserving** | Consent if used | TBD | TBD | ## Cross-border Hosting regions TBD (Fly/Railway/Vercel). Document SCCs when vendors process EU data. ## Data subject requests Email dev@zunialab.com. Process access and erasure for server-side records. On-device keys are user-controlled; erasure means uninstall or wipe the device. # App Store encryption declaration checklist URL: https://docs.zunialab.com/docs/legal/app-store-encryption Use when submitting iOS / macOS builds. **Confirm with counsel**; this is an engineering checklist, not legal advice. ## Typical Zunia facts (draft) - [ ] App uses encryption for HTTPS / TLS to backends and RPCs - [ ] App uses standard OS crypto (Keychain / Secure Enclave / Keystore) for key material at rest - [ ] App implements wallet cryptography (signing) using approved algorithms (e.g. secp256k1) in `zunia-core` - [ ] App implements the documented `zunia.connect.v2` pairing crypto (X25519, HKDF-SHA256, ChaCha20-Poly1305) so QR sessions are end-to-end encrypted - [ ] App does **not** implement undocumented proprietary encryption beyond TLS, OS key stores, wallet signing and that pairing protocol - [ ] Export compliance / EAR questions answered consistently with Apple's "encryption" questionnaire - [ ] If only exempt encryption (HTTPS + OS crypto), document whether annual self-classification / EAR exemption applies in your jurisdiction - [ ] Privacy nutrition labels match [Privacy Policy draft](https://docs.zunialab.com/docs/legal/privacy) - [ ] No seed phrases transmitted off device ## References - Apple: Export compliance / encryption documentation in App Store Connect - Internal: [key management](https://github.com/Zunia-Lab/zunia-infra/blob/main/docs/security/key-management.md) (when published) # Architecture decisions URL: https://docs.zunialab.com/docs/adr/overview Product and security ADRs for Zunia. | ADR | Topic | Status | |-----|-------|--------| | [Core 0003](https://github.com/Zunia-Lab/zunia-core/blob/main/docs/adr/0003-custody-model.md) | Custody model | **Accepted: self-custody only** | | [Indexer 0001](https://github.com/Zunia-Lab/zunia-indexer/blob/main/docs/adr/0001-user-scoped-tx-history.md) | Tx history | **Accepted: user-scoped LCD** (first 5, cap 50) | | [Indexer 0002](https://github.com/Zunia-Lab/zunia-indexer/blob/main/docs/adr/0002-realtime-subscribed-txs.md) | Realtime detect/queue | **Accepted** | | [Indexer 0003](https://github.com/Zunia-Lab/zunia-indexer/blob/main/docs/adr/0003-realtime-worker-host.md) | Worker host | **Accepted: container (Fly/Railway/Render), not Vercel serverless** | | [Core 0002](https://github.com/Zunia-Lab/zunia-core/blob/main/docs/adr/0002-wallet-kernel-language.md) | Kernel language | **Accepted: one Rust kernel** (WASM in the extension, FFI on mobile) | | [Core 0004](https://github.com/Zunia-Lab/zunia-core/blob/main/docs/adr/0004-cosmos-client-libs.md) | Cosmos client libs | **Accepted: signing and encoding in Rust**, CosmJS for network I/O only | | [Core 0005](https://github.com/Zunia-Lab/zunia-core/blob/main/docs/adr/0005-polyrepo-package-flow.md) | Polyrepo package flow | **Accepted: published, versioned packages** | ## Indexer Hono + Zod + Postgres (`DATABASE_URL`) for durable history; realtime WS on always-on worker via `zunia-infra`. See [zunia-indexer](https://github.com/Zunia-Lab/zunia-indexer). ## Infra [`zunia-infra`](https://github.com/Zunia-Lab/zunia-infra) holds production hosting: nginx and systemd on a Hetzner host, behind Cloudflare, plus shared DNS for `api.`, `link.` and `status.`. Its Pulumi code is still a stub. The chain registry keeps its own Pulumi stack. # Reproducible builds URL: https://docs.zunialab.com/docs/developers/reproducible-builds Zunia aims for **bit-for-bit verifiable** wallet artifacts so users and auditors can rebuild what they install. ## Kernel (`zunia-core`) The signing kernel ships npm WASM, native mobile libraries, and Dart FFI bindings. Release process, tags, checksums, and consumer update PRs are documented in: **[zunia-core/RELEASING.md](https://github.com/Zunia-Lab/zunia-core/blob/main/RELEASING.md)** Verify release artifacts against `SHA256SUMS` (and GPG signatures when the release key is published). ## Browser extension Extension packaging must be rebuildable from a signed git tag. The draft rebuild steps are in [zunia-extension/docs/reproducible-builds.md](https://github.com/Zunia-Lab/zunia-extension/blob/main/docs/reproducible-builds.md). No external rebuild has passed them yet, so the Chrome Web Store build is **not** claimed reproducible. Store rewrapping means a bit-identical Chrome Web Store package is not a goal yet either. ## What is not claimed yet - App Store / Play Store binaries are **not** asserted reproducible until the release pipeline and public instructions are complete. - Do not treat draft docs or local `pnpm build` output as an audited release.